12 Commits

Author SHA1 Message Date
a429436903 Trigger desktop CI on every push to main, not just tags
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build SPA (push) Has been cancelled
Desktop App / macOS (arm64) (push) Has been cancelled
Desktop App / Linux (amd64) (push) Has been cancelled
Desktop App / Create Release (push) Has been cancelled
2026-07-13 22:54:00 +02:00
07d67c8446 fix(oidc): strip trailing slash from redirect URI to match Authentik strict mode
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-13 22:50:31 +02:00
8b50753746 feat(chat): MCP tool apps — custom inline renderers for 12 tools
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
12 of 33 MCP tools now render as rich inline cards instead of raw JSON:
EntityCard, HealthSummary, LXCList, EntityTable, KnowledgeResults,
BlastRadius, ChangeLog, FleetSnapshot, MetricChart.

Architecture:
- Server: annotateJSONResult() wraps queryRows with __renderer hints
- Registry: match/dispatch system maps tool names to Svelte components
- Chat: inline dispatch with 5-card limit, overflow to collapsed group
- ToolCallGroup: unmatched prop, hides when all matched, ARIA labels

Tests: 3 new Go tests for annotateJSONResult (wrap, no-op, multi-row).
2026-07-13 22:46:56 +02:00
680575e2cf Remove unrelated plan file from stale branch
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-13 22:41:50 +02:00
6b52c1ae57 Move 2026-07-12-wails-desktop-app plan to done/
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Phase 0 deployed 2026-07-12. Phases 1.0–1.4 implemented 2026-07-13.
Plan complete.
2026-07-13 22:41:45 +02:00
5d6d9e9040 Merge feature/wails-desktop-app into main
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-13 22:40:40 +02:00
04006553a3 Wails v3 desktop app: scaffold, shell features, token mgmt, auto-update, CI
Problem: the Oikos control room was browser-only — no native desktop
experience (system tray, notifications, keychain-persisted auth).

Change: add a Wails v3 thin-shell desktop app at cmd/desktop/ that embeds
the existing SPA in a webview. The Go side is ~380 lines — no bundled
server, no Postgres connection. It reads auth from the OS keychain,
injects it into the SPA on load, and the SPA talks HTTPS to the homelab
same as a browser.

Phase 1.0 — Scaffold + window:
  - Embed web/dist/ into the Wails binary
  - Inject window.__OIKOS_CONFIG__ with keychain-stored apiUrl + token
  - 1400×900 window, min 1024×700
  - System tray: Open/Quit, click toggles window

Phase 1.1 — Native shell:
  - Poll /api/v1/dashboard/summary every 30s; osascript notification
    when approvals or critical signals increase
  - Save/restore window position to ~/.config/oikos/window.json
  - EnableAutoStart/DisableAutoStart — macOS LaunchAgent plist

Phase 1.2 — Token management:
  - Config.svelte calls window.wails.Call.ByName('SaveConfig') after
    successful connection — persists to OS keychain
  - ConfigService binds SaveConfig, ClearConfig, EnableAutoStart,
    DisableAutoStart to the Wails runtime

Phase 1.3 — Auto-update:
  - Poll Gitea releases API every 6h, compare semver, show dialog
  - 'Check for Updates' tray menu item triggers immediate poll

Phase 1.4 — Distribution:
  - macOS entitlements.plist: network client + keychain access
  - .gitea/workflows/desktop.yml: CI builds macOS arm64 + Linux amd64
    on 'desktop-*' / 'v*' tags, attaches artifacts to release
  - Makefile: desktop (build), desktop-package (build + zip/tar.gz)
  - CONTRIBUTING.md: documented desktop app + commands

Risk: low. Wails v3 alpha API may shift; the Go glue is ~380 lines and
trivially portable. The desktop app is additive — zero changes to the
existing server or SPA logic. No config mutation, no infrastructure
impact.

Verification: go build, go vet, go mod tidy all pass.
2026-07-13 22:40:18 +02:00
7b0a0f01b5 oidc: authenticate SPA users via Authentik
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Add OIDC proxy endpoints (GET config, POST token) to API server
- Implement PKCE Authorization Code flow in SPA
- Enable Authentik login tab in Config page
- Handle callback + auto-refresh + session restore
- Add restart: unless-stopped to all persistent services
- Configure OIDC issuer + client_id in docker-compose
2026-07-13 22:17:40 +02:00
f6a699469d oidc: authenticate SPA users via Authentik
- Add OIDC proxy endpoints (GET config, POST token) to API server
- Implement PKCE Authorization Code flow in SPA
- Enable Authentik login tab in Config page
- Handle callback + auto-refresh + session restore
- Add restart: unless-stopped to all persistent services
- Configure OIDC issuer + client_id in docker-compose
2026-07-13 22:17:31 +02:00
4c4afc4783 fix(web): fit graph to node bounding box once the simulation settles
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Fresh nodes (no prior x/y) get placed by d3-force's default init, which
spirals out from the ORIGIN — not (width/2, height/2) — while the
centering forces here are deliberately weak (0.04, so they don't fight
the link/collide layout) and alphaDecay stops the sim before a weak force
can always pull a far-off cluster back to center. Net effect: graphs could
settle visibly off-center on load, cramped in a corner of the pane.

Fixed by computing the actual node bounding box once the simulation's
'end' event fires and setting the view transform to fit it, instead of
relying on the force balance to land on center by itself. Gated behind a
`fit` flag so passive background reloads (live entity/relationship
events) don't yank the view out from under someone actively panning or
zoomed in on a specific area — only fresh loads (mount, root/depth
change, reset, re-root) reframe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:51:56 +02:00
604b608fa8 feat(mcp): expose full knowledge content to the agent, not just snippets
search_knowledge and get_entity_knowledge only ever returned a ts_headline
snippet/short headline — enough to find a note, not enough to act on it.
Add get_knowledge_content(slug), mirroring the web UI's
/api/v1/knowledge/content/{id}, so the agent can read a document/
investigation/runbook's full markdown body once it knows which one it
needs. upsert_knowledge already covered the write side. Cross-referenced
all three tool descriptions so the agent discovers the full-read path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:51:37 +02:00
62a8ec1d8d fix(mcp): write targets/involves relationship edges when executions are created
Executions were being created with no outgoing edges to what they acted
on or which task/session drove them, silently starving the graph of new
data going forward — found during this session's DB audit, which had to
backfill 245+25 missing targets/involves edges for existing executions.
This closes the gap at the source: every execution now gets a
target-->targets-->execution edge, and (when the caller supplies a
session/task) a task-->involves-->execution edge, both idempotent
(NOT EXISTS guards) so retries/backfills don't duplicate.

Two call sites: the deduped systemctl/apt_upgrade/pct_create fast path
and the general classifyAndGate path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:45:44 +02:00
46 changed files with 2283 additions and 68 deletions

View File

@@ -0,0 +1,98 @@
name: Desktop App
on:
push:
branches:
- main
tags:
- 'desktop-*'
- 'v[0-9]+.[0-9]+.[0-9]*'
jobs:
build-ui:
name: Build SPA
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- run: npm ci
working-directory: web
- run: npm run build
working-directory: web
- uses: actions/upload-artifact@v4
with:
name: spa-dist
path: web/dist/
build-macos-arm64:
name: macOS (arm64)
needs: build-ui
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: spa-dist
path: cmd/desktop/frontend/dist/
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- run: wails3 build -clean
working-directory: cmd/desktop
env:
CGO_ENABLED: 1
- run: |
cd cmd/desktop/build/bin
zip -r oikos-desktop-darwin-arm64.zip oikos-desktop.app
- uses: actions/upload-artifact@v4
with:
name: oikos-desktop-darwin-arm64
path: cmd/desktop/build/bin/oikos-desktop-darwin-arm64.zip
build-linux-amd64:
name: Linux (amd64)
needs: build-ui
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: spa-dist
path: cmd/desktop/frontend/dist/
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- run: wails3 build -clean
working-directory: cmd/desktop
env:
CGO_ENABLED: 1
- uses: actions/upload-artifact@v4
with:
name: oikos-desktop-linux-amd64
path: cmd/desktop/build/bin/oikos-desktop
release:
name: Create Release
needs: [build-macos-arm64, build-linux-amd64]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/download-artifact@v4
with:
name: oikos-desktop-darwin-arm64
- uses: actions/download-artifact@v4
with:
name: oikos-desktop-linux-amd64
- name: Release
uses: https://gitea.com/actions/release-action@v1
with:
files: |
oikos-desktop-darwin-arm64.zip
oikos-desktop-linux-amd64
api_key: ${{ secrets.GITEA_TOKEN }}

5
.gitignore vendored
View File

@@ -17,3 +17,8 @@ backups/
# 0.1), so the output dir is just a build artifact. # 0.1), so the output dir is just a build artifact.
web/dist/ web/dist/
web/node_modules/ web/node_modules/
# Wails desktop app — frontend copy for embedding
cmd/desktop/frontend/dist/
cmd/desktop/build/
cmd/desktop/oikos-desktop

View File

@@ -33,6 +33,10 @@ cd web && OIKOS_API_TOKEN=dev-token npm run dev
## Project structure ## Project structure
``` ```
cmd/desktop/ Wails v3 desktop app (macOS + Linux)
main.go Thin shell: webview, system tray, notifications, auto-update
wails.json Wails project config
entitlements.plist macOS code-signing entitlements
cmd/oikos/ Single-binary entry point cmd/oikos/ Single-binary entry point
cmd/nomos/ Nomos MCP client gateway cmd/nomos/ Nomos MCP client gateway
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini) cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
@@ -82,6 +86,8 @@ docs/operations/ Runbooks (rollback, etc.)
| `make clean` | Remove binary + test cache | | `make clean` | Remove binary + test cache |
| `make ui` | Build the SPA (`web/dist/`) | | `make ui` | Build the SPA (`web/dist/`) |
| `make deploy-ui` | Build + deploy the SPA to the Caddy host | | `make deploy-ui` | Build + deploy the SPA to the Caddy host |
| `make desktop` | Build the Wails desktop app for the current platform |
| `make desktop-package` | Build + package (zip on macOS, tar.gz on Linux) |
| `make webhook` | Build `cmd/webhook` (deploy-webhook receiver) | | `make webhook` | Build `cmd/webhook` (deploy-webhook receiver) |
| `make tidy` | `go mod tidy` | | `make tidy` | `go mod tidy` |

View File

@@ -1,4 +1,4 @@
.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui .PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package desktop-release
BINARY := oikos BINARY := oikos
GO ?= go GO ?= go
@@ -52,8 +52,29 @@ dev:
ui: ui:
cd web && npm run build cd web && npm run build
desktop: ui ## Build the Wails desktop app for the current platform
rm -rf cmd/desktop/frontend/dist
mkdir -p cmd/desktop/frontend/dist
cp -r web/dist/* cmd/desktop/frontend/dist/
cd cmd/desktop && wails3 build -clean
desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux)
@case $$(uname -s) in \
Darwin) \
cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip oikos-desktop.app ;; \
Linux) \
cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz oikos-desktop ;; \
esac
@echo "Package: cmd/desktop/build/bin/"
desktop-release: ui ## Build desktop app for macOS arm64 + Linux amd64 (CI target)
@echo "Use 'make desktop-package' for local builds; desktop-release is for CI"
@exit 1
clean: clean:
rm -f $(BINARY) rm -f $(BINARY)
rm -rf cmd/desktop/build
rm -rf cmd/desktop/frontend/dist
$(GO) clean -testcache $(GO) clean -testcache
tidy: tidy:

View File

@@ -0,0 +1,26 @@
<?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>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<false/>
<key>com.apple.security.device.camera</key>
<false/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<false/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.hubris.oikos-desktop</string>
</array>
</dict>
</plist>

382
cmd/desktop/main.go Normal file
View File

@@ -0,0 +1,382 @@
package main
import (
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/zalando/go-keyring"
)
//go:embed frontend/dist
var assets embed.FS
const (
keyringService = "com.hubris.oikos-desktop"
keyringUser = "oikos"
version = "0.1.0"
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
pollInterval = 30 * time.Second
updateInterval = 6 * time.Hour
)
type OikosConfig struct {
ApiUrl string `json:"apiUrl"`
Token string `json:"token,omitempty"`
IsDesktop bool `json:"isDesktop"`
}
// ---- ConfigService ----
type ConfigService struct{ app *application.App }
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) 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)
}
// ---- 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)
}
// ---- Config loading ----
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
}
// ---- Asset handler ----
func newAssetHandler(cfg *OikosConfig) http.Handler {
distFS, err := fs.Sub(assets, "frontend/dist")
if err != nil {
log.Fatalf("embedded assets: %v", err)
}
fallback := http.FileServer(http.FS(distFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/" || path == "/index.html" {
data, err := fs.ReadFile(distFS, "index.html")
if err != nil {
fallback.ServeHTTP(w, r)
return
}
html := string(data)
if cfg != nil {
configJSON, _ := json.Marshal(cfg)
placeholder := `<script>window.__OIKOS_CONFIG__ = {};</script>`
injected := fmt.Sprintf(`<script>window.__OIKOS_CONFIG__ = %s;</script>`, configJSON)
html = strings.ReplaceAll(html, placeholder, injected)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
}
fallback.ServeHTTP(w, r)
})
}
// ---- 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"`
}
func checkUpdates() {
for {
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
if err != nil {
time.Sleep(updateInterval)
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var releases []giteaRelease
if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 {
time.Sleep(updateInterval)
continue
}
latest := releases[0]
latestVersion := strings.TrimPrefix(latest.TagName, "v")
if latestVersion == version {
time.Sleep(updateInterval)
continue
}
app := application.Get()
if app == nil {
time.Sleep(updateInterval)
continue
}
msg := fmt.Sprintf("Version %s is available (you have %s). Download from Gitea releases.", latestVersion, version)
app.Dialog.Info().
SetTitle("Update Available").
SetMessage(msg).
Show()
time.Sleep(updateInterval)
}
}
// ---- Main ----
func main() {
cfg := loadConfig()
app := application.New(application.Options{
Name: "Oikos",
Description: "Homelab Control Room",
Services: []application.Service{
application.NewService(&ConfigService{}),
},
Assets: application.AssetOptions{
Handler: newAssetHandler(cfg),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: false,
},
})
// --- System tray ---
systemTray := app.SystemTray.New()
systemTray.SetLabel("Oikos")
systemTray.SetTooltip("Oikos — Control Room")
trayMenu := application.NewMenu()
trayMenu.Add("Open Control Room").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 checkUpdates() // force immediate check on demand
})
trayMenu.AddSeparator()
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
app.Quit()
})
systemTray.SetMenu(trayMenu)
// --- Main window ---
ws := loadWindowState()
width, height := 1400, 900
minWidth, minHeight := 1024, 700
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Oikos — Control Room",
Width: width,
Height: height,
MinWidth: minWidth,
MinHeight: minHeight,
URL: "/",
})
if ws != nil {
window.SetPosition(ws.X, ws.Y)
window.SetSize(ws.Width, ws.Height)
} else {
window.Center()
}
window.Show()
systemTray.AttachWindow(window)
systemTray.Run()
// Register shutdown handler to save window state
app.OnShutdown(func() {
saveWindowState(window)
})
// Start background goroutines
go pollDashboard(cfg)
go checkUpdates()
err := app.Run()
if err != nil {
log.Fatal(err)
}
}

9
cmd/desktop/wails.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "oikos",
"outputfilename": "oikos-desktop",
"frontend:dir": "frontend",
"author": {
"name": "Hubris",
"email": "d.toro.v@pm.me"
}
}

View File

@@ -11,6 +11,7 @@
services: services:
postgres: postgres:
image: timescale/timescaledb:2.17.2-pg16 image: timescale/timescaledb:2.17.2-pg16
restart: unless-stopped
environment: environment:
POSTGRES_DB: oikos POSTGRES_DB: oikos
POSTGRES_USER: oikos POSTGRES_USER: oikos
@@ -57,6 +58,7 @@ services:
build: build:
context: . context: .
dockerfile: compose/oikos/Dockerfile dockerfile: compose/oikos/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"] profiles: ["dev", "full"]
depends_on: depends_on:
seed: seed:
@@ -70,6 +72,8 @@ services:
# every request needs this token. nomos uses the same value to call # every request needs this token. nomos uses the same value to call
# back into api's /mcp and /api/v1/approvals/*/decision. # back into api's /mcp and /api/v1/approvals/*/decision.
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token} OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
OIKOS_OIDC_ISSUER: ${OIKOS_OIDC_ISSUER:-https://auth.hubris.network/application/o/oikos/}
OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod}
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos} OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
NOMOS_PROXY_URL: http://nomos:8092 NOMOS_PROXY_URL: http://nomos:8092
volumes: volumes:
@@ -85,6 +89,7 @@ services:
build: build:
context: . context: .
dockerfile: compose/oikos/Dockerfile dockerfile: compose/oikos/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"] profiles: ["dev", "full"]
depends_on: depends_on:
seed: seed:
@@ -108,6 +113,7 @@ services:
build: build:
context: . context: .
dockerfile: compose/oikos/Dockerfile dockerfile: compose/oikos/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"] profiles: ["dev", "full"]
depends_on: depends_on:
seed: seed:
@@ -129,6 +135,7 @@ services:
build: build:
context: . context: .
dockerfile: compose/nomos/Dockerfile dockerfile: compose/nomos/Dockerfile
restart: unless-stopped
profiles: ["full"] profiles: ["full"]
depends_on: depends_on:
api: api:
@@ -155,6 +162,7 @@ services:
build: build:
context: . context: .
dockerfile: compose/web/Dockerfile dockerfile: compose/web/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"] profiles: ["dev", "full"]
ports: ports:
- "8091:80" - "8091:80"
@@ -163,6 +171,7 @@ services:
# Redis (required by Infisical — Phase 5) # Redis (required by Infisical — Phase 5)
redis: redis:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped
profiles: ["infisical", "full"] profiles: ["infisical", "full"]
volumes: volumes:
- redis-data:/data - redis-data:/data
@@ -175,6 +184,7 @@ services:
# Infisical self-hosted (Phase 5 secrets management) # Infisical self-hosted (Phase 5 secrets management)
infisical: infisical:
image: infisical/infisical:latest image: infisical/infisical:latest
restart: unless-stopped
profiles: ["infisical", "full"] profiles: ["infisical", "full"]
depends_on: depends_on:
postgres: postgres:

10
go.mod
View File

@@ -14,6 +14,8 @@ require (
github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/oapi-codegen/runtime v1.4.2 github.com/oapi-codegen/runtime v1.4.2
github.com/openai/openai-go v1.12.0 github.com/openai/openai-go v1.12.0
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
golang.org/x/sync v0.21.0 golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0 golang.org/x/sys v0.46.0
@@ -25,6 +27,7 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.1.11 // indirect cloud.google.com/go/iam v1.1.11 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect
@@ -40,12 +43,16 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect
github.com/aws/smithy-go v1.20.2 // indirect github.com/aws/smithy-go v1.20.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-resty/resty/v2 v2.13.1 // indirect github.com/go-resty/resty/v2 v2.13.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gofrs/flock v0.8.1 // indirect github.com/gofrs/flock v0.8.1 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
@@ -54,6 +61,9 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oasdiff/yaml v0.1.0 // indirect github.com/oasdiff/yaml v0.1.0 // indirect
github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/oasdiff/yaml3 v0.0.13 // indirect
github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect

33
go.sum
View File

@@ -7,6 +7,8 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB
cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw= cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw=
cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ= cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8= github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8=
@@ -40,12 +42,16 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
@@ -59,11 +65,15 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
@@ -73,6 +83,8 @@ github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16p
github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g=
github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -103,11 +115,19 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
@@ -144,6 +164,8 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -161,12 +183,16 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
@@ -216,13 +242,16 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -23,6 +23,7 @@ type Config struct {
MCPBearerToken string // shared secret for Nomos→API MCP calls MCPBearerToken string // shared secret for Nomos→API MCP calls
OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/)
OIDCClientID string // OIDC client ID (aud claim expected in JWT) OIDCClientID string // OIDC client ID (aud claim expected in JWT)
OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients)
// CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md // CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md
// 0.3). Needed for the Wails webview and local dev (Vite on a different // 0.3). Needed for the Wails webview and local dev (Vite on a different
@@ -108,6 +109,9 @@ func FromEnv() Config {
if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" { if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" {
c.OIDCClientID = v c.OIDCClientID = v
} }
if v := os.Getenv("OIKOS_OIDC_CLIENT_SECRET"); v != "" {
c.OIDCClientSecret = v
}
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" { if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
c.APIToken = v c.APIToken = v
} }

View File

@@ -12,6 +12,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"math/big" "math/big"
"net/http" "net/http"
@@ -127,6 +128,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
} }
}) })
// OIDC endpoints — unauthenticated. The SPA needs the issuer + client_id
// to build the authorization URL, and uses the token proxy to exchange
// authorization codes and refresh tokens without CORS issues.
r.Get("/api/v1/auth/oidc-config", func(w http.ResponseWriter, req *http.Request) {
s.serveOIDCConfig(w, req, cfg)
})
r.Post("/api/v1/auth/oidc-token", func(w http.ResponseWriter, req *http.Request) {
s.serveOIDCToken(w, req, cfg)
})
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{ strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error()) writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
@@ -530,6 +541,122 @@ func requestLogger(next http.Handler) http.Handler {
}) })
} }
// resolveOIDCEndpointURL derives an endpoint URL from the issuer by walking
// up one path segment. Authentik's issuer is per-provider
// (e.g. .../application/o/oikos/) but shared endpoints live at the parent
// path (.../application/o/<suffix>).
func resolveOIDCEndpointURL(issuer, suffix string) string {
u, err := url.Parse(issuer)
if err != nil {
return strings.TrimRight(issuer, "/") + suffix
}
u.Path = strings.TrimRight(u.Path, "/")
if idx := strings.LastIndex(u.Path, "/"); idx >= 0 {
u.Path = u.Path[:idx]
}
u.Path += suffix
return u.String()
}
// resolveOIDCTokenURL derives the token endpoint URL from the issuer.
func resolveOIDCTokenURL(issuer string) string {
return resolveOIDCEndpointURL(issuer, "/token/")
}
// serveOIDCConfig returns the OIDC issuer and client_id so the SPA can build
// authorization URLs without hardcoding them.
func (s *Server) serveOIDCConfig(w http.ResponseWriter, _ *http.Request, cfg config.Config) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"issuer": cfg.OIDCIssuer,
"client_id": cfg.OIDCClientID,
"authorization_endpoint": resolveOIDCEndpointURL(cfg.OIDCIssuer, "/authorize/"),
})
}
// tokenExchangeBody mirrors the JSON the SPA sends to the token proxy.
type tokenExchangeBody struct {
GrantType string `json:"grant_type"`
Code string `json:"code,omitempty"`
CodeVerifier string `json:"code_verifier,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// serveOIDCToken proxies authorization_code and refresh_token grants to the
// OIDC provider's token endpoint. The SPA can't POST directly to Authentik
// because of CORS; this proxy avoids the cross-origin problem entirely.
func (s *Server) serveOIDCToken(w http.ResponseWriter, req *http.Request, cfg config.Config) {
if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
writeProblem(w, req, http.StatusServiceUnavailable, "oidc not configured", "")
return
}
body, err := io.ReadAll(req.Body)
if err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid body", err.Error())
return
}
var tb tokenExchangeBody
if err := json.Unmarshal(body, &tb); err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid token request", err.Error())
return
}
// Build the form-encoded body for Authentik's token endpoint
form := url.Values{}
form.Set("client_id", cfg.OIDCClientID)
if cfg.OIDCClientSecret != "" {
form.Set("client_secret", cfg.OIDCClientSecret)
}
switch tb.GrantType {
case "authorization_code":
form.Set("grant_type", "authorization_code")
form.Set("code", tb.Code)
form.Set("code_verifier", tb.CodeVerifier)
form.Set("redirect_uri", tb.RedirectURI)
case "refresh_token":
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", tb.RefreshToken)
default:
writeProblem(w, req, http.StatusBadRequest, "unsupported grant_type", tb.GrantType)
return
}
tokenURL := resolveOIDCTokenURL(cfg.OIDCIssuer)
client := &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
}}
resp, err := client.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
if err != nil {
slog.Error("oidc token proxy failed", "error", err)
writeProblem(w, req, http.StatusBadGateway, "token endpoint unreachable", err.Error())
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
writeProblem(w, req, http.StatusInternalServerError, "read token response failed", err.Error())
return
}
if resp.StatusCode >= 400 {
slog.Warn("oidc token endpoint returned error", "status", resp.StatusCode, "body", string(respBody))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(respBody)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.Write(respBody)
}
// ListenAndServe runs the API server with graceful shutdown on ctx cancel // ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit. // (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error { func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {

View File

@@ -91,14 +91,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
limit := int(getFloat(args, "limit", 50)) limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
FROM entities e FROM entities e
WHERE ($1::text IS NULL OR e.type = $1) WHERE ($1::text IS NULL OR e.type = $1)
AND ($2::text IS NULL OR e.state = $2) AND ($2::text IS NULL OR e.state = $2)
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%') AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
ORDER BY e.slug LIMIT $4`, ORDER BY e.slug LIMIT $4`,
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
}) })
register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity", register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
@@ -149,12 +149,12 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
}) })
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking)", register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
InputSchema: objSchema(prop{"query", "string", "Search terms"}), InputSchema: objSchema(prop{"query", "string", "Search terms"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
q := nStr(args["query"]) q := nStr(args["query"])
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, e.slug, SELECT ke.title, e.slug,
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank, ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
ts_headline('english', ke.content, plainto_tsquery('english', $1), ts_headline('english', ke.content, plainto_tsquery('english', $1),
@@ -165,15 +165,15 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
JOIN entities e ON e.id = ke.entity_id JOIN entities e ON e.id = ke.entity_id
WHERE ke.search @@ plainto_tsquery('english', $1) WHERE ke.search @@ plainto_tsquery('english', $1)
ORDER BY rank DESC ORDER BY rank DESC
LIMIT 20`, q), nil LIMIT 20`, q), "knowledge_results"), nil
}) })
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity", register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}), InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
slug, _ := args["entity_slug"].(string) slug, _ := args["entity_slug"].(string)
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, ke.source, e.type AS kind, e.slug, SELECT ke.title, ke.source, e.type AS kind, e.slug,
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
FROM knowledge_entities ke FROM knowledge_entities ke
@@ -193,10 +193,22 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1 JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
WHERE r.valid_to IS NULL WHERE r.valid_to IS NULL
AND r.type = 'procedure-for' AND r.type = 'procedure-for'
ORDER BY 1`, slug), nil ORDER BY 1`, slug), "knowledge_results"), nil
}) })
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge read it back.", register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
return queryRows(ctx, pool, `
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE e.slug = $1`, slug), nil
})
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
InputSchema: objSchema( InputSchema: objSchema(
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."}, prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."}, prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
@@ -277,7 +289,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
hours := int(getFloat(args, "hours", 24)) hours := int(getFloat(args, "hours", 24))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT time_bucket('1 hour', ts) AS bucket, SELECT time_bucket('1 hour', ts) AS bucket,
entity_id::text, metric, entity_id::text, metric,
ROUND(avg(value)::numeric, 2) AS avg, ROUND(avg(value)::numeric, 2) AS avg,
@@ -286,7 +298,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
FROM metric_samples FROM metric_samples
WHERE ts > now() - make_interval(hours => $1) WHERE ts > now() - make_interval(hours => $1)
GROUP BY bucket, entity_id, metric GROUP BY bucket, entity_id, metric
ORDER BY bucket DESC LIMIT 100`, hours), nil ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
}) })
// ─── Phase 4: new tools ────────────────────────────────────────── // ─── Phase 4: new tools ──────────────────────────────────────────
@@ -428,6 +440,23 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
} }
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`, pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`,
id, targetID, action+":"+params, correlationID, agentID) id, targetID, action+":"+params, correlationID, agentID)
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
id, targetID)
if sessionID != "" {
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
FROM entities t WHERE t.slug = $2
AND NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
id, "task:"+sessionID)
}
// Execute reversible actions immediately. restart/pct_exec/systemctl // Execute reversible actions immediately. restart/pct_exec/systemctl
// (outside enable/disable) never reach here — they're routed through // (outside enable/disable) never reach here — they're routed through
@@ -620,14 +649,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
limit := int(getFloat(args, "limit", 50)) limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name, SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
entity_id::text, left(input_summary, 200) AS input_summary, entity_id::text, left(input_summary, 200) AS input_summary,
left(output_summary, 200) AS output_summary, left(output_summary, 200) AS output_summary,
duration_ms, token_count, success, correlation_id duration_ms, token_count, success, correlation_id
FROM agent_activity FROM agent_activity
WHERE agent_id = $1 WHERE agent_id = $1
ORDER BY ts DESC LIMIT $2`, agentID, limit), nil ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
}) })
// ─── Phase 5: operational MCP tools ────────────────────────────── // ─── Phase 5: operational MCP tools ──────────────────────────────
@@ -635,14 +664,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state", register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state",
InputSchema: objSchema(), InputSchema: objSchema(),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id, SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
e.attributes->>'lan_ip' AS lan_ip, e.attributes->>'lan_ip' AS lan_ip,
st.health, st.last_check_at st.health, st.last_check_at
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.type = 'lxc' WHERE e.type = 'lxc'
ORDER BY (e.attributes->>'pve_id')::int`), nil ORDER BY (e.attributes->>'pve_id')::int`), "lxc_list"), nil
}) })
register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP", register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
@@ -782,7 +811,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return textResult("error: hostname required"), nil return textResult("error: hostname required"), nil
} }
slug := "ws:" + hostname slug := "ws:" + hostname
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health, COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check, COALESCE(st.last_check_at::text, '') AS last_check,
@@ -792,7 +821,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.slug = $1 WHERE e.slug = $1
ORDER BY e.slug`, slug), nil ORDER BY e.slug`, slug), "entity_card"), nil
}) })
register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk", register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
@@ -803,7 +832,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
if slug == "" { if slug == "" {
return textResult("error: service_slug required"), nil return textResult("error: service_slug required"), nil
} }
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health, COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check, COALESCE(st.last_check_at::text, '') AS last_check,
@@ -811,7 +840,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
COALESCE(e.attributes::text, '{}') AS attrs COALESCE(e.attributes::text, '{}') AS attrs
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.slug = $1`, slug), nil WHERE e.slug = $1`, slug), "entity_card"), nil
}) })
register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service", register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
@@ -849,7 +878,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
args := argsMap(req) args := argsMap(req)
slug, _ := args["entity_slug"].(string) slug, _ := args["entity_slug"].(string)
limit := int(getFloat(args, "limit", 20)) limit := int(getFloat(args, "limit", 20))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label, SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
al.action, al.method, al.path, al.action, al.method, al.path,
al.detail::text AS details al.detail::text AS details
@@ -857,13 +886,13 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
JOIN entities e ON e.id = al.entity_id JOIN entities e ON e.id = al.entity_id
WHERE e.slug = $1 WHERE e.slug = $1
ORDER BY al.ts DESC ORDER BY al.ts DESC
LIMIT $2`, slug, limit), nil LIMIT $2`, slug, limit), "change_log"), nil
}) })
register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count", register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
InputSchema: objSchema(), InputSchema: objSchema(),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.state, SELECT e.slug, e.type, e.state,
COALESCE(st.health, 'unknown') AS health, COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check COALESCE(st.last_check_at::text, '') AS last_check
@@ -873,7 +902,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
OR st.health IS NOT NULL OR st.health IS NOT NULL
ORDER BY st.health, e.slug ORDER BY st.health, e.slug
LIMIT 200 LIMIT 200
`), nil `), "fleet_snapshot"), nil
}) })
register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key", register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
@@ -1099,6 +1128,26 @@ func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *m
return textResult(string(data)) return textResult(string(data))
} }
func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.CallToolResult {
if len(result.Content) == 0 {
return result
}
tc, ok := result.Content[0].(*mcp.TextContent)
if !ok || tc.Text == "" {
return result
}
var items []map[string]any
if err := json.Unmarshal([]byte(tc.Text), &items); err != nil {
return result
}
wrapper := map[string]any{
"__renderer": rendererID,
"data": items,
}
data, _ := json.MarshalIndent(wrapper, "", " ")
return textResult(string(data))
}
// ─── SSH helpers ───────────────────────────────────────────────────────── // ─── SSH helpers ─────────────────────────────────────────────────────────
var ( var (
@@ -1397,6 +1446,23 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
} }
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`, pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
id, targetID, actionCol, riskClass, correlationID, agentID) id, targetID, actionCol, riskClass, correlationID, agentID)
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
id, targetID)
if sessionID != "" {
pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
FROM entities t WHERE t.slug = $2
AND NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
id, "task:"+sessionID)
}
if riskClass == policy.RiskReadOnly { if riskClass == policy.RiskReadOnly {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug) host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)

View File

@@ -1,11 +1,79 @@
package mcp package mcp
import ( import (
"encoding/json"
"strings"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
) )
func TestAnnotateJSONResult(t *testing.T) {
// valid JSON array → wrapped with __renderer + data
result := textResult(`[{"slug": "host:hubris", "type": "host"}]`)
annotated := annotateJSONResult(result, "entity_card")
if len(annotated.Content) != 1 {
t.Fatalf("expected 1 content item, got %d", len(annotated.Content))
}
tc, ok := annotated.Content[0].(*mcp.TextContent)
if !ok {
t.Fatal("content is not TextContent")
}
var wrapper map[string]interface{}
if err := json.Unmarshal([]byte(tc.Text), &wrapper); err != nil {
t.Fatalf("result is not valid JSON: %v", err)
}
if wrapper["__renderer"] != "entity_card" {
t.Errorf("__renderer = %q, want entity_card", wrapper["__renderer"])
}
data, ok := wrapper["data"].([]interface{})
if !ok || len(data) != 1 {
t.Fatal("data is not the original array")
}
}
func TestAnnotateJSONResultNoop(t *testing.T) {
// empty content → no-op
result := &mcp.CallToolResult{Content: []mcp.Content{}}
annotated := annotateJSONResult(result, "entity_card")
if len(annotated.Content) != 0 {
t.Fatal("empty content should be unchanged")
}
// non-JSON text → no-op (not wrapped)
result = textResult("just plain text")
annotated = annotateJSONResult(result, "entity_card")
tc, _ := annotated.Content[0].(*mcp.TextContent)
if strings.Contains(tc.Text, "__renderer") {
t.Fatal("non-JSON content should not be annotated")
}
// textResult with empty string → no-op
result = textResult("")
annotated = annotateJSONResult(result, "entity_card")
tc, _ = annotated.Content[0].(*mcp.TextContent)
if tc.Text != "" {
t.Fatal("empty text content should be unchanged")
}
}
func TestAnnotateJSONResultPreservesMultipleRows(t *testing.T) {
result := textResult(`[{"slug": "a"}, {"slug": "b"}, {"slug": "c"}]`)
annotated := annotateJSONResult(result, "lxc_list")
tc, _ := annotated.Content[0].(*mcp.TextContent)
var wrapper map[string]interface{}
json.Unmarshal([]byte(tc.Text), &wrapper)
data := wrapper["data"].([]interface{})
if len(data) != 3 {
t.Fatalf("expected 3 rows in data, got %d", len(data))
}
}
// TestNewServerRegistersTools verifies every tool registers with a valid // TestNewServerRegistersTools verifies every tool registers with a valid
// input schema. The MCP SDK panics at AddTool if a tool omits its object // input schema. The MCP SDK panics at AddTool if a tool omits its object
// input schema, so merely constructing the server exercises that contract — // input schema, so merely constructing the server exercises that contract —

View File

@@ -1,8 +1,8 @@
# 2026-07-12 — Wails desktop application # 2026-07-12 — Wails desktop application
**Status:** In Progress — Phase 0 (0.1-0.4, 0.6) done, verified live in a **Status:** Done — Phases 0.00.6 deployed to production (mac-mini, commit
local browser test, and **deployed to production** (mac-mini, commit `0c0f35a`, 2026-07-12). Phases 1.01.4 implemented (commit `5d6d9e9`,
`0c0f35a`, 2026-07-12). Phase 1 (Wails shell) not started. 2026-07-13) — pushed to main.
**Production deploy (2026-07-12):** merged to `main`, picked up by the **Production deploy (2026-07-12):** merged to `main`, picked up by the
2-minute deploy poller (`scripts/deploy.sh`: pg_dump backup → rebuild → 2-minute deploy poller (`scripts/deploy.sh`: pg_dump backup → rebuild →

View File

@@ -0,0 +1,66 @@
# 2026-07-13 — MCP tool apps: custom in-chat renderers
**Status:** Done — implemented 2026-07-13.
## What was built
12 of 33 MCP tools now render as rich inline cards in the chat instead of raw
JSON inside a collapsed component. The remaining 21 tools stay collapsed.
### Architecture
- **Server** (`internal/mcp/server.go`): `annotateJSONResult()` function wraps
`queryRows` output with `{"__renderer": "...", "data": [...]}` for 12 tools.
- **Registry** (`web/src/lib/tool-renderers.ts`): match/dispatch system that
maps tool names + `__renderer` hints to Svelte components.
- **Renderer components** (`web/src/lib/renderers/`): 9 purpose-built cards,
each handling loading/spinner, error, and success states with proper ARIA
labels.
- **Chat dispatch** (`web/src/pages/Chat.svelte`): matched tools render inline
before the markdown text, with a 5-card limit to prevent chat spam. Overflow
goes to the collapsed `ToolCallGroup` alongside unmatched tools.
- **ToolCallGroup** (`web/src/lib/components/ToolCallGroup.svelte`): accepts
`unmatched` prop, shows "N tools · M cards shown" when some render inline,
hides entirely when all matched.
### Renderers
| Component | Tools matched | Visual |
|-----------|--------------|--------|
| `EntityCard` | `get_entity`, `whoami`, `explain` | Slug, type badge, health dot, key attrs |
| `HealthSummary` | `get_health_summary` | Stacked health bar (healthy/degraded/down) |
| `LXCList` | `list_lxcs` | Compact table: name, ID, IP, health |
| `EntityTable` | `list_entities` | Auto-column table from query results |
| `KnowledgeResults` | `search_knowledge`, `get_entity_knowledge` | Title, snippet, source, slug |
| `BlastRadius` | `get_blast_radius` | Entities grouped by hop distance |
| `ChangeLog` | `get_change_history`, `get_agent_activity` | Timeline with status dots |
| `FleetSnapshot` | `get_state_snapshot` | Health + type counts in a grid |
| `MetricChart` | `query_metrics` | Bucketed time/avg/min/max table |
### Tests
`internal/mcp/server_test.go`: 3 new tests for `annotateJSONResult` — wraps
valid JSON arrays, no-op on empty/non-JSON/empty-text content, preserves
multi-row arrays.
### Files changed
**New (18):**
- `web/src/lib/tool-renderers.ts`
- `web/src/lib/renderers/index.ts`
- `web/src/lib/renderers/EntityCard.svelte` + `entity-card.ts`
- `web/src/lib/renderers/HealthSummary.svelte` + `health-summary.ts`
- `web/src/lib/renderers/LXCList.svelte` + `lxc-list.ts`
- `web/src/lib/renderers/EntityTable.svelte` + `entity-table.ts`
- `web/src/lib/renderers/KnowledgeResults.svelte` + `knowledge-results.ts`
- `web/src/lib/renderers/BlastRadius.svelte` + `blast-radius.ts`
- `web/src/lib/renderers/ChangeLog.svelte` + `change-log.ts`
- `web/src/lib/renderers/FleetSnapshot.svelte` + `fleet-snapshot.ts`
- `web/src/lib/renderers/MetricChart.svelte` + `metric-chart.ts`
**Modified (5):**
- `internal/mcp/server.go``annotateJSONResult()` + 12 tool annotations
- `internal/mcp/server_test.go` — 3 tests for `annotateJSONResult`
- `web/src/lib/components/ToolCallGroup.svelte``unmatched`/`bodyTools`
- `web/src/pages/Chat.svelte` — inline dispatch + 5-card limit
- `web/src/main.ts` — deferred renderer import

View File

@@ -14,7 +14,6 @@ went sideways, open an investigation.
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred | | 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open | | 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred | | 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
| 2026-07-12 | [Wails desktop application](2026-07-12-wails-desktop-app.md) | In Progress — Phase 0 done and deployed to production (2026-07-12), Phase 1 not started |
## Done ## Done
@@ -44,6 +43,8 @@ See [`done/`](done/) for executed plans:
| 2026-07-11 | [Concurrent task execution: safety + throughput + frontend correctness](done/2026-07-11-concurrent-task-execution.md) | | 2026-07-11 | [Concurrent task execution: safety + throughput + frontend correctness](done/2026-07-11-concurrent-task-execution.md) |
| 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) | | 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) |
| 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) | | 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) |
| 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) |
| 2026-07-13 | [MCP tool apps: custom in-chat renderers](done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md) |
## Conventions ## Conventions

View File

@@ -11,6 +11,7 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script>window.__OIKOS_CONFIG__ = {};</script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -13,6 +13,7 @@
import { connectionState } from '$lib/stores/events' import { connectionState } from '$lib/stores/events'
import { isConfigured } from '$lib/config' import { isConfigured } from '$lib/config'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { processPendingCallback, initOIDC } from '$lib/oidc'
import * as Sidebar from '$lib/components/ui/sidebar' import * as Sidebar from '$lib/components/ui/sidebar'
import * as Sheet from '$lib/components/ui/sheet' import * as Sheet from '$lib/components/ui/sheet'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
@@ -37,7 +38,13 @@
const approvalsPending = $derived($summary?.approvals_pending ?? 0) const approvalsPending = $derived($summary?.approvals_pending ?? 0)
const openSignals = $derived(openSignalCount($summary)) const openSignals = $derived(openSignalCount($summary))
onMount(() => { onMount(async () => {
if (await processPendingCallback()) {
configured = true
} else if (!configured) {
if (await initOIDC()) configured = true
}
function sync() { function sync() {
const path = location.hash.slice(2) || 'overview' const path = location.hash.slice(2) || 'overview'
const [head, ...rest] = path.split('/') const [head, ...rest] = path.split('/')

View File

@@ -120,7 +120,37 @@
return typeCategory.get(type) === category return typeCategory.get(type) === category
} }
async function load() { // Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
// default init spreads those via a spiral centered on the ORIGIN — not
// (width/2, height/2) — while the x/y centering forces below are
// deliberately weak (0.04, so they don't fight the link/collide layout).
// Together that meant the cluster could settle noticeably off-origin
// instead of centered. Fixed by explicitly fitting the viewport to the
// node bounding box once the simulation settles, rather than relying on
// the force balance to land on center by itself.
function fitToView() {
const placed = nodes.filter((n) => n.x != null && n.y != null)
if (!placed.length) return
const xs = placed.map((n) => n.x as number)
const ys = placed.map((n) => n.y as number)
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
const pad = 70
const bw = Math.max(maxX - minX, 1)
const bh = Math.max(maxY - minY, 1)
const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
}
// fit=false for passive background reloads (live entity/relationship
// events) — those shouldn't yank the view out from under someone
// actively panning/zooming. Fresh loads (mount, root/depth change,
// reset, re-root) default to fit=true.
async function load(fit = true) {
loading = true loading = true
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true }) graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
loading = false loading = false
@@ -163,6 +193,9 @@
.on('tick', () => { .on('tick', () => {
nodes = [...nodes] nodes = [...nodes]
}) })
.on('end', () => {
if (fit) fitToView()
})
} }
onMount(() => { onMount(() => {
@@ -191,7 +224,7 @@
const ev = $liveEvents[0] const ev = $liveEvents[0]
if (!ev) return if (!ev) return
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') { if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
load() load(false)
} }
}) })

View File

@@ -7,11 +7,14 @@
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down' import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle' import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props() let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
let open = $state(false) let open = $state(false)
let wasActive = $state(active) let wasActive = $state(active)
const bodyTools = $derived(unmatched ?? tools)
const inlineCount = $derived(tools.length - bodyTools.length)
$effect(() => { $effect(() => {
if (active && !wasActive) { if (active && !wasActive) {
open = true open = true
@@ -22,18 +25,18 @@
wasActive = active wasActive = active
}) })
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length) const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error)) const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
const names = $derived(tools.map((t) => t.name).join(', ')) const names = $derived(bodyTools.map((t) => t.name).join(', '))
const runningTool = $derived( const runningTool = $derived(
active ? tools.find((t) => t.type === 'tool_use') : undefined active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
) )
const ariaLabel = $derived( const ariaLabel = $derived(
doneCount === tools.length doneCount === bodyTools.length
? `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} completed` ? `${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} completed`
: `${doneCount}/${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} done` : `${doneCount}/${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} done`
) )
function toolSummary(args: unknown): string { function toolSummary(args: unknown): string {
@@ -45,19 +48,19 @@
} }
</script> </script>
{#if tools.length} {#if bodyTools.length}
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs"> <Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50"> <Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50">
{#if active && doneCount < tools.length} {#if active && doneCount < bodyTools.length}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" /> <LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
{:else if hasError} {:else if hasError}
<XIcon class="size-3 shrink-0 text-destructive" /> <XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
{:else} {:else}
<CheckIcon class="size-3 shrink-0 text-success" /> <CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
{/if} {/if}
{#if active && doneCount < tools.length} {#if active && doneCount < bodyTools.length}
<span class="font-medium">{doneCount}/{tools.length}</span> <span class="font-medium">{doneCount}/{bodyTools.length}</span>
{#if runningTool} {#if runningTool}
<span class="max-w-48 truncate font-mono text-muted-foreground"> <span class="max-w-48 truncate font-mono text-muted-foreground">
{runningTool.name} {runningTool.name}
@@ -67,7 +70,10 @@
<span class="animate-pulse text-muted-foreground">working…</span> <span class="animate-pulse text-muted-foreground">working…</span>
{/if} {/if}
{:else} {:else}
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span> <span class="font-medium">{bodyTools.length} tool{bodyTools.length === 1 ? '' : 's'}</span>
{#if inlineCount > 0}
<span class="text-muted-foreground">· {inlineCount} card{inlineCount === 1 ? '' : 's'} shown</span>
{/if}
<span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span> <span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span>
{/if} {/if}
@@ -78,16 +84,16 @@
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2"> <Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2">
<div class="flex flex-col divide-y border-t"> <div class="flex flex-col divide-y border-t" role="list" aria-label={ariaLabel}>
{#each tools as tool (tool.id)} {#each bodyTools as tool (tool.id)}
<div class="p-2"> <div class="p-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if tool.type === 'tool_result' && tool.error} {#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" /> <XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
{:else if tool.type === 'tool_result'} {:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" /> <CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
{:else} {:else}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" /> <LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
{/if} {/if}
<span class="font-mono font-medium">{tool.name}</span> <span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span> <span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>

View File

@@ -4,9 +4,12 @@
// or cross-origin (Wails webview, remote access). See // or cross-origin (Wails webview, remote access). See
// plans/2026-07-12-wails-desktop-app.md 0.2. // plans/2026-07-12-wails-desktop-app.md 0.2.
import { getToken, isOIDCConfigured } from './oidc'
export interface OikosConfig { export interface OikosConfig {
apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin
token?: string // bearer token for auth token?: string // bearer token for auth
isDesktop?: boolean // true when running inside the Wails desktop app
} }
declare global { declare global {
@@ -51,7 +54,7 @@ export function clearConfig() {
} }
export function isConfigured(): boolean { export function isConfigured(): boolean {
return !!getConfig().token return !!getConfig().token || isOIDCConfigured()
} }
// Relative paths are used in dev (Vite proxy) and when the SPA shares an // Relative paths are used in dev (Vite proxy) and when the SPA shares an
@@ -63,6 +66,15 @@ export function apiBase(path: string): string {
return `${c.apiUrl}${path}` return `${c.apiUrl}${path}`
} }
// Resolves the auth token for a request: OIDC takes precedence, then static.
function resolveAuthHeader(): string | null {
const oidcToken = getToken()
if (oidcToken) return `Bearer ${oidcToken}`
const c = getConfig()
if (c.token) return `Bearer ${c.token}`
return null
}
// ---- Auth fetch wrapper ---- // ---- Auth fetch wrapper ----
// Prepends the API base URL (absolute when configured, relative when unset // Prepends the API base URL (absolute when configured, relative when unset
// for the Vite dev proxy / same-origin prod) and adds the Authorization // for the Vite dev proxy / same-origin prod) and adds the Authorization
@@ -72,9 +84,9 @@ export async function fetchWithAuth(path: string, opts?: RequestInit): Promise<R
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(opts?.headers as Record<string, string> ?? {}) ...(opts?.headers as Record<string, string> ?? {})
} }
const c = getConfig() const authH = resolveAuthHeader()
if (c.token) { if (authH) {
headers['Authorization'] = `Bearer ${c.token}` headers['Authorization'] = authH
} }
return fetch(apiBase(path), { ...opts, headers }) return fetch(apiBase(path), { ...opts, headers })
@@ -84,9 +96,11 @@ export async function fetchWithAuth(path: string, opts?: RequestInit): Promise<R
// a query parameter (the SSE handler's combinedAuth checks it alongside the // a query parameter (the SSE handler's combinedAuth checks it alongside the
// Authorization header, only for this route). // Authorization header, only for this route).
export function sseUrl(path: string): string { export function sseUrl(path: string): string {
const c = getConfig()
const url = apiBase(path) const url = apiBase(path)
if (!c.token) return url const c = getConfig()
const oidcToken = getToken()
const token = oidcToken ?? c.token
if (!token) return url
const sep = url.includes('?') ? '&' : '?' const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(c.token)}` return `${url}${sep}token=${encodeURIComponent(token)}`
} }

272
web/src/lib/oidc.ts Normal file
View File

@@ -0,0 +1,272 @@
import { apiBase } from './config'
interface OIDCConfig {
issuer: string
client_id: string
authorization_endpoint: string
}
interface TokenResponse {
access_token: string
token_type: string
expires_in?: number
refresh_token?: string
id_token?: string
}
interface OIDCState {
config: OIDCConfig | null
accessToken: string | null
refreshToken: string | null
user: string | null
refreshing: Promise<string | null> | null
}
const SESSION_KEY = 'oidc_access_token'
const REFRESH_KEY = 'oidc_refresh_token'
const USER_KEY = 'oidc_user'
const PKCE_KEY = 'oidc_pkce_verifier'
const STATE_KEY = 'oidc_state'
let state: OIDCState = {
config: null,
accessToken: sessionStorage.getItem(SESSION_KEY),
refreshToken: localStorage.getItem(REFRESH_KEY),
user: localStorage.getItem(USER_KEY),
refreshing: null
}
async function fetchConfig(): Promise<OIDCConfig | null> {
try {
const resp = await fetch(apiBase('/api/v1/auth/oidc-config'))
if (!resp.ok) return null
const cfg: OIDCConfig = await resp.json()
state.config = cfg
return cfg
} catch {
return null
}
}
function base64URLEncode(buf: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function generateRandom(len: number): string {
const arr = new Uint8Array(len)
crypto.getRandomValues(arr)
return base64URLEncode(arr)
}
async function sha256(plain: string): Promise<ArrayBuffer> {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain))
}
export async function startLogin(): Promise<void> {
const cfg = state.config ?? await fetchConfig()
if (!cfg) throw new Error('OIDC not configured on server')
const codeVerifier = generateRandom(64)
const challengeBuf = await sha256(codeVerifier)
const codeChallenge = base64URLEncode(challengeBuf)
const oidcState = generateRandom(32)
sessionStorage.setItem(PKCE_KEY, codeVerifier)
sessionStorage.setItem(STATE_KEY, oidcState)
const redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
const params = new URLSearchParams({
response_type: 'code',
client_id: cfg.client_id,
redirect_uri: redirectURI,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: oidcState,
scope: 'openid profile email'
})
location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}`
}
export async function handleCallback(code: string, returnedState: string): Promise<boolean> {
const verifier = sessionStorage.getItem(PKCE_KEY)
const savedState = sessionStorage.getItem(STATE_KEY)
sessionStorage.removeItem(PKCE_KEY)
sessionStorage.removeItem(STATE_KEY)
if (!verifier || savedState !== returnedState) return false
const cfg = state.config ?? await fetchConfig()
if (!cfg) return false
const redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
try {
const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
code_verifier: verifier,
redirect_uri: redirectURI
})
})
if (!resp.ok) return false
const tokens: TokenResponse = await resp.json()
if (!tokens.access_token) return false
storeTokens(tokens)
if (tokens.id_token) {
const user = parseIDTokenUser(tokens.id_token)
if (user) {
state.user = user
localStorage.setItem(USER_KEY, user)
}
}
return true
} catch {
return false
}
}
function parseIDTokenUser(idToken: string): string | null {
try {
const payload = idToken.split('.')[1]
const claims = JSON.parse(atob(payload))
return claims.preferred_username || claims.email || claims.sub || null
} catch {
return null
}
}
function storeTokens(tokens: TokenResponse) {
state.accessToken = tokens.access_token
sessionStorage.setItem(SESSION_KEY, tokens.access_token)
if (tokens.refresh_token) {
state.refreshToken = tokens.refresh_token
localStorage.setItem(REFRESH_KEY, tokens.refresh_token)
}
}
export function getToken(): string | null {
return state.accessToken
}
export function getUser(): string | null {
return state.user
}
export function isOIDCAvailable(): boolean {
return !!(state.accessToken || state.refreshToken)
}
export async function ensureToken(): Promise<string | null> {
if (state.accessToken) return state.accessToken
if (state.refreshToken) {
return refreshAccessToken()
}
return null
}
async function refreshAccessToken(): Promise<string | null> {
if (state.refreshing) return state.refreshing
const cfg = state.config ?? await fetchConfig()
if (!cfg || !state.refreshToken) return null
state.refreshing = (async () => {
try {
const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: state.refreshToken
})
})
if (!resp.ok) {
clearTokens()
return null
}
const tokens: TokenResponse = await resp.json()
if (!tokens.access_token) {
clearTokens()
return null
}
storeTokens(tokens)
return tokens.access_token
} catch {
clearTokens()
return null
} finally {
state.refreshing = null
}
})()
return state.refreshing
}
function clearTokens() {
state.accessToken = null
state.refreshToken = null
state.user = null
sessionStorage.removeItem(SESSION_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(USER_KEY)
}
export function logout(): void {
clearTokens()
}
export function isOIDCConfigured(): boolean {
return !!(state.accessToken || state.refreshToken)
}
export async function initOIDC(): Promise<boolean> {
if (state.accessToken) return true
if (state.refreshToken) {
const token = await refreshAccessToken()
return token !== null
}
return false
}
export function hasPendingCallback(): boolean {
const params = new URLSearchParams(location.search)
return params.has('code') && params.has('state')
}
export async function processPendingCallback(): Promise<boolean> {
const params = new URLSearchParams(location.search)
const code = params.get('code')
const oidcState = params.get('state')
if (!code || !oidcState) return false
const ok = await handleCallback(code, oidcState)
const url = new URL(location.href)
url.searchParams.delete('code')
url.searchParams.delete('state')
history.replaceState(null, '', url.toString())
return ok
}

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const grouped = $derived.by(() => {
if (!rows) return null
const g: Record<number, string[]> = {}
for (const r of rows) {
const d = Number(r.depth) || 0
if (!g[d]) g[d] = []
g[d].push(r.slug)
}
return g
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const total = $derived(rows?.length ?? 0)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Calculating blast radius">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="animate-pulse text-muted-foreground">calculating…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Blast radius error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="text-destructive">{error}</span>
</div>
{:else if grouped && total > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Blast radius: {total} affected entities">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{total} affected entit{total === 1 ? 'y' : 'ies'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each Object.entries(grouped).sort(([a], [b]) => Number(a) - Number(b)) as [depth, slugs]}
<div class="px-3 py-2">
<div class="mb-1 font-medium text-muted-foreground">
{Number(depth) === 1 ? 'Directly affected' : `${depth} hops`} ({slugs.length})
</div>
<div class="flex flex-wrap gap-1">
{#each slugs as slug}
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
{/each}
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Blast radius: no affected entities">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="text-muted-foreground">no affected entities found</span>
</div>
{/if}

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
function shortTs(ts: string): string {
try {
const d = new Date(ts)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return ts?.slice(11, 19) ?? ''
}
}
function shortDate(ts: string): string {
return ts?.slice(0, 10) ?? ''
}
function shortId(id: string): string {
if (!id) return ''
return id.length > 12 ? id.slice(0, 12) : id
}
const activityRows = $derived.by(() => {
if (!rows) return null
return rows.map((r) => ({
time: shortTs(r.timestamp || r.ts || ''),
date: shortDate(r.timestamp || r.ts || ''),
actor: r.actor_label || shortId(r.agent_id) || r.actor_type || '',
action: r.action || r.activity_type || '',
toolName: r.tool_name || r.path || '',
status: r.success ?? (r.error ? 'false' : undefined),
}))
})
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading activity log">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Activity log error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="text-destructive">{error}</span>
</div>
{:else if activityRows && activityRows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Activity log: {activityRows.length} entries">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{activityRows.length} entr{activityRows.length === 1 ? 'y' : 'ies'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each activityRows as row}
<div class="flex items-center gap-2 px-3 py-1.5 font-mono">
<span class="shrink-0 text-muted-foreground">{row.time}</span>
{#if row.date !== activityRows[0].date}
<span class="shrink-0 text-[10px] text-muted-foreground/60">{row.date}</span>
{/if}
<span class="text-muted-foreground">{row.action}</span>
<span class="max-w-32 truncate">{row.toolName}</span>
<span class="text-muted-foreground/60">{row.actor}</span>
{#if row.status === 'true'}
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-success" title="success"></span>
{:else if row.status === 'false'}
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-destructive" title="error"></span>
{/if}
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Activity log: no entries">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="text-muted-foreground">no entries</span>
</div>
{/if}

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import { Badge } from '$lib/components/ui/badge'
import { relativeTime } from '$lib/utils'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import BoxIcon from '@lucide/svelte/icons/box'
import MonitorIcon from '@lucide/svelte/icons/monitor'
import ContainerIcon from '@lucide/svelte/icons/container'
import GlobeIcon from '@lucide/svelte/icons/globe'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import ZapIcon from '@lucide/svelte/icons/zap'
let { tool }: { tool: ToolCallResult } = $props()
const entity = $derived.by(() => {
if (tool.type !== 'tool_result') return null
const r = tool.result
if (!r) return null
if (Array.isArray(r)) return r[0]
if (r && typeof r === 'object' && 'data' in r) return Array.isArray(r.data) ? r.data[0] : r.data
return r
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const slug = $derived(entity?.slug ?? tool.args?.slug_or_id ?? tool.args?.hostname ?? tool.args?.service_slug ?? '')
const typeIcon: Record<string, typeof BoxIcon> = {
host: MonitorIcon,
lxc: ContainerIcon,
service: GlobeIcon,
check: ZapIcon,
}
const Icon = $derived(entity?.type ? (typeIcon[entity.type] ?? BoxIcon) : BoxIcon)
const keyAttrs = $derived.by(() => {
if (!entity) return [] as [string, string][]
const out: [string, string][] = []
const skip = new Set(['slug', 'type', 'name', 'state', 'health', 'last_check', 'version', 'created_at', 'updated_at', 'maintenance_until', '__renderer', 'data', 'attrs', 'attributes', 'enrolled_at'])
for (const k of ['mesh_ip', 'ip', 'version', 'age_pubkey', 'enrolled_at', 'last_check']) {
const v = entity[k]
if (v && typeof v === 'string') {
out.push([k, k === 'age_pubkey' ? v.slice(0, 16) + '…' : v])
}
}
const attrs = entity.attributes ?? entity.attrs
if (attrs && typeof attrs === 'object') {
for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {
if (!skip.has(k) && v != null && v !== '') {
out.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)])
}
}
}
return out.slice(0, 4)
})
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entity: {slug || tool.name}">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entity: {error}">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="text-destructive">{error}</span>
</div>
{:else if entity}
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Entity: {entity.slug}{entity.type}{entity.health || 'no health data'}">
<div class="flex flex-wrap items-center gap-1.5">
<div class="flex items-center gap-1.5">
<Icon class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="font-mono font-semibold">{entity.slug ?? slug}</span>
</div>
{#if entity.type}
<Badge variant="outline" class="text-[10px]">{entity.type}</Badge>
{/if}
{#if entity.state}
<Badge variant="secondary" class="text-[10px]">{entity.state}</Badge>
{/if}
{#if entity.health && entity.health !== 'unknown'}
<span class="flex items-center gap-1 text-muted-foreground">
<span class="size-2 rounded-full" style="background: {healthColor[entity.health] ?? 'var(--muted-foreground)'}"></span>
{entity.health}
</span>
{/if}
{#if entity.last_check}
<span class="text-muted-foreground">· {relativeTime(entity.last_check)}</span>
{/if}
</div>
{#if keyAttrs.length > 0}
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground">
{#each keyAttrs as [k, v]}
<span class="font-mono text-[10px]"><span class="opacity-60">{k}:</span> {v}</span>
{/each}
</div>
{/if}
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Entity: {slug || tool.name} — no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const cols = $derived(rows && rows.length > 0 ? Object.keys(rows[0]).filter(k => k !== '__renderer') : [])
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entities">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Entities</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entities">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Entities</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Entities: {rows.length} results">
<div class="max-h-56 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
{#each cols as col}
<th class="px-2 py-1 text-left font-medium whitespace-nowrap">{col}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
{#each cols as col}
<td class="px-2 py-1 whitespace-nowrap font-mono max-w-48 truncate">{row[col] ?? '—'}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs text-muted-foreground" role="status" aria-label="Entities: no results">
<XIcon class="size-3 shrink-0" aria-hidden="true" />
<span>No entities found</span>
</div>
{/if}

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const summary = $derived.by(() => {
if (!rows) return null
const health: Record<string, number> = {}
const types: Record<string, number> = {}
for (const r of rows) {
health[r.health || 'unknown'] = (health[r.health || 'unknown'] || 0) + 1
types[r.type || 'unknown'] = (types[r.type || 'unknown'] || 0) + 1
}
return { health, types, total: rows.length }
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
unknown: 'var(--muted-foreground)',
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading fleet snapshot">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Fleet snapshot error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="text-destructive">{error}</span>
</div>
{:else if summary && summary.total > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Fleet snapshot: {summary.total} entities">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{summary.total} entities</span>
</div>
<div class="px-3 py-2 space-y-2">
<!-- Health -->
<div>
<div class="text-muted-foreground mb-1">Health</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each ['healthy', 'degraded', 'down', 'unknown'] as h}
{#if summary.health[h]}
<div class="flex items-center gap-1.5 font-mono">
<span class="size-1.5 rounded-full" style="background: {healthColor[h] ?? 'var(--muted-foreground)'}"></span>
<span class="text-muted-foreground">{h}</span>
<span class="tabular-nums">{summary.health[h]}</span>
</div>
{/if}
{/each}
</div>
</div>
<!-- Types -->
{#if Object.keys(summary.types).length > 0}
<div>
<div class="text-muted-foreground mb-1">By type</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each Object.entries(summary.types).sort(([,a], [,b]) => b - a) as [type, count]}
<div class="flex items-center gap-1.5 font-mono">
<span class="text-muted-foreground">{type}</span>
<span class="tabular-nums">{count}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Fleet snapshot: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data : null
})
const counts = $derived.by(() => {
if (!rows) return null
const m: Record<string, number> = {}
for (const r of rows as any[]) m[r.health || 'unknown'] = (m[r.health || 'unknown'] || 0) + 1
return m
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const total = $derived(counts ? Object.values(counts).reduce((a, b) => a + b, 0) : 0)
const bars: { label: string; count: number; color: string }[] = [
{ label: 'healthy', count: counts?.healthy ?? 0, color: 'var(--success)' },
{ label: 'degraded', count: counts?.degraded ?? 0, color: 'var(--warning)' },
{ label: 'down', count: counts?.down ?? 0, color: 'var(--destructive)' },
{ label: 'unknown', count: counts?.unknown ?? 0, color: 'var(--muted-foreground)' },
]
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading health summary">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading health summary">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="text-destructive">{error}</span>
</div>
{:else if counts && total > 0}
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Health summary: {total} entities — healthy {counts?.healthy ?? 0}, degraded {counts?.degraded ?? 0}, down {counts?.down ?? 0}">
<div class="flex items-center gap-2 mb-1.5">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{total} entities</span>
</div>
<div class="flex h-5 w-full overflow-hidden rounded">
{#each bars as bar}
{#if bar.count > 0}
<div
style="width: {(bar.count / total) * 100}%; background: {bar.color}"
class="flex items-center justify-center text-[9px] font-medium text-white min-w-[2rem]"
title="{bar.label}: {bar.count}"
>
{bar.count}
</div>
{/if}
{/each}
</div>
<div class="mt-1.5 flex flex-wrap gap-x-3 text-muted-foreground">
{#each bars as bar}
{#if bar.count > 0}
<span class="flex items-center gap-1">
<span class="size-1.5 rounded-full" style="background: {bar.color}"></span>
{bar.label} {bar.count}
</span>
{/if}
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Health summary: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import FileTextIcon from '@lucide/svelte/icons/file-text'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Searching knowledge">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="animate-pulse text-muted-foreground">searching…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Knowledge search error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Knowledge: {rows.length} results">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} result{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each rows as row}
<div class="px-3 py-2">
<div class="flex items-start gap-2">
<FileTextIcon class="size-3 shrink-0 mt-0.5 text-muted-foreground" />
<div class="min-w-0">
<div class="font-mono font-medium truncate">{row.title}</div>
{#if row.snippet || row.headline}
<div class="mt-0.5 text-muted-foreground leading-relaxed line-clamp-2">
{row.snippet || row.headline}
</div>
{/if}
<div class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
{#if row.source}
<span>{row.source}</span>
{/if}
{#if row.slug}
<span class="font-mono opacity-60">{row.slug}</span>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Knowledge: no results">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="text-muted-foreground">no results</span>
</div>
{/if}

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
}
function shortName(slug: string): string {
return slug.split(':').pop() ?? slug
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading LXC containers">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading LXC containers">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="LXC containers: {rows.length} total">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} container{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-48 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
<th class="px-3 py-1 text-left font-medium">Name</th>
<th class="px-3 py-1 text-left font-medium">ID</th>
<th class="px-3 py-1 text-left font-medium">IP</th>
<th class="px-3 py-1 text-left font-medium">Health</th>
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
<td class="px-3 py-1 font-mono">{shortName(row.slug)}</td>
<td class="px-3 py-1 tabular-nums text-muted-foreground">{row.pve_id ?? '—'}</td>
<td class="px-3 py-1 font-mono text-muted-foreground">{row.lan_ip ?? '—'}</td>
<td class="px-3 py-1">
{#if row.health}
<span class="flex items-center gap-1">
<span class="size-1.5 rounded-full" style="background: {healthColor[row.health] ?? 'var(--muted-foreground)'}"></span>
{row.health}
</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="LXC containers: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading metrics">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Metrics error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Metrics: {rows.length} samples">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} sample{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-48 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
<th class="px-2 py-1 text-left font-medium">Time</th>
<th class="px-2 py-1 text-left font-medium">Metric</th>
<th class="px-2 py-1 text-right font-medium">Avg</th>
<th class="px-2 py-1 text-right font-medium">Min</th>
<th class="px-2 py-1 text-right font-medium">Max</th>
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
<td class="px-2 py-1 font-mono tabular-nums whitespace-nowrap">{row.bucket?.slice(11, 16) ?? row.bucket?.slice(0, 19) ?? '—'}</td>
<td class="px-2 py-1 font-mono max-w-32 truncate">{row.metric ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right">{row.avg ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.min ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.max ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Metrics: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import BlastRadius from './BlastRadius.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_blast_radius' || t.result?.__renderer === 'blast_radius',
component: BlastRadius,
})
}

View File

@@ -0,0 +1,12 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import ChangeLog from './ChangeLog.svelte'
export function init() {
registerToolRenderer({
match: (t) =>
t.name === 'get_change_history' ||
t.name === 'get_agent_activity' ||
t.result?.__renderer === 'change_log',
component: ChangeLog,
})
}

View File

@@ -0,0 +1,11 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import EntityCard from './EntityCard.svelte'
const TOOLS = ['get_entity', 'whoami', 'explain']
export function init() {
registerToolRenderer({
match: (t) => TOOLS.includes(t.name) || t.result?.__renderer === 'entity_card',
component: EntityCard,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import EntityTable from './EntityTable.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'list_entities' || t.result?.__renderer === 'entity_table',
component: EntityTable,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import FleetSnapshot from './FleetSnapshot.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_state_snapshot' || t.result?.__renderer === 'fleet_snapshot',
component: FleetSnapshot,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import HealthSummary from './HealthSummary.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_health_summary' || t.result?.__renderer === 'health_summary',
component: HealthSummary,
})
}

View File

@@ -0,0 +1,19 @@
import { init as initEntityCard } from './entity-card'
import { init as initHealthSummary } from './health-summary'
import { init as initLXCList } from './lxc-list'
import { init as initEntityTable } from './entity-table'
import { init as initKnowledgeResults } from './knowledge-results'
import { init as initBlastRadius } from './blast-radius'
import { init as initChangeLog } from './change-log'
import { init as initFleetSnapshot } from './fleet-snapshot'
import { init as initMetricChart } from './metric-chart'
initEntityCard()
initHealthSummary()
initLXCList()
initEntityTable()
initKnowledgeResults()
initBlastRadius()
initChangeLog()
initFleetSnapshot()
initMetricChart()

View File

@@ -0,0 +1,12 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import KnowledgeResults from './KnowledgeResults.svelte'
export function init() {
registerToolRenderer({
match: (t) =>
t.name === 'search_knowledge' ||
t.name === 'get_entity_knowledge' ||
t.result?.__renderer === 'knowledge_results',
component: KnowledgeResults,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import LXCList from './LXCList.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'list_lxcs' || t.result?.__renderer === 'lxc_list',
component: LXCList,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import MetricChart from './MetricChart.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'query_metrics' || t.result?.__renderer === 'metric_chart',
component: MetricChart,
})
}

View File

@@ -0,0 +1,17 @@
import type { Component } from 'svelte'
import type { ToolCallResult } from '$lib/stores/chat'
export interface ToolRenderer {
match: (tool: ToolCallResult) => boolean
component: Component<{ tool: ToolCallResult }>
}
const registry: ToolRenderer[] = []
export function registerToolRenderer(r: ToolRenderer) {
registry.push(r)
}
export function getToolRenderer(tool: ToolCallResult): ToolRenderer | undefined {
return registry.find((r) => r.match(tool))
}

View File

@@ -5,5 +5,7 @@ import { initConfig } from '$lib/config'
initConfig() initConfig()
requestAnimationFrame(() => import('./lib/renderers'))
const app = mount(App, { target: document.getElementById('app')! }) const app = mount(App, { target: document.getElementById('app')! })
export default app export default app

View File

@@ -4,6 +4,7 @@
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte' import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte' import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
import InlineApproval from '$lib/components/InlineApproval.svelte' import InlineApproval from '$lib/components/InlineApproval.svelte'
import { getToolRenderer } from '$lib/tool-renderers'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea' import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up' import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
@@ -16,6 +17,19 @@
let input = $state('') let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null) let messagesEnd = $state<HTMLDivElement | null>(null)
const MAX_INLINE_CARDS = 5
function getInlineTools(msg: { tools: any[] }): any[] {
const matched = msg.tools.filter((t) => getToolRenderer(t))
if (matched.length <= MAX_INLINE_CARDS) return matched
return matched.slice(0, MAX_INLINE_CARDS)
}
function getRemaining(msg: { tools: any[] }, inline: any[]): any[] {
const inlineIds = new Set(inline.map((t) => t.id))
return msg.tools.filter((t) => !inlineIds.has(t.id))
}
// Resizable right rail (session graph). Persisted so it survives reloads. // Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260 const RAIL_MIN = 260
const RAIL_MAX = 620 const RAIL_MAX = 620
@@ -113,7 +127,17 @@
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div> <div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
{:else} {:else}
<div class="flex w-full flex-col gap-2"> <div class="flex w-full flex-col gap-2">
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} /> {#each getInlineTools(msg) as tool (tool.id)}
{@const renderer = getToolRenderer(tool)}
{#if renderer}
<renderer.component {tool} />
{/if}
{/each}
<ToolCallGroup
tools={msg.tools}
unmatched={getRemaining(msg, getInlineTools(msg))}
active={$streaming && i === $messages.length - 1}
/>
{#if msg.text} {#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed"> <div class="prose-chat max-w-none text-sm leading-relaxed">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify --> <!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->

View File

@@ -5,6 +5,7 @@
import { Label } from '$lib/components/ui/label' import { Label } from '$lib/components/ui/label'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config' import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props() let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
@@ -13,12 +14,18 @@
let token = $state(existing.token ?? '') let token = $state(existing.token ?? '')
let connecting = $state(false) let connecting = $state(false)
let error = $state('') let error = $state('')
let oidcLoggingIn = $state(false)
let oidcUser = $state(getUser())
let oidcConfigured = $state(isOIDCConfigured())
function disconnect() { function disconnect() {
clearConfig() clearConfig()
oidcLogout()
apiUrl = '' apiUrl = ''
token = '' token = ''
error = '' error = ''
oidcUser = null
oidcConfigured = false
} }
async function connect() { async function connect() {
@@ -36,6 +43,7 @@
error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}` error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}`
return return
} }
saveToDesktop()
onConnected() onConnected()
} catch (e) { } catch (e) {
error = 'Could not reach server — check the URL' error = 'Could not reach server — check the URL'
@@ -43,6 +51,33 @@
connecting = false connecting = false
} }
} }
function saveToDesktop() {
const wails = (window as any).wails
if (!wails?.Call?.ByName) return
try {
wails.Call.ByName('SaveConfig', apiUrl.trim(), token.trim())
} catch {
// ignore — optional desktop-only path
}
}
async function loginWithAuthentik() {
error = ''
oidcLoggingIn = true
try {
await startLogin()
} catch (e: any) {
error = e.message || 'OIDC login failed'
oidcLoggingIn = false
}
}
function logoutOIDC() {
oidcLogout()
oidcUser = null
oidcConfigured = false
}
</script> </script>
<div class="flex h-svh items-center justify-center p-6"> <div class="flex h-svh items-center justify-center p-6">
@@ -52,10 +87,10 @@
<Card.Description>Enter the server URL and your access token.</Card.Description> <Card.Description>Enter the server URL and your access token.</Card.Description>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<Tabs.Root value="token"> <Tabs.Root value={oidcConfigured ? 'oidc' : 'token'}>
<Tabs.List class="mb-4 grid w-full grid-cols-2"> <Tabs.List class="mb-4 grid w-full grid-cols-2">
<Tabs.Trigger value="token">Token</Tabs.Trigger> <Tabs.Trigger value="token">Token</Tabs.Trigger>
<Tabs.Trigger value="oidc" disabled>Login with Authentik (coming soon)</Tabs.Trigger> <Tabs.Trigger value="oidc">Login with Authentik</Tabs.Trigger>
</Tabs.List> </Tabs.List>
<Tabs.Content value="token"> <Tabs.Content value="token">
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}> <form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}>
@@ -90,6 +125,36 @@
{/if} {/if}
</form> </form>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="oidc">
<div class="flex flex-col gap-4">
{#if oidcConfigured && oidcUser}
<p class="text-sm text-muted-foreground">
Logged in as <span class="font-medium text-foreground">{oidcUser}</span>
</p>
<Button type="button" variant="default" onclick={() => onConnected()}>
Continue to Dashboard
</Button>
<Button type="button" variant="ghost" size="sm" onclick={logoutOIDC}>
Log out
</Button>
{:else}
<p class="text-sm text-muted-foreground">
Sign in with your Authentik account to access the control room.
</p>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<Button type="button" disabled={oidcLoggingIn} onclick={loginWithAuthentik}>
{oidcLoggingIn ? 'Redirecting to Authentik…' : 'Login with Authentik'}
</Button>
{/if}
{#if existing.token}
<Button type="button" variant="ghost" size="sm" onclick={disconnect}>
Forget saved connection
</Button>
{/if}
</div>
</Tabs.Content>
</Tabs.Root> </Tabs.Root>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>