36 KiB
2026-07-12 — Wails desktop application
Status: Done — Phases 0.0–0.6 deployed to production (mac-mini, commit
0c0f35a, 2026-07-12). Phases 1.0–1.4 implemented (commit 5d6d9e9,
2026-07-13) — pushed to main.
Production deploy (2026-07-12): merged to main, picked up by the
2-minute deploy poller (scripts/deploy.sh: pg_dump backup → rebuild →
rolling restart → health check), healthy after 1s. Verified post-deploy:
unauthenticated /api/v1/* now 401s (the dev-open bypass was live in
production before this — OIKOS_ENV=dev with no token set — so this closed
a real, currently-exploitable hole, not just future prep); /healthz stayed
open; nomos reconnected its MCP session with the new
OIKOS_MCP_BEARER_TOKEN and a real tool call round-tripped end to end
(get_health_summary via /query). A real random token was generated and
added to mac-mini's .env (not committed — gitignored) before deploy, so
the ${OIKOS_MCP_BEARER_TOKEN:-dev-token} fallback in docker-compose.yml
never activated with the weak literal default.
Deliberately not done as part of this deploy (out of scope — a different
host/repo than "mac-mini", not touched): the Caddy LXC (121) and
dtoro/caddy-conf. Checked the real production Caddyfile directly — there is
no oikos.hubris.network site block at all yet, so the Authentik-bypass
risk (gap 1 below) doesn't apply yet; there's no public UI exposed to break.
mcp.hubris.network exists but still reverse-proxies to the old
pre-consolidation service on LXC 105 (192.168.8.205:9810), unrelated to
this stack — stale, but pre-existing and out of scope here. Exposing
oikos.hubris.network publicly (with the @api bypass this plan's
Caddyfile.oikos reference copy already has) is unstarted follow-up work, not
a regression from this deploy.
Plan review — gaps found before starting Phase 0
Reviewed against the current codebase and the live Caddy topology
(compose/caddy/Caddyfile.oikos) before writing any code. Six gaps, each
with the resolution taken:
- Authentik forward-auth vs. bearer-token clients. The deployed
oikos.hubris.networksite gates every route (including/agent/*and, after this plan,/api/v1/*) withimport authentik— a browser-session forward-auth check, not a header a non-browser client can supply. Closing the dev-open gate (0.4) makes every API route require a bearer token, but says nothing about how a bearer-token client (Wails, curl, a future mobile client) gets past Authentik's login redirect in front of it. Same shape as the existing@enrollbypass for/api/v1/clients/enroll. Resolution: updated the reference copy (Caddyfile.oikos) with an@api path /api/v1/* /mcp /agent/*bypass aroundimport authentik, same pattern as@enroll, and moved static-SPA serving into thehandle {}fallback (0.6). This repo's copy is not what's deployed — the real file lives indtoro/caddy-confand auto-deploys from there — so the equivalent change still needs to land there before a Wails client (or anything else that can't complete Authentik's browser login) can actually reach the API in production. Flagged explicitly as risk #6 below so it isn't discovered the hard way. - Nomos's own gateway (C1) is a parallel, unauthenticated path to the same
backend. 2026-07-11-nomos-agent-code-review.md's
C1 finding — nomos's port 8092 has zero auth of its own — is still open.
Phase 0.3's CORS/auth work only touches
internal/httpapi(theapiprocess);cmd/nomosis untouched. The architecture diagram in this plan shows Caddy'shandle_path /agent/*proxying straight to:8092, bypassingapi'scombinedAuthentirely and relying solely on Authentik. Closing the API's dev-open gate does nothing for this path — nomos's direct mesh-published port (docker-compose.yml:133) andnomos.hubris.networkremain reachable with no credential check at all. Resolution: not fixed by this plan — flagged as a pre-existing, independent gap (already tracked as C1) that the Wails desktop app inherits rather than introduces. Added as risk #6 below so it isn't mistaken for something Phase 0 closes. github.com/go-chi/corsisn't a dependency yet, and the plan's sample CORS config (AllowCredentials: truewith a default"*"origin) is spec-invalid — browsers and webviews reject a wildcardAccess-Control-Allow-Originwhen credentials are requested. This API authenticates viaAuthorization: Bearer, not cookies, so credentialed CORS mode isn't needed at all. Resolution: dropAllowCredentialsfrom the middleware config in 0.3 rather than ship a setting that silently breaks the first time an origin other than*is configured.- Closing dev-open (0.4) breaks local
docker compose --profile dev upout of the box — none of the compose services currently set a token, and today they rely entirely onOIKOS_ENV=dev+ devOpen. Worse:cmd/nomositself is an unauthenticated client ofapi's/mcpendpoint and/api/v1/approvals/{id}/decision(chat-assent approvals) —grep -rn "Authorization" cmd/nomos/*.goreturned nothing before this fix. Closing dev-open without touching nomos would have broken nomos's own connection to the API, not just local dev ergonomics; this wasn't called out anywhere in the original plan text. Resolution: added atokenfield threaded throughmcpClient/mcpClientPoolandagent.apiToken, both readingOIKOS_MCP_BEARER_TOKEN(the same shared secretapialready validates static tokens against) and sent asAuthorization: Bearer ...on every request nomos makes toapi.docker-compose.ymlsetsOIKOS_MCP_BEARER_TOKEN(defaultdev-token) on both theapiandnomosservices so local dev keeps working. - 0.2's
const API = apiBase('/api/v1')pattern bakes in a stale origin. Module-level constants evaluate once, at import time — beforemain.ts'sinitConfig()runs (ES module imports are hoisted ahead of a file's own top-level statements) and beforeConfig.svelteor a Wails-injectedwindow.__OIKOS_CONFIG__can setapiUrl. A first-launch Wails webview would resolveAPIto a relative path and try to fetchwails://.../api/v1/..., which doesn't exist. Resolution:api.tskeepsBASE/APIas bare path prefixes (/agent,/api/v1, never resolved to a URL) and letsfetchWithAuthcallapiBase()fresh on every request — the same fix pattern as gap 4's SSE snippet: resolve at call time, not at module-load time. api's own/agentreverse-proxy mount (to nomos) was never behindcombinedAuth— found while auditing every route for the dev-open removal. server.go'sr.Mount("/agent", ...)was registered directly on the base router, unlike every other custom route (/mcp,/api/v1/knowledge/recent, etc.), which all user.With(combinedAuth(cfg, false)). Harmless while dev-open made the whole API open anyway; a real hole the moment 0.4 closes it — any request toapi's/agent/*would reach nomos with no credential check at all, independent of C1 (nomos's own gateway on :8092, still open) and independent of gap 1 (Caddy/Authentik). Resolution: wrapped the mount incombinedAuth(cfg, false), matching every other route.
Also: 0.4's local-dev token delivery ended up simpler than described —
"Vite injects it into window.__OIKOS_CONFIG__ at dev time" isn't needed at
all for the relative-path dev case. The Vite proxy (0.2) already injects
Authorization: Bearer $OIKOS_API_TOKEN server-side on every proxied
/api//agent request, so relative-path fetches during npm run dev are
authenticated before they leave the dev server — no client-side config
needed. window.__OIKOS_CONFIG__ injection is still exactly what Phase 1's
Wails shell needs (absolute URL, no dev proxy to lean on).
Also: 0.3's SSE-auth snippet checks GetActor(r.Context()) == nil inside
serveSSE and validates the query token there — but serveSSE only runs
after combinedAuth has already accepted or rejected the request, and
combinedAuth requires a header today, so EventSource requests (no custom
headers) never reach serveSSE at all; they 401 in the middleware first.
Actual implementation: combinedAuth itself takes an allowQueryToken bool; when set (only for the /api/v1/events/stream route) it falls back to
?token= when the Authorization header is absent, before running the same
OIDC/static validation as every other route. This reuses all existing auth
logic instead of duplicating a static-token-only path inside serveSSE, and
keeps the gate at the middleware layer rather than half-open inside the
handler. The static-token comparison itself was extracted into
staticTokenActor(cfg, raw), shared between the header and query-param
paths.
Goal
Transform the Oikos control room into a native desktop application using Wails, built on top of a clean client/server split. The server (API, MCP, scheduler, notifier, Nomos) stays on the homelab as a long-running service. The client (SPA) is separated from the server binary and deployed independently — any browser talks to the server over HTTP. The Wails app is a thin native client: it loads the same SPA in a webview, configured with the server URL and auth token, and adds system tray, native notifications, auto-start, and auto-update.
Architecture
┌────────────────────────────────────────────────────────────┐
│ Server (homelab, permanent) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ oikos api │ │ oikos sched │ │ oikos notif │ │
│ │ :8090 │ │ (observe) │ │ (Matrix) │ │
│ │ REST + SSE │ └──────────────┘ └──────────────┘ │
│ │ MCP /mcp │ │
│ └──────┬───────┘ ┌──────────────┐ │
│ │ │ nomos serve │ │
│ ├──────────┤ :8092 │ │
│ │ MCP │ /agent/* │ │
│ │ └──────┬───────┘ │
│ │ │ │
│ └────────┬────────┘ │
│ ┌──────▼──────┐ │
│ │ Postgres │ │
│ └─────────────┘ │
│ │
│ Caddy: /api/* → :8090 /agent/* → :8092 /mcp → :8090 │
│ / → static SPA (web/dist/) │
└───────────────────────┬────────────────────────────────────┘
│ HTTPS (bearer auth or OIDC)
┌───────────────┼───────────────┐
│ │ │
┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Browser │ │ Wails app │ │ CLI/mobile │
│ (SPA at /) │ │ (SPA in │ │ (future) │
│ │ │ webview) │ │ │
└──────────────┘ └─────────────┘ └─────────────┘
Server
The existing server roles (oikos api, oikos scheduler, oikos notifier,
nomos serve, Postgres) run on the homelab mac-mini as systemd services —
unchanged. The SPA is no longer embedded in the oikos binary; it's a
standalone static build served by Caddy at /. The API routes (/api/v1/*,
/mcp, /healthz) don't conflict with root, and the old root redirect is
removed, so no path prefix is needed.
Clients
Any HTTP client that speaks the REST API + bearer auth. The SPA is the canonical client, deployed as static files. The Wails app wraps the same SPA in a native webview. Future clients (CLI, mobile) use the same API.
Stack
Server: existing Go code in internal/ — no changes. cmd/oikos removes
the SPA embed and /ui/* routes. Caddy serves web/dist/ at / with SPA
fallback.
SPA: existing Svelte 5 + Vite + Tailwind 4 + shadcn-svelte in web/.
API base URL and auth token become runtime-configurable. base: '/' — no
path prefix needed since the SPA is served at root.
Desktop: Wails v3 (Go + webview). The Wails app is a thin shell:
- Embeds the SPA as static assets (Wails's
go:embed-based asset system) - Reads server URL + token from OS keychain at startup, injects into webview
- SPA talks to the remote server over HTTPS — same as the browser
- No Go backend, no Postgres connection, no bundled sidecars
- Native shell: system tray, notifications, auto-start, auto-update, window persistence
Phase 0 — Client/server split
This phase separates the SPA from the oikos binary and makes it a
standalone client. The Wails app depends on this split being done first.
0.1 — Remove SPA embed from the server
- Delete
web/embed.go— the server no longer embedsweb/dist/. cmd/oikos/main.go— removeuiHandler()(~35 lines). Thehttpapi.ListenAndServe()signature no longer takes auiHandlerparam; passniland handle nil inserver.go.internal/httpapi/server.go— remove the/ui/*and/uiroutes (~15 lines atserver.go:175-182), and the root redirect to/ui/(server.go:183-185).web/dist/.gitkeep— delete (no longer needed to keep backend-only builds green).- Dockerfile — remove the node/ui-builder stage and
COPY --from=ofweb/dist/. The 3-stage Dockerfile (node → go → runtime) becomes a 2-stage build (go → runtime). ~20 lines deleted.
~80 lines deleted. The oikos api binary is now API-only: REST, SSE, MCP,
healthz.
0.2 — Make SPA API base URL configurable and add auth interceptor
The SPA currently hardcodes relative paths and has no auth headers:
// web/src/lib/api.ts
const BASE = '/agent'
const API = '/api/v1'
Replace with a runtime-configuration module (web/src/lib/config.ts):
// web/src/lib/config.ts
interface OikosConfig {
apiUrl: string // e.g. "https://oikos.hubris.network"
token?: string // bearer token for auth
}
declare global {
interface Window {
__OIKOS_CONFIG__?: OikosConfig
}
}
let cfg: OikosConfig | undefined
export function initConfig(override?: OikosConfig) {
cfg = override ?? window.__OIKOS_CONFIG__
if (cfg?.token) {
localStorage.setItem('oikos_token', cfg.token)
if (cfg.apiUrl) localStorage.setItem('oikos_api_url', cfg.apiUrl)
}
}
export function getConfig(): OikosConfig {
if (!cfg) {
const token = localStorage.getItem('oikos_token')
const apiUrl = localStorage.getItem('oikos_api_url')
if (token || apiUrl) {
cfg = { apiUrl: apiUrl ?? '', token: token ?? undefined }
}
}
return cfg ?? { apiUrl: '' }
}
export function isConfigured(): boolean {
const c = getConfig()
return !!c.apiUrl && !!c.token
}
// Relative paths are used in dev (Vite proxy) and when the SPA shares an
// origin with the API server (Caddy reverse proxy). Absolute paths are used
// when the API server is on a different origin (Wails webview, remote access).
function apiBase(path: string): string {
const c = getConfig()
if (!c.apiUrl) return path // relative — relies on same-origin or Vite proxy
return `${c.apiUrl}${path}`
}
// ---- Auth fetch wrapper ----
// Replaces every raw fetch() call in api.ts. Prepends the API base URL
// (absolute when configured, relative when unset for Vite dev proxy) and
// adds the Authorization header.
async function fetchWithAuth(path: string, opts?: RequestInit): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(opts?.headers as Record<string, string> ?? {}),
}
const c = getConfig()
if (c.token) {
headers['Authorization'] = `Bearer ${c.token}`
}
return fetch(apiBase(path), { ...opts, headers })
}
// SSE path builder — EventSource doesn't take headers, so pass the token
// as a query parameter (the SSE handler in server.go checks it alongside
// the Authorization header).
export function sseUrl(path: string): string {
const c = getConfig()
const url = apiBase(path)
if (!c.token) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(c.token)}`
}
// Export for api.ts to use throughout
export { fetchWithAuth, apiBase }
Then web/src/lib/api.ts — replace every fetch(...) call with
fetchWithAuth(...). Example:
// Before:
// const res = await fetch(`${API}/entities?${params}`)
// After:
import { fetchWithAuth, apiBase } from './config'
const API = apiBase('/api/v1')
const BASE = apiBase('/agent')
// ...
const res = await fetchWithAuth(`/api/v1/entities?${params}`)
web/src/lib/stores/events.ts — replace new EventSource(...) with
new EventSource(sseUrl(...)):
import { sseUrl } from '$lib/config'
// Before:
// source = new EventSource('/api/v1/events/stream')
// After:
source = new EventSource(sseUrl('/api/v1/events/stream'))
web/src/lib/stores/chat.ts — the chat SSE is POST + ReadableStream via
fetch(), which already goes through streamChat in api.ts. When the plan
says "replace every fetch(...) call", streamChat is included — the POST to
/agent/chat becomes fetchWithAuth('/agent/chat', ...).
web/src/lib/stores/context.ts — refreshContext() calls
fetchDashboardSummary() and fetchApprovals() from api.ts. Those
already go through fetchWithAuth. No change needed here.
web/src/lib/stores/workspace.ts — calls fetchPlan() and
fetchQuestions() from api.ts. No change needed.
web/src/main.ts — call initConfig() before mounting the app:
import { initConfig, isConfigured } from '$lib/config'
initConfig()
const app = mount(isConfigured() ? App : Setup, {
target: document.getElementById('app')!
})
export default app
This is the single largest frontend change: ~40 fetch() calls spread across
api.ts (all routes), stores/events.ts (EventSource), and the chat stream.
Each gets replaced with fetchWithAuth() or sseUrl().
web/vite.config.ts — base: '/' (remove /ui/ prefix, since the SPA
is served at root after the split). The dev proxy stays — same origin in dev
means relative paths work. After 0.4 closes the dev-open auth gate, inject
the token via a configure hook:
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [tailwindcss(), svelte()],
base: '/',
resolve: {
alias: { $lib: '/src/lib' }
},
build: {
outDir: 'dist',
emptyOutDir: true
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8090',
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
const token = process.env.OIKOS_API_TOKEN
if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`)
})
}
},
'/agent': {
target: 'http://localhost:8092',
rewrite: (path) => path.replace(/^\/agent/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
const token = process.env.OIKOS_API_TOKEN
if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`)
})
}
}
}
}
})
0.3 — Add SSE query-param auth and CORS to the API server
SSE auth. The SSE handler at /api/v1/events/stream currently relies on
combinedAuth middleware for bearer token validation. EventSource can't
send custom headers, so the SPA passes the token as a query param
(?token=...). The SSE handler needs to extract and validate it.
internal/httpapi/sse.go — in serveSSE, before using the context's
actor, check for a query-param token:
func (s *Server) serveSSE(w http.ResponseWriter, r *http.Request) {
// If combinedAuth didn't set an actor (no Authorization header —
// EventSource can't send one), try the query param.
if GetActor(r.Context()) == nil {
token := r.URL.Query().Get("token")
if token != "" {
validateStaticToken(s.cfg, r, token)
}
}
// ... rest of SSE handler
}
Extract the static-token validation from combinedAuth into a shared helper
so both the middleware and the SSE handler use the same logic.
CORS middleware. Add CORS to the chi router. This is needed for Wails
(webview origin differs from the remote server) and local dev (Vite on
:5173 vs server on :8090). For the browser production deployment (Caddy
serves both SPA and API from the same origin), it's a no-op.
internal/httpapi/server.go — add before the auth middleware:
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{cfg.CORSAllowedOrigin},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "If-Match"},
AllowCredentials: true,
MaxAge: 86400,
}))
internal/config/config.go — add CORSAllowedOrigin string, populated
from OIKOS_CORS_ORIGIN. Default: "*" in dev, the Caddy site URL in prod.
~30 lines added.
0.4 — Auth: close the dev-open gate
Currently combinedAuth opens the gate when OIKOS_ENV=dev and no tokens
are set (server.go:228). After the split, a client from any origin can hit
the API — the dev-open path is a security hole.
- Remove the
devOpenpath fromcombinedAuth— every request must carry a valid bearer token (viaAuthorizationheader or?token=query param for SSE). - For local dev: set
OIKOS_API_TOKEN=dev-tokenand the SPA reads it fromOIKOS_API_TOKENenv var (Vite injects it intowindow.__OIKOS_CONFIG__at dev time, and the Vite proxy forwards it). - Browser (production): the SPA's
Config.sveltepage accepts a static token (stored inlocalStorage). OIDC login flows are a follow-on milestone. - Desktop (production): the Wails app reads the token from the OS
keychain and injects it into
window.__OIKOS_CONFIG__before the webview loads.
0.5 — SPA config page (first-launch / setup)
The SPA needs a page for entering the server URL and auth token on first launch. This page also serves as the foundation for future OIDC login.
New file web/src/pages/Config.svelte:
- Two fields: "Server URL" (text input) and "Token" (password input)
- "Connect" button: calls
fetchWithAuth('/api/v1/dashboard/summary')to validate, stores inlocalStorageon success, callsinitConfig()to refresh runtime config, navigates to#/overview - Tabs placeholder for future OIDC flow: "Token", "Login with Authentik" (the second tab is disabled with "coming soon")
- Error state: connection failed, wrong token, server unreachable
web/src/App.svelte — check isConfigured() at mount. If false, render
Config.svelte instead of the sidebar. On successful config, transition to
the full app.
web/src/main.ts — simplify to always mount App.svelte (the config check
lives in App.svelte's mount hook, not in main.ts):
import { mount } from 'svelte'
import App from './App.svelte'
import './app.css'
import { initConfig } from '$lib/config'
initConfig()
mount(App, { target: document.getElementById('app')! })
0.6 — Deploy SPA as standalone static files
The SPA is built with base: '/' and served by Caddy at / with SPA
fallback. API routes take priority (explicit handle_path blocks in Caddy).
Caddy config (add to the existing compose/caddy/Caddyfile.oikos):
handle {
root * /var/www/oikos-ui
file_server
try_files {path} /index.html
}
The index.html doesn't need a placeholder (__OIKOS_API_URL__) in the
Caddy deployment case — the SPA and API share an origin, so relative paths
work and __OIKOS_CONFIG__ only needs apiUrl unset. The token is entered
by the user on the Config page and stored in localStorage.
Build + deploy:
ui: ## Build the SPA for standalone deployment
cd web && npm run build
deploy-ui: ui ## Deploy SPA to the Caddy host
scp -r web/dist/* mac-mini:/var/www/oikos-ui/
ssh mac-mini sudo systemctl reload caddy
Dockerfile (server image, no Node required):
# Stage 1: Build Go binary
FROM golang:1.26 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o oikos -tags timetzdata ./cmd/oikos
# Stage 2: Runtime
FROM debian:bookworm-slim
COPY --from=builder /app/oikos /usr/local/bin/oikos
EXPOSE 8090
ENTRYPOINT ["oikos"]
Drops the node builder stage entirely. The UI is built and deployed separately.
0.7 — Verification
# Server
oikos api # API + SSE + MCP, no UI
curl localhost:8090/healthz # {"status":"ok"}
curl -H "Authorization: Bearer dev-token" \
localhost:8090/api/v1/dashboard/summary # data
# SPA (dev)
OIKOS_API_TOKEN=dev-token npm run dev # Vite at :5173, proxy to :8090
open http://localhost:5173/ # Config page (enter URL + token)
# → Overview with live data
# SPA (production test)
cd web && npm run build
caddy file-server -root dist --listen :3000 # serve SPA locally
# Run oikos api separately, open http://localhost:3000,
# enter apiUrl=http://localhost:8090 + token on Config page
Phase 1 — Wails desktop app
Built on top of the split. The Wails app is a thin native wrapper around the same SPA, configured to talk to the deployed server over HTTPS. No bundled Go server, no Postgres connection, no nomos sidecar.
1.0 — Scaffold and window (1 session)
Create the Wails project with a native window loading the built SPA.
cmd/desktop/main.go — Wails v3 app:
- On startup: read config from OS keychain (
keyringpackage or Wails secrets plugin). Keys:oikos_server_url,oikos_token. - If no config in keychain: load the SPA anyway —
Config.sveltehandles first-launch setup. - If config exists: inject
window.__OIKOS_CONFIG__before the webview mounts. Wails v3'sAssetsHandlercan mutateindex.htmlbefore serving:assetsHandler: func(ctx context.Context, name string) (string, []byte, error) { if name == "index.html" { b, _ := assets.ReadFile("index.html") html := strings.Replace(string(b), `<script>window.__OIKOS_CONFIG__ = {};</script>`, fmt.Sprintf(`<script>window.__OIKOS_CONFIG__ = %s;</script>`, configJSON), 1) return "index.html", []byte(html), nil } b, _ := assets.ReadFile(name) return name, b, nil }index.htmlincludes a placeholder<script>tag that gets replaced:<script>window.__OIKOS_CONFIG__ = {};</script> <script type="module" src="/src/main.ts"></script> - Window: title "Oikos — Control Room", 1400×900, min 1024×700, dark
title bar (
mac.TitleBarStyleHiddenInsetor equivalent dark mode). - Wails embeds
web/dist/into the binary (via//go:embed all:distin the Wails project or the standard Wails asset system).
cmd/desktop/wails.json — Wails project config:
{
"name": "oikos-desktop",
"frontend:dir": "../../web",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev"
}
Makefile:
desktop: ui ## Build the Wails desktop app
wails build -clean -o oikos-desktop
Dev loop for the desktop app:
# Terminal 1: run the server locally (or point at remote)
OIKOS_API_TOKEN=dev-token oikos api
# Terminal 2: start Wails in dev mode (hot-reload, connects to Vite)
cd cmd/desktop && wails dev
web/vite.config.ts — add base: '/' (already done in 0.2). No
Wails-specific Vite config needed since Wails v3 uses the standard Vite dev
server.
Verify:
OIKOS_SERVER_URL=https://oikos.hubris.network OIKOS_DESKTOP_TOKEN=... make desktop
./oikos-desktop
# Window opens → Config page (if no keychain entry) or Overview with live data
1.1 — Native shell features (1 session)
-
System tray (Wails v3
application.NewSystemTray):- Oikos logo icon (from
web/public/favicon.svg) - Menu: "Open Control Room" (focus/restore window), "Pending Approvals: N" (fetched via backchannel HTTP call from Go, not the SPA), separator, "Quit"
- When window is closed: minimize to tray instead of quitting (set
HideOnClose)
- Oikos logo icon (from
-
Native notifications (
application.Notification):- A backchannel goroutine polls
GET /api/v1/dashboard/summaryevery 30s (separate HTTPS client in Go, independent of the webview) - Fires OS notification when
approvals_pendingorsignals_by_severity.criticalincreases since last poll - Click notification →
Window.Restore()+ send a message to the SPA via Wails events to navigate to the relevant page
- A backchannel goroutine polls
-
Window persistence: remember size/position via Wails v3
window.PersistStateor a JSON file in~/.config/oikos/window.json -
Auto-start on login (macOS):
- During setup flow, offer a checkbox: "Start automatically on login"
- Writes a LaunchAgent plist to
~/Library/LaunchAgents/com.hubris.oikos-desktop.plistthat runs the binary on login - Linux equivalent:
~/.config/autostart/oikos-desktop.desktop
Verify: Close window → app stays in tray. New approval arrives → OS notification appears with count. Click notification → window opens to Ops page. Restart machine → app opens automatically on login.
1.2 — Token management (1 session)
- First launch:
Config.svelteprompts for server URL + token (same page as the browser SPA's setup) - On "Connect" success,
Config.sveltecalls a Wails bindingSaveConfig(apiUrl, token)that stores in the OS keychain:func (a *App) SaveConfig(apiUrl string, token string) error { keyring.Set("oikos_server_url", apiUrl) keyring.Set("oikos_token", token) return nil } - Subsequent launches: Wails reads keychain, injects config, SPA skips Config page
- Logout: "Log out" menu item in system tray clears keychain and
refreshes the webview →
Config.svelteappears
Verify: Enter URL + token on first launch, quit, reopen → skips setup and loads Overview.
1.3 — Auto-update (1 session)
- Check Gitea releases (or a configured update URL) on startup and every 6 hours
- Wails v3 update plugin or a custom Go goroutine:
GET /releases/latest→ compare semver → download binary → verify checksum → prompt restart - Update manifest published alongside each release:
oikos-desktop-darwin-arm64.jsonwithversion,url,sha256
Verify: Build v1.0.0, publish v1.0.1 → app detects update, downloads, prompts restart. After restart, version is 1.0.1.
1.4 — Distribution and packaging (1 session)
- macOS:
.appbundle viawails build, code-sign with Apple Developer ID, notarize viaxcrun notarytool- Bundle ID:
com.hubris.oikos-desktop - Entitlements: network client, keychain access
- Bundle ID:
- Linux:
.deband AppImage viawails build+ packaging scripts - CI:
.gitea/workflows/desktop.yml— builds all targets on tag push, uploads artifacts to Gitea releases - AGENTS.md update: document the desktop app as a first-class client
Verify: Download .app on a fresh Mac, open → first-launch setup →
connect to the homelab → full app works with zero dev tools.
What does NOT change
internal/— every package imported as-is. Zero modifications.cmd/oikos/— minus the SPA embed (0.1), theoikosbinary is unchanged.cmd/nomos/— unchanged. The desktop app talks to nomos through the server's/agentreverse proxy — same as the browser.web/— SPA source shared between browser and desktop builds. Gainsconfig.ts(auth interceptor),Config.svelte(setup page), andvite.config.tsdrops/ui/prefix + adds proxy token injection. All existing pages, components, stores, hooks reused.api/openapi.yaml— unchanged.seeds/— unchanged.docker-compose.yml— server deployment unchanged (minus the Dockerfile losing the UI build stage).
Risks and open questions
-
Wails v3 maturity. v3 is newer than v2. Fallback: Wails v2 — same architecture (Go + webview + embedded assets), different Go APIs. Scope of impact: one file (
cmd/desktop/main.go). The thin-wrapper approach means the Wails API surface is ~50 lines of Go — trivially portable. -
OIDC login flow. The browser SPA needs an OIDC redirect flow via Authentik for production use (static tokens are fine for homelab dev but not for external access). The
Config.sveltepage has a tab placeholder for this. It's a separate milestone — for now, both browser and desktop clients use a static bearer token configured at first launch. -
SSE query-param token in logs. The token in
?token=...appears in Caddy access logs and server request logs. Mitigation: log redaction in Caddy (log { format filter { wrap json { fields { request>uri replace "token=[^&]*" "token=***" } } } }) and strip the query param from the request logger inserver.go. -
Webview CORS for embedded assets. Wails loads the SPA from
wails://orasset://origin, making cross-origin requests to the remote server. CORS middleware (0.3) handles this. TheAccess-Control-Allow-Originmust match the webview's origin, which may change between Wails versions. Mitigation: allow the configured origin explicitly; fall back to*for dev; Wails v3 document its asset origin. -
Multiple clients hitting the same SSE broker. Browser, Wails app, and Nomos all connect to
/api/v1/events/stream. The SSE broker already handles multiple subscribers (fan-out via the subscriber list insse.go). Each client gets its own connection and replay. No change needed. -
Deploy-time Caddy changes this plan does not make. Two changes are required outside this repo before Phase 0's auth tightening actually protects anything in production, both in
dtoro/caddy-conf:- Add a bearer-token bypass around
import authentikfor/api/v1/*and/mcponoikos.hubris.network, mirroring the existing@enrollbypass — otherwise closing the dev-open gate just adds a second, redundant auth layer behind Authentik's browser-session check, and non-browser clients (Wails, curl) can never get past the first one. - Nomos's gateway (port 8092) has no auth of its own (C1, tracked in
2026-07-11-nomos-agent-code-review.md).
Phase 0 does not fix this — the mesh-published port and
nomos.hubris.networkremain open regardless of anything done here. Treat C1 as a co-requisite for a production Wails rollout, not something this plan's auth work incidentally covers.
- Add a bearer-token bypass around