# 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 and iterated (commits through `eeb78ed`, 2026-07-14). App installed to `/Applications` on dev Mac, end-to-end OIDC login via lokal HTTP server + system browser confirmed working. **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: 1. **Authentik forward-auth vs. bearer-token clients.** The deployed `oikos.hubris.network` site gates every route (including `/agent/*` and, after this plan, `/api/v1/*`) with `import 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 `@enroll` bypass for `/api/v1/clients/enroll`. **Resolution:** updated the reference copy ([Caddyfile.oikos](compose/caddy/Caddyfile.oikos)) with an `@api path /api/v1/* /mcp /agent/*` bypass around `import authentik`, same pattern as `@enroll`, and moved static-SPA serving into the `handle {}` fallback (0.6). This repo's copy is not what's deployed — the real file lives in `dtoro/caddy-conf` and 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. 2. **Nomos's own gateway (C1) is a parallel, unauthenticated path to the same backend.** [2026-07-11-nomos-agent-code-review.md](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` (the `api` process); `cmd/nomos` is untouched. The architecture diagram in this plan shows Caddy's `handle_path /agent/*` proxying straight to `:8092`, bypassing `api`'s `combinedAuth` entirely 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`) and `nomos.hubris.network` remain 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. 3. **`github.com/go-chi/cors` isn't a dependency yet**, and the plan's sample CORS config (`AllowCredentials: true` with a default `"*"` origin) is spec-invalid — browsers and webviews reject a wildcard `Access-Control-Allow-Origin` when credentials are requested. This API authenticates via `Authorization: Bearer`, not cookies, so credentialed CORS mode isn't needed at all. **Resolution:** drop `AllowCredentials` from the middleware config in 0.3 rather than ship a setting that silently breaks the first time an origin other than `*` is configured. 4. **Closing dev-open (0.4) breaks local `docker compose --profile dev up` out of the box** — none of the compose services currently set a token, and today they rely entirely on `OIKOS_ENV=dev` + devOpen. Worse: `cmd/nomos` itself is an unauthenticated client of `api`'s `/mcp` endpoint and `/api/v1/approvals/{id}/decision` (chat-assent approvals) — `grep -rn "Authorization" cmd/nomos/*.go` returned 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 a `token` field threaded through `mcpClient`/`mcpClientPool` and `agent.apiToken`, both reading `OIKOS_MCP_BEARER_TOKEN` (the same shared secret `api` already validates static tokens against) and sent as `Authorization: Bearer ...` on every request nomos makes to `api`. `docker-compose.yml` sets `OIKOS_MCP_BEARER_TOKEN` (default `dev-token`) on both the `api` and `nomos` services so local dev keeps working. 5. **0.2's `const API = apiBase('/api/v1')` pattern bakes in a stale origin.** Module-level constants evaluate once, at import time — before `main.ts`'s `initConfig()` runs (ES module imports are hoisted ahead of a file's own top-level statements) and before `Config.svelte` or a Wails-injected `window.__OIKOS_CONFIG__` can set `apiUrl`. A first-launch Wails webview would resolve `API` to a relative path and try to fetch `wails://.../api/v1/...`, which doesn't exist. **Resolution:** `api.ts` keeps `BASE`/`API` as bare path prefixes (`/agent`, `/api/v1`, never resolved to a URL) and lets `fetchWithAuth` call `apiBase()` fresh on every request — the same fix pattern as gap 4's SSE snippet: resolve at call time, not at module-load time. 6. **`api`'s own `/agent` reverse-proxy mount (to nomos) was never behind `combinedAuth` — found while auditing every route for the dev-open removal.** [server.go](../internal/httpapi/server.go)'s `r.Mount("/agent", ...)` was registered directly on the base router, unlike every other custom route (`/mcp`, `/api/v1/knowledge/recent`, etc.), which all use `r.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 to `api`'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 in `combinedAuth(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](https://wails.io), 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 embeds `web/dist/`. - **`cmd/oikos/main.go`** — remove `uiHandler()` (~35 lines). The `httpapi.ListenAndServe()` signature no longer takes a `uiHandler` param; pass `nil` and handle nil in `server.go`. - **`internal/httpapi/server.go`** — remove the `/ui/*` and `/ui` routes (~15 lines at `server.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=` of `web/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: ```ts // web/src/lib/api.ts const BASE = '/agent' const API = '/api/v1' ``` Replace with a runtime-configuration module (`web/src/lib/config.ts`): ```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 { const headers: Record = { 'Content-Type': 'application/json', ...(opts?.headers as Record ?? {}), } 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: ```ts // 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(...))`: ```ts 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: ```ts 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: ```ts 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: ```go 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: ```go 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 `devOpen` path** from `combinedAuth` — every request must carry a valid bearer token (via `Authorization` header or `?token=` query param for SSE). - **For local dev:** set `OIKOS_API_TOKEN=dev-token` and the SPA reads it from `OIKOS_API_TOKEN` env var (Vite injects it into `window.__OIKOS_CONFIG__` at dev time, and the Vite proxy forwards it). - **Browser (production):** the SPA's `Config.svelte` page accepts a static token (stored in `localStorage`). 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 in `localStorage` on success, calls `initConfig()` 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): ```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:** ```makefile 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): ```dockerfile # 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 (`keyring` package or Wails secrets plugin). Keys: `oikos_server_url`, `oikos_token`. - If no config in keychain: load the SPA anyway — `Config.svelte` handles first-launch setup. - If config exists: inject `window.__OIKOS_CONFIG__` before the webview mounts. Wails v3's `AssetsHandler` can mutate `index.html` before serving: ```go assetsHandler: func(ctx context.Context, name string) (string, []byte, error) { if name == "index.html" { b, _ := assets.ReadFile("index.html") html := strings.Replace(string(b), ``, fmt.Sprintf(``, configJSON), 1) return "index.html", []byte(html), nil } b, _ := assets.ReadFile(name) return name, b, nil } ``` `index.html` includes a placeholder ` ``` - Window: title "Oikos — Control Room", 1400×900, min 1024×700, dark title bar (`mac.TitleBarStyleHiddenInset` or equivalent dark mode). - Wails embeds `web/dist/` into the binary (via `//go:embed all:dist` in the Wails project or the standard Wails asset system). **`cmd/desktop/wails.json`** — Wails project config: ```json { "name": "oikos-desktop", "frontend:dir": "../../web", "frontend:build": "npm run build", "frontend:dev:watcher": "npm run dev" } ``` **`Makefile`**: ```makefile desktop: ui ## Build the Wails desktop app wails build -clean -o oikos-desktop ``` **Dev loop for the desktop app:** ```sh # 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:** ```sh 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`) - **Native notifications** (`application.Notification`): - A backchannel goroutine polls `GET /api/v1/dashboard/summary` every 30s (separate HTTPS client in Go, independent of the webview) - Fires OS notification when `approvals_pending` or `signals_by_severity.critical` increases since last poll - Click notification → `Window.Restore()` + send a message to the SPA via Wails events to navigate to the relevant page - **Window persistence**: remember size/position via Wails v3 `window.PersistState` or 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.plist` that 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.svelte` prompts for server URL + token (same page as the browser SPA's setup) - On "Connect" success, `Config.svelte` calls a Wails binding `SaveConfig(apiUrl, token)` that stores in the OS keychain: ```go 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.svelte` appears **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.json` with `version`, `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**: `.app` bundle via `wails build`, code-sign with Apple Developer ID, notarize via `xcrun notarytool` - Bundle ID: `com.hubris.oikos-desktop` - Entitlements: network client, keychain access - **Linux**: `.deb` and AppImage via `wails 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), the `oikos` binary is unchanged. - `cmd/nomos/` — unchanged. The desktop app talks to nomos through the server's `/agent` reverse proxy — same as the browser. - `web/` — SPA source shared between browser and desktop builds. Gains `config.ts` (auth interceptor), `Config.svelte` (setup page), and `vite.config.ts` drops `/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 1. **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. 2. **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.svelte` page 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. 3. **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 in `server.go`. 4. **Webview CORS for embedded assets.** Wails loads the SPA from `wails://` or `asset://` origin, making cross-origin requests to the remote server. CORS middleware (0.3) handles this. The `Access-Control-Allow-Origin` must 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. 5. **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 in `sse.go`). Each client gets its own connection and replay. No change needed. 6. **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 authentik` for `/api/v1/*` and `/mcp` on `oikos.hubris.network`, mirroring the existing `@enroll` bypass — 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](2026-07-11-nomos-agent-code-review.md)). Phase 0 does not fix this — the mesh-published port and `nomos.hubris.network` remain 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.