25 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
335fa67d55 fix(web): scope 1-hop neighbor expansion to rooted graph views only
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Two bugs found while verifying against real production data:

- Excluded activity types (execution/check/task etc., see categories.ts)
  were falling through inCategory's "unknown type -> always visible"
  fallback, since typeCategory only stored entries whose category was
  defined. That fallback exists for types the ontology never returned at
  all; it wrongly re-admitted types the ontology returned but categories.ts
  deliberately excludes. Fixed by storing every type (including undefined
  categories) and checking key presence, not value truthiness.

- Once that was fixed, the previous commit's 1-hop neighbor expansion
  (dimmed cross-category context) turned out fine for a rooted view but
  flooded an unrooted "browse the whole category" view: Fleet's ~49 focus
  entities are hub-like enough that 1-hop pulled in 325+ of the system's
  479 total entities. Neighbor expansion now only applies when a root is
  set; the unscoped view goes back to same-category-only edges, which
  measured at a clean 49 nodes for Fleet.

Verified against live production data (real bearer token, real DB) rather
than mocks: Fleet unrooted = 49 nodes matching the DB's compute+physical
count exactly; rooting on host:strong shows 33 nodes with both bright
same-category and dimmed cross-category neighbors, no isolated dots.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:39:31 +02:00
d1243aceac fix(web): decode percent-encoded slugs in the knowledge content route
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
chi.URLParam returns the raw, still-encoded path segment — unlike the
OpenAPI-generated routes, which decode via
runtime.BindStyledParameterWithOptions before the handler sees them. Slugs
like "document:containers/101-jellyfin" (encoded by the frontend's
encodeURIComponent) were arriving undecoded and matching no row. Found via
a standalone chi repro, not by patching the live deploy checkout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:29:42 +02:00
61ad785fef fix(web): render full document content, keep graph connected under categories
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Two fixes to the new category taxonomy:

- Knowledge Base couldn't show a document/investigation/runbook's own
  markdown body — knowledge_entities.content was never exposed by any
  endpoint (GetEntityKnowledge answers "what knowledge references this
  entity", not "what is this entity's content"). Add GET
  /api/v1/knowledge/content/{id} and render it with the existing
  marked+DOMPurify pipeline in a new Content section.

- The graph hid any edge whose other endpoint wasn't in the active
  category, so nodes with only cross-category neighbors rendered as
  disconnected dots. Queried the real relationship table: ~70% of infra
  edges cross Fleet/Network/Services/Storage lines (compute+network+
  software+storage+physical used to be one "infrastructure" layer).
  EntityGraph now keeps 1-hop neighbors visible but dimmed instead of
  hiding them, so the edges — and what they connect to — stay visible.

- categories.ts: `cognition` domain conflated true knowledge (document/
  investigation/runbook, 58 entities) with operational telemetry
  (execution/check/task/signal/approval/pattern/skill/classification/
  feedback, 300+ entities with their own Operations/Signals/Learning
  pages). Mapping the whole domain to Knowledge pulled in 245 execution
  entities fanning out from ~17 compute nodes via `targets` edges — the
  single biggest source of graph clutter. Knowledge now maps by type
  (document/investigation/runbook only); the rest of cognition is
  excluded from Knowledge Base browsing entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 10:17:13 +02:00
35c54ceef5 feat(web): browse Knowledge Base by mixed Network/Fleet/Services/Storage/Identity/Knowledge categories
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Replace the layer-based (Infrastructure/Governance/Cognition) browsing tabs
with a synthesized category taxonomy built from the ontology's finer-grained
`domain` field, since layer lumped unrelated entity types (an LXC and a DNS
record and a storage volume) into one bucket. Network and Fleet each span
two domains, so the table view now fans out per-domain fetches and merges,
while the graph view maps domain->category client-side. Also carries over
several detail-panel polish items (Tasks-not-raw-executions, slug URL
encoding, MultiSelectFilter) from earlier in this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 09:53:15 +02:00
f8e03806aa feat(deploy): containerize the web UI as its own compose service
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
The SPA-from-binary split (0c0f35a) left `make deploy-ui` pointing at a
deploy path that was never actually wired up: scp to a "mac-mini" SSH
host that doesn't resolve from itself, a /var/www/oikos-ui/ that doesn't
exist, and `systemctl reload caddy` on a box with no Caddy installed at
all (not brew, not a container, nothing on 80/443).

Add a `web` service (compose/web/Dockerfile: node build -> caddy:2-alpine
static + SPA-fallback serving) to docker-compose.yml so the UI deploys
through the same push-to-main -> webhook -> docker compose build/up
pipeline the rest of the stack already uses, instead of a manual
scp/ssh step. Drop the broken `deploy-ui` Makefile target; `make ui`
stays as a local build sanity-check.

Update the reference Caddy config (compose/caddy/Caddyfile.oikos) to
reverse_proxy the new :8091 service instead of reading static files off
local disk, and fill in the <mac-mini-mesh-ip> placeholders with the
actual LAN IP (192.168.178.182 — the LXC and mac-mini subnets are
routed). This file is a reference only; the real caddy-conf repo change
is applied separately after review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-12 22:36:40 +02:00
94c94c0758 feat(web): merge Entities + Graph into a single Knowledge Base page
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Replaces the separate Entities/Graph nav items with one Knowledge Base
page that browses all entities as either a table or a force-graph,
scoped by ontology layer (Infrastructure/Governance/Cognition), with a
resizable browse/detail split instead of a slide-over sheet.

- New KnowledgeBase.svelte: layer tabs, view toggle, resizable
  browse/detail split (pattern from Chat.svelte's rail).
- EntityTable/EntityGraph extracted as presentational sub-components;
  their search/filter/root/depth toolbars live in the shared page
  toolbar (not the resizable pane) so they don't truncate when the
  divider is dragged narrow, and both views start flush with the
  detail pane for consistent height.
- EntityTable columns are sortable (slug/type/name/state/health).
- EntityDetailContent redesigned as a single-column list of
  collapsible sections (DetailSection.svelte), collapsed by default
  when empty; relation entries are clickable and select the entity in
  the browse pane + detail pane (and drill in-place in EntitySheet
  wherever it's used elsewhere in the app).
- api.ts: add layer filter to fetchEntities, add fetchEntityTypes for
  client-side graph layer scoping (the graph endpoint has no layer
  param).

Old hash routes (#/entities, #/graph) redirect to #/kb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-12 22:21:29 +02:00
d80a394b7f docs: fix plan/repo drift, retire dead Goose+Nomos and Caveman tooling
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Documentation and repo-hygiene pass following the client/server split:

Plan drift (audited all other active plans against current code):
- oikos-gaps-and-improvements.md: mark Section C and D.5 resolved (both
  described cmd/hermes, renamed to cmd/nomos with a real LLM loop since);
  refresh ~10 stale file:line citations; fix tool-count (33, not 28).
- liveness-drift-and-ux-cohesion.md: fix stale default-model claim (now
  deepseek-v4-pro since 2026-07-10) and "not yet deployed" status.
- nomos-agent-code-review.md: fix C1's citation (one unauthenticated route
  to nomos now, not two, after the client/server split).
- wails-desktop-app.md: record the production deploy outcome.

Repo structure: added missing directories to README/CONTRIBUTING layout
tables (checks/, tools/, cmd/webhook/, docs/operations/), fixed a broken
link, added ADR 0015 documenting the auth/CORS/client-split model (there
wasn't one despite CONTRIBUTING's own process requiring it), normalized
ADR 0013/0014's format drift, added an Authentication section to
AGENTS.md/CLIENTS.md (every example call was missing the now-required
bearer header).

Retired the Goose+Nomos workstation flow (bootstrap.sh --with-nomos,
tools/setup-nomos-soul.sh, .agents/operations/nomos-agent.md) and the
Caveman auto-install tooling (tools/setup-caveman.sh, tools/caveman/) —
both superseded by the production containerized Nomos agent, which has
never used either. Kept .agents/shared/caveman.md itself (the terse
writing-style convention agents still follow by reading it).

Deleted the orphaned legacy Python oikos/ directory — nothing imports it,
and bin/homelab (the CLI it was kept for) no longer exists in the repo.

Rewrote .agents/operations/agent-enrollment.md (365 -> ~110 lines) and
commands.md to match the current architecture instead of the retired
`homelab` CLI; migrated the still-true networking prerequisites (Netbird,
split-horizon DNS, SSH key distribution) into the knowledge base as a
runbook via upsert_knowledge rather than duplicating them in markdown.
Updated all 10 .agents/skills/ runbooks referencing the dead CLI with
their real MCP tool / REST API equivalents, or flagged them as needing
verification where no equivalent is confirmed yet.

Two real bugs found and fixed, not just docs:
- The tools/setup-*.sh auto-setup glob was tools/*.setup.sh in THREE
  places (tools/post-pull.sh, bootstrap.sh, and internal/httpapi/impl.go's
  GetClientContext handler) since the mechanism's introduction on
  2026-06-02 — never matched any real filename, so no client has ever
  picked up an auto-setup script via git-pull or the context-poller sync.
  Fixed all three; the Go server-side fix is the one that actually matters
  since it's what the current context-poller mechanism depends on.
- bootstrap.sh removed dead vestigial --gitea-token/--gitea-user flags
  (parsed, never consumed) left over from an earlier clone-based model.

Also flagged, not fixed (documented as an open gap in
client-enrollment/SKILL.md): bootstrap.sh tells a freshly-enrolled client
to call POST /api/v1/clients/{slug}/activate to finish enrollment, but
that route doesn't exist in api/openapi.yaml — EnrollClient sets entities
to provisioning and nothing currently transitions them to active.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 18:19:41 +02:00
0c0f35a3a9 feat(web): split SPA from oikos binary, require auth on every route
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Phase 0 of plans/2026-07-12-wails-desktop-app.md. The control-room SPA
is no longer embedded (web/embed.go deleted); it's a standalone static
build served separately (make ui / make deploy-ui). The api process
adds CORS and drops the dev-open auth bypass — every route now needs a
real bearer token, including SSE (?token= query param, EventSource
can't set headers) and api's own /agent proxy to nomos (previously
unauthenticated by omission).

nomos was an unauthenticated client of api's /mcp and approval-decision
endpoints; closing dev-open would have broken it, so it now sends
Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN on every call back to api.

SPA gets a runtime config module (config.ts) and a Config.svelte
first-launch/reconfigure page, reachable afterwards via a "Connection"
entry in the sidebar footer. Every fetch() in api.ts routes through
fetchWithAuth so the same build works same-origin (browser prod, Vite
dev proxy) or cross-origin (future Wails webview, remote access).

Six gaps found against the plan and the live Caddy topology while
implementing — documented in the plan's "Plan review" section, most
notably: api's own /agent mount was never behind combinedAuth (fixed),
and production's Authentik forward-auth needs a bearer-token bypass for
API routes that this repo's Caddyfile.oikos reference copy now has, but
the real dtoro/caddy-conf deploy does not yet.

Verified live: cross-origin static SPA + API, CORS, bearer auth, SSE
query-token auth, and localStorage persistence all confirmed working
in-browser. Full Go test suite and npm run build pass with no
regressions against the pre-change baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 15:49:42 +02:00
346eb2f144 chore: gitignore compiled binaries at root
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-12 12:40:55 +02:00
48827d5bb1 fix(deploy): add poller as fallback when Gitea webhook can't reach mac-mini
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Gitea (LXC 104, 192.168.8.x) can't reach mac-mini (192.168.178.182) due to
ALLOWED_HOST_LIST. As a fallback, a 2-minute launchd poller checks if
origin/main has new commits and runs deploy.sh if so.
2026-07-12 12:14:28 +02:00
56979ac4bd feat(deploy): add webhook receiver and launchd service for push-to-deploy
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- cmd/webhook/main.go: HMAC-validated webhook receiver on :9797
- launchd plist: keeps webhook running, PATH includes docker
- Makefile: 'make webhook' target
- Registered as Gitea webhook id 15 on dtoro/oikos

Fixes: auto-deploy was not wired on mac-mini after the consolidation
2026-07-12 12:12:01 +02:00
3157e6102a plans: Wails desktop app with client/server split
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Phase 0 separates the SPA from the oikos binary (delete embed.go, add CORS,
make API base URL configurable, add auth interceptor, close dev-open gate).
Phase 1 builds a thin Wails v3 desktop wrapper — native window + tray +
notifications + auto-start + auto-update. SPA shared between browser and
desktop builds.
2026-07-12 11:49:27 +02:00
6807e353e3 feat(web): redesign Overview as the homepage with a living graph backdrop
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Overview replaces Tasks as the default route: a centered new-task entry
with live fleet metrics, a scrollable/filterable task table, and an
ambient canvas rendering of the real entity graph (autonomous camera
drift + mouse parallax) behind it. Tasks sidebar entry is removed;
its status-bucketing logic moves to lib/tasks.ts for reuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 11:07:36 +02:00
122 changed files with 6627 additions and 3058 deletions

View File

@@ -1,19 +1,27 @@
# NOMOS.md — Agent persona for homelab clients
This file is the canonical agent persona for **all** AI agents running on
machines in the **hubris** homelab. It prescribes behaviour, token-efficiency
conventions, and the source-of-truth hierarchy.
This file is the canonical agent persona for AI agents running on machines
in the **hubris** homelab (Claude Code, Codex, or similar). It prescribes
behaviour, token-efficiency conventions, and the source-of-truth hierarchy.
The *production* Nomos agent (`cmd/nomos`, the containerized MCP client
gateway everyone actually talks to) uses a separate, code-adjacent persona —
`nomos/SOUL.md`, baked into its Docker image at build time
(`compose/nomos/Dockerfile`). This file is unrelated to that one; it's for
AI coding agents working *on* a homelab client machine, not the Nomos
service itself.
## Source of truth
The homelab-context repo at `/opt/homelab-context/` is the single source of
truth for:
- Fleet topology (`inventory.yaml`, `inventory.yaml`)
- Service endpoints and credentials (via `homelab secret`)
- Fleet topology (`inventory.yaml`)
- Agent behaviour and conventions
- Everything in this file
When in doubt, check `/opt/homelab-context/` first.
When in doubt, check `/opt/homelab-context/` first, or query the Oikos API/MCP
server directly (see [AGENTS.md](../AGENTS.md) §3-4) — the database is
authoritative at runtime.
## Runbooks — load, don't rediscover
@@ -28,69 +36,9 @@ wiki when a runbook already encodes it. See [OIKOS.md](OIKOS.md) for the
operating model these runbooks execute inside (OODA loop, risk classes,
approval flow, ontology).
## Agent type — how this file gets loaded
## Token efficiency
| Agent | Loading mechanism |
|-------|------------------|
| **Nomos** | `tools/setup-nomos-soul.sh` (auto-setup) → provisions `~/.nomos/SOUL.md` from this file |
| **Goose** | `.goosehints` symlink at `~/.config/goose/.goosehints``/opt/homelab-context/NOMOS.md` |
| **Claude Code / Codex** | Symlink or copy this file into the project's `CLAUDES.md` / `.claude` instructions |
**Do not edit SOUL.md or .goosehints directly.** Edit this file in the
homelab-context repo instead. Changes propagate to all clients on the next
sync (`sudo homelab sync`).
---
## Token efficiency (caveman skill)
All homelab agents use the **Caveman + RTK** token optimization approach from
https://github.com/adityahimaone/hermes-agent-rtk-caveman.
### Before running any CLI command, ask:
1. **Is there a caveman wrapper equivalent?** Use the wrapper for token-efficient
output. Available wrappers (installed at `~/bin/caveman_wrapper.sh`):
- `~/bin/caveman_wrapper.sh git-status` — compact git status
- `~/bin/caveman_wrapper.sh git-log [n]` — compact git log
- `~/bin/caveman_wrapper.sh lint [target]` — compact lint results
- `~/bin/caveman_wrapper.sh test-results [cmd]` — compact test results
2. **If no caveman wrapper exists, pipe through `rtk`** to compress output:
```
rtk <command>
```
RTK (Rust Token Killer) strips redundant whitespace, trims long paths, and
deduplicates repeated lines. This reduces token usage by 60-90% on CLI
operations.
3. **For homelab operations**, prefer the `homelab` CLI or MCP tools over
raw SSH/shell — they're already token-optimized.
### Templates
Caveman templates live at `~/templates/`:
- `git_status.txt` — compact git status format
- `git_log.txt` — compact git log format
- `lint_results.txt` — compact ESLint format
- `test_results.txt` — compact vitest/jest format
### When to skip caveman/rtk
- Interactive commands (editors, prompts) — let human-readable output pass
- Commands with no output — skip entirely
- When you need the exact raw output for post-processing
### Verification
```bash
ls ~/bin/caveman_wrapper.sh && echo "caveman ready"
```
## Important note for Nomos agents
If you are reading this as a Nomos agent, your SOUL.md was auto-provisioned
by `tools/setup-nomos-soul.sh`. This file is the canonical original — you
can verify the content matches or re-provision by running:
bash /opt/homelab-context/tools/setup-nomos-soul.sh
Apply [caveman.md](shared/caveman.md) — terse, fragment-heavy chat responses
(not committed documentation). There's no separate tool to install for
this; it's a response-style convention any agent follows by reading the
file.

View File

@@ -10,6 +10,7 @@ see [CONTRIBUTING.md](../../CONTRIBUTING.md) for a human-friendly version.
cmd/oikos/main.go Entry point. Subcommands: api, scheduler, notifier, migrate,
seed, export, secret, all
cmd/nomos/main.go Nomos MCP client gateway (standalone binary, formerly Hermes)
cmd/webhook/main.go Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
internal/httpapi/ REST + MCP server. Chi router. OpenAPI-generated types from
internal/httpapi/gen/api.gen.go. Strict server in impl.go.
internal/mcp/ MCP tool implementations (get_entity, search_knowledge, etc.)
@@ -26,14 +27,19 @@ internal/domain/ Core types: entities, approvals, executions, signals
internal/ontology/ Type hierarchy validation, relationship checks
internal/knowledge/ Knowledge YAML seed ingestion
internal/config/ Config loading from env vars
web/ Control-room SPA (Svelte 5) — standalone static build, not
embedded in the oikos binary (plans/2026-07-12-wails-desktop-app.md)
api/openapi.yaml REST API contract. Source of truth for endpoints.
api/codegen.yaml oapi-codegen config → generates internal/httpapi/gen/
migrations/ Forward-only SQL. Format: NNN_name.up.sql. No down migrations.
seeds/ Bootstrap YAML. ontology.yaml, inventory.yaml, policy.yaml,
knowledge.yaml. Regenerated from DB via oikos export.
compose/ Dockerfiles. oikos/ (multi-stage), nomos/ (distroless).
compose/ Dockerfiles. oikos/ (2-stage, Go only — SPA is built/deployed
separately), nomos/ (distroless).
Caddy config at compose/caddy/Caddyfile.oikos.
scripts/ Deploy, rollback, watchdog, verification, cutover checklist.
checks/ Host health-check scripts run over SSH by the scheduler.
tools/ Client auto-setup scripts (checks).
nomos/ Nomos config.yaml, SOUL.md, skills.
.agents/ Agent instruction files, domains, shared conventions, skills.
plans/ Design documents. active/ + done/.

View File

@@ -1,365 +1,120 @@
# Agent enrollment — bootstrap a client into the homelab context system
# Agent enrollment — operational notes
This walks through enrolling a new machine (workstation, LXC, or VM) so it
joins the cross-client context system: a `/opt/homelab-context/` clone of
this repo that auto-syncs every 5 min, a per-client age key for SOPS
decryption, the `homelab` CLI, and an MCP endpoint in Claude Code's config.
**For the actual enrollment flow, see [CLIENTS.md](../../CLIENTS.md#enrollment)
— it's the current, authoritative version.** This page used to duplicate
that flow in more detail, describing a `homelab` CLI-based two-step
ceremony (`homelab client add` reserves an inventory slot → client
bootstraps → operator finalizes the pubkey). That CLI and that flow don't
exist anymore — enrollment today is one shot: `bootstrap.sh` calls
`POST /api/v1/clients/enroll` directly and gets back an age keypair +
Infisical identity in the same response. What's left here is the handful
of things that are still true and weren't already covered elsewhere.
> Onboarding a Nous-Hermes-powered Goose agent on top of standard enrollment?
> See [nomos-agent.md](nomos-agent.md). It uses the same `bootstrap.sh`
> with an additional `--with-nomos` flag.
Architecture in [project_homelab_context_plan](https://… memory link); the
operational reference is here.
## Prerequisites the client must satisfy
## Prerequisites
| Requirement | Why | How to check |
| --- | --- | --- |
| Hostname matches an entry in `inventory.yaml` | The bootstrap looks up `hosts/$(hostname).yaml`. | `hostname` (Linux) / `scutil --get LocalHostName` (macOS) |
| Hostname matches an entry in `inventory.yaml` | `EnrollClient` looks up the entity by slug derived from hostname; it must exist in `planned`/`provisioning` state. | `hostname` (Linux) / `scutil --get LocalHostName` (macOS) |
| OS is Linux or macOS | bootstrap detects via `uname -s` | `uname -s` |
| On the mesh (Netbird or Tailscale) **or** on the LAN | issuance is gated to mesh + LAN subnets. **For Netbird: use a setup-key, not interactive auth** — see "Getting onto Netbird" below. | `netbird status` / `tailscale status` |
| `git`, `python3`, `python3-yaml`, `age`, `sops` | bootstrap preflight; `homelab` CLI imports yaml | See per-OS commands below |
| Can resolve `*.hubris.network` | bootstrap calls `https://secrets.hubris.network/issue` and writes `https://mcp.hubris.network/mcp` | `dig +short mcp.hubris.network` (should return `192.168.8.175`) |
| On the mesh (Netbird) **or** on the LAN | enrollment validates mesh IP against expected subnets | `netbird status` |
| `curl`, `jq`, `age`, `python3` | bootstrap preflight (`bootstrap.sh:100`) — auto-installed on Fedora/RHEL/Debian/Ubuntu/macOS if missing | `command -v curl jq age python3` |
| Can resolve `*.hubris.network` | bootstrap calls the Oikos API and writes `https://mcp.hubris.network/mcp` | `dig +short mcp.hubris.network` |
### Hostname mismatch is the most common bootstrap failure
If the bootstrap exits with `no hosts/<name>.yaml in the repo`, the
hostname doesn't match any inventory entry. Two fixes:
If the entity for your hostname doesn't exist yet (in `planned` or
`provisioning` state), enrollment 4xxs. Two fixes:
- **Rename the host**: `sudo hostnamectl set-hostname <inventory-name>`
(Linux) or System Preferences → Sharing (macOS), then re-run.
- **Rename the inventory entry**: edit `inventory.yaml` on hubris,
update `inventory.yaml`, push. The next sync (≤5 min) propagates.
- **Rename the host** to match an existing planned entity:
`sudo hostnamectl set-hostname <inventory-name>` (Linux) or System
Preferences → Sharing (macOS), then re-run.
- **Add/rename the inventory entry**: edit `seeds/inventory.yaml`, ingest
via `oikos seed` (or the equivalent MCP/API entity-creation path), then
re-run bootstrap.
### Getting onto Netbird
### Networking prerequisites (Netbird, DNS, SSH key distribution)
Bootstrap auto-installs netbird and drives `netbird up` if the mesh isn't already connected (since commit `<bootstrap-tier1>`). Both paths below produce the same end state: `netbird status` shows `Management: Connected`, peer IP `100.122.x.x/16`.
**Path B — interactive OIDC (default; recommended):**
The new client runs bootstrap straight from a fresh OS. Bootstrap installs netbird (apt/dnf/brew based on the OS), then runs `netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400`. A device-code URL prints inline. The operator opens it (in a browser logged into Authentik), goes through identification → password → consent, and the CLI returns `Connected`. Bootstrap then proceeds with the rest of preflight.
Pre-condition: the operator must be a registered user in Authentik (typically the lab owner). The first user-login against a netbird account with existing peers is added as `pending_approval=1` and needs an sqlite promotion to `owner` — see [124-authentik.md First-time owner promotion gotcha](../../archive/knowledge/containers/106-auth-outpost.md). Only needed once per account.
**Path A — setup-key (headless/scripted onboarding):**
Useful for headless servers (no browser at all) or unattended cloud-init bootstraps.
1. From an already-enrolled machine, log into the dashboard at `https://netbird.hubris.network/`.
2. **Setup Keys** → Create → set reusable + expiry → copy.
3. On the new client (after installing netbird, OR let bootstrap install it and skip its `netbird up` driver):
```bash
sudo netbird up --setup-key <KEY> \
--management-url https://netbird.hubris.network \
--ssh-jwt-cache-ttl 86400
```
**Why we can't OIDC-login from the public internet (still open as a follow-up):**
`auth.hubris.network` resolves publicly to the VPS (`82.165.190.79`), but Traefik on the VPS doesn't currently route that hostname — only `netbird.hubris.network` is exposed. A brand-new client *off the mesh* hitting `auth.hubris.network` directly gets a Traefik default 404. In practice, Path B works fine because the operator's BROWSER (which clicks the device-code URL) is usually on a network that can reach Authentik through the public IONOS IP via some path. But "fresh laptop in a coffee shop with no prior session anywhere" still gets stuck. Future-session fix: add a Traefik route on the VPS forwarding `auth.hubris.network` via the netbird-routed `192.168.8.0/24` to LXC 124.
### DNS prerequisite
`*.hubris.network` resolves via the split-horizon dnsmasq on LXC 124
([dns.md](../../archive/knowledge/infrastructure/dns.md)) for LAN clients, **but only if the
client uses 192.168.8.180 as its resolver**. Most LXCs and roaming
workstations don't by default. Options:
- **LAN client**: set DNS to 192.168.8.180 (per-interface or
`/etc/resolv.conf`).
- **Off-LAN workstation on Netbird**: configure Netbird DNS forwarder to
point `*.hubris.network` at LXC 124.
- **Hack-fix anywhere**: append to `/etc/hosts`:
```
192.168.8.175 mcp.hubris.network secrets.hubris.network
192.168.8.175 git.hubris.network
```
(192.168.8.175 = caddy on LXC 121, terminates all `*.hubris.network`.)
If DNS isn't an option at all, override the URLs at bootstrap time:
```bash
sudo HOMELAB_GITEA_TOKEN=... \
HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/oikos.git \
HOMELAB_ISSUANCE_NETBIRD=http://192.168.8.205:9820/issue \
HOMELAB_MCP_URL=http://192.168.8.205:9810/mcp \
bash /tmp/bootstrap.sh --with-mcp
```
## Install dependencies
Bootstrap auto-installs missing prerequisites (`git`, `python3` + PyYAML, `age`, `sops`, `netbird`) on Fedora/RHEL/Debian/Ubuntu/macOS — no manual `apt`/`dnf`/`brew` needed before running it. The only thing you must have on hand BEFORE the `curl ... | sudo bash` line is `curl` itself (used to pipe the script).
Manual install is still possible (e.g. for air-gapped or unusual platforms); the per-OS recipes are below for reference but optional.
<details>
<summary>Manual recipes (Fedora / Debian / macOS)</summary>
```bash
# Fedora / RHEL / Nobara
sudo dnf install -y git python3-pyyaml age curl
SOPS_VERSION=v3.9.4
sudo curl -fsSL https://github.com/getsops/sops/releases/download/$SOPS_VERSION/sops-$SOPS_VERSION.linux.amd64 \
-o /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
# Debian / Ubuntu
sudo apt update && sudo apt install -y git python3-yaml age curl
SOPS_VERSION=v3.9.4
sudo curl -fsSL https://github.com/getsops/sops/releases/download/$SOPS_VERSION/sops-$SOPS_VERSION.linux.amd64 \
-o /usr/local/bin/sops && sudo chmod +x /usr/local/bin/sops
# macOS
brew install git age sops
pip3 install pyyaml # if `python3 -c "import yaml"` fails
```
</details>
## Run the bootstrap
You need a Gitea read-only personal access token for the initial clone
(the in-cluster shared PAT is encrypted at `secrets/gitea-readonly-pat.yaml`
but a new client can't decrypt it before bootstrap — chicken-and-egg).
Ask the operator (or generate in Gitea: Settings → Applications → Generate
New Token → scope `read:repository`).
```bash
TOKEN=... # your Gitea PAT, scope read:repository
# Fetch bootstrap.sh from gitea (HTTPS uses split-DNS → caddy).
curl -fsSL -u "dtoro:$TOKEN" \
https://git.hubris.network/dtoro/oikos/raw/branch/main/bootstrap.sh \
-o /tmp/bootstrap.sh
# Run it.
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp
```
Flags:
| Flag | Effect |
| --- | --- |
| `--with-mcp` | Merges the homelab MCP server into `~/.claude/.mcp.json` of the invoking user |
| `--no-secrets` | Skips age-key issuance (use when bringing up the first hosts before secrets-issuance exists) |
| `--dry-run` | Prints actions without executing |
The bootstrap is idempotent: re-running on an enrolled client just
verifies state, re-issues the age key only if it doesn't match the
inventory pubkey, and refreshes the sync timer + symlinks.
## Verify
```bash
homelab whoami # prints hosts/$(hostname).yaml
homelab list # shows the full topology
homelab status # ping + HTTP-check across hosts/services
homelab secret hello # decrypt the bootstrap-test secret
systemctl list-timers homelab-context-sync.timer
# next run within ≤5 min
```
For Claude Code: start a new session — the `homelab` MCP server appears
in `~/.claude/.mcp.json` and registers 14 tools (8 context, 5 management,
1 secrets-metadata).
## Post-bootstrap: SSH reachability
A new workstation must be reachable from other workstations and must be
able to reach every host by short hostname. Run these steps after the
bootstrap verify passes:
### 1. Enable SSH server
```bash
# macOS:
sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist
# Linux:
sudo systemctl enable --now sshd
```
### 2. Generate SSH key (if missing)
```bash
ls ~/.ssh/id_ed25519.pub 2>/dev/null || ssh-keygen -t ed25519 -a 100
```
### 3. Publish pubkey to the repo
```bash
cp ~/.ssh/id_ed25519.pub /opt/homelab-context/ssh/authorized_keys/$(hostname -s).pub
cd /opt/homelab-context && git add ssh/authorized_keys/ && git commit -m 'ssh: add $(hostname -s) pubkey' && git push
```
### 4. Deploy keys to all hosts
From any existing enrolled machine (hubris or another workstation):
```bash
ssh root@192.168.8.77 "cd /opt/homelab-context && git pull --ff-only && bash ssh/deploy-keys.sh"
```
This adds the new workstation's pubkey to hubris and every running LXC.
### 5. Generate SSH config
```bash
homelab ssh-config --install
```
Verify:
```bash
ssh hubris hostname # should return "hubris" without password
ssh gitea hostname # should return "gitea" without password
ssh mac-mini hostname # should return "mac-mini" without password (workstation-to-workstation)
```
### 6. Add LAN IP to inventory (if on LAN)
If the workstation has a static or reserved LAN IP, add it to
`inventory.yaml`:
```yaml
hosts:
your-hostname:
lan_ip: 192.168.8.xxx
```
This gives it a primary LAN entry in the generated SSH config (faster
than the Netbird fallback). Commit + push, then:
```bash
cd /opt/homelab-context && git pull --ff-only && homelab ssh-config --install
```
Migrated to a runbook in the knowledge base — query
`search_knowledge("netbird mesh dns")` or `get_entity_knowledge`, or ask
Nomos. Covers: getting onto the Netbird mesh (interactive OIDC vs.
setup-key), why OIDC login can fail from off-mesh, split-horizon DNS
options, and distributing a new workstation's SSH pubkey across the fleet
via `ssh/deploy-keys.sh`.
## Claude Code permissions for fleet ops
By default Claude Code's auto-mode classifier asks for confirmation on every
ssh into the mesh. The bootstrap already installs the ssh ControlMaster block
so subsequent in-session sshes multiplex, but the *first* ssh of each session
still gets classifier-evaluated. Pre-authorize the common fleet ssh patterns
by adding to `~/.claude/settings.json`:
ssh into the mesh. Pre-authorize the common fleet ssh pattern by adding to
`~/.claude/settings.json`:
```json
{
"permissions": {
"defaultMode": "auto",
"allow": [
"Bash(ssh -p 22022 *)",
"Bash(homelab *)"
"Bash(ssh -p 22022 *)"
]
}
}
```
The first rule covers any ssh to a mesh peer on the homelab netbird port; the
second covers all `homelab` CLI invocations. Both are scoped tight enough that
the classifier doesn't gate them but loose enough to handle the variety of
arguments.
This covers any ssh to a mesh peer on the homelab netbird port, scoped tight
enough that the classifier doesn't gate it but loose enough to handle the
variety of arguments.
If you also want the netbird `--ssh-jwt-cache-ttl` flag rationale to be
visible to the classifier (it's not actually durable in 0.71.2, but the
ControlMaster block is — see [runbook-dpkg-interrupted](../skills/runbook-dpkg-interrupted/SKILL.md)
for context), drop a free-text rule into `autoMode.allow` describing the
authorization. Optional.
## Open questions (not verified against current architecture — don't
guess these from the old flow)
## Adding a new client to inventory
The old two-step ceremony had answers for these; the current one-shot
`/api/v1/clients/enroll` flow may handle them differently and this hasn't
been re-verified:
If the hostname you want isn't yet in inventory, enrollment is a two-step
ceremony driven from an existing enrolled client (e.g. hubris). The
`homelab` CLI handles steps 1 + 4; you provide steps 2 + 3.
```bash
# 1. On hubris (or any existing client): add the inventory entry.
homelab client add my-new-machine
# Prompts for kind, os, netbird FQDN, role. Commits + pushes.
# 2. Join the new machine to Netbird (out-of-band, Netbird console / setup key).
# 3. On the new machine: install deps + run bootstrap (above).
# Bootstrap calls /issue, receives a fresh age keypair, and prints the
# public key for the operator to commit back to inventory.
# 4. On hubris: finalize the age public key.
homelab client add my-new-machine --finalize-pubkey age1...
# Updates inventory.yaml hosts.my-new-machine.age_pubkey, regenerates
# inventory.yaml, commits + pushes. The 5-min sync propagates.
```
## Granting a secret to a new client
Adding a client doesn't grant them every secret. Recipients are explicit
per file via `.sops.yaml` glob rules. To grant a client access to (say)
`secrets/hello.yaml`:
1. Edit `.sops.yaml` at the repo root, add the client's `age_pubkey` to
the matching `creation_rules` block.
2. Re-key the existing ciphertext for the new recipient list:
```bash
sops updatekeys -y secrets/hello.yaml
```
3. Commit + push. On the next sync (≤5 min), the client can decrypt.
## Removing a client
```bash
# From any existing client:
homelab client remove my-old-machine
```
This:
1. Removes the inventory entry and `hosts/my-old-machine.yaml`.
2. Runs `sops updatekeys -y` against every file in `secrets/` (operator
must first remove the pubkey from `.sops.yaml` rules).
3. Calls `secrets-issuance` `/revoke` (admin-token-gated, on LXC 105) to
shred the key file and add the hostname to the denylist.
4. Commits + pushes.
The CLI prints a follow-up checklist that the operator must do manually:
- Revoke the peer in the Netbird console (denies future mesh access).
- **Rotate any credentials whose ciphertext the removed client already
has on disk.** The age key revocation only protects *future*
ciphertext; what's already been pulled is still decryptable until the
underlying credential changes.
- Optional: `homelab nuke my-old-machine` SSHes in, shreds
`/etc/age/key.txt`, removes `/opt/homelab-context`, disables sync.
- **Removing a client.** No current equivalent confirmed for the old
`homelab client remove` (inventory removal + secret re-keying + key
revocation). Likely maps to an entity lifecycle transition
(`.agents/skills/lifecycle-deprecate-node/` or `lifecycle-destroy-node/`)
but those skills reference the same dead CLI and need their own check.
- **Granting a secret to an already-enrolled client.** The old flow
hand-edited `.sops.yaml` `creation_rules` + `sops updatekeys`. Given
Infisical is now the primary secrets backend (SOPS is the DR fallback),
the current mechanism is probably Infisical-side, not a `.sops.yaml` edit
— not confirmed.
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `no hosts/<hostname>.yaml in the repo` | Hostname doesn't match inventory entry | Rename either side (see above) |
| `fatal: could not read Username for 'http://192.168.8.121:3000'` | bootstrap.sh's credentials file has wrong scheme | Fixed in commit `de6f8be`; pull latest `bootstrap.sh` |
| `gnutls_handshake() failed: TLS connection was non-properly terminated` cloning `git.hubris.network` | Client DNS resolves `*.hubris.network` to the public VPS IP | Configure split-DNS (LXC 180 / Netbird forwarder) or `/etc/hosts` override; or use `HOMELAB_REPO_URL=http://192.168.8.121:3000/dtoro/oikos.git` |
| `TLS/SSL connection has been closed (EOF)` connecting MCP | Same — `mcp.hubris.network` resolves to public VPS without this vhost | Same DNS fix |
| `Invalid Host header` from MCP server | FastMCP's DNS-rebinding protection (default whitelist is 127.0.0.1 only) | Fixed in commit `6848640`; pull latest `mcp/server.py` and redeploy |
| `python3-yaml` install fails on Fedora | Wrong package name | Use `python3-pyyaml` (Fedora) instead of `python3-yaml` (Debian) |
| `address already in use` for FastMCP | FastMCP defaults to 127.0.0.1:8000 | Fixed: server now sets `mcp.settings.host/port` from env (default `0.0.0.0:9810`) |
| `homelab: no age key at /etc/age/key.txt` even after bootstrap | `/etc/age` is 0700 root, so non-root users couldn't even stat the key file; existence check returned False under regular users | Fixed in commit `df6aca8`: the CLI re-execs `sops -d` via sudo when invoked as a non-root user. On older deployments, re-link the CLI with `sudo ln -sfn /opt/homelab-context/bin/homelab /usr/local/bin/homelab` after the 5-min sync. |
| `homelab` CLI doesn't pick up repo updates | Pre-`02db…` bootstrap copied the binary instead of symlinking | One-time migration: `sudo ln -sfn /opt/homelab-context/bin/homelab /usr/local/bin/homelab`. New bootstraps use the symlink, which auto-tracks the synced repo. |
| `homelab-context-sync.service` journal shows `fatal: could not read Username for 'https://git.hubris.network'` | Pre-fix bootstrap set the gitea credential helper via `git config --global`, which writes to `/root/.gitconfig` — invisible to the systemd timer's git process (no HOME set). | One-time migration: `sudo git config --system credential.helper "store --file=/etc/homelab-context/git-credentials"`. New bootstraps store the helper in `/etc/gitconfig` instead. |
| Enrollment 404s / entity not found | Hostname doesn't match a `planned`/`provisioning` inventory entry | See "Hostname mismatch" above |
| `gnutls_handshake() failed` / TLS errors reaching `*.hubris.network` | Client DNS resolves `*.hubris.network` to the public VPS instead of the LAN/mesh path | See the networking runbook (split-horizon DNS section) |
| Chat-mode `!` shell can't `sudo` (`a terminal is required to read the password`) | Claude Code's `!` invocation doesn't allocate a tty, and standard `sudo` won't read its password from stdin or a non-tty pipe. | Run the sudo'd command in a real terminal outside chat. For commands the agent issues repeatedly, configure passwordless sudo for the narrow set (e.g. `/etc/sudoers.d/homelab-self` with `<user> ALL=(ALL) NOPASSWD: /usr/bin/dnf upgrade -y, /usr/bin/apt-get *`). |
| `netbird status -d` reports `192.168.8.180:53 ... is Unavailable` but DNS actually works | netbird's UDP-53 probe times out over the relay latency (~90ms), but actual queries still flow through systemd-resolved. Cosmetic. | Ignore unless `dig @192.168.8.180 git.hubris.network` also fails — then check dnsmasq on [LXC 124](../../archive/knowledge/containers/106-auth-outpost.md). |
| `netbird ssh` rejected with `JWT authentication failed: validate token (expected issuer=https://netbird.hubris.network/oauth2 ...)` | Peer's SSH JWT validator cached the OLD embedded-Dex issuer from before the 2026-05-21 Authentik migration. `systemctl restart netbird` and `netbird down/up` don't clear it — `client/internal/engine_ssh.go` bails out of `updateSSH()` if the SSH server is already running. | Full daemon bounce: `sudo systemctl stop netbird; sleep 3; sudo systemctl start netbird`. Verify with `grep -iE "issuer\|audience" /var/log/netbird/client.log \| tail`. Apply once per peer post-migration. |
| `netbird ssh` JWT passes but session closes with `user privilege check failed: user dtoro not found: unknown user dtoro` | netbird-ssh defaults the remote username to the LOCAL one (operator's laptop user). Hubris and LXCs only have `root`. | Always use explicit `root@` prefix manually: `netbird ssh -p 22022 root@proxmox-server.netbird.selfhosted`. `homelab ssh <host>` does this automatically via `inventory.yaml`'s per-host `ssh.user` field (defaults to `root`). |
| `homelab ssh hubris` (or any host on the LAN) fails with `Connection refused` or hangs, despite mesh routing being up | Off-LAN networks (operator on a VPN / coffee shop / symmetric NAT) sometimes can't reach the LAN IP even with the netbird subnet route. | Newer homelab CLIs probe the LAN with a 1.5s TCP connect and transparently fall back to the netbird FQDN. If your `/usr/local/bin/homelab` is a symlink to `/opt/homelab-context/bin/homelab` it'll pick up the fix on the next 5-min context sync. Otherwise pull the latest from gitea. |
## Changelog
### 2026-06-02 — SSH reachability post-bootstrap steps
Added a new "Post-bootstrap: SSH reachability" section covering SSH key
generation, pubkey publication, deployment to hosts, SSH config generation,
and LAN IP registration. New workstations enrolled via this doc will
automatically join the universal SSH mesh.
### 2026-05-31 — cross-link to nomos-agent.md
### 2026-07-12 — trimmed to current architecture
Removed everything describing the retired `homelab` CLI-based two-step
enrollment ceremony (now: `CLIENTS.md`'s one-shot flow), the Nous-Hermes/
Goose cross-link (that whole flow was removed the same day), and CLI-syntax
troubleshooting rows with no current equivalent. Migrated the still-true
Netbird/DNS/SSH-distribution content to a knowledge-base runbook rather
than duplicating it here. What's left is genuinely current or explicitly
flagged as unverified. Original ~365-line version is in git history
(`git log -- .agents/operations/agent-enrollment.md`) if any of the removed
detail turns out to still be needed.
Added a sibling page covering Nous-Hermes-on-Goose enrollment ([nomos-agent.md](nomos-agent.md)) and noted it at the top of this page. The Nomos flow extends `bootstrap.sh` with `--with-nomos` and `homelab client add` with the same flag; it does not change the underlying enrollment steps documented here.
### 2026-06-02 — SSH reachability post-bootstrap steps
Added a section covering SSH key generation, pubkey publication,
deployment to hosts, SSH config generation, and LAN IP registration. New
workstations enrolled via this doc automatically join the SSH mesh.
(Superseded 2026-07-12 — migrated to the networking runbook.)
### 2026-05-31 — cross-link to nomos-agent.md
Added a sibling page covering Nous-Hermes-on-Goose enrollment. (Removed
2026-07-12 along with the rest of that flow.)
### 2026-05-21 — netbird-ssh JWT issuer + username + LAN-fallback troubleshooting rows
Added three rows to the troubleshooting table covering issues surfaced during the netbird vanilla migration: (1) post-migration SSH JWT validator cache stuck on old Dex issuer (full `systemctl stop/start` required, not `restart`), (2) `user not found` from netbird-ssh's local-username default (use explicit `root@`), and (3) homelab CLI's LAN→netbird-FQDN fallback for off-LAN operators. Companion code change: per-host `ssh.user` field in `inventory.yaml` + `homelab` CLI's `ssh_target()` helper.
Added three rows to the troubleshooting table covering issues surfaced
during the netbird vanilla migration. (Migrated 2026-07-12 to the
networking runbook.)
### 2026-05-20 — initial page
Captures the enrollment flow validated during Phase 2 of the homelab

View File

@@ -53,34 +53,36 @@ Run from the [hubris host](../../archive/knowledge/hosts/hubris.md) as root. Whe
## Fleet apt operations
Two `homelab` subcommands wrap the common patterns; both fan out to hubris + every LXC.
| Command | What it does |
| --- | --- |
| `homelab apt-audit [--target HOST]` | Per-host table: dpkg-interrupted state, holds, upgradable count, non-apt binaries in system paths, DNS health. Exits nonzero if any host has dpkg-interrupted state. |
| `homelab apt-upgrade --target HOST` | Launch `apt update && apt upgrade` inside a transient `systemd-run --collect` unit on the target. Survives ssh teardown. Apt configured with `Acquire::Retries=3` + `ForceIPv4=true`. |
| `homelab apt-upgrade --all` | Same, fanned out across the standard targets. |
| `homelab apt-upgrade ... --status` | Show running unit + tail `/var/log/homelab-apt-upgrade.log` on each target. |
| `homelab apt-upgrade ... --safe` | Take a pre-upgrade snapshot per LXC first (`pct snapshot``vzdump` fallback for bind-mounted LXCs). Refuses if any snapshot fails unless `--force`. |
| `homelab apt-upgrade ... --force` | Skip both the dpkg-audit gate and snapshot-failure refusal. |
PVE/kernel deferral on hubris: `homelab apt-upgrade --target hubris` will try every upgrade, including kernel + `pve-*`. To skip those, `apt-mark hold` the relevant packages on hubris first; `homelab apt-audit` shows held packages so you can confirm.
**No current CLI equivalent.** `homelab apt-audit`/`apt-upgrade` (dpkg-state
audit, fanned-out apt upgrade with pre-upgrade snapshots) were part of the
retired Python `homelab` CLI and don't have a ported replacement — apt
patching today is ad hoc `run` MCP tool calls per host, without the
audit/snapshot/status wrapping this used to provide. If that wrapping is
still wanted, it needs to be rebuilt (e.g. as a runbook driving `run`, or a
new MCP tool) — see
[runbook-dpkg-interrupted](../skills/runbook-dpkg-interrupted/SKILL.md) for
the dpkg-interrupted recovery procedure specifically.
## Oikos (agent OS layer)
See [OIKOS.md](../OIKOS.md) for the operating model. Quick reference:
See [OIKOS.md](../OIKOS.md) for the operating model. The `homelab` CLI this
section used to document is retired; the actual current interface is the
33 MCP tools cataloged in [AGENTS.md](../../AGENTS.md#3-the-mcp-server) plus
the REST API. Closest current equivalents for what used to live here:
| Command | What it does |
| Old `homelab` command | Current equivalent |
| --- | --- |
| `homelab service <name> explain\|health\|docs\|log\|actions\|history` | Service Console v0 — context card, cached health (`--live` to force a probe), docs, logs, safe actions + risk class, ledger history |
| `homelab node <name> relations` | Ontology blast-radius query: what this host/service impacts, is affected by, and its full transitive blast radius |
| `homelab change preflight <service>` | Dry-run report before mutating: risk class, current health, config repo, verification command |
| `homelab decide <action> <entity>` | Decision classifier: risk × blast radius × confidence → auto-act or escalate |
| `homelab signal list\|raise\|ack\|resolve\|mute` | The attention layer — pending updates, thresholds, drift, anything needing attention |
| `homelab approval request\|list\|reply\|check` | Escalate-route grants (Matrix-delivered via Nomos, or the Oikos Console's `/approvals` page) |
| `homelab restart <service> [--approval-id <id>]` | `--approval-id` is required whenever the service's risk class needs approval (e.g. `caddy`, `dns`) — refuses mechanically without a valid grant |
| `homelab service <name> explain\|health\|docs\|log` | MCP `explain`, `get_service_status`, `tail_log`, `get_entity_knowledge` |
| `homelab node <name> relations` | MCP `get_blast_radius` |
| `homelab change preflight <service>` | MCP `preflight` |
| `homelab signal list\|ack\|resolve\|mute` | MCP `get_signal_history`, or REST `POST /api/v1/signals/{id}/ack\|resolve\|mute` (the control-room UI's Signals page wraps these) |
| `homelab approval request\|list\|reply\|check` | REST `GET/POST /api/v1/approvals*` (Matrix-delivered via the notifier, or the control-room UI's Operations page) |
| `homelab restart <service> --approval-id <id>` | MCP `run` (policy-gated — auto-executes if read-only/reversible_low, otherwise queues for the same Matrix/UI approval) |
| `homelab decide <action> <entity>` | No direct equivalent — classification now happens inline inside `run`/`request_execution`, not as a separate dry-run call |
Oikos Console (read-mostly dashboard): `oikos.hubris.network` once deployed — see [oikos/console/deploy/README.md](../../archive/oikos-cards/).
There is no separately-deployed "Oikos Console" anymore — the control-room
SPA (`web/`) is the operator dashboard, served standalone (see
[plans/2026-07-12-wails-desktop-app.md](../../plans/2026-07-12-wails-desktop-app.md)).
## Related
- [Hubris host](../../archive/knowledge/hosts/hubris.md)

View File

@@ -1,210 +0,0 @@
# Nomos agent — LLM-powered terminal sessions on a homelab client
Onboards [Nous Research's Hermes](https://nousresearch.com/) (a fine-tuned
Llama variant) as a working terminal agent on a homelab client. Builds on top
of standard client enrollment (see [agent-enrollment.md](agent-enrollment.md))
— this page covers only the Hermes-specific additions.
The agent runs as a [Goose](https://goose-docs.ai/) session. Goose provides:
- The chat loop, multi-turn history, and streaming
- The OpenRouter provider that routes to the configured LLM
- The built-in `developer` extension (shell + file editor — same surface Claude
Code has)
- A remote MCP extension pointed at `mcp.hubris.network` for read-only
homelab context (`list_lxcs`, `tail_log`, `search_docs`, etc.)
The persona is `/opt/homelab-context/NOMOS.md`, symlinked as Goose's global
`.goosehints` so it's injected into the system prompt on every session.
## Prerequisites
| Requirement | How |
| --- | --- |
| Standard enrollment complete (`homelab whoami` works) | [agent-enrollment.md](agent-enrollment.md) |
| `secrets/openrouter-api-key.yaml` exists with a real `sk-or-...` value | See "Seeding the OpenRouter key" below |
| The host's `age_pubkey` is on the openrouter-api-key.yaml sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-nomos` |
## Onboarding flow
```bash
# 1. On hubris (or any enrolled client): reserve the inventory entry.
homelab client add new-machine
# 2. Join new-machine to Netbird (setup-key or OIDC).
# 3. On new-machine: bootstrap with --with-nomos.
TOKEN=... # gitea PAT, read:repository
curl -fsSL -u "dtoro:$TOKEN" \
https://git.hubris.network/dtoro/oikos/raw/branch/main/bootstrap.sh \
-o /tmp/bootstrap.sh
sudo HOMELAB_GITEA_TOKEN=$TOKEN bash /tmp/bootstrap.sh --with-mcp --with-nomos
# 4. Back on hubris: finalize the age pubkey AND grant the Nomos secret.
homelab client add new-machine \
--finalize-pubkey age1... \
--with-nomos
# 5. Wait ≤5 min for sync, then on new-machine:
nomos "what LXCs are running?"
```
The bootstrap `--with-nomos` flag does five things, all idempotent:
1. Downloads the latest Goose binary into the operator's `~/.local/bin/goose`
(upstream installer) and symlinks `/usr/local/bin/goose` to it.
2. Symlinks `/opt/homelab-context/bin/nomos``/usr/local/bin/nomos`.
3. Symlinks `/opt/homelab-context/NOMOS.md``/root/NOMOS.md` (Linux) or
`/etc/NOMOS.md` (macOS) for `cat`-as-operator convenience.
4. Drops `~/.config/goose/config.yaml` pinning the provider, model, and
extensions (preserves any keys the operator added by hand).
5. Symlinks `~/.config/goose/.goosehints` → NOMOS.md, so the persona is
injected as the system prompt on every session.
## Seeding the OpenRouter key
The first time anyone enrolls with `--with-nomos`, the encrypted file
`secrets/openrouter-api-key.yaml` contains a placeholder. On hubris (or any
existing recipient):
```bash
sops secrets/openrouter-api-key.yaml
# editor opens; replace api_key value with the real sk-or-... key, save, close.
git -C /opt/homelab-context add secrets/openrouter-api-key.yaml
git -C /opt/homelab-context commit -m 'openrouter-api-key: seed real key'
git -C /opt/homelab-context push
```
Until this step happens, `nomos …` exits with `openrouter-api-key.yaml still
contains the placeholder`. Subsequent enrollees get the real key automatically
via `--with-nomos` (which adds them as a sops recipient on
`secrets/openrouter-api-key.yaml`).
## Granting the OpenRouter key to an already-enrolled host
If a host was enrolled without `--with-nomos` and you want to add it later:
```bash
# On hubris:
PUBKEY=$(homelab whoami --hostname <host> | grep age_pubkey | awk '{print $2}')
homelab client add <host> --finalize-pubkey "$PUBKEY" --with-nomos
```
`--finalize-pubkey` is required by the existing flow even when the pubkey is
unchanged — it's also the trigger that runs the sops grant.
After ≤5 min sync the host can decrypt the key. Bootstrap doesn't need to
re-run; only the secret recipient list changed.
## Verifying
```bash
homelab whoami # standard enrollment OK
homelab secret openrouter-api-key | head -c 8 # decrypts (prints `api_key:`)
which goose && which nomos # binaries present
goose info -v # provider/model wiring sane
nomos "what LXCs are running?" # interactive Goose session
# Non-interactive smoke test:
echo "List the homelab MCP tools you have available" | nomos
```
## Configuration
The bootstrap-managed keys in `~/.config/goose/config.yaml`:
```yaml
GOOSE_PROVIDER: openrouter
GOOSE_MODEL: deepseek/deepseek-v4-flash
GOOSE_MODE: smart_approve # asks before destructive tool calls
extensions:
developer:
type: builtin
bundled: true
enabled: true
name: developer
timeout: 300
homelab:
type: streamable_http
enabled: true
name: homelab
uri: https://mcp.hubris.network/mcp
timeout: 60
```
Override via env on a single bootstrap run:
```bash
HOMELAB_NOMOS_MODEL=nousresearch/hermes-3-llama-3.1-405b \
HOMELAB_NOMOS_MCP_URI=https://mcp.hubris.network/mcp \
sudo bash /tmp/bootstrap.sh --with-nomos
```
Any keys you add by hand (e.g. `GOOSE_TEMPERATURE`, extra `extensions.*`) are
preserved across re-bootstraps — the merge only overwrites the keys it manages.
## Tool permissions
`GOOSE_MODE: smart_approve` is the bootstrap default: Goose runs read-only
shell commands without prompting and asks for confirmation before destructive
ones. To make the agent fully unattended (e.g. for scheduled jobs), set
`GOOSE_MODE: auto` in `~/.config/goose/config.yaml`. To require confirmation on
every tool call, use `approve`. See
[goose-permissions](https://goose-docs.ai/docs/guides/managing-tools/goose-permissions/).
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `nomos: could not decrypt secrets/openrouter-api-key.yaml` | Host isn't a recipient on the sops rule | `homelab client add <host> --finalize-pubkey <age1...> --with-nomos` from hubris |
| `nomos: openrouter-api-key.yaml still contains the placeholder` | No real key has been seeded yet | See "Seeding the OpenRouter key" above |
| Goose hangs on first `nomos` invocation with no output | Goose's interactive `configure` ran on first launch and is awaiting input | Re-run; the installer is supposed to skip it (CONFIGURE=false). If it persists, run `goose configure` once manually in a real terminal to commit the config. |
| `homelab` extension fails to connect / no MCP tools listed | MCP server upgraded in Go rewrite (`internal/mcp/server.go`, Streamable HTTP via official MCP SDK). Old FastMCP SSE transport is deprecated. | Run `docker compose --profile full up` on mac-mini, or wait for the production cutover from apps/105. |
| `goose: command not found` after bootstrap | Upstream installer dropped binary in `~/.local/bin/` but `/usr/local/bin/goose` symlink didn't land | Re-run bootstrap with `--with-nomos`; the symlink step is at the end of the install block. If still missing, `ln -sfn ~/.local/bin/goose /usr/local/bin/goose` manually. |
| Tool calls hit OpenRouter rate limits | One shared key across many hosts | Future: per-host keys; for now, see the rate-limits guide referenced in `goose info -v`. |
## Cross-references
- [agent-enrollment.md](agent-enrollment.md) — base client onboarding the
Nomos flow assumes is done.
- [`NOMOS.md`](../NOMOS.md) — the persona the Nomos agent reads on every
session start (via `~/.config/goose/.goosehints`).
- [`bin/nomos`](../../bin/nomos) — the wrapper that decrypts the OpenRouter key
and execs `goose session`.
- [`bootstrap.sh`](../../bootstrap.sh) — the `--with-nomos` flag's install block.
## Follow-ups
1. **Migrate the MCP server to streamable_http.** Goose 1.x deprecated SSE
(`"SSE transport is no longer supported - kept only for config file
compatibility"` in `crates/goose/src/agents/extension.rs`). Our FastMCP
server at `internal/mcp/server.go` uses Streamable HTTP (official MCP SDK). Until
that's changed, the `homelab` MCP extension in Goose will fail to connect.
The developer extension (shell + edit) covers most ops without it; this is
a polish item, not a blocker.
2. **Per-host OpenRouter keys** for billing attribution. Today all Nomos
hosts share one key.
3. **Pin the model version** rather than tracking `nousresearch/hermes-4-405b`
directly — OpenRouter periodically rotates the underlying weights.
4. **Local-inference fallback** (ollama / vllm) once the homelab has a GPU
node. The wrapper, persona, and MCP wiring stay unchanged; only
`GOOSE_PROVIDER`/`GOOSE_MODEL` change.
7. **Caveman auto-setup via post-pull hook.** The sync timer now calls
`tools/post-pull.sh`, which runs any `tools/*.setup.sh` after git pull.
Currently this auto-installs the Caveman npm package, wrapper scripts, and
compact output templates on all agent hosts (*token efficiency*).
## Changelog
### 2026-06-01 — caveman + post-pull auto-setup
Added `tools/post-pull.sh` sync hook that auto-runs `tools/*.setup.sh`
after every git pull. First user: `tools/setup-caveman.sh` installed Caveman
templating + `~/bin/caveman_wrapper.sh` + `~/templates/*.txt` for token-
efficient CLI output. Replaces raw `git pull` in launchd/systemd timers.
Also created `tools/caveman/` with the wrapper script, JS renderer, and
templates — the canonical source for all agent hosts.
Captures the Nomos-on-Goose onboarding flow added in the same commit as
`bootstrap.sh --with-nomos`, `bin/nomos`, the sops rule for
`secrets/openrouter-api-key.yaml`, and the `homelab client add --with-nomos`
extension. MCP streamable_http migration is queued as follow-up #1.

View File

@@ -2,39 +2,46 @@
name: client-enrollment
risk_class: config_mutation
inputs: [hostname, kind, role]
verification: "homelab doctor (on the new client)"
verification: "MCP whoami(hostname) shows the entity active"
docs_update_checklist: [hosts_narrative_page_if_lxc_or_vm]
---
# Client enrollment
Goal: bring a new host (workstation, LXC, VM) into inventory and the
secrets model, with mesh membership only where it's actually needed.
This wraps the existing `homelab client add` flow — see
[operations/agent-enrollment.md](../../operations/agent-enrollment.md) for
the full walkthrough; this runbook is the risk/lifecycle framing.
secrets model, with mesh membership only where it's actually needed. See
[CLIENTS.md](../../../CLIENTS.md#enrollment) for the actual current
flow and [operations/agent-enrollment.md](../../operations/agent-enrollment.md)
for operational notes; this runbook is the risk/lifecycle framing.
1. On any enrolled client: `homelab client add <hostname>` — appends a
`hosts.<name>:` block to `inventory.yaml` (lifecycle `state: planned`
`provisioning`, per [seeds/ontology.yaml](../../../seeds/ontology.yaml)),
commits + pushes.
1. The entity must exist in `planned`/`provisioning` state before the new
host can self-enroll — add a `hosts.<name>:` block to
`seeds/inventory.yaml` and `oikos seed` to ingest it (lifecycle
`planned``provisioning`, per
[seeds/ontology.yaml](../../../seeds/ontology.yaml)).
2. Netbird join is **optional, not a required step** — only needed for
hosts that must be reachable off-LAN (workstations that roam, e.g.
`republic-laptop`, `mac-mini`). A node reachable on the household LAN
(192.168.8.0/24 — most LXCs/VMs) doesn't need it: it's already
reachable directly, and off-LAN clients reach it too via hubris's
routed `192.168.8.0/24` Netbird network resource. Skip this step for
LAN-only nodes; do it (out-of-band, console or setup key) only for
hosts that need independent off-LAN reachability.
3. On the new host: run `bootstrap.sh` (add `--with-nomos` to also
enroll the Hermes agent). This provisions `/etc/age/key.txt`, the
sync timer, and prints an age pubkey.
4. Back on an enrolled client: `homelab client add <hostname>
--finalize-pubkey <age1...>` — sets `age_pubkey`, grants shared
secrets, re-keys SOPS, commits + pushes. This is the
`provisioning → active` transition.
5. Verify: `homelab doctor` on the new client should show all checks
green (clone, sync timer, age key, CLI symlink, MCP reachable).
`mac-mini`). A node reachable on the household LAN (192.168.8.0/24 —
most LXCs/VMs) doesn't need it. Skip for LAN-only nodes; do it
(out-of-band, console or setup key) only for hosts that need
independent off-LAN reachability.
3. On the new host: run `bootstrap.sh`. This calls
`POST /api/v1/clients/enroll`, which validates the entity exists and
the mesh IP is in an expected subnet, then returns an age keypair and
Infisical machine identity in one response — provisions
`/etc/age/key.txt`, `/etc/infisical/identity`, and the context poller.
4. **Known gap, confirmed 2026-07-12: `provisioning → active` has no
working path.** `EnrollClient` (`internal/httpapi/impl.go`) sets the
entity's state to `provisioning`, never `active`. `bootstrap.sh` prints
`POST /api/v1/clients/ws:$HNAME/activate` as the next step, but that
route doesn't exist — `api/openapi.yaml` only has `/clients/enroll`,
`/clients/{slug}/context`, `/clients/{slug}/secrets`. Until this is
fixed (add the route, or use the generic entity PATCH to flip `state`),
a freshly-enrolled client is stuck in `provisioning` — MCP `preflight`
and policy's `lifecycle_overrides` for `provisioning` still apply, but
nothing transitions it onward automatically.
5. Verify: MCP `whoami(hostname)` shows the entity in `active` state with
its peers and health.
Docs-update checklist: if the new host is an LXC/VM, add its narrative
page under `containers/` or `vms/` and set `doc_page` in its inventory

View File

@@ -2,7 +2,7 @@
name: config-change-deploy
risk_class: config_mutation
inputs: [service_name, change_description]
verification: "curl -sf <service_url> (or homelab service <name> health)"
verification: "curl -sf <service_url> (or MCP get_service_status)"
docs_update_checklist: [doc_page, changelog]
---
@@ -11,11 +11,10 @@ docs_update_checklist: [doc_page, changelog]
Goal: change a tracked config repo (Caddy, Gitea customizations, an app's
own repo) and get it live, safely.
1. `homelab change preflight <service>` — current health, the service's
`config_repo`, its risk class, and the verification command to run
after. If risk class requires approval (`config_mutation` or
`destructive`), stop and get operator sign-off before editing — see
`seeds/policy.yaml`.
1. MCP `preflight` — current health, the service's `config_repo`, its
risk class, and the verification command to run after. If risk class
requires approval (`config_mutation` or `destructive`), stop and get
operator sign-off before editing — see `seeds/policy.yaml`.
2. Clone/pull the `config_repo` (never edit the backend's working tree
directly — tracked configs change by commit + push, per
[OIKOS.md](../../OIKOS.md) conventions).
@@ -24,10 +23,10 @@ own repo) and get it live, safely.
[infrastructure/auto-deploy.md](../../../archive/knowledge/infrastructure/auto-deploy.md) for
the exact receiver/reload for this service).
5. Run the preflight's verification command. If it fails, check
`homelab service <name> log` for the reload/restart error.
6. Record the change: once `oikos/ledger.py` is wired into deploy tooling
(Week 3), this is automatic; until then, note the change and outcome
in the relevant investigation/plan doc.
MCP `tail_log` for the reload/restart error.
6. No manual record-keeping step needed — mutations made through the API
(e.g. via the `run` MCP tool) are recorded automatically in the
`audit_log` table.
Docs-update checklist: update the service's `doc_page` if the change
alters its behavior, ingress route, or ownership; add a changelog entry

View File

@@ -10,22 +10,23 @@ docs_update_checklist: [investigations_entry]
Goal: understand what broke and why, before touching anything.
1. `homelab service <name> explain` (or `homelab node <name> relations`
if the affected entity is a host) — get the blast radius and doc
pointer first. Don't start pulling logs blind.
2. `homelab service <name> health` + `homelab service <name> log` (or
MCP `get_service_status` / `tail_log`) for the affected service.
1. MCP `explain` (or `get_blast_radius` if the affected entity is a
host) — get the blast radius and doc pointer first. Don't start
pulling logs blind.
2. MCP `get_service_status` + `tail_log` for the affected service.
3. Walk the blast radius: is a shared dependency down (`caddy`, `dns`,
`authentik`, or the backend host itself)? `homelab node <name>
relations` shows "affected by" — check those first.
4. `homelab apt-audit` if the symptom looks like a dpkg/upgrade
interaction.
`authentik`, or the backend host itself)? MCP `get_blast_radius`
shows "affected by" — check those first.
4. If the symptom looks like a dpkg/upgrade interaction, see
[runbook-dpkg-interrupted](../runbook-dpkg-interrupted/SKILL.md) —
there's no fleet-wide apt-audit tool anymore, check the host directly.
5. Check the change ledger for recent mutations to the affected entity
or anything upstream of it: `homelab service <name> history` (once
populated) or grep `ledger/*.jsonl`.
6. Write findings to a new `knowledge/sources/investigations/<date>-<slug>.md` — symptom,
timeline, root cause, fix applied, prevention. This is the durable
record; don't rely on chat history.
or anything upstream of it: MCP `get_change_history` or `get_audit_trail`.
6. Write findings via MCP `upsert_knowledge` (`kind: investigation`) —
symptom, timeline, root cause, fix applied, prevention, `about` set to
the affected entity's slug. The DB is the durable record now, not a
markdown file — `search_knowledge`/`get_entity_knowledge` read it back;
a chat message alone is forgotten.
Docs-update checklist: always create the investigation entry. If the
root cause was stale/wrong inventory data (a `doc_page`, `config_repo`,

View File

@@ -2,7 +2,7 @@
name: lifecycle-activate-node
risk_class: config_mutation
inputs: [node_name]
verification: "homelab service <name> health (if it hosts a service); homelab doctor (if it's a client)"
verification: "MCP get_service_status (if it hosts a service); MCP whoami (if it's a client)"
docs_update_checklist: [doc_page_complete]
transition: "provisioning -> active"
---
@@ -14,23 +14,22 @@ enrolled if it needs secrets, mesh joined if it needs off-LAN reach,
ingress live if public, health check answering, doc page complete,
ledger entry.
1. If the node is a `homelab` client: finish enrollment per
[client-enrollment.md](../client-enrollment/SKILL.md) (`--finalize-pubkey`,
mesh join, `homelab doctor` green).
1. If the node self-enrolls as a client: finish enrollment per
[CLIENTS.md](../../../CLIENTS.md#enrollment) (`bootstrap.sh`
`/api/v1/clients/enroll`, mesh join, MCP `whoami` returns the entity).
2. If it hosts a public service: add the `services:` entry in
`inventory.yaml` (backend, url, doc_page, config_repo, risk_notes —
see the Week-1 service contract fields) and wire the Caddy route in
`dtoro/caddy-conf`.
3. Confirm the health check answers: `homelab service <name> health` or
a direct `curl`.
`seeds/inventory.yaml` (backend, url, doc_page, config_repo,
risk_notes) and wire the Caddy route in `dtoro/caddy-conf`.
3. Confirm the health check answers: MCP `get_service_status` or a
direct `curl`.
4. Flip `state: provisioning``state: active` (or delete the `state:`
field — `active` is the default) in `inventory.yaml`.
field — `active` is the default) in `seeds/inventory.yaml`, then
`oikos seed` to ingest.
5. Complete the doc page (stub → full narrative: role, specs, how it's
configured, dependencies).
6. Record the activation: `oikos/ledger.py append host:<name> activate
config_mutation --result ok` (or let the CLI wrapper do this once
Week 3's runbook automation lands).
6. No manual record-keeping step needed — the activation (via whatever
API call flipped the state) is recorded automatically in `audit_log`.
Regenerate derived data: `python3 mcp/build_host_files.py && python3
inventory.yaml` so `inventory.yaml`, the topology diagram, and
the context card all reflect the new state.
Regenerate: `oikos seed` re-ingests `seeds/inventory.yaml`; `oikos export`
writes DB state back out to the YAML if you mutated via the API/MCP
instead of editing the file directly.

View File

@@ -2,7 +2,7 @@
name: lifecycle-deprecate-node
risk_class: config_mutation
inputs: [node_name, replacement_node_or_reason]
verification: "homelab node <name> relations — 'affected by' must be empty before completing"
verification: "MCP get_blast_radius — 'affected by' must be empty before completing"
docs_update_checklist: [doc_page_deprecation_note]
transition: "active -> deprecated"
---
@@ -16,14 +16,14 @@ suggestion; `seeds/policy.yaml` `lifecycle_overrides.deprecated.refuse`
lists `new-inbound-edges` as refused going forward.
1. Set `state: deprecated` on the node.
2. `homelab node <name> relations` — read `affected_by`. Every entry
there is something still relying on this node.
2. MCP `get_blast_radius` — read `affected_by`. Every entry there is
something still relying on this node.
3. Migrate or retire each dependent one at a time (point its `backend`/
`config_repo`/ingress route elsewhere, or deprecate it too if it's
being retired alongside).
4. Re-run `homelab node <name> relations` after each dependent is moved.
The transition to `destroyed` is only safe once `affected_by` is
empty — check this every time, don't assume from memory.
4. Re-run MCP `get_blast_radius` after each dependent is moved. The
transition to `destroyed` is only safe once `affected_by` is empty —
check this every time, don't assume from memory.
5. Note the deprecation on the doc page: reason, replacement (if any),
date.

View File

@@ -2,7 +2,7 @@
name: lifecycle-destroy-node
risk_class: destructive
inputs: [node_name]
verification: "homelab node <name> relations returns unknown-entity; pct list on the backend no longer shows it"
verification: "MCP get_blast_radius returns unknown-entity; pct list on the backend no longer shows it"
docs_update_checklist: [archaeology_entry, containers_index_update]
transition: "deprecated -> destroyed"
---
@@ -15,28 +15,35 @@ recipients removed + re-keyed, ingress/DNS removed, archaeology entry,
ledger entry.
1. Confirm the node is `deprecated` with zero `affected_by` edges
(`homelab node <name> relations`) — do not skip this even if the
deprecation runbook was followed recently; state can drift.
2. If it's an enrolled client: `homelab client remove <name>` — revokes
the age key, re-keys SOPS, removes the inventory entry. This is
already destructive-class and confirmed in the CLI.
(MCP `get_blast_radius`) — do not skip this even if the deprecation
runbook was followed recently; state can drift.
2. **If it's an enrolled client: no current tool for revoking its age key /
removing its Infisical identity.** The old `homelab client remove`
(age key revocation + SOPS re-key + inventory removal, all one
destructive-class CLI call) is retired along with the rest of that CLI
and hasn't been re-verified against the current enrollment
architecture (`POST /api/v1/clients/enroll` + Infisical machine
identities) — see the "Open questions" section in
[agent-enrollment.md](../../operations/agent-enrollment.md). Until
that's confirmed, treat key/identity revocation as a manual step:
at minimum remove the client's `age_pubkey` from any SOPS recipient
lists and rotate credentials whose ciphertext it already decrypted.
3. Remove any ingress route (Caddy config repo) and DNS record still
pointing at it.
4. Verify backups of anything on it are retained per policy before the
disk goes away (see `backs-up-to`).
5. Destroy the LXC/VM (`pct destroy` / `qm destroy`).
6. Move the `hosts.<name>:` block (if any inventory remnant survives
`client remove`, e.g. infra-only LXCs with no age key) into
inventory.yaml's `archaeology:` section: `pve_id`, `destroyed` date,
`reason`. Add a row to `containers/index.md` "Recently destroyed"
table (kept for human-readable browsing alongside the structured
data).
7. `oikos/ledger.py append host:<name> destroy destructive --result ok`.
8. Regenerate: `python3 mcp/build_host_files.py && python3
inventory.yaml` — the node drops out of `inventory.yaml` and
appears in the topology doc's archaeology table.
6. Update the entity's `state` to `destroyed` in `seeds/inventory.yaml`
(or move it to an `archaeology:`-style section if the schema still has
one) — `pve_id`, `destroyed` date, `reason` — then `oikos seed` to
ingest. Add a row to `containers/index.md` "Recently destroyed" table
(kept for human-readable browsing alongside the structured data).
7. No manual ledger step — mutations through the API are recorded
automatically in the `audit_log` table (MCP `get_audit_trail`,
`get_change_history`). The old `oikos/ledger.py append` was retired
when this became automatic.
If the destroy fails partway (e.g. secrets revoked but pct destroy
errors), do not re-run step 2 — `client remove` is not idempotent
against a second revocation attempt on the issuance server. Finish the
remaining steps manually and note the partial state in an investigation.
If the destroy fails partway (e.g. secrets not fully revoked but pct
destroy errors), finish the remaining steps manually and note the
partial state in an investigation (MCP `upsert_knowledge`,
`kind: investigation`).

View File

@@ -2,7 +2,7 @@
name: lifecycle-migrate-node
risk_class: config_mutation
inputs: [node_name, source_host, target_host]
verification: "homelab node <name> relations (re-check blast radius); homelab service <svc> health for every hosted service"
verification: "MCP get_blast_radius (re-check blast radius); MCP get_service_status for every hosted service"
docs_update_checklist: [doc_page_migration_note, inventory_host_and_lan_ip]
transition: "active -> migrating -> active"
---
@@ -10,12 +10,12 @@ transition: "active -> migrating -> active"
# Lifecycle: migrate a node
Modeled on the strong Phase 1+2 migration
([plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md](../../../.hermes/plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md)).
([archive/hermes-plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md](../../../archive/hermes-plans/2026-06-03_110000-library-ssd-migration-to-ludo-mini.md)).
Requires (ontology): preflight + backup-verified before migrating;
post-verify + Caddy backends checked + mounts checked + docs updated
before returning to `active`.
1. `homelab change preflight <every service the node hosts>` — capture
1. MCP `preflight` for every service the node hosts — capture
current health as a baseline.
2. Verify backups are current for anything with data at rest on the
node (see `backs-up-to` edges once populated).
@@ -29,11 +29,12 @@ before returning to `active`.
6. Post-verify: re-run the Week-1 drift check by hand — confirm Caddy's
backend IP for each affected service matches the new `lan_ip`
(automatic in Week 3's drift detector), confirm mounts still resolve.
7. `homelab service <name> health` for every service the node hosts.
7. MCP `get_service_status` for every service the node hosts.
8. Set `state: active`. Add a migration note to the node's doc page
(old host/IP → new, date, phase reference) — this repo's convention
for every past migration (see `archive/knowledge/containers/101-jellyfin.md`,
`containers/129-house.md`).
Regenerate: `python3 mcp/build_host_files.py && python3
inventory.yaml`.
Regenerate: `oikos seed` (re-ingests `seeds/inventory.yaml` into the DB —
the DB is authoritative at runtime, the YAML is the source of truth
on disk).

View File

@@ -21,10 +21,12 @@ chosen, doc page stub.
`qm create`), choosing the storage pool deliberately — record it as
the `storage:` field once populated (Week 1 schema; not yet backfilled
for existing nodes).
2. Add the inventory entry: `homelab client add <name>` for anything that
will run the `homelab` CLI, or a direct `hosts.<name>:` block with
`state: provisioning`, `kind`, `host`, `pve_id`, `lan_ip` for
infra-only LXCs that won't self-enroll.
2. Add the inventory entry: a `hosts.<name>:` block in
`seeds/inventory.yaml` with `state: provisioning`, `kind`, `host`,
`pve_id`, `lan_ip`, then `oikos seed` to ingest it. For anything that
will self-enroll as a client afterward (see
[CLIENTS.md](../../../CLIENTS.md#enrollment)), the entity must exist in
`planned`/`provisioning` state before `bootstrap.sh` runs there.
3. Stub the doc page (`containers/<pve_id>-<name>.md` or
`vms/<pve_id>-<name>.md`) — even a one-line "provisioning, see plan X"
is enough to satisfy the transition requirement.

View File

@@ -18,7 +18,7 @@ summarised into targets and fixed costs.
- `yuvomi-mcp` is running on LXC 129 and connected as an MCP server in Claude.
- The CSV is an N26 export (columns: Booking Date, Value Date, Partner Name,
Partner Iban, Type, Payment Reference, Account Name, Amount (EUR), …).
- API token: `homelab secret yuvomi-api-token` (decrypts on any enrolled client).
- API token: `yuvomi-api-token`, via Infisical (primary) or `oikos secret` (SOPS fallback).
- Direct API base: `https://house.hubris.network/api/v1`
---

View File

@@ -13,7 +13,8 @@ has packages that are **unpacked but not configured**. Symptoms:
manually run 'dpkg --configure -a' to correct the problem.`
- `dpkg --audit` lists packages with header
`The following packages have been unpacked but not yet configured.`
- `homelab apt-audit` shows `DPKG: DIRTY(N)` for the host.
- `dpkg --audit` on the host directly shows unpacked-not-configured packages
(there's no fleet-wide audit tool anymore — check per-host).
The system is still running the **old** binaries (still in memory), but the
**new** binaries are unpacked and waiting for their postinst to run. Two
@@ -33,19 +34,20 @@ config dirs, capabilities, etc.). The system might not come back up cleanly.
## Path A — target is still reachable over ssh (preferred)
```
homelab ssh <host> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
ssh <host> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
```
Or for an LXC by name:
Or for an LXC by name (via the MCP `run` tool, or directly on the Proxmox
host):
```
homelab pct <lxc> exec -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
pct exec <lxc> -- bash -c 'DEBIAN_FRONTEND=noninteractive dpkg --configure -a && apt -y -o Dpkg::Options::=--force-confold upgrade'
```
When that returns, confirm:
```
homelab apt-audit --target <host>
ssh <host> -- dpkg --audit
```
Expect `DPKG: ok` and the remaining `UPGR` count to match what's intentionally
@@ -89,10 +91,11 @@ DEBIAN_FRONTEND=noninteractive dpkg --configure -a \
## Prevention
The `homelab apt-upgrade` wrapper launches apt inside a `systemd-run --collect`
unit on the target, so it survives ssh teardown — the failure mode that put
hubris into this state in the first place is no longer reachable through the
standard tool. If you absolutely need to run apt manually over ssh, wrap it:
The old `homelab apt-upgrade` wrapper (retired along with the rest of the
`homelab` CLI) used to launch apt inside a `systemd-run --collect` unit on
the target so it survived ssh teardown — that's the failure mode that put
hubris into this state in the first place. There's no fleet-wide wrapper
anymore; if you run apt manually over ssh, wrap it yourself the same way:
```
ssh <host> systemd-run --unit=apt-recovery --collect bash -c 'apt -y upgrade'

View File

@@ -2,7 +2,7 @@
name: service-health-check
risk_class: read_only
inputs: [service_name]
verification: "homelab service <name> health"
verification: "MCP get_service_status"
docs_update_checklist: []
---
@@ -10,15 +10,15 @@ docs_update_checklist: []
Goal: determine whether a service is actually healthy, without ad-hoc SSH.
1. `homelab service <name> explain` — read the context card: backend,
blast radius, doc pointer, risk notes.
2. `homelab service <name> health` — live health probe (HTTP code against
the service's `url`/`endpoint`). Once the Week-3 scheduler ships, this
reads a cached snapshot by default; pass `--live` to force a fresh probe.
3. If unhealthy, `homelab service <name> log` (or MCP `tail_log`) for the
last 200 lines.
4. Cross-check blast radius: `homelab node <name> relations` — is this
entity's own backend host healthy? A downstream failure (e.g. `strong`
1. MCP `explain` — read the context card: backend, blast radius, doc
pointer, risk notes.
2. MCP `get_service_status` — live health probe (HTTP code against the
service's `url`/`endpoint`); the scheduler also probes on its own
interval, so this may reflect a recent cached result, not necessarily
a fresh one.
3. If unhealthy, `tail_log` for the last 200 lines.
4. Cross-check blast radius: MCP `get_blast_radius` — is this entity's
own backend host healthy? A downstream failure (e.g. a Proxmox host
down) will show up here before the service's own logs explain anything.
5. If the fix is a restart: classify first (`seeds/policy.yaml`
`service-restart` is `reversible_low` unless the service has a

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 }}

24
.gitignore vendored
View File

@@ -2,25 +2,23 @@
__pycache__/
*.pyc
# Regenerated every scheduler run; ephemeral health-probe cache.
oikos/state.json
# Compiled binaries (Go rewrite — bin/oikos, bin/nomos)
bin/oikos
bin/nomos
oikos/oikos
oikos
webhook
# Legacy Python oikos (superseded by cmd/oikos Go binary — Phase 1-6 rewrite).
# oikos/ kernel files are still imported by bin/homelab for operational CLI
# commands (ssh, pct, logs, restart, status, open, secret, client, sync, mcp).
# Remove oikos/* when bin/homelab is ported to Go.
backups/
.env
.infisical-credentials
# Web UI (Svelte 5) — build artifacts. Ignore built output but keep the
# .gitkeep placeholder so `//go:embed all:dist` (web/embed.go) compiles on a
# fresh checkout before the UI is built.
web/dist/*
!web/dist/.gitkeep
# Web UI (Svelte 5) — build artifacts. The SPA is a standalone static build,
# deployed separately from the oikos binary (plans/2026-07-12-wails-desktop-app.md
# 0.1), so the output dir is just a build artifact.
web/dist/
web/node_modules/
# Wails desktop app — frontend copy for embedding
cmd/desktop/frontend/dist/
cmd/desktop/build/
cmd/desktop/oikos-desktop

104
AGENTS.md
View File

@@ -34,7 +34,8 @@ Run `hostname` (Linux) or `scutil --get LocalHostName` (macOS), then read:
That file tells you your role, your peers, what's mounted, and what services
you host. If it does not exist, this client was not enrolled — stop and tell
the operator to run `homelab client add <hostname>` from an existing client.
the operator; see [CLIENTS.md](CLIENTS.md#enrollment) for the enrollment flow
(the entity needs to exist in `planned`/`provisioning` state first).
## 2. The topology
@@ -49,17 +50,24 @@ the operator to run `homelab client add <hostname>` from an existing client.
## 3. The MCP server
The homelab exposes a Model Context Protocol server with structured tools.
Endpoint: `https://mcp.hubris.network/mcp`.
Endpoint: `https://mcp.hubris.network/mcp`. Every call needs
`Authorization: Bearer <token>` — the API has no unauthenticated path except
enrollment and `/healthz` (see "Authentication" below for where the token
comes from).
Available tools (21 total):
Available tools (33 total):
Context — observe + orient:
get_entity(slug), list_entities(type, limit, cursor),
get_relations(entity), get_blast_radius(entity),
search_knowledge(query) — ILIKE search over documents, investigations,
runbooks in the knowledge_entities table
get_entity_knowledge(entity_slug) — every document, investigation, and
runbook linked to one entity, in one call
get_patterns(status, entity_type, action) — learned action patterns
get_skills(status) — available automation skills
http_get(url) — fetch a public page/raw file (e.g. researching how to
deploy something before provisioning it); HTTP/HTTPS only, ~16KB cap
Management — live state:
get_service_status(service_slug) — systemctl is-active on target host
@@ -67,6 +75,8 @@ Available tools (21 total):
list_lxcs() — all LXC containers with ID, host, IP, health
get_lxc_state(lxc_slug) — pct status from Proxmox host
ping_service(service_slug) — HTTP reachability from entity_status
list_my_secrets(caller_pubkey) — secrets accessible to this client by
age public key
Oikos — decisions:
explain(service_slug) — compact context card (type, state, health, relations)
@@ -84,11 +94,27 @@ Available tools (21 total):
get_trend(entity_id, days=7) — metric slope over time
get_event_timeline(severity, entity_slug, limit) — recent events
Execution — the single mutation path:
request_execution(target, action, params) — policy-gated.
reversible_low (restart, reload, pct_exec, apt audit) runs immediately;
config_mutation (systemctl enable/disable, apt upgrade) queues for operator
approval via Matrix, then executes on ✅.
Knowledge — keep the graph current (none require approval; this updates
the knowledge graph, not live infrastructure):
upsert_knowledge(title, content) — record what you learned after solving
a non-obvious problem; the only way anything persists past a session
update_entity_attributes(slug, attributes) — merge a discovered fact
(IP, version, port, ...) into an entity so a future task doesn't
rediscover it from scratch
create_relationship(source, target, type) — record a discovered edge
(depends-on, hosts, routes-to, ...) between two entities
Execution — mutating the live infrastructure:
run(target, command) — the general execution primitive. Run any shell
command against a host or LXC; every command is auto-classified —
read-only inspection runs immediately, anything state-changing needs
operator approval, and destructive patterns (rm -rf, dd, mkfs,
pct/qm destroy, DROP TABLE, reboot, curl-pipe-to-shell, ...) always
need approval regardless of what you declare. Prefer this over
request_execution for anything not already covered by its fixed enum.
request_execution(target, action, params) — the older, fixed-enum path
(restart, systemctl, pct_exec, apt_upgrade, pct_create). Still the
route for those specific actions; policy-gated the same way `run` is.
get_execution_status(execution_id) — poll progress
**When to prefer MCP over grepping the clone:** always for knowledge queries.
@@ -97,7 +123,18 @@ the DB with entity links. `get_entity_knowledge("lxc:jellyfin")` returns documen
runbooks, and investigations in one call. Grep the clone only when MCP is
unreachable.
## 4. Knowledge conventions
## 4. Authentication
Every API/MCP route requires `Authorization: Bearer <token>` except
`POST /api/v1/clients/enroll` and `/healthz`. Enrollment (see
[CLIENTS.md](CLIENTS.md#enrollment)) does not currently issue a per-client
API/MCP bearer token — there is one shared
secret (`OIKOS_MCP_BEARER_TOKEN`, validated in `internal/httpapi/server.go`'s
`combinedAuth`); get it from the operator until per-client token issuance
exists. The SPA has its own flow instead: a first-launch Config screen that
stores a token in `localStorage` (see `web/src/pages/Config.svelte`).
## 5. Knowledge conventions
All narrative knowledge (documents, investigations, runbooks) lives in the DB
(`knowledge_entities` table) and is seeded from `seeds/knowledge.yaml`. Agents
@@ -122,47 +159,52 @@ per the DB-as-source-of-truth plan.
running state, update the DB *in the same session* via the API. The `oikos export`
command regenerates `seeds/knowledge.yaml` for version control.
## 5. Acting on the homelab
## 6. Acting on the homelab
- **Read state**: use MCP tools. Nomos (the AI agent) is the primary
operator interface — it has 21 MCP tools for observe/orient/decide/act.
- **Actions** (restart, logs, apt, pct exec): Nomos calls `request_execution`
via MCP. `reversible_low` actions execute immediately; `config_mutation`
and `destructive` actions are queued for operator approval via Matrix.
- **Secrets**: managed by Infisical (`oikos secret` subcommand for migration).
Never hardcode secrets — use env vars from `.env`.
operator interface — it has 33 MCP tools for observe/orient/decide/act
(§3).
- **Actions** (restart, logs, apt, pct exec, or anything else): Nomos calls
`run` (the general execution primitive) or `request_execution` (the older
fixed-enum path) via MCP. `reversible_low`/read-only actions execute
immediately; `config_mutation` and `destructive` actions are queued for
operator approval via Matrix or the control-room UI's Operations page.
- **Secrets**: managed by Infisical (`oikos secret` subcommand for
migration). Never hardcode secrets — use env vars from `.env`.
- **Mutations** (restart, edit configs, etc.): classified against
`seeds/policy.yaml`. `reversible_low` actions auto-execute;
`config_mutation`/`destructive` actions require approval.
a valid `--approval-id` from `homelab approval request` — see OIKOS.md.
`config_mutation`/`destructive` actions require approval — granted by
the operator via Matrix reply or the control-room UI, not a CLI flag.
See OIKOS.md.
## 6. Communication mode
## 7. Communication mode
Read and apply `/opt/homelab-context/.agents/shared/caveman.md` (if present). It defines the lab's
terse-communication standard — drop filler, keep substance, use fragments.
## 7. Auto-setup mechanism
## 8. Auto-setup mechanism
The homelab-context repo ships tooling that gets automatically installed
on every client after `git pull`. This is handled by `tools/post-pull.sh`
(replaces the raw git pull in the sync timer) which runs any script matching
`tools/*.setup.sh` after pull.
`tools/setup-*.sh` after pull.
Currently auto-setup:
- **Caveman + templates** (`tools/setup-caveman.sh`): Installs Caveman npm
package, wrapper scripts, and compact output templates for token-efficient
CLI output. Wrapper at `~/bin/caveman_wrapper.sh`.
- **Nomos agent persona** (`tools/setup-nomos-soul.sh`): Provisions
`~/.nomos/SOUL.md` from `NOMOS.md` on Nomos agents. This ensures every
Nomos agent follows the canonical homelab persona (token efficiency, source
of truth hierarchy). No-op on non-Nomos agents.
- **Host checks** (`tools/setup-checks.sh`): Deploys `checks/install.sh`'s
health-check scripts to `/opt/oikos/checks` on each host. The scheduler's
`ssh-script` check kind depends on these actually being there — 20 are
live in the DB as of 2026-07-12.
To add a new auto-setup, create `tools/<name>.setup.sh` in the repo,
To add a new auto-setup, create `tools/setup-<name>.sh` in the repo,
commit and push. All enrolled clients pick it up within 5 minutes.
To trigger sync manually: `sudo homelab sync` or wait for the 5-min timer.
To trigger sync manually: run `/opt/homelab/tools/context-poller.sh`, or
wait for the 5-min timer. (This mechanism — and the server-side
`tools_changed` detection behind it — only correctly recognized
`setup-*.sh` scripts as of 2026-07-12; before that it silently matched
nothing, so nothing auto-ran on any client via this path.)
## 8. When in doubt
## 9. When in doubt
Use MCP tools: `search_knowledge <query>` for narrative context,
`get_entity <slug>` for structured data, `get_entity_knowledge <slug>` for

View File

@@ -24,9 +24,22 @@ Docker stack on mac-mini and exposes an MCP server + REST API.
| State snapshot (health, disk, drift) | MCP `get_state_snapshot` |
| Secrets (Infisical) | REST API + `oikos secret` CLI |
| Approval tokens | Matrix via notifier |
| Run a command on a host/LXC (policy-gated) | MCP `run` |
| Record a discovered fact/relationship | MCP `update_entity_attributes`, `create_relationship`, `upsert_knowledge` |
All MCP tools are read-only. Mutations use the `homelab` CLI with operator
approval.
Most MCP tools are read-only; a few mutate the knowledge graph (recording
what you learned) or the live infrastructure (`run`, `request_execution`),
gated by risk classification and — for `config_mutation`/`destructive`
actions — operator approval. See [AGENTS.md](AGENTS.md#3-the-mcp-server) for
the full tool catalog.
## Authentication
Every API/MCP call needs `Authorization: Bearer <token>` — there is no
unauthenticated path except `POST /api/v1/clients/enroll` and `/healthz`.
Enrollment (below) does not currently hand out a per-client bearer token;
get the shared `OIKOS_MCP_BEARER_TOKEN` from the operator until per-client
token issuance exists.
## Enrollment
@@ -41,7 +54,6 @@ curl -fsSL https://git.hubris.network/dtoro/oikos/raw/main/bootstrap.sh | sudo b
# Or with optional tooling:
curl ... | sudo bash -s -- --with-mcp # wire Claude's MCP config
curl ... | sudo bash -s -- --with-nomos # install Goose + Nomos
```
This calls `POST /api/v1/clients/enroll` on the Oikos API, which:
@@ -56,7 +68,7 @@ This calls `POST /api/v1/clients/enroll` on the Oikos API, which:
### What changes on your machine
- `/opt/homelab/` — agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md)
- `/opt/homelab/tools/` — tooling scripts (caveman, nomos-soul)
- `/opt/homelab/tools/` — tooling scripts (checks)
- `/etc/age/key.txt` — age private key for SOPS decryption (fallback)
- `/etc/infisical/identity` — Infisical machine identity (primary secrets)
- Context poller — launchd/systemd timer hits `GET /api/v1/clients/{slug}/context` every 5 minutes for agent file updates

View File

@@ -9,10 +9,13 @@ repo, see [.agents/dev/CONTRIBUTING.md](.agents/dev/CONTRIBUTING.md).
- **Go 1.26+** (see `go.mod` for pinned version)
- **PostgreSQL with TimescaleDB** — the compose stack includes `timescale/timescaledb:2.17.2-pg16`
- **Docker** for the full dev stack
- **Node 22+** for `web/` (the control-room SPA — standalone, not part of the
compose stack or the `oikos` binary)
```bash
# Start dependencies (Postgres + Redis)
docker compose --profile dev up -d
# Start dependencies (Postgres + Redis). api/nomos require a shared bearer
# token — no dev-open bypass — so set one even for local dev.
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d
# Run all tests
make test
@@ -22,13 +25,21 @@ make test-db
# Build the binary
make build
# SPA dev server (proxies to api/nomos, injecting the same token)
cd web && OIKOS_API_TOKEN=dev-token npm run dev
```
## 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/nomos/ Nomos MCP client gateway
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
internal/ All Go packages
httpapi/ REST + MCP server (OpenAPI-generated)
mcp/ MCP tool implementations
@@ -42,15 +53,20 @@ internal/ All Go packages
domain/ Core types: entities, approvals, signals, patterns
ontology/ Type hierarchy, relationship validation
knowledge/ Knowledge YAML seed ingestion
web/ Control-room SPA (Svelte 5) — standalone, not embedded
in the oikos binary; see plans/2026-07-12-wails-desktop-app.md
api/openapi.yaml API contract — the source of truth for endpoints
migrations/ Forward-only SQL migrations (TimescaleDB)
seeds/ Bootstrap YAML: ontology, inventory, policy, knowledge
compose/ Dockerfiles + Caddy config
scripts/ Deploy, watchdog, rollback
checks/ Host health-check scripts run over SSH by the scheduler
tools/ Client auto-setup scripts (checks)
nomos/ Nomos config, persona, skills
.agents/ Agent instruction files + skills
plans/ Design documents
docs/adr/ Architecture decision records
docs/operations/ Runbooks (rollback, etc.)
```
## Commands
@@ -68,6 +84,12 @@ docs/adr/ Architecture decision records
| `make export` | Export DB state to YAML seeds |
| `make dev` | Start compose dev stack |
| `make clean` | Remove binary + test cache |
| `make ui` | Build the SPA (`web/dist/`) |
| `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 tidy` | `go mod tidy` |
## Conventions

View File

@@ -1,4 +1,4 @@
.PHONY: build test test-db lint generate generate-check dev migrate seed export clean tidy
.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package desktop-release
BINARY := oikos
GO ?= go
@@ -6,6 +6,9 @@ GO ?= go
build:
$(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos
webhook:
$(GO) build -o webhook -tags timetzdata ./cmd/webhook
test:
$(GO) test -race -cover ./...
@@ -42,8 +45,36 @@ export:
dev:
docker compose --profile dev up -d
# Local sanity-check build of the SPA. Not embedded in the oikos binary
# (plans/2026-07-12-wails-desktop-app.md 0.1) — deploys as its own
# container (compose/web/Dockerfile) via `docker compose --profile full
# up -d web`, same push-to-main pipeline as everything else.
ui:
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:
rm -f $(BINARY)
rm -rf cmd/desktop/build
rm -rf cmd/desktop/frontend/dist
$(GO) clean -testcache
tidy:

View File

@@ -13,18 +13,24 @@ learns from outcomes, and escalates when uncertain.
## Quick start
```bash
# Dev stack (postgres + api + scheduler + notifier)
docker compose --profile dev up -d
# Dev stack (postgres + api + scheduler + notifier). The api/nomos
# services need a shared token — every route requires a real bearer
# credential, there's no dev-open bypass.
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d
# Full stack (adds Nomos agent gateway)
docker compose --profile full up -d
OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile full up -d
# Build standalone binary
go build -o bin/oikos -tags timetzdata ./cmd/oikos
# Run all roles in one process (dev mode)
OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" \
OIKOS_API_TOKEN=dev-token \
go run ./cmd/oikos all
# Control-room SPA (separate from the Go binary — see web/)
cd web && OIKOS_API_TOKEN=dev-token npm run dev # http://localhost:5173
```
## Architecture
@@ -59,16 +65,19 @@ OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disa
| 5 — Secrets | ✅ | Infisical backend + SOPS fallback, rotation runbooks |
| 6 — Deploy | ✅ | CI pipeline, cutover checklist, watchdog, rollback |
Full plan: [plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md).
Full plan: [plans/done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](plans/done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md).
## Operations
### API endpoints
```bash
curl http://localhost:8090/api/v1/entities?type=service # fleet
curl http://localhost:8090/api/v1/health # fleet health
curl http://localhost:8090/api/v1/agent-activity # agent log
curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \
http://localhost:8090/api/v1/entities?type=service # fleet
curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \
http://localhost:8090/api/v1/health # fleet health
curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \
http://localhost:8090/api/v1/agent-activity # agent log
```
### Nomos queries
@@ -97,24 +106,40 @@ oikos secret list # enumerate SOPS secrets
oikos secret migrate # SOPS → Infisical
```
### Web UI
`web/` is a standalone Svelte 5 SPA — not embedded in the `oikos` binary, not
part of `docker-compose.yml`. It talks to `api`/`nomos` over HTTP with a
bearer token entered on first launch (see `web/src/pages/Config.svelte`).
Build with `make ui`, deploy with `make deploy-ui` (Caddy serves the static
output). A native desktop wrapper is planned — see
[plans/2026-07-12-wails-desktop-app.md](plans/2026-07-12-wails-desktop-app.md).
## Repo layout
```
cmd/oikos/ Go entry point — single binary
cmd/nomos/ Nomos MCP client gateway
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
internal/ Go packages (httpapi, mcp, scheduler, actuator, learning,
notifier, policy, secrets, db, config, ontology, domain,
knowledge)
web/ Control-room SPA (Svelte 5) — standalone, not embedded
api/openapi.yaml API contract (OpenAPI 3.1)
migrations/ Forward-only SQL migrations (TimescaleDB)
seeds/ Bootstrap YAML (ontology, inventory, policy, knowledge)
compose/ Dockerfiles + Caddy config
scripts/ Deploy, watchdog, verification, rollback
checks/ Host health-check scripts run over SSH by the scheduler
tools/ Client auto-setup scripts (checks)
ssh/ Deploy keys + authorized_keys management
vps/ Caddy/TURN config templates for the netbird VPS
nomos/ Nomos config, persona, skills
.agents/ Agent instruction files, shared conventions, skills
archive/ Historical reference (legacy wiki, plans, SOPS backups)
plans/ Design documents (active + done)
docs/adr/ Architecture decision records
docs/operations/ Runbooks (rollback, etc.)
```
## For agents

View File

@@ -3,15 +3,14 @@
#
# Thin client model (rev 2): no git clone, no sync timer. Fetches only the
# agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md) and tooling
# (caveman, nomos-soul) from the raw Gitea URL. Enrolls via the Oikos API
# to receive an age keypair and Infisical machine identity. A lightweight
# context poller replaces the old 5-minute git pull.
# (checks) from the raw Gitea URL. Enrolls via the Oikos API to receive an
# age keypair and Infisical machine identity. A lightweight context poller
# replaces the old 5-minute git pull.
#
# Usage:
# curl -fsSL https://git.hubris.network/dtoro/oikos/raw/main/bootstrap.sh \
# | sudo bash
# curl ... | sudo bash -s -- --with-mcp # wire Claude's .mcp.json
# curl ... | sudo bash -s -- --with-nomos # install Goose + Nomos
# curl ... | sudo bash -s -- --dry-run # show what would happen
#
# Prerequisites:
@@ -28,16 +27,10 @@ REPO_RAW_URL="${HOMELAB_RAW_URL:-https://git.hubris.network/dtoro/oikos/raw/main
OIKOS_API_URL="${HOMELAB_OIKOS_URL:-https://oikos.hubris.network/api/v1}"
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
MCP_URL="${HOMELAB_MCP_URL:-https://mcp.hubris.network/mcp}"
NOMOS_MCP_URI="${HOMELAB_NOMOS_MCP_URI:-https://mcp.hubris.network/mcp}"
NOMOS_MODEL="${HOMELAB_NOMOS_MODEL:-nousresearch/hermes-4-405b}"
WITH_MCP=0
WITH_NOMOS=0
DRY_RUN=0
GITEA_TOKEN="${HOMELAB_GITEA_TOKEN:-}"
GITEA_USER="${HOMELAB_GITEA_USER:-dtoro}"
# ── helpers ──────────────────────────────────────────────────────────
log() { echo "[oikos] $*"; }
@@ -79,10 +72,7 @@ detect_mesh_ip() {
while [ $# -gt 0 ]; do
case "$1" in
--with-mcp) WITH_MCP=1 ;;
--with-nomos) WITH_NOMOS=1 ;;
--dry-run) DRY_RUN=1 ;;
--gitea-token) GITEA_TOKEN="$2"; shift ;;
--gitea-user) GITEA_USER="$2"; shift ;;
*) die "unknown flag: $1" ;;
esac
shift
@@ -148,7 +138,7 @@ done
# ── fetch tools ──────────────────────────────────────────────────────
log "fetching tools..."
for tool in setup-caveman.sh setup-nomos-soul.sh caveman.js caveman_wrapper.sh post-pull.sh; do
for tool in setup-checks.sh post-pull.sh; do
url="$REPO_RAW_URL/tools/${tool}"
dest="$CLONE_DIR/tools/${tool}"
dry mkdir -p "$(dirname "$dest")"
@@ -161,16 +151,6 @@ for tool in setup-caveman.sh setup-nomos-soul.sh caveman.js caveman_wrapper.sh p
fi
done
# ── fetch caveman templates ──────────────────────────────────────────
for tmpl in git_log.txt git_status.txt test_results.txt; do
url="$REPO_RAW_URL/tools/caveman/templates/${tmpl}"
dest="$CLONE_DIR/tools/caveman/templates/${tmpl}"
dry mkdir -p "$(dirname "$dest")"
if curl -fsSL --connect-timeout 10 "$url" -o "$dest.tmp" 2>/dev/null; then
mv "$dest.tmp" "$dest"
fi
done
# ── detect control-plane (use localhost if API is reachable directly) ─
if [ -z "${HOMELAB_OIKOS_URL:-}" ]; then
if curl -s --connect-timeout 2 http://localhost:8090/api/v1/health >/dev/null 2>&1; then
@@ -300,7 +280,7 @@ case "$OS" in
esac
# ── run auto-setup scripts ───────────────────────────────────────────
for setup in "$CLONE_DIR"/tools/*.setup.sh; do
for setup in "$CLONE_DIR"/tools/setup-*.sh; do
[ -f "$setup" ] || continue
log "running setup: $(basename "$setup")"
dry bash "$setup"
@@ -319,17 +299,6 @@ if [ "$WITH_MCP" -eq 1 ]; then
log " + MCP wired to $MCP_URL"
fi
# ── --with-nomos: install Goose + Nomos wrapper ────────────────────
if [ "$WITH_NOMOS" -eq 1 ]; then
log "installing Nomos agent..."
GOOSE_URL="https://github.com/block/goose/releases/latest/download/goose-${OS}-${ARCH:-amd64}"
if [ "$OS" = Darwin ]; then GOOSE_URL="https://github.com/block/goose/releases/latest/download/goose-darwin-${ARCH:-arm64}"; fi
dry curl -fsSL "$GOOSE_URL" -o /usr/local/bin/goose 2>/dev/null && chmod +x /usr/local/bin/goose || warn "goose not installed"
# Drop Nomos persona
cp "$CLONE_DIR/NOMOS.md" "$CLONE_DIR/.agents/NOMOS.md" 2>/dev/null || true
log " + Nomos agent installed"
fi
# ── netbird SSH JWT cache ────────────────────────────────────────────
if command -v netbird >/dev/null 2>&1; then
dry netbird up --management-url https://netbird.hubris.network --ssh-jwt-cache-ttl 86400 2>/dev/null || true

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

@@ -55,6 +55,7 @@ type agent struct {
agentID uuid.UUID
reqOpts []option.RequestOption
apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals
apiToken string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it (no dev-open bypass)
httpClient *http.Client
}
@@ -114,6 +115,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
agentID: agentID,
reqOpts: reqOpts,
apiBase: apiBase,
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
httpClient: &http.Client{Timeout: 15 * time.Second},
}, nil
}

View File

@@ -164,6 +164,9 @@ func (a *agent) approveExecution(ctx context.Context, execID string) (ok bool, s
return false, "", err
}
req.Header.Set("Content-Type", "application/json")
if a.apiToken != "" {
req.Header.Set("Authorization", "Bearer "+a.apiToken)
}
resp, err := a.httpClient.Do(req)
if err != nil {
return false, "", err

View File

@@ -29,6 +29,10 @@ func main() {
if mcpURL == "" {
mcpURL = "http://localhost:8090/mcp"
}
// api's combinedAuth requires a bearer token on every request (no
// dev-open bypass — plans/2026-07-12-wails-desktop-app.md 0.4); this is
// the same shared secret api validates against (OIKOS_MCP_BEARER_TOKEN).
mcpToken := os.Getenv("OIKOS_MCP_BEARER_TOKEN")
agentSlug := os.Getenv("NOMOS_AGENT_SLUG")
if agentSlug == "" {
@@ -48,12 +52,12 @@ func main() {
// One MCP client PER SESSION, not one shared client for the whole
// process — see mcpClientPool's doc comment. A dedicated client is
// created lazily on each session's first tool call.
clientPool := newMCPClientPool(mcpURL)
clientPool := newMCPClientPool(mcpURL, mcpToken)
// Prove connectivity at startup the same way the old single-client
// constructor did, so a misconfigured/unreachable MCP endpoint still
// fails fast on boot instead of only on the first real chat. Doesn't
// reuse the pool (nothing to key it by yet) — just a throwaway probe.
if probe, err := newMCPClient(mcpURL); err != nil {
if probe, err := newMCPClient(mcpURL, mcpToken); err != nil {
slog.Error("nomos: mcp connect", "url", mcpURL, "error", err)
os.Exit(1)
} else {
@@ -482,6 +486,7 @@ func truncate(s string, n int) string {
type mcpClient struct {
baseURL string
token string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it on every request (no dev-open bypass)
sessionID string
http *http.Client
nextID int
@@ -500,9 +505,10 @@ type mcpClient struct {
toolsCache []toolDef
}
func newMCPClient(baseURL string) (*mcpClient, error) {
func newMCPClient(baseURL, token string) (*mcpClient, error) {
c := &mcpClient{
baseURL: baseURL,
token: token,
http: &http.Client{Timeout: 30 * time.Second},
}
@@ -599,6 +605,9 @@ func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCRespo
if c.sessionID != "" {
req.Header.Set("Mcp-Session-Id", c.sessionID)
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
@@ -721,6 +730,7 @@ func (c *mcpClient) close() {
// at a time within a turn), but no longer block anyone else's.
type mcpClientPool struct {
baseURL string
token string
mu sync.Mutex
clients map[string]*pooledMCPClient
}
@@ -730,8 +740,8 @@ type pooledMCPClient struct {
lastUsed time.Time
}
func newMCPClientPool(baseURL string) *mcpClientPool {
return &mcpClientPool{baseURL: baseURL, clients: make(map[string]*pooledMCPClient)}
func newMCPClientPool(baseURL, token string) *mcpClientPool {
return &mcpClientPool{baseURL: baseURL, token: token, clients: make(map[string]*pooledMCPClient)}
}
// get returns the client for sessionID, creating and initializing one (a
@@ -758,7 +768,7 @@ func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) {
// Initialize outside the lock — it's a network round-trip, and holding
// the pool mutex for it would serialize unrelated sessions' first calls
// behind each other, undermining the whole point of this pool.
c, err := newMCPClient(p.baseURL)
c, err := newMCPClient(p.baseURL, p.token)
if err != nil {
return nil, err
}

View File

@@ -1,17 +1,14 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
@@ -21,50 +18,9 @@ import (
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/scheduler"
"github.com/dtoro/oikos/internal/secrets"
"github.com/dtoro/oikos/web"
"github.com/jackc/pgx/v5"
)
// uiHandler serves the control-room SPA from assets embedded at build time
// (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*;
// the /ui prefix is stripped to index into the embedded dist/ tree. Files are
// written via http.ServeContent (not http.FileServer) to avoid its
// index.html -> "./" canonical redirect, which loops for /ui/.
func uiHandler() http.Handler {
dist, err := web.DistFS()
if err != nil {
slog.Warn("ui: embedded assets unavailable", "error", err)
return http.NotFoundHandler()
}
serve := func(w http.ResponseWriter, r *http.Request, name string) bool {
f, err := dist.Open(name)
if err != nil {
return false
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return false
}
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
return true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/")
if name == "" {
name = "index.html"
}
if serve(w, r, name) {
return
}
// SPA fallback: serve index.html for unknown client-side routes.
if serve(w, r, "index.html") {
return
}
http.NotFound(w, r)
})
}
var schedulerRunner = scheduler.RunnerForMain()
var notifierRunner = notifier.RunnerForMain()
@@ -129,7 +85,7 @@ func main() {
go notifierRunner(ctx, pool, cfg)
slog.Info("all: starting api with scheduler + notifier in background")
if err := httpapi.ListenAndServe(ctx, pool, cfg, uiHandler()); err != nil {
if err := httpapi.ListenAndServe(ctx, pool, cfg); err != nil {
slog.Error("api failed", "error", err)
os.Exit(1)
}
@@ -308,7 +264,7 @@ func runAPI(ctx context.Context, cfg config.Config) error {
return fmt.Errorf("migrations: %w", err)
}
err = httpapi.ListenAndServe(ctx, pool, cfg, uiHandler())
err = httpapi.ListenAndServe(ctx, pool, cfg)
if err == http.ErrServerClosed {
return nil
}

96
cmd/webhook/main.go Normal file
View File

@@ -0,0 +1,96 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"time"
"github.com/dtoro/oikos/internal/safego"
)
func main() {
port := os.Getenv("WEBHOOK_LISTEN")
if port == "" {
port = ":9797"
}
secret := os.Getenv("WEBHOOK_HMAC_SECRET")
if secret == "" {
fmt.Fprintln(os.Stderr, "WEBHOOK_HMAC_SECRET must be set")
os.Exit(1)
}
repoDir := os.Getenv("WEBHOOK_REPO_DIR")
if repoDir == "" {
repoDir = os.Getenv("HOME") + "/Projects/oikos"
}
mux := http.NewServeMux()
mux.HandleFunc("/deploy", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body failed", 400)
return
}
sigHex := r.Header.Get("X-Hub-Signature-256")
if sigHex == "" {
http.Error(w, "missing signature", 401)
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sigHex), []byte(expected)) {
slog.Warn("webhook: invalid signature")
http.Error(w, "invalid signature", 401)
return
}
slog.Info("webhook: deploy triggered")
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"status":"deploy started"}`))
safego.Go("webhook:deploy", func() {
cmd := exec.Command(repoDir + "/scripts/deploy.sh")
cmd.Dir = repoDir
cmd.Env = append(os.Environ(),
"REPO_DIR="+repoDir,
"PROFILE=full",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
start := time.Now()
if err := cmd.Run(); err != nil {
slog.Error("webhook: deploy failed", "error", err, "duration", time.Since(start))
return
}
slog.Info("webhook: deploy succeeded", "duration", time.Since(start))
})
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("ok"))
})
slog.Info("webhook: listening", "port", port)
if err := http.ListenAndServe(port, mux); err != nil {
slog.Error("webhook: serve failed", "error", err)
os.Exit(1)
}
}

View File

@@ -1,35 +1,59 @@
# Caddy reverse-proxy snippet for Oikos — Phase 6 cutover
# Lives in dtoro/caddy-conf repo; auto-deploys to caddy (LXC 121).
# Replaces the old MCP server on apps/105 with the Docker stack on mac-mini.
# Caddy reverse-proxy snippet for Oikos — Phase 6 cutover, updated for the
# client/server split (plans/2026-07-12-wails-desktop-app.md, Phase 0).
# Lives in dtoro/caddy-conf repo; auto-deploys to caddy (LXC 121). THIS COPY
# IS A REFERENCE, NOT DEPLOYED FROM HERE — keep it in sync manually.
#
# The SPA is no longer embedded in the oikos binary; it's built and served
# by its own container (compose/web/Dockerfile, docker-compose.yml's `web`
# service, mac-mini:8091) rather than as static files read off local disk —
# see that service's comment for why. Every API/MCP/agent route now requires
# a bearer token in all cases (api's dev-open bypass was removed) —
# non-browser clients (Wails, curl, a future mobile client) can't complete
# Authentik's browser-session login, so those routes bypass `import
# authentik` the same way the enrollment endpoint always has and rely on
# api's own combinedAuth instead. See the Wails plan's "Plan review"
# section, gap 1.
#
# mac-mini and the LXC subnet are routed, so these target its direct LAN IP
# rather than the mesh (netbird) hostname.
# Oikos REST API (operator) — enrollment endpoint bypasses Authentik
oikos.hubris.network {
tls {
dns ionos {env.IONOS_AUTH_API_TOKEN}
}
@enroll path /api/v1/clients/enroll
handle @enroll {
reverse_proxy <mac-mini-mesh-ip>:8090
reverse_proxy 192.168.178.182:8090
}
# Nomos agent, same-origin for the control-room UI (EventSource/fetch can't
# set cross-origin auth headers). Authentik gates it; handle_path strips
# the /agent prefix so /agent/chat -> nomos /chat.
handle_path /agent/* {
import authentik
reverse_proxy <mac-mini-mesh-ip>:8092
# Bearer-token clients — api's combinedAuth (internal/httpapi/server.go)
# is the real gate for all three; Authentik would just reject non-browser
# callers before they ever get there. /agent/* now goes through api's own
# (auth'd) proxy mount rather than straight to nomos:8092, so it's
# covered by the same check as /api/v1/* and /mcp.
@api path /api/v1/* /mcp /agent/*
handle @api {
reverse_proxy 192.168.178.182:8090
}
# Everything else: the static SPA shell, served by the `web` container.
# No sensitive data lives here — real enforcement is the bearer-token
# check above — Authentik is just a first line of defense against
# anonymous crawlers finding the bundle.
handle {
import authentik
reverse_proxy <mac-mini-mesh-ip>:8090
reverse_proxy 192.168.178.182:8091
}
}
# Oikos MCP endpoint (agents) — no auth required
# Oikos MCP endpoint (agents) — bearer token required (api's combinedAuth),
# no separate gate here.
mcp.hubris.network {
reverse_proxy <mac-mini-mesh-ip>:8090
reverse_proxy 192.168.178.182:8090
}
# Nomos gateway (workstation access) — formerly hermes.hubris.network
# Nomos's own gateway (workstation access) — still has NO auth of its own
# (C1, plans/2026-07-11-nomos-agent-code-review.md, still open). Anyone who
# can reach this host can talk to nomos directly, bypassing api entirely.
# Not fixed by the client/server split — tracked separately.
nomos.hubris.network {
reverse_proxy <mac-mini-mesh-ip>:8092
reverse_proxy 192.168.178.182:8092
}

View File

@@ -1,14 +1,7 @@
# Multi-stage Dockerfile for Oikos (ADR 0001: single binary)
# Stage 1: build web UI
FROM node:22-alpine AS ui-builder
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Stage 2: build Go binary
# Dockerfile for Oikos API server. The SPA is no longer embedded (see
# plans/2026-07-12-wails-desktop-app.md 0.1) — it's built and deployed
# separately as static files (see `make ui` / `make deploy-ui`).
# Stage 1: build Go binary
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache git ca-certificates
@@ -18,8 +11,6 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Bring in the built SPA so //go:embed all:dist (web/embed.go) has real assets.
COPY --from=ui-builder /web/dist ./web/dist
RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos
@@ -31,6 +22,5 @@ RUN apk add --no-cache ca-certificates openssh-client-default
COPY --from=builder /oikos /oikos
COPY --from=builder /build/seeds /seeds
COPY --from=builder /build/migrations /migrations
# web/dist is embedded in the binary (web/embed.go) — no runtime copy needed.
ENTRYPOINT ["/oikos"]

5
compose/web/Caddyfile Normal file
View File

@@ -0,0 +1,5 @@
:80 {
root * /srv
file_server
try_files {path} /index.html
}

18
compose/web/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Dockerfile for the oikos control-room SPA. Built separately from the
# oikos binary (compose/oikos/Dockerfile) — see docker-compose.yml's `web`
# service. The outer production Caddy (caddy-conf repo, LXC 121) handles
# Authentik + splits /api/*, /mcp, /agent/* off to the api service; this
# container only serves static files with SPA-fallback routing.
FROM node:22-alpine AS builder
WORKDIR /build/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM caddy:2-alpine
COPY --from=builder /build/web/dist /srv
COPY compose/web/Caddyfile /etc/caddy/Caddyfile

View File

@@ -1,10 +1,17 @@
# Docker Compose for Oikos development
# Usage: docker compose up -d postgres (just the DB)
# make dev (full dev stack)
#
# The SPA isn't embedded in the oikos binary (see
# plans/2026-07-12-wails-desktop-app.md 0.1/0.6) but it IS part of this
# stack as its own `web` service (compose/web/Dockerfile), so it deploys
# through the same push-to-main pipeline as everything else. `npm run dev`
# in web/ is still the fast local-iteration path.
services:
postgres:
image: timescale/timescaledb:2.17.2-pg16
restart: unless-stopped
environment:
POSTGRES_DB: oikos
POSTGRES_USER: oikos
@@ -51,6 +58,7 @@ services:
build:
context: .
dockerfile: compose/oikos/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"]
depends_on:
seed:
@@ -60,6 +68,12 @@ services:
OIKOS_API_LISTEN: ":8090"
OIKOS_ENV: dev
OIKOS_DEBUG: "true"
# No dev-open auth bypass (plans/2026-07-12-wails-desktop-app.md 0.4) —
# every request needs this token. nomos uses the same value to call
# back into api's /mcp and /api/v1/approvals/*/decision.
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}
NOMOS_PROXY_URL: http://nomos:8092
volumes:
@@ -75,6 +89,7 @@ services:
build:
context: .
dockerfile: compose/oikos/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"]
depends_on:
seed:
@@ -98,6 +113,7 @@ services:
build:
context: .
dockerfile: compose/oikos/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"]
depends_on:
seed:
@@ -119,6 +135,7 @@ services:
build:
context: .
dockerfile: compose/nomos/Dockerfile
restart: unless-stopped
profiles: ["full"]
depends_on:
api:
@@ -129,14 +146,32 @@ services:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro}
DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
# Must match api's OIKOS_MCP_BEARER_TOKEN above — api's combinedAuth
# rejects every request without it now (no dev-open bypass).
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
ports:
- "8092:8092"
stop_signal: SIGTERM
stop_grace_period: 10s
# Control-room SPA — static build served behind Caddy. The outer
# production Caddy (caddy-conf repo, LXC 121) splits /api/*, /mcp,
# /agent/* off to api:8090 and sends everything else here; this
# container only serves static files with SPA-fallback routing.
web:
build:
context: .
dockerfile: compose/web/Dockerfile
restart: unless-stopped
profiles: ["dev", "full"]
ports:
- "8091:80"
stop_signal: SIGTERM
# Redis (required by Infisical — Phase 5)
redis:
image: redis:7-alpine
restart: unless-stopped
profiles: ["infisical", "full"]
volumes:
- redis-data:/data
@@ -149,6 +184,7 @@ services:
# Infisical self-hosted (Phase 5 secrets management)
infisical:
image: infisical/infisical:latest
restart: unless-stopped
profiles: ["infisical", "full"]
depends_on:
postgres:

View File

@@ -1,4 +1,7 @@
# Signal Trigger Architecture
# ADR 0013 — Signal trigger architecture
**Status:** Accepted
**Date:** 2026-07-08
## Overview

View File

@@ -1,6 +1,6 @@
# Oikos Entity Model — Types, Relationships & Interactions
# ADR 0014 — Entity model: types, relationships & interactions
**Status:** Adopted
**Status:** Accepted
**Date:** 2026-07-08
**Scope:** Full inventory of every entity type, relationship, state machine, and
cognition pipeline — with clear markers for what is **code-real** vs **schema-only**.

View File

@@ -0,0 +1,62 @@
# ADR 0015 — Bearer-token auth for every route + client/server split
Status: accepted (2026-07-12) · Plan: plans/2026-07-12-wails-desktop-app.md, Phase 0
## Context
The control-room SPA was embedded in the `oikos` binary (`go:embed`,
ADR 0001) and served at `/ui/*`. `combinedAuth` (`internal/httpapi/server.go`)
opened a dev-open bypass — no credential required at all — whenever
`OIKOS_ENV=dev` and no static token/OIDC issuer was configured. That was
true not just in local dev but in the actual mac-mini production deploy:
`docker-compose.yml`'s `api` service hardcoded `OIKOS_ENV: dev` with no
token set, so every route (`/api/v1/*`, `/mcp`, and an `/agent` reverse-proxy
mount to nomos that had never been wrapped in `combinedAuth` at all) was
reachable unauthenticated from anywhere on the mesh/LAN. A planned Wails
desktop client and any future non-browser client can't rely on same-origin
requests or a dev-open bypass; they need the SPA to be a standalone,
CORS-capable client that authenticates over HTTP like any other caller.
## Decision
- Delete the SPA embed (`web/embed.go`, the `/ui/*` routes). `web/` is a
standalone static build, deployed separately (`make ui` / `make
deploy-ui`), served at `/` by Caddy with SPA fallback.
- Remove the dev-open bypass entirely. Every route requires a valid
static bearer token (`OIKOS_API_TOKEN` / `OIKOS_MCP_BEARER_TOKEN`) or an
OIDC JWT, with two narrow exceptions: `/healthz` (liveness) and
`POST /api/v1/clients/enroll` (IP-gated in the handler instead).
`GET /api/v1/events/stream` additionally accepts the token as a
`?token=` query param, since `EventSource` can't set custom headers.
- Add CORS (`github.com/go-chi/cors`, `OIKOS_CORS_ORIGIN`, default `*`) so a
cross-origin SPA (Vite dev server, a future Wails webview) can reach the
API. No `AllowCredentials` — auth is a header, not a cookie, so
credentialed CORS mode isn't needed and the two don't combine safely with
a wildcard origin.
- Wrap the previously-unauthenticated `/agent` proxy mount in the same
`combinedAuth` middleware as every other route.
- `cmd/nomos` becomes an authenticated client of `api`: it now sends
`Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN` on its own outbound calls
(MCP + the chat-assent approval-decision endpoint), which it never did
before — dev-open covered for it until now.
- The SPA gets a runtime config module (`web/src/lib/config.ts`) and a
first-launch `Config.svelte` screen: server URL + token, stored in
`localStorage`, injected into every `fetch()` via a shared
`fetchWithAuth` wrapper. Resolved fresh per request (not cached at
module-load time), so the same build works same-origin or cross-origin
without a rebuild.
## Consequences
- Closing dev-open was a live security fix, not just future-proofing —
verified post-deploy that unauthenticated requests to production now 401.
- Nomos's *own* HTTP gateway (`cmd/nomos`, port 8092) still has no auth of
its own — out of scope here, tracked separately
(plans/2026-07-11-nomos-agent-code-review.md, finding C1).
- Production Caddy (`dtoro/caddy-conf`, not this repo) does not yet expose
`oikos.hubris.network` at all, so the interaction between Authentik
forward-auth and bearer-token clients (a non-browser client can't
complete a browser SSO redirect) is unresolved — needs an `@enroll`-style
bypass for `/api/v1/*`/`/mcp`/`/agent/*` before public exposure. This
repo's `compose/caddy/Caddyfile.oikos` (a reference copy, not deployed
from here) has the bypass; the real config does not yet.
- There is one shared bearer secret for all agents/clients, not per-client
tokens — acceptable for the current fleet size, revisit if per-client
revocation becomes necessary.

View File

@@ -5,7 +5,7 @@ after acceptance — superseding decisions get a new ADR that links back.
Statuses: proposed | accepted | superseded-by-NNNN.
| ADR | Title |
|---|---|---|
|---|---|
| [0001](0001-go-single-binary.md) | Go with single-binary role packaging |
| [0002](0002-postgres-timescale-only-datastore.md) | PostgreSQL + TimescaleDB as the only datastore |
| [0003](0003-db-native-ontology-yaml-seeds.md) | DB-native ontology with YAML seed manifests |
@@ -16,7 +16,8 @@ Statuses: proposed | accepted | superseded-by-NNNN.
| [0008](0008-forward-only-migrations.md) | Forward-only migrations |
| [0009](0009-sse-over-websocket.md) | SSE over WebSocket for the event stream |
| [0010](0010-infisical-with-sops-fallback.md) | Infisical secrets with SOPS DR fallback |
| [0011](0011-client-lifecycle-flows.md) | Client lifecycle flows — enrollment, bootstrap, sync |
| [0012](0012-hermes-oikos-interactions.md) | HermesOikos interactions — agent/OS contract |
| [0013](0013-signal-triggers.md) | Signal triggers — host health checks via scheduler |
| [0011](0011-client-lifecycle-flows.md) | Client lifecycle sequence diagrams |
| [0012](0012-hermes-oikos-interactions.md) | Hermes/Oikos interaction architecture |
| [0013](0013-signal-triggers.md) | Signal trigger architecture |
| [0014](0014-entity-model.md) | Entity model — types, relationships, state machines, OODA loop |
| [0015](0015-api-bearer-auth-client-server-split.md) | Bearer-token auth for every route + client/server split |

11
go.mod
View File

@@ -5,6 +5,7 @@ go 1.26.3
require (
github.com/getkin/kin-openapi v0.140.0
github.com/go-chi/chi/v5 v5.3.1
github.com/go-chi/cors v1.2.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/jsonschema-go v0.4.3
github.com/google/uuid v1.6.0
@@ -13,6 +14,8 @@ require (
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/oapi-codegen/runtime v1.4.2
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/sync v0.21.0
golang.org/x/sys v0.46.0
@@ -24,6 +27,7 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // 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/aws/aws-sdk-go-v2 v1.27.2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect
@@ -39,12 +43,16 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect
github.com/aws/smithy-go v1.20.2 // 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/go-logr/logr v1.4.3 // 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/swag/jsonname v0.25.5 // 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/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
@@ -53,6 +61,9 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/yaml3 v0.0.13 // indirect
github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect

35
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/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ=
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/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
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/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/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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
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/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
@@ -57,11 +63,17 @@ github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k
github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
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/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-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.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
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/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/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
@@ -71,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/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0=
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/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -101,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/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/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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
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/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/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
@@ -142,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.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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -159,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/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
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/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/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
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/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/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
@@ -214,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/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-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-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-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-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.6.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.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -19,10 +19,16 @@ type Config struct {
APIEnv string // dev, prod
// Auth (Phase 2: static bearer tokens + OIDC JWT)
APIToken string // operator/CI bearer token for the REST API
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/)
OIDCClientID string // OIDC client ID (aud claim expected in JWT)
APIToken string // operator/CI bearer token for the REST API
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/)
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
// 0.3). Needed for the Wails webview and local dev (Vite on a different
// port than the API); a no-op when the SPA and API share an origin.
CORSAllowedOrigin string
// Observability
Debug bool // verbose logging, probe payloads, SQL
@@ -73,6 +79,7 @@ func Default() Config {
DatabaseURL: "postgres://oikos:***@localhost:5432/oikos?sslmode=disable",
APIListen: ":8090",
APIEnv: "dev",
CORSAllowedOrigin: "*",
SeedsDir: "seeds",
MigrationsDir: "migrations",
SchedulerInterval: 30 * time.Second,
@@ -102,12 +109,18 @@ func FromEnv() Config {
if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" {
c.OIDCClientID = v
}
if v := os.Getenv("OIKOS_OIDC_CLIENT_SECRET"); v != "" {
c.OIDCClientSecret = v
}
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
c.APIToken = v
}
if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" {
c.MCPBearerToken = v
}
if v := os.Getenv("OIKOS_CORS_ORIGIN"); v != "" {
c.CORSAllowedOrigin = v
}
if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" {
c.SeedsDir = v
}

View File

@@ -93,15 +93,34 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
}
}
return NewHandler(handlerCtx, pool, cfg, nil)
return NewHandler(handlerCtx, pool, cfg)
}
// testAuthToken is the static bearer token devConfig() configures. There is
// no dev-open bypass (removed — plans/2026-07-12-wails-desktop-app.md 0.4),
// so every test handler needs a real credential; get/postJSON/do inject it
// by default. Pass an explicit "" value for "Authorization" in headers to
// test the no-credential path.
const testAuthToken = "test-dev-token"
// applyHeaders sets req's default Authorization header, then layers headers
// on top. A "" value deletes the header instead of setting it, so tests can
// exercise the missing-credential case.
func applyHeaders(req *http.Request, headers map[string]string) {
req.Header.Set("Authorization", "Bearer "+testAuthToken)
for k, v := range headers {
if v == "" {
req.Header.Del(k)
} else {
req.Header.Set(k, v)
}
}
}
func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
applyHeaders(req, headers)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var body map[string]any
@@ -113,6 +132,7 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
applyHeaders(req, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var body map[string]any
@@ -122,7 +142,8 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt
func devConfig() config.Config {
c := config.Default()
c.APIEnv = "dev" // no tokens → dev-open auth
c.APIEnv = "dev"
c.APIToken = testAuthToken
return c
}
@@ -295,7 +316,7 @@ func TestAPIBearerAuth(t *testing.T) {
}
// API requires the token
rec, body := get(t, h, "/api/v1/entities", nil)
rec, body := get(t, h, "/api/v1/entities", map[string]string{"Authorization": ""})
if rec.Code != 401 {
t.Errorf("no token = %d, want 401 (%v)", rec.Code, body)
}

View File

@@ -1290,7 +1290,10 @@ func (s *Server) GetClientContext(ctx context.Context, req gen.GetClientContextR
for rows.Next() {
var p string
if scanErr := rows.Scan(&p); scanErr == nil {
if strings.HasPrefix(p, "tools/") && strings.HasSuffix(p, ".setup.sh") {
// Matches tools/setup-*.sh (the auto-setup convention —
// see tools/post-pull.sh). Was tools/*.setup.sh until
// 2026-07-12, which never matched any real filename.
if strings.HasPrefix(p, "tools/setup-") && strings.HasSuffix(p, ".sh") {
toolsChanged = append(toolsChanged, p)
} else if p == ".sops.yaml" {
sopsChanged = true

View File

@@ -5,9 +5,11 @@ import (
"encoding/json"
"log/slog"
"net/http"
"net/url"
"strconv"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
@@ -107,6 +109,52 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
})
}
// serveKnowledgeContent returns the full markdown body for a document/
// investigation/runbook entity, by its own entity id or slug. Nothing else
// exposes knowledge_entities.content — GetEntityKnowledge (below) answers a
// different question ("what knowledge references THIS entity"), and
// SearchKnowledge only returns a short ts_headline snippet. The KB detail
// panel needs the entity's own full content when it IS a knowledge entity.
func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
// chi.URLParam returns the raw, still-percent-encoded segment (unlike
// the OpenAPI-generated routes, which decode via
// runtime.BindStyledParameterWithOptions before reaching the handler) —
// slugs like "document:containers/101-jellyfin" arrive as
// "document%3Acontainers%2F101-jellyfin" and must be unescaped here.
idOrSlug, err := url.PathUnescape(chi.URLParam(req, "id"))
if err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
return
}
var title, content, source string
var tags []string
var updatedAt string
err = s.pool.QueryRow(ctx, `
SELECT ke.title, ke.content, COALESCE(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 OR e.id::text = $1`, idOrSlug).
Scan(&title, &content, &source, &tags, &updatedAt)
if err != nil {
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
return
}
if tags == nil {
tags = []string{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"title": title,
"content": content,
"source": source,
"tags": tags,
"updated_at": updatedAt,
})
}
func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledgeRequestObject) (gen.SearchKnowledgeResponseObject, error) {
q := request.Params.Q
limit := clampLimit(request.Params.Limit)

View File

@@ -24,9 +24,7 @@ func do(t *testing.T, h http.Handler, method, path string, body any, headers map
}
req := httptest.NewRequest(method, path, rdr)
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
applyHeaders(req, headers)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var decoded map[string]any

View File

@@ -154,6 +154,7 @@ func TestPhase4MCPEndpointAlive(t *testing.T) {
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Authorization", "Bearer "+testAuthToken)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)

View File

@@ -12,6 +12,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"math/big"
"net/http"
@@ -29,6 +30,7 @@ import (
"github.com/dtoro/oikos/internal/safego"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
@@ -70,7 +72,7 @@ type secretsBackend interface {
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
// before closing the pool — otherwise the held connection never releases
// and pool.Close() deadlocks.
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) http.Handler {
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler {
s := &Server{
pool: pool,
cfg: cfg,
@@ -88,6 +90,12 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(requestLogger)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{cfg.CORSAllowedOrigin},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "If-Match"},
MaxAge: 86400,
}))
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
@@ -120,6 +128,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
}
})
// 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{
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
@@ -130,7 +148,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
gen.HandlerWithOptions(strict, gen.ChiServerOptions{
BaseURL: "/api/v1",
BaseRouter: r,
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg)},
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg, false)},
ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
},
@@ -141,24 +159,32 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
// registration wins). The strict-server path can't Flush() per event;
// this one uses the real ResponseWriter for real-time delivery. It
// inherits the router's base middleware and applies auth via With().
r.With(combinedAuth(cfg)).Get("/api/v1/events/stream", s.serveSSE)
// allowQueryToken=true: EventSource can't set custom headers, so the
// SPA passes the token as ?token=... instead of Authorization.
r.With(combinedAuth(cfg, true)).Get("/api/v1/events/stream", s.serveSSE)
// Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the
// Knowledge page's "what the system has learned" view. Registered after
// HandlerWithOptions so it wins over any generated catch-all.
r.With(combinedAuth(cfg)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
// Custom (non-OpenAPI) route: full markdown content for a knowledge
// entity (document/investigation/runbook) by its own id or slug — the
// generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a
// different question (knowledge referencing this entity), not this one.
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
// unlike ListExecutions which sorts by target for pagination) and the
// per-session "what did this session do" digest.
r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity)
r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity)
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
// Learning view: capability timeline + success trend, both derived from
// executions (real, growing data) rather than the patterns/skills tables,
// which are correctly modeled but have no writers anywhere yet.
r.With(combinedAuth(cfg)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
r.With(combinedAuth(cfg)).Get("/api/v1/learning/trend", s.serveLearningTrend)
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/trend", s.serveLearningTrend)
// Mount MCP at /mcp (plan R3-10)
nomosAgentID := uuid.Nil
@@ -170,33 +196,30 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
}
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
r.Get("/ui/*", func(w http.ResponseWriter, req *http.Request) {
if uiHandler != nil {
uiHandler.ServeHTTP(w, req)
}
})
r.Get("/ui", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
})
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
})
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
target, _ := url.Parse(nomosURL)
proxy := httputil.NewSingleHostReverseProxy(target)
r.Mount("/agent", http.StripPrefix("/agent", proxy))
// Was unauthenticated (pre-existing gap, predates the client/server
// split — this mount was never wrapped in combinedAuth, unlike every
// other custom route below). Harmless while dev-open was in effect;
// a real hole now that every route needs a real credential.
r.Mount("/agent", combinedAuth(cfg, false)(http.StripPrefix("/agent", proxy)))
}
return r
}
// combinedAuth tries OIDC JWT validation first (if configured), falls back to
// static bearer token validation, and opens the gate in dev mode when no
// credentials are configured.
func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
// combinedAuth tries OIDC JWT validation first (if configured), then falls
// back to static bearer token validation. Every request needs a valid
// credential — there is no dev-open bypass (closed as part of the
// client/server split, plans/2026-07-12-wails-desktop-app.md 0.4: once the
// SPA is a separate client, a dev-open API is reachable from any origin).
// When allowQueryToken is set, a missing Authorization header falls back to
// a `?token=` query param — only used for the SSE route, since EventSource
// can't set custom headers.
func combinedAuth(cfg config.Config, allowQueryToken bool) func(http.Handler) http.Handler {
hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != ""
hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != ""
@@ -217,31 +240,14 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
}
}
var staticTokens [][]byte
if cfg.APIToken != "" {
staticTokens = append(staticTokens, []byte(cfg.APIToken))
}
if cfg.MCPBearerToken != "" {
staticTokens = append(staticTokens, []byte(cfg.MCPBearerToken))
}
devOpen := cfg.APIEnv == "dev" && !hasStatic && !hasOIDC
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devOpen {
ctx := context.WithValue(r.Context(), actorKey, actor{
Type: "system",
Label: "dev:anonymous",
ID: "dev",
TokenType: "none",
})
next.ServeHTTP(w, r.WithContext(ctx))
return
}
auth := r.Header.Get("Authorization")
raw, ok := strings.CutPrefix(auth, "Bearer ")
if (!ok || raw == "") && allowQueryToken {
raw = r.URL.Query().Get("token")
ok = raw != ""
}
if !ok || raw == "" {
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"missing bearer token")
@@ -275,21 +281,10 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
// Fall back to static tokens
if hasStatic {
for _, t := range staticTokens {
if subtle.ConstantTimeCompare([]byte(raw), t) == 1 {
label := "operator:api"
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
label = "agent:mcp"
}
ctx := context.WithValue(r.Context(), actorKey, actor{
Type: label[:strings.IndexByte(label, ':')],
Label: label,
ID: raw[:8] + "...",
TokenType: "static",
})
next.ServeHTTP(w, r.WithContext(ctx))
return
}
if act, ok := staticTokenActor(cfg, raw); ok {
ctx := context.WithValue(r.Context(), actorKey, act)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
@@ -299,6 +294,28 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
}
}
// staticTokenActor validates raw against the configured static bearer
// tokens (API token, MCP token) in constant time and returns the resolved
// actor. Shared between combinedAuth's header-based check and serveSSE's
// query-param check (EventSource can't set custom headers, so the SSE
// stream takes the token as ?token=...).
func staticTokenActor(cfg config.Config, raw string) (actor, bool) {
if raw == "" {
return actor{}, false
}
idPrefix := raw
if len(idPrefix) > 8 {
idPrefix = idPrefix[:8]
}
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
return actor{Type: "agent", Label: "agent:mcp", ID: idPrefix + "...", TokenType: "static"}, true
}
if cfg.APIToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.APIToken)) == 1 {
return actor{Type: "operator", Label: "operator:api", ID: idPrefix + "...", TokenType: "static"}, true
}
return actor{}, false
}
// jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
// verification, identified by its key ID (kid).
type jwtVerificationKey struct {
@@ -524,12 +541,128 @@ 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
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) error {
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
srv := &http.Server{
Addr: cfg.APIListen,
Handler: NewHandler(ctx, pool, cfg, uiHandler),
Handler: NewHandler(ctx, pool, cfg),
ReadHeaderTimeout: 10 * time.Second,
}

View File

@@ -29,6 +29,7 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) {
// Connect to the stream.
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/events/stream", nil)
req.Header.Set("Authorization", "Bearer "+testAuthToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("connect stream: %v", err)
@@ -60,7 +61,10 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) {
// POSTing to the SAME live server (same DB → NOTIFY the listener sees).
time.Sleep(300 * time.Millisecond)
payload, _ := json.Marshal(map[string]any{"slug": "service:sse-rt", "type": "service", "name": "sse-rt"})
cResp, err := http.Post(srv.URL+"/api/v1/entities", "application/json", bytes.NewReader(payload))
createReq, _ := http.NewRequestWithContext(ctx, "POST", srv.URL+"/api/v1/entities", bytes.NewReader(payload))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+testAuthToken)
cResp, err := http.DefaultClient.Do(createReq)
if err != nil {
t.Fatalf("trigger create: %v", err)
}

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) {
args := argsMap(req)
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
FROM entities e
WHERE ($1::text IS NULL OR e.type = $1)
AND ($2::text IS NULL OR e.state = $2)
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
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",
@@ -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
})
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"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
q := nStr(args["query"])
return queryRows(ctx, pool, `
return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, e.slug,
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
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
WHERE ke.search @@ plainto_tsquery('english', $1)
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)"}),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
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,
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
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
WHERE r.valid_to IS NULL
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(
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."},
@@ -277,7 +289,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
hours := int(getFloat(args, "hours", 24))
return queryRows(ctx, pool, `
return annotateJSONResult(queryRows(ctx, pool, `
SELECT time_bucket('1 hour', ts) AS bucket,
entity_id::text, metric,
ROUND(avg(value)::numeric, 2) AS avg,
@@ -286,7 +298,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
FROM metric_samples
WHERE ts > now() - make_interval(hours => $1)
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 ──────────────────────────────────────────
@@ -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`,
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
// (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) {
args := argsMap(req)
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,
entity_id::text, left(input_summary, 200) AS input_summary,
left(output_summary, 200) AS output_summary,
duration_ms, token_count, success, correlation_id
FROM agent_activity
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 ──────────────────────────────
@@ -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",
InputSchema: objSchema(),
}, 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,
e.attributes->>'lan_ip' AS lan_ip,
st.health, st.last_check_at
FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id
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",
@@ -782,7 +811,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return textResult("error: hostname required"), nil
}
slug := "ws:" + hostname
return queryRows(ctx, pool, `
return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health,
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
LEFT JOIN entity_status st ON st.entity_id = e.id
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",
@@ -803,7 +832,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
if slug == "" {
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,
COALESCE(st.health, 'unknown') AS health,
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
FROM entities e
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",
@@ -849,7 +878,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
args := argsMap(req)
slug, _ := args["entity_slug"].(string)
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,
al.action, al.method, al.path,
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
WHERE e.slug = $1
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",
InputSchema: objSchema(),
}, 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,
COALESCE(st.health, 'unknown') AS health,
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
ORDER BY st.health, e.slug
LIMIT 200
`), nil
`), "fleet_snapshot"), nil
})
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))
}
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 ─────────────────────────────────────────────────────────
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`,
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 {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)

View File

@@ -1,11 +1,79 @@
package mcp
import (
"encoding/json"
"strings"
"testing"
"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
// input schema. The MCP SDK panics at AddTool if a tool omits its object
// input schema, so merely constructing the server exercises that contract —

View File

@@ -1,237 +0,0 @@
#!/usr/bin/env python3
"""
Generate knowledge/wiki/infrastructure/topology.md (Mermaid views) and per-entity context
cards from inventory.yaml.
Views:
1. Compute & ingress — hypervisors → guests → services → public URLs
2. Storage — mounts and pools per guest
Context cards (oikos/cards/<name>.md): one compact (~30-line) file per
host and service — identity, ontology edges, safe actions + risk class,
doc pointer, recent ledger history. This is the token-efficiency layer:
an agent orienting on an entity reads one card instead of several
search_docs/get_page round-trips.
Run from the repo root:
python3 oikos/gen-topology.py # writes topology.md + cards/
python3 oikos/gen-topology.py --check # exit 1 if output would change
Wired into the same regeneration path as mcp/build_host_files.py so the
diagrams and cards never drift from inventory. Edges follow
oikos/ontology.yaml (hosts, provides, routes-to, mounts, stores-on).
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
print("PyYAML is required: pip install pyyaml", file=sys.stderr)
sys.exit(2)
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from oikos import gen_topology_lib as lib # noqa: E402
from oikos import ledger as oikos_ledger # noqa: E402
from oikos import policy as oikos_policy # noqa: E402
from oikos import relations as oikos_relations # noqa: E402
INVENTORY = REPO / "inventory.yaml"
OUTPUT = REPO / "knowledge" / "wiki" / "infrastructure" / "topology.md"
CARDS_DIR = REPO / "oikos" / "cards"
BANNER = (
"<!-- Generated by oikos/gen-topology.py from inventory.yaml. -->\n"
"<!-- Do NOT edit by hand - your changes will be overwritten. -->\n"
)
# View/graph logic lives in oikos/gen_topology_lib.py (importable — this
# file's hyphenated name can't be). Re-exported here so existing call
# sites in this module don't need a rename.
node_id = lib.node_id
guest_label = lib.guest_label
compute_view = lib.compute_view
storage_view = lib.storage_view
archaeology_table = lib.archaeology_table
def _host_card(name: str, entry: dict, inv: dict) -> str:
lines = [f"# {name} (host:{name})\n"]
tag = "LXC" if entry.get("kind") == "lxc" else "VM" if entry.get("kind") == "vm" else entry.get("kind", "")
pve = entry.get("pve_id")
lines.append(f"- kind: {entry.get('kind', '?')}" + (f" ({tag} {pve})" if pve else ""))
lines.append(f"- state: {entry.get('state', 'active')}")
if entry.get("host"):
lines.append(f"- runs-on: host:{entry['host']}")
if entry.get("role"):
lines.append(f"- role: {entry['role']}")
addr = entry.get("lan_ip", "")
mesh = entry.get("mesh", {})
mesh_bits = []
for m, v in mesh.items():
if isinstance(v, dict) and (v.get("ip") or v.get("fqdn")):
mesh_bits.append(f"{m}:{v.get('fqdn') or v.get('ip')}")
if addr or mesh_bits:
lines.append(f"- address: {addr}" + (f" (mesh: {', '.join(mesh_bits)})" if mesh_bits else ""))
if entry.get("mounts"):
lines.append(f"- mounts: {', '.join(entry['mounts'])}")
doc = None
if entry.get("kind") == "lxc" and pve:
cand = REPO / "knowledge" / "wiki" / "containers" / f"{pve}-{name}.md"
if cand.exists():
doc = str(cand.relative_to(REPO))
elif entry.get("kind") == "vm" and pve:
cand = REPO / "knowledge" / "wiki" / "vms" / f"{pve}-{name}.md"
if cand.exists():
doc = str(cand.relative_to(REPO))
elif entry.get("kind") == "proxmox-host":
cand = REPO / "knowledge" / "wiki" / "hosts" / f"{name}.md"
if cand.exists():
doc = str(cand.relative_to(REPO))
if doc:
lines.append(f"- doc: {doc}")
if entry.get("age_pubkey"):
lines.append("- secrets: enrolled (age key present)")
rel = oikos_relations.relations(f"host:{name}", inv)
lines.append("\n## Blast radius")
lines.append(f"- impacts: {', '.join(rel['impacts']) or '(none)'}")
lines.append(f"- affected by: {', '.join(rel['affected_by']) or '(none)'}")
if rel["blast_radius"]:
lines.append(f"- full blast radius: {', '.join(rel['blast_radius'])}")
lines.append("\n## Safe actions")
lines.append("- see the services this host runs for action-level risk classes")
hist = oikos_ledger.history(f"host:{name}", limit=5)
lines.append("\n## Recent changes")
if hist:
for h in hist:
lines.append(f"- {h.get('ts', '?')} {h.get('action', '?')} ({h.get('risk', '?')}) — {h.get('result', '?')}")
else:
lines.append("- (none yet)")
return "\n".join(lines) + "\n"
def _service_card(name: str, entry: dict, inv: dict) -> str:
lines = [f"# {name} (service:{name})\n"]
if entry.get("backend"):
lines.append(f"- backend: host:{entry['backend']}")
url = entry.get("url") or entry.get("endpoint")
if url:
lines.append(f"- url: {url}")
if entry.get("doc_page"):
lines.append(f"- doc: {entry['doc_page']}")
if entry.get("config_repo"):
lines.append(f"- config repo: {entry['config_repo']}")
if entry.get("risk_notes"):
lines.append(f"- risk notes: {entry['risk_notes']}")
rel = oikos_relations.relations(f"service:{name}", inv)
lines.append("\n## Blast radius")
lines.append(f"- impacts: {', '.join(rel['impacts']) or '(none)'}")
lines.append(f"- affected by: {', '.join(rel['affected_by']) or '(none)'}")
lines.append("\n## Safe actions")
for a in oikos_policy.safe_actions_for_service(name, entry):
lines.append(f"- {a['action']}{a['risk']} (approval: {a['approval']})")
hist = oikos_ledger.history(f"service:{name}", limit=5)
lines.append("\n## Recent changes")
if hist:
for h in hist:
lines.append(f"- {h.get('ts', '?')} {h.get('action', '?')} ({h.get('risk', '?')}) — {h.get('result', '?')}")
else:
lines.append("- (none yet)")
return "\n".join(lines) + "\n"
def generate_cards(inv: dict) -> dict[Path, str]:
desired: dict[Path, str] = {}
for name, entry in inv.get("hosts", {}).items():
desired[CARDS_DIR / f"host-{name}.md"] = _host_card(name, entry, inv)
for name, entry in inv.get("services", {}).items():
if isinstance(entry, dict):
desired[CARDS_DIR / f"service-{name}.md"] = _service_card(name, entry, inv)
return desired
def write_cards(inv: dict, check: bool = False) -> int:
CARDS_DIR.mkdir(parents=True, exist_ok=True)
desired = generate_cards(inv)
diff_count = 0
for path, content in desired.items():
existing = path.read_text() if path.exists() else ""
if existing != content:
diff_count += 1
if not check:
path.write_text(content)
for existing_path in CARDS_DIR.glob("*.md"):
if existing_path not in desired:
diff_count += 1
if not check:
existing_path.unlink()
return diff_count
def render(inv: dict) -> str:
hosts = inv.get("hosts", {})
services = inv.get("services", {})
counts = (
f"{sum(1 for e in hosts.values() if e.get('kind') == 'proxmox-host')} hypervisors, "
f"{sum(1 for e in hosts.values() if e.get('kind') == 'lxc')} LXCs, "
f"{sum(1 for e in hosts.values() if e.get('kind') == 'vm')} VMs, "
f"{sum(1 for e in hosts.values() if e.get('kind') == 'workstation')} workstations, "
f"{len(services)} services"
)
parts = [
BANNER,
"# Topology (generated)\n",
f"Source: [inventory.yaml](../../../inventory.yaml) — {counts}.",
"Edge semantics: [oikos/ontology.yaml](../../../oikos/ontology.yaml). "
"Operating model: [OIKOS.md](../../../.agents/OIKOS.md).\n",
"## Compute & ingress\n",
"\n".join(compute_view(inv)) + "\n",
"## Storage (mounts)\n",
"\n".join(storage_view(inv)) + "\n",
]
arch = archaeology_table(inv)
if arch:
parts += ["## Archaeology (destroyed nodes)\n", "\n".join(arch) + "\n"]
return "\n".join(parts)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true",
help="exit 1 if output would change (don't write)")
args = parser.parse_args()
inv = yaml.safe_load(INVENTORY.read_text())
content = render(inv)
existing = OUTPUT.read_text() if OUTPUT.exists() else ""
topology_changed = existing != content
card_diffs = write_cards(inv, check=args.check)
if args.check:
if topology_changed:
print(f"{OUTPUT.relative_to(REPO)} would change", file=sys.stderr)
if card_diffs:
print(f"{card_diffs} card(s) in oikos/cards/ would change", file=sys.stderr)
return 1 if (topology_changed or card_diffs) else 0
if topology_changed:
OUTPUT.write_text(content)
print(f"wrote {OUTPUT.relative_to(REPO)}")
if card_diffs:
print(f"wrote/updated {card_diffs} card(s) in oikos/cards/")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,112 +0,0 @@
"""oikos/gen_topology_lib.py — shared Mermaid-view logic.
Split out of oikos/gen-topology.py so it's importable (a hyphenated
filename can't be `import`ed as a module). oikos/gen-topology.py is the
CLI entrypoint that writes knowledge/wiki/infrastructure/topology.md + oikos/cards/;
oikos/console/app.py imports this module directly to render the live
/graph page without shelling out.
"""
from __future__ import annotations
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
INVENTORY = REPO / "inventory.yaml"
def load_inventory() -> dict:
return yaml.safe_load(INVENTORY.read_text())
def node_id(name: str) -> str:
"""Mermaid-safe node id."""
return name.replace("-", "_").replace(".", "_").replace("/", "_").strip("_")
def guest_label(name: str, entry: dict) -> str:
pve = entry.get("pve_id")
role = entry.get("role", "")
tag = f"LXC {pve}" if entry.get("kind") == "lxc" and pve else \
f"VM {pve}" if entry.get("kind") == "vm" and pve else entry.get("kind", "")
ip = entry.get("lan_ip", "")
parts = [name, tag, role, ip]
return "<br/>".join(str(p) for p in parts if p)
def compute_view(inv: dict) -> list[str]:
hosts = inv.get("hosts", {})
services = inv.get("services", {})
lines = ["```mermaid", "flowchart LR"]
hypervisors = {n: e for n, e in hosts.items() if e.get("kind") == "proxmox-host"}
guests = {n: e for n, e in hosts.items() if e.get("kind") in ("lxc", "vm")}
others = {n: e for n, e in hosts.items()
if e.get("kind") in ("workstation", "external")}
for hv in hypervisors:
lines.append(f' subgraph {node_id(hv)}_sub["{hv} (Proxmox)"]')
for g, e in guests.items():
if e.get("host") == hv:
lines.append(f' {node_id(g)}["{guest_label(g, e)}"]')
lines.append(" end")
# guests without a parent hypervisor recorded (e.g. rclone)
for g, e in guests.items():
if e.get("host") not in hypervisors:
lines.append(f' {node_id(g)}["{guest_label(g, e)}"]')
for n, e in others.items():
shape = "([{}])" if e.get("kind") == "workstation" else "[[{}]]"
lines.append(f' {node_id(n)}{shape.format(guest_label(n, e))}')
# ingress: public URL -> backend (routes-to)
for svc, e in sorted(services.items()):
if not isinstance(e, dict):
continue
backend = e.get("backend")
url = e.get("url") or (
f'https://{e["public_host"]}' if e.get("public_host") else None)
if backend and url and backend in hosts:
host = url.removeprefix("https://").removeprefix("http://")
# hypervisors are rendered as subgraphs; point edges at the subgraph id
target = node_id(backend) + ("_sub" if backend in hypervisors else "")
lines.append(
f' {node_id("url_" + svc)}(["{host}"]) -->|routes-to| {target}')
lines.append("```")
return lines
def storage_view(inv: dict) -> list[str]:
hosts = inv.get("hosts", {})
lines = ["```mermaid", "flowchart LR"]
pools: set[str] = set()
edges: list[str] = []
for name, e in hosts.items():
for mount in e.get("mounts", []):
pools.add(mount)
edges.append(f' {node_id(name)}["{name}"] -->|mounts| {node_id(mount)}')
for pool in sorted(pools):
lines.append(f' {node_id(pool)}[("{pool}")]')
lines.extend(sorted(set(edges)))
lines.append("```")
return lines
def archaeology_table(inv: dict) -> list[str]:
arch = inv.get("archaeology", {})
if not arch:
return []
lines = ["| Node | ID | Destroyed | Reason |", "|---|---|---|---|"]
entries = sorted(arch.items(), key=lambda kv: str(kv[1].get("destroyed", "")),
reverse=True)
for name, e in entries:
lines.append(
f'| {name} | {e.get("pve_id", "")} | {e.get("destroyed", "")} '
f'| {e.get("reason", "")} |')
return lines

View File

@@ -4,6 +4,16 @@
`signal.acked`/`signal.resolved`/`signal.muted` and `relationship.created`/
`relationship.ended` API calls don't emit `observability.Event`, and
trusted-proxy header auth for Authentik was never added to `combinedAuth`).
**Superseded (2026-07-12):** the embed architecture below (`go:embed
all:web/dist`, served at `/ui/`) was removed —
[2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md) Phase 0
separates the SPA from the `oikos` binary into a standalone static build,
served at `/` (no `/ui/` prefix), talking to the API over bearer-token
auth (the dev-open bypass mentioned nowhere in this plan was also removed).
The trusted-proxy-header gap noted above is moot under the new model — every
route requires a real bearer token regardless of what's in front of it. M1-M3
and the SPA/component work below are unaffected; only the packaging and auth
sections are stale.
N0-N3 (Nomos amendment: chat home + sessions), M1
(dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte
component system), M2 (Operations ledger with approve/deny + cancel, Signals

View File

@@ -1,8 +1,11 @@
# 2026-07-08 — Liveness, drift, and UX cohesion
**Status:** In Progress — Phases 14 code complete; not yet deployed. Phase 5 deferred.
(Audited 2026-07-11 — still accurate; prompt caching within Phase 4 also
confirmed not implemented.)
**Status:** In Progress — Phases 14 code complete and now deployed
(re-verified 2026-07-12: mac-mini was redeployed from `main` that day for
unrelated auth work — plans/2026-07-12-wails-desktop-app.md — which carried
every commit up to that point, including this plan's, so "not yet deployed"
below is stale). Phase 5 deferred. (Audited 2026-07-11 — still accurate;
prompt caching within Phase 4 also confirmed not implemented.)
- **Phase 1 (drift/staleness):** done. Health/metrics/events misattribution
fix, staleness sweep, `/entities` health+freshness, dashboard/fleet-health
@@ -26,20 +29,19 @@ confirmed not implemented.)
- **Phase 4 (agent efficiency):** core piece done — prior turns' tool
calls/results are now replayed into the conversation (previously dropped
entirely), and a compact live fleet-health snapshot is injected into the
system prompt each turn so the agent starts oriented. Prompt caching and
reconsidering the default model are **not done** (lower priority, no
measured regression without them).
system prompt each turn so the agent starts oriented. Prompt caching is
**not done** (lower priority, no measured regression without it).
Reconsidering the default model — done, but not by this plan: switched to
`deepseek/deepseek-v4-pro` on 2026-07-10 (`cmd/nomos/agent.go:70`) for
reliability, per that commit's own comment ("the flash tier over-narrates,
occasionally emits canned refusals, and is unreliable at multi-step tool
use").
- **Phase 5 (CRUD):** `PatchEntity` and a full `/checks` CRUD API
(list/create/patch, including enable/disable) already existed server-side;
the new Monitoring card's toggle uses `PatchCheck`. **Not done**: a
"run check now" endpoint (no scheduler on-demand entrypoint exists yet),
relationship editing, and an entity attribute editor UI.
**Not yet deployed** — the live `oikos-api`/`oikos-scheduler`/nomos
containers still run the pre-fix binaries; rebuilding and restarting them
needs an explicit go-ahead since it touches the running homelab control
plane.
Addresses five felt problems with the current system: (1) the agent reports
stale machine state as if it were fresh, (2) sessions can't be opened and feel
disconnected from chat, (3) the Nomos agent re-derives state every turn and

View File

@@ -1,14 +1,24 @@
# 2026-07-08 — Oikos gaps, broken things, and improvements
**Status:** In Progress — audited 2026-07-11. Done: A1 (approval FK bug),
A3 (Hermes→Nomos help text), D1 (`upsert_knowledge`), D4-partial (general
`run` tool). Still open: A2 (notifier flooding/dedup), A4 (`resolveHost`
dead code), A5 (`queryRows` stringly-typed columns), A6 (stale
`get_state_snapshot` description), B1-B5 (enrollment auth, fake Infisical
creds, `/query` mesh-only auth unenforced, insecure host key checking,
optional `caller_pubkey`), D2/D3 (no `get_approval_status`/
`list_pending_approvals`/signal ack-resolve-mute tools), E (README tool
count, Caddyfile placeholders, NOMOS.md duplicate line).
**Status:** In Progress — audited 2026-07-11, re-audited 2026-07-12 for
drift from the `cmd/hermes``cmd/nomos` rename and later fixes. Done: A1
(approval FK bug), A3 (Hermes→Nomos help text), **Section C** (toy NLU /
silent-wrong-answer fallback — nomos now calls real `listTools()` and
routes unmatched queries to `/chat` instead of guessing, per
`cmd/nomos/main.go:444-465`), **D.5** (SOUL.md/actuator architecture
mismatch — `nomos/SOUL.md:21-22,40` now accurately documents SSH via the
`run` tool), D1 (`upsert_knowledge`), D4-partial (general `run` tool).
Still open: A2 (notifier flooding/dedup), A4 (`resolveHost` dead code), A5
(`queryRows` stringly-typed columns), A6 (stale `get_state_snapshot`
description), B1-B5 (enrollment auth, fake Infisical creds, `/query`
mesh-only auth unenforced, insecure host key checking, optional
`caller_pubkey`), D2/D3 (no `get_approval_status`/`list_pending_approvals`/
signal ack-resolve-mute tools), E-partial (Caddyfile placeholders still
present; tool count now 33, documented in AGENTS.md as of 2026-07-12).
2026-07-12 re-audit also refreshed every `cmd/hermes``cmd/nomos` and
`internal/mcp/server.go` line-number citation below (the file grew from 28
to 33 registered tools since 2026-07-11) — content/status of each finding
unchanged, only citations moved.
## Goal
@@ -78,22 +88,24 @@ named `tools/list`, which doesn't exist. The correct `listTools()` helper
### A4. `resolveHost` never returns a per-entity SSH user
`internal/mcp/server.go:943` — the named return `sshUser` is always `""`; the
per-entity user branch is dead and everything relies on `sshExec`'s global
default fallback. **Fix:** read the SSH user from entity attributes or delete
the dead return to make the behavior honest.
`internal/mcp/server.go:1222` (was :943 — line moved) — the named return
`sshUser` is always `""`; the per-entity user branch is dead and everything
relies on `sshExec`'s global default fallback. **Fix:** read the SSH user
from entity attributes or delete the dead return to make the behavior
honest.
### A5. `queryRows` stringifies every column
`internal/mcp/server.go:861` renders all values via `fmt.Sprintf("%v", ...)`,
so numbers, bools, timestamps, and JSON all reach agents as strings.
**Fix:** type-preserving serialization (pass through pgx-native values into
`json.Marshal`) — improves every read tool at once.
`internal/mcp/server.go:1090` (was :861 — line moved) renders all values via
`fmt.Sprintf("%v", ...)`, so numbers, bools, timestamps, and JSON all reach
agents as strings. **Fix:** type-preserving serialization (pass through
pgx-native values into `json.Marshal`) — improves every read tool at once.
### A6. `get_state_snapshot` description is stale
`internal/mcp/server.go:689` still advertises "disk, drift count" — columns
removed in commit 3ea43ad. **Fix:** update the description.
`internal/mcp/server.go:863` (was :689 — line moved) still advertises "disk,
drift count" — columns removed in commit 3ea43ad. **Fix:** update the
description.
---
@@ -101,12 +113,13 @@ removed in commit 3ea43ad. **Fix:** update the description.
### B1. Enrollment is unauthenticated, with a false comment
`internal/httpapi/server.go:97` says "unauthenticated (IP-gated in handler)"
but `EnrollClient` (`internal/httpapi/impl.go:1099`) performs no IP check at
all — the only gate is the target entity being in state
`planned`/`provisioning`. Caddy's `@enroll` matcher bypasses Authentik.
Anyone reaching `oikos.hubris.network` who knows (or guesses) a planned slug
receives that node's **age private key** in the HTTP response body.
`internal/httpapi/server.go:111` (was :97) says "unauthenticated (IP-gated
in handler)" but `EnrollClient` (`internal/httpapi/impl.go:1166`, was
:1099) performs no IP check at all — the only gate is the target entity
being in state `planned`/`provisioning`. Caddy's `@enroll` matcher bypasses
Authentik. Anyone reaching `oikos.hubris.network` who knows (or guesses) a
planned slug receives that node's **age private key** in the HTTP response
body. Still open — line numbers only, substance unchanged.
**Fix:** enforce a real gate (mesh-CIDR check, one-time enrollment token
minted when the entity is created, or both), and stop returning the age
@@ -114,34 +127,49 @@ private key in the response — have the client fetch it from the secret store.
### B2. Fake Infisical credentials returned to enrollees
`internal/httpapi/impl.go:1191-1192` returns `"inf_client_"+uuid` /
`"inf_secret_"+uuid` — random strings wired to nothing. Enrolled clients hold
credentials that authenticate against nothing.
`internal/httpapi/impl.go:1260-1261` (was :1191-1192) returns
`"inf_client_"+uuid` / `"inf_secret_"+uuid` — random strings wired to
nothing. Enrolled clients hold credentials that authenticate against
nothing. Still open — line numbers only, substance unchanged.
**Fix:** implement `CreateMachineIdentity` in `internal/secrets/infisical.go`,
or return no credentials and document the manual step.
### B3. Hermes `/query` has no auth
### B3. Nomos's `/query` has no auth
`hermes/config.yaml:9` sets `mesh_only: true` but `cmd/hermes/main.go` never
reads or enforces it — it serves any caller on :8092, who can invoke
`request_execution`. **Fix:** enforce mesh-CIDR (or bearer token) in the
handler; fail closed.
`nomos/config.yaml:9` (was `hermes/config.yaml:9`) sets `mesh_only: true`
but `cmd/nomos/main.go` (was `cmd/hermes/main.go`) never reads or enforces
it — it serves any caller on :8092, who can invoke `request_execution`.
Still open, now also tracked as C1 in
[2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md),
deferred by the operator. **Fix:** enforce mesh-CIDR (or bearer token) in
the handler; fail closed.
### B4. SSH host keys not verified
`ssh.InsecureIgnoreHostKey()` at `internal/mcp/server.go:920`.
`ssh.InsecureIgnoreHostKey()` at `internal/mcp/server.go:1155` (was :920).
Still open — line number only, substance unchanged.
**Fix:** known_hosts pinning (keys are already inventory-managed per node).
### B5. `list_my_secrets` enumerates all node pubkeys
Without `caller_pubkey`, `internal/mcp/server.go:709-720` returns every entity
that has an `age_pubkey`; nothing ties the caller to what it may list.
Without `caller_pubkey`, `internal/mcp/server.go:879-883` (was :709-720)
returns every entity that has an `age_pubkey`; nothing ties the caller to
what it may list. Still open — line numbers only, substance unchanged.
**Fix:** require `caller_pubkey` and scope results to the caller's
entitlements.
---
## C. User perspective (interacting via Hermes)
## C. User perspective (interacting via Hermes) — RESOLVED
**Resolved as of the Hermes→Nomos rewrite (verified 2026-07-12).** This
entire section described `cmd/hermes`, which no longer exists — Hermes was
renamed and rebuilt as `cmd/nomos`, a real LLM-backed agent loop, which is
exactly the recommendation below. `cmd/nomos/main.go:444-465` now calls the
real `listTools()` for "help"/"what can you do", and routes unmatched
queries to "natural language queries belong to `/chat`..." instead of
silently falling back to `get_health_summary`. Kept below for history —
original text unchanged.
- `routeQuery` NLU is hardcoded `strings.Contains`; `extractEntity`
(`cmd/hermes/main.go:173`) recognizes only 5 services (`authentik, caddy,
@@ -175,27 +203,41 @@ says 21 — both stale). Missing capabilities:
`pending_approval`, an agent has no way to check or reference the approval.
Add `get_approval_status` / `list_pending_approvals`.
4. Execution actions limited to `restart | systemctl | pct_exec |
apt_upgrade` — no deploy/rollback/config-edit path.
5. Architecture/doc mismatch: `hermes/SOUL.md` claims "no SSH access; all
mutations flow through the actuator", but the MCP server runs
`restart`/`pct_exec` synchronously over SSH from inside the api process
(`sshExec`, server.go:902). Align docs or move execution to the actuator.
apt_upgrade` — no deploy/rollback/config-edit path. Partially
superseded: the general `run` MCP tool (D4-partial, done) covers
arbitrary commands now; `request_execution`'s fixed enum is still there
for the specific actions it names (see
[2026-07-10-general-gated-execution.md](2026-07-10-general-gated-execution.md)).
5. **RESOLVED (verified 2026-07-12).** Architecture/doc mismatch:
`hermes/SOUL.md` claimed "no SSH access; all mutations flow through the
actuator", but the MCP server ran `restart`/`pct_exec` synchronously over
SSH from inside the api process. `nomos/SOUL.md:21-22,40` now accurately
documents SSH access via the policy-gated `run` tool — matches the
architecture the general-gated-execution plan built. No longer a
mismatch.
---
## E. Doc drift / housekeeping
- Tool counts: README 15 / AGENTS.md 21 / actual 28 — regenerate from
`internal/mcp/server.go` (consider a doc-gen make target).
- `compose/caddy/Caddyfile.oikos` retains literal `<mac-mini-mesh-ip>`
placeholders in all three vhosts.
- `.agents/HERMES.md` lists "`inventory.yaml`, `inventory.yaml`" (duplicate).
- `plans/index.md` drift: fix-MCP-tools row sat in Active with a broken link
after the file moved to `done/` (fixed alongside this plan); TRMNL listed
active though in `done/`; Grimmory header says `in-progress` though in
`done/`; `.hermes/plans/` (7 executed plans) missing from disk.
- `plans/2026-07-05-oikos-prometheus-lxc.md` (~0% done) references deleted
`oikos/scheduler.py` and `bin/homelab`; LXC 131 collision unresolved.
- **RESOLVED (verified 2026-07-12):** Tool counts. README 15 / AGENTS.md 21
/ actual 28 was already stale by 2026-07-11 (registered tools grew to
33) — AGENTS.md now documents all 33 with the full catalog (2026-07-12).
- **Still open:** `compose/caddy/Caddyfile.oikos` retains literal
`<mac-mini-mesh-ip>` placeholders (this repo's copy is a reference only —
see [2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md)'s
"Plan review" — the real config lives in `dtoro/caddy-conf`).
- **RESOLVED:** `.agents/HERMES.md` renamed to `.agents/NOMOS.md`; the
duplicate-line bug itself is still present at `.agents/NOMOS.md:11` —
only the file citation was stale, the underlying nit is still open.
- **RESOLVED (verified 2026-07-12):** `plans/index.md` drift — the broken
link, TRMNL/Grimmory Active/Done mismatch, and missing `.hermes/plans/`
entries described here are no longer present in the current
`plans/index.md`; already fixed sometime after this plan was written.
- **RESOLVED (verified 2026-07-12):** `plans/2026-07-05-oikos-prometheus-lxc.md`
already self-corrected both the deleted-file references and the LXC 131
collision in its own 2026-07-08 changelog — this bullet describes a
pre-fix state.
---

View File

@@ -182,16 +182,27 @@ board reflects reality instead of showing a task that looks perpetually
### C1. Nomos's own HTTP gateway has zero authentication
[docker-compose.yml:133](../docker-compose.yml) publishes port 8092 directly
[docker-compose.yml:144](../docker-compose.yml) publishes port 8092 directly
(`"8092:8092"`, comment: *"mesh-published"*) and
[Caddyfile.oikos](../compose/caddy/Caddyfile.oikos:19,34) reverse-proxies to
it from two routes. `grep -n "Authorization\|Bearer\|auth" cmd/nomos/main.go`
returns **nothing**`/chat`, `/sessions`, `/sessions/{id}` (including
`DELETE`), and `/query` have no credential check of any kind. Anyone who can
reach the LAN or mesh network can converse with Nomos directly: start tasks,
read/delete any session, answer pending questions, and — via chat-assent —
approve gated executions by typing "yes" or "I confirm" to whatever the agent
proposes, with no authentication at all. This is the same class of gap
[Caddyfile.oikos:52-54](../compose/caddy/Caddyfile.oikos) reverse-proxies to
it — as of the client/server split
([2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md)), only
from `nomos.hubris.network` now, not two routes: `/agent/*` on
`oikos.hubris.network` was repointed to go through `api`'s own authenticated
proxy mount instead of straight to nomos:8092, but that's `combinedAuth`
authenticating the *hop into api*, not anything nomos itself checks — this
finding is unaffected by that change, still fully open. `grep -n
"Authorization\|Bearer\|auth" cmd/nomos/main.go` still returns **nothing**
for nomos's inbound routes (nomos did gain outbound auth as *part of* the
client/server split — it now sends `Authorization: Bearer
$OIKOS_MCP_BEARER_TOKEN` on its own calls to `api` — but that's the opposite
direction from this finding) — `/chat`, `/sessions`, `/sessions/{id}`
(including `DELETE`), and `/query` have no credential check of any kind.
Anyone who can reach the LAN or mesh network can converse with Nomos
directly: start tasks, read/delete any session, answer pending questions,
and — via chat-assent — approve gated executions by typing "yes" or "I
confirm" to whatever the agent proposes, with no authentication at all. This
is the same class of gap
[oikos-gaps-and-improvements](2026-07-08-oikos-gaps-and-improvements.md)
flagged for the `api`/MCP surface (items B1-B5), but specifically for nomos's
*own* port, which doesn't sit behind `combinedAuth` the way `api`'s routes do.

View File

@@ -0,0 +1,829 @@
# 2026-07-12 — Wails desktop application
**Status:** Done — Phases 0.00.6 deployed to production (mac-mini, commit
`0c0f35a`, 2026-07-12). Phases 1.01.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:
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<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:
```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),
`<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.html` includes a placeholder `<script>` tag that gets replaced:
```html
<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.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.

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

@@ -10,7 +10,7 @@ went sideways, open an investigation.
| ---- | ----- | ------ |
| 2026-07-05 | [Oikos Prometheus LXC](2026-07-05-oikos-prometheus-lxc.md) | Planned — not started |
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | In Progress — security items (B1-B5) and doc drift (E) still open |
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress — packaging/auth sections superseded by the Wails plan's Phase 0 (client/server split); M4 still open |
| 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-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 |
@@ -43,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 | [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-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

View File

@@ -0,0 +1,31 @@
<?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>network.hubris.oikos-deploy-poller</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>cd $HOME/Projects/oikos && git fetch origin main && SHA_LOCAL=$(git rev-parse HEAD) && SHA_REMOTE=$(git rev-parse origin/main) && if [ "$SHA_LOCAL" != "$SHA_REMOTE" ]; then echo "deploying $SHA_LOCAL -> $SHA_REMOTE" && git pull origin main && scripts/deploy.sh; fi</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/dtoro</string>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>WorkingDirectory</key>
<string>/Users/dtoro/Projects/oikos</string>
<key>StartInterval</key>
<integer>120</integer>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>/Users/dtoro/Library/Logs/oikos-deploy-poller.log</string>
<key>StandardErrorPath</key>
<string>/Users/dtoro/Library/Logs/oikos-deploy-poller.log</string>
</dict>
</plist>

View File

@@ -0,0 +1,35 @@
<?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>network.hubris.oikos-deploy-webhook</string>
<key>ProgramArguments</key>
<array>
<string>/Users/dtoro/Projects/oikos/webhook</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>WEBHOOK_HMAC_SECRET</key>
<string>6502524162d6dbc3f6d137000395d401f1837d74ef9bb0a876f8e6bbd65d1ff2</string>
<key>WEBHOOK_REPO_DIR</key>
<string>/Users/dtoro/Projects/oikos</string>
<key>WEBHOOK_LISTEN</key>
<string>:9797</string>
<key>HOME</key>
<string>/Users/dtoro</string>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>WorkingDirectory</key>
<string>/Users/dtoro/Projects/oikos</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/dtoro/Library/Logs/oikos-webhook.log</string>
<key>StandardErrorPath</key>
<string>/Users/dtoro/Library/Logs/oikos-webhook.log</string>
</dict>
</plist>

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env node
// Caveman template renderer — reads template + data JSON files and renders output
// Installed automatically via homelab-context post-pull hook
const caveman = require("caveman");
const fs = require("fs");
const path = require("path");
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: caveman <template> [data.json]");
process.exit(1);
}
const templatePath = args[0];
let data = {};
if (args.length > 1) {
data = JSON.parse(fs.readFileSync(args[1], "utf8"));
}
const template = fs.readFileSync(templatePath, "utf8");
const templateName = path.basename(templatePath, path.extname(templatePath));
caveman.register(templateName, template);
console.log(caveman.render(templateName, data).trim());

View File

@@ -1,76 +0,0 @@
#!/usr/bin/env bash
# Caveman + RTK Wrapper - Automated token-efficient output formatting
# Installed automatically via homelab-context post-pull hook
# Source: https://github.com/adityahimaone/hermes-agent-rtk-caveman
# Usage: caveman_wrapper.sh <workflow> [options]
set -e
WORKFLOW="$1"
shift
CAVEMAN=~/bin/caveman
TEMPLATES_DIR=~/templates
DATA_DIR=/tmp/caveman_data
mkdir -p "$DATA_DIR"
case "$WORKFLOW" in
git-status)
git status --porcelain | awk '
BEGIN { staged=0; modified=0; untracked=0; deleted=0 }
/^[MARC]./ { staged_arr[staged++] = substr($0, 4) }
/^.[MARC]/ { modified_arr[modified++] = substr($0, 4) }
/^\?\?/ { untracked_arr[untracked++] = substr($0, 4) }
/^D/ || /^.D/ { deleted_arr[deleted++] = substr($0, 4) }
END {
printf "{"
printf "\"staged\":["
for(i=0;i<staged;i++) printf "%s\"%s\"", (i>0?",":""), staged_arr[i]
printf "],\"modified\":["
for(i=0;i<modified;i++) printf "%s\"%s\"", (i>0?",":""), modified_arr[i]
printf "],\"untracked\":["
for(i=0;i<untracked;i++) printf "%s\"%s\"", (i>0?",":""), untracked_arr[i]
printf "],\"deleted\":["
for(i=0;i<deleted;i++) printf "%s\"%s\"", (i>0?",":""), deleted_arr[i]
printf "]}"
}' > "$DATA_DIR/git_status.json"
if command -v rtk &>/dev/null; then
rtk "$CAVEMAN" "$TEMPLATES_DIR/git_status.txt" "$DATA_DIR/git_status.json"
else
node "$CAVEMAN" "$TEMPLATES_DIR/git_status.txt" "$DATA_DIR/git_status.json"
fi
;;
git-log)
LIMIT="${1:-10}"
git log --oneline -"$LIMIT" --format='{"hash":"%h","author":"%an","date":"%ad","message":"%s"}' --date=short | \
jq -s '.' > "$DATA_DIR/git_log.json"
jq '{commits: .}' "$DATA_DIR/git_log.json" > "$DATA_DIR/git_log_final.json"
if command -v rtk &>/dev/null; then
rtk "$CAVEMAN" "$TEMPLATES_DIR/git_log.txt" "$DATA_DIR/git_log_final.json"
else
node "$CAVEMAN" "$TEMPLATES_DIR/git_log.txt" "$DATA_DIR/git_log_final.json"
fi
;;
test-results)
TEST_CMD="${1:-npx vitest run}"
$TEST_CMD --reporter json 2>/dev/null | \
jq '{total: .numTotalTests, passed: .numPassedTests, failed: .numFailedTests, suites: [.testResults[] | {name: .name, status: .status, duration: .duration}]}' > "$DATA_DIR/test_results.json" || true
if command -v rtk &>/dev/null; then
rtk "$CAVEMAN" "$TEMPLATES_DIR/test_results.txt" "$DATA_DIR/test_results.json"
else
node "$CAVEMAN" "$TEMPLATES_DIR/test_results.txt" "$DATA_DIR/test_results.json"
fi
;;
*)
echo "Usage: caveman_wrapper.sh <workflow> [options]"
echo " git-status - Compact git status"
echo " git-log [limit] - Recent git commits"
echo " test-results [cmd] - Compact test results"
exit 1
;;
esac

View File

@@ -1,3 +0,0 @@
Recent Commits:
{{- for d.commits as commit }} {{commit.hash}} {{commit.date}} {{commit.message}}
{{- end }}

View File

@@ -1,12 +0,0 @@
{{- if d.staged }}Staged:
{{- for d.staged as file }} + {{file}}
{{- end }}{{- end }}
{{- if d.modified }}Modified:
{{- for d.modified as file }} ~ {{file}}
{{- end }}{{- end }}
{{- if d.untracked }}Untracked:
{{- for d.untracked as file }} ? {{file}}
{{- end }}{{- end }}
{{- if d.deleted }}Deleted:
{{- for d.deleted as file }} - {{file}}
{{- end }}{{- end }}

View File

@@ -1,4 +0,0 @@
{{- if d.failed }}Tests: {{d.passed}}/{{d.total}} passed ({{d.failed}} failed)
{{- for d.suites as suite }}{{- if suite.status == "failed" }} {{suite.name}} ({{suite.duration}}ms)
{{- end }}{{- end }}{{- else }}All {{d.total}} tests passed
{{- end }}

View File

@@ -3,7 +3,7 @@
# Replaces raw `git pull` in the launchd/systemd timer.
# Runs after every git pull to auto-setup tools from the repo.
#
# Convention: any script at tools/*.setup.sh is sourced/exec'd after pull.
# Convention: any script at tools/setup-*.sh is sourced/exec'd after pull.
# This lets us ship new tooling to all agent hosts via a simple git push.
set -euo pipefail
@@ -31,7 +31,7 @@ else
fi
# 2. Run any auto-setup scripts
for setup_script in "$CONTEXT_DIR"/tools/*.setup.sh; do
for setup_script in "$CONTEXT_DIR"/tools/setup-*.sh; do
[ -f "$setup_script" ] || continue
echo "[post-pull] running $setup_script..."
bash "$setup_script" || echo "[post-pull] WARNING: $setup_script exited with code $?"

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# setup-caveman.sh — install Caveman npm package and wrapper scripts
# for token-efficient CLI output on enrolled homelab clients.
set -euo pipefail
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
BIN_DIR="$HOME/bin"
TOOLS_DIR="$CLONE_DIR/tools"
mkdir -p "$BIN_DIR"
# Install the caveman npm package globally.
if ! command -v caveman >/dev/null 2>&1; then
if command -v npm >/dev/null 2>&1; then
npm install -g caveman 2>/dev/null || true
echo "[setup-caveman] caveman npm package installed"
fi
fi
# Copy wrapper to ~/bin.
if [ -f "$TOOLS_DIR/caveman_wrapper.sh" ]; then
cp "$TOOLS_DIR/caveman_wrapper.sh" "$BIN_DIR/caveman_wrapper.sh"
chmod +x "$BIN_DIR/caveman_wrapper.sh"
echo "[setup-caveman] wrapper installed to $BIN_DIR/caveman_wrapper.sh"
fi
# Copy templates.
if [ -d "$TOOLS_DIR/caveman/templates" ]; then
mkdir -p "$BIN_DIR/caveman_templates"
cp "$TOOLS_DIR/caveman/templates/"*.txt "$BIN_DIR/caveman_templates/" 2>/dev/null || true
echo "[setup-caveman] templates installed"
fi
echo "[setup-caveman] done"

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# setup-checks.sh — deploy check scripts to /opt/oikos/checks on each host.
# Auto-setup hook: tools/*.setup.sh runs after every git pull.
# Auto-setup hook: tools/setup-*.sh runs after every git pull.
set -euo pipefail
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"

View File

@@ -1,14 +0,0 @@
#!/usr/bin/env bash
# setup-nomos-soul.sh — provision Nomos agent persona.
# Copies ~/.nomos/SOUL.md from nomos/SOUL.md. No-op on non-Nomos agents.
set -euo pipefail
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
if [ -f "$CLONE_DIR/nomos/SOUL.md" ]; then
mkdir -p "$HOME/.nomos"
cp "$CLONE_DIR/nomos/SOUL.md" "$HOME/.nomos/SOUL.md"
echo "[setup-nomos-soul] SOUL.md provisioned"
else
echo "[setup-nomos-soul] no nomos/SOUL.md found; skipping"
fi

0
web/dist/.gitkeep vendored
View File

View File

@@ -1,21 +0,0 @@
// Package web embeds the compiled control-room SPA (web/dist) into the oikos
// binary, preserving the single-binary deployment (ADR-0001). The dist tree is
// produced by `npm run build` (or the Docker ui-builder stage); a committed
// web/dist/.gitkeep keeps a backend-only `go build` green when the UI has not
// been built.
package web
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// DistFS returns the built SPA rooted at dist/. When the UI has not been built
// (only the .gitkeep placeholder is present), Open("index.html") will fail and
// the caller serves a 404 — the binary still starts.
func DistFS() (fs.FS, error) {
return fs.Sub(dist, "dist")
}

View File

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

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && touch dist/.gitkeep",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {

View File

@@ -1,18 +1,19 @@
<script lang="ts">
import Chat from './pages/Chat.svelte'
import Tasks from './pages/Tasks.svelte'
import Overview from './pages/Overview.svelte'
import Entities from './pages/Entities.svelte'
import KnowledgeBase from './pages/KnowledgeBase.svelte'
import Ops from './pages/Ops.svelte'
import Signals from './pages/Signals.svelte'
import Graph from './pages/Graph.svelte'
import EntityDetail from './pages/EntityDetail.svelte'
import Knowledge from './pages/Knowledge.svelte'
import Learning from './pages/Learning.svelte'
import Config from './pages/Config.svelte'
import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { connectionState } from '$lib/stores/events'
import { isConfigured } from '$lib/config'
import { onMount } from 'svelte'
import { processPendingCallback, initOIDC } from '$lib/oidc'
import * as Sidebar from '$lib/components/ui/sidebar'
import * as Sheet from '$lib/components/ui/sheet'
import { Button } from '$lib/components/ui/button'
@@ -20,39 +21,51 @@
import { Separator } from '$lib/components/ui/separator'
import { Toaster } from '$lib/components/ui/sonner'
import PlusIcon from '@lucide/svelte/icons/plus'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
import DatabaseIcon from '@lucide/svelte/icons/database'
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import SirenIcon from '@lucide/svelte/icons/siren'
import NetworkIcon from '@lucide/svelte/icons/share-2'
import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
import SettingsIcon from '@lucide/svelte/icons/settings'
let page = $state('tasks')
let page = $state('overview')
let routeParam = $state('')
let drawerOpen = $state(false)
let configured = $state(isConfigured())
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
const openSignals = $derived(openSignalCount($summary))
onMount(() => {
onMount(async () => {
if (await processPendingCallback()) {
configured = true
} else if (!configured) {
if (await initOIDC()) configured = true
}
function sync() {
const path = location.hash.slice(2) || 'tasks'
const path = location.hash.slice(2) || 'overview'
const [head, ...rest] = path.split('/')
page = head || 'tasks'
// Entities + Graph were merged into Knowledge Base — keep old links working.
if (head === 'entities' || head === 'graph') {
location.hash = '#/kb'
return
}
page = head || 'overview'
routeParam = rest.join('/')
}
sync()
window.addEventListener('hashchange', sync)
const unsubscribeCtx = subscribeContext()
return () => window.removeEventListener('hashchange', sync)
})
return () => {
window.removeEventListener('hashchange', sync)
unsubscribeCtx()
}
// Context (dashboard summary + approvals poll) and the SSE stream both
// authenticate — don't subscribe until a token exists.
$effect(() => {
if (!configured) return
return subscribeContext()
})
function navigate(p: string) {
@@ -61,8 +74,7 @@
const navItems = [
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
{ id: 'entities', label: 'Entities', icon: DatabaseIcon },
{ id: 'graph', label: 'Graph', icon: NetworkIcon },
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
@@ -70,6 +82,13 @@
]
</script>
{#if !configured}
<Config
onConnected={() => (configured = true)}
onCancel={isConfigured() ? () => (configured = true) : undefined}
/>
{:else}
<Toaster />
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
@@ -114,20 +133,10 @@
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={page === 'tasks' || page === 'chat'} onclick={() => navigate('tasks')} tooltipContent="Tasks">
{#snippet child({ props })}
<button {...props}>
<ListTodoIcon />
<span>Tasks</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{#each navItems as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton
isActive={page === item.id}
isActive={page === item.id || (item.id === 'overview' && page === 'chat')}
onclick={() => navigate(item.id)}
tooltipContent={item.label}
>
@@ -158,6 +167,16 @@
<PanelRightIcon />
<span>Chat drawer</span>
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={() => (configured = false)}
title="Server connection settings"
>
<SettingsIcon />
<span>Connection</span>
</Button>
</Sidebar.Footer>
</Sidebar.Root>
@@ -166,11 +185,11 @@
<Sidebar.Trigger class="-ms-1" />
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
{#if page === 'chat'}
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('tasks')}>Tasks</button>
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Overview</button>
<span class="text-muted-foreground">/</span>
<span class="text-base font-medium">Conversation</span>
{:else}
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page}</span>
{/if}
<div class="ms-auto flex items-center gap-2.5">
{#if $summary}
@@ -199,12 +218,8 @@
<main class="min-h-0 flex-1 overflow-hidden">
{#if page === 'overview'}
<Overview />
{:else if page === 'tasks'}
<Tasks />
{:else if page === 'entities'}
<Entities />
{:else if page === 'graph'}
<Graph />
{:else if page === 'kb'}
<KnowledgeBase />
{:else if page === 'entity' && routeParam}
<EntityDetail slug={routeParam} />
{:else if page === 'ops'}
@@ -233,3 +248,5 @@
</div>
</Sheet.Content>
</Sheet.Root>
{/if}

View File

@@ -1,3 +1,10 @@
import { fetchWithAuth } from './config'
// Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual
// origin (relative vs. configured apiUrl) fresh on every call via
// config.ts's apiBase(), so these can't be pre-resolved once at module load
// (the config may not be known yet at import time, e.g. before Config.svelte
// or a Wails-injected __OIKOS_CONFIG__ runs).
const BASE = '/agent'
const API = '/api/v1'
@@ -26,21 +33,21 @@ export interface Message {
}
export async function fetchSessions(): Promise<Session[]> {
const res = await fetch(`${BASE}/sessions`)
const res = await fetchWithAuth(`${BASE}/sessions`)
if (!res.ok) return []
const data = await res.json()
return data.sessions ?? []
}
export async function fetchMessages(sessionId: string): Promise<Message[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}`)
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`)
if (!res.ok) return []
const data = await res.json()
return data.messages ?? []
}
export async function deleteSession(sessionId: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
return res.ok
}
@@ -57,7 +64,7 @@ export interface PlanStep {
}
export async function fetchPlan(sessionId: string): Promise<PlanStep[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}/plan`)
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/plan`)
if (!res.ok) return []
const data = await res.json()
return data.steps ?? []
@@ -74,16 +81,15 @@ export interface SessionQuestion {
}
export async function fetchQuestions(sessionId: string): Promise<SessionQuestion[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}/questions`)
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions`)
if (!res.ok) return []
const data = await res.json()
return data.questions ?? []
}
export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer })
})
return res.ok
@@ -105,9 +111,8 @@ export function streamChat(
): AbortController {
const controller = new AbortController()
fetch(`${BASE}/chat`, {
fetchWithAuth(`${BASE}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
signal: controller.signal
}).then(async (res) => {
@@ -161,7 +166,7 @@ export interface DashboardSummary {
}
export async function fetchDashboardSummary(): Promise<DashboardSummary | null> {
const res = await fetch(`${API}/dashboard/summary`)
const res = await fetchWithAuth(`${API}/dashboard/summary`)
if (!res.ok) return null
return res.json()
}
@@ -180,11 +185,14 @@ export interface Entity {
updated_at: string
health?: EntityHealth | null
last_check_at?: string | null
maintenance_until?: string | null
}
export interface EntityFilters {
type?: string
state?: string
domain?: string
layer?: string
q?: string
}
@@ -192,14 +200,36 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
const params = new URLSearchParams()
if (filters.type) params.set('type', filters.type)
if (filters.state) params.set('state', filters.state)
if (filters.domain) params.set('domain', filters.domain)
if (filters.layer) params.set('layer', filters.layer)
if (filters.q) params.set('q', filters.q)
params.set('limit', '200')
const res = await fetch(`${API}/entities?${params}`)
const res = await fetchWithAuth(`${API}/entities?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition'
export interface EntityType {
name: string
parent_type?: string | null
is_abstract: boolean
domain: string
layer: OntologyLayer
description?: string | null
}
// The graph endpoint has no layer param, so callers build a type→layer map from
// this to scope the graph client-side (the entities table filters server-side).
export async function fetchEntityTypes(): Promise<EntityType[]> {
const res = await fetchWithAuth(`${API}/ontology`)
if (!res.ok) return []
const data = await res.json()
return data.entity_types ?? []
}
export interface EventFilters {
type?: string
severity?: string
@@ -210,7 +240,7 @@ export async function fetchEvents(filters: EventFilters = {}): Promise<import('.
if (filters.type) params.set('type', filters.type)
if (filters.severity) params.set('severity', filters.severity)
params.set('limit', '100')
const res = await fetch(`${API}/events?${params}`)
const res = await fetchWithAuth(`${API}/events?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -235,7 +265,7 @@ export async function fetchApprovals(status?: string): Promise<Approval[]> {
const params = new URLSearchParams()
if (status) params.set('status', status)
params.set('limit', '200')
const res = await fetch(`${API}/approvals?${params}`)
const res = await fetchWithAuth(`${API}/approvals?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -246,9 +276,8 @@ export async function decideApproval(
decision: 'approve' | 'deny' | 'revoke',
note?: string
): Promise<Approval | null> {
const res = await fetch(`${API}/approvals/${id}/decision`, {
const res = await fetchWithAuth(`${API}/approvals/${id}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, note })
})
if (!res.ok) return null
@@ -277,20 +306,20 @@ export async function fetchExecutions(status?: string): Promise<Execution[]> {
const params = new URLSearchParams()
if (status) params.set('status', status)
params.set('limit', '200')
const res = await fetch(`${API}/executions?${params}`)
const res = await fetchWithAuth(`${API}/executions?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function getExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}`)
const res = await fetchWithAuth(`${API}/executions/${id}`)
if (!res.ok) return null
return res.json()
}
export async function cancelExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}/cancel`, { method: 'POST' })
const res = await fetchWithAuth(`${API}/executions/${id}/cancel`, { method: 'POST' })
if (!res.ok) return null
return res.json()
}
@@ -309,7 +338,7 @@ export interface ActivityItem {
}
export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> {
const res = await fetch(`${API}/activity/recent?limit=${limit}`)
const res = await fetchWithAuth(`${API}/activity/recent?limit=${limit}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -325,7 +354,7 @@ export interface SessionDigest {
}
export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> {
const res = await fetch(`${API}/activity/session/${sessionId}`)
const res = await fetchWithAuth(`${API}/activity/session/${sessionId}`)
if (!res.ok) return null
return res.json()
}
@@ -338,7 +367,7 @@ export interface CapabilityTimelineItem {
}
export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> {
const res = await fetch(`${API}/learning/timeline`)
const res = await fetchWithAuth(`${API}/learning/timeline`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -351,7 +380,7 @@ export interface TrendBucket {
}
export async function fetchLearningTrend(): Promise<TrendBucket[]> {
const res = await fetch(`${API}/learning/trend`)
const res = await fetchWithAuth(`${API}/learning/trend`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -372,7 +401,7 @@ export interface Pattern {
}
export async function fetchPatterns(): Promise<Pattern[]> {
const res = await fetch(`${API}/patterns`)
const res = await fetchWithAuth(`${API}/patterns`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -390,7 +419,7 @@ export interface Skill {
}
export async function fetchSkills(): Promise<Skill[]> {
const res = await fetch(`${API}/skills`)
const res = await fetchWithAuth(`${API}/skills`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -418,22 +447,21 @@ export async function fetchSignals(filters: { state?: string; severity?: string
if (filters.state) params.set('state', filters.state)
if (filters.severity) params.set('severity', filters.severity)
params.set('limit', '200')
const res = await fetch(`${API}/signals?${params}`)
const res = await fetchWithAuth(`${API}/signals?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function ackSignal(id: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/ack`, { method: 'POST' })
const res = await fetchWithAuth(`${API}/signals/${id}/ack`, { method: 'POST' })
if (!res.ok) return null
return res.json()
}
export async function resolveSignal(id: string, note?: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/resolve`, {
const res = await fetchWithAuth(`${API}/signals/${id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note })
})
if (!res.ok) return null
@@ -441,9 +469,8 @@ export async function resolveSignal(id: string, note?: string): Promise<Signal |
}
export async function muteSignal(id: string, muteUntil: string, note?: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/mute`, {
const res = await fetchWithAuth(`${API}/signals/${id}/mute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mute_until: muteUntil, note })
})
if (!res.ok) return null
@@ -481,7 +508,7 @@ export async function fetchGraph(filters: GraphFilters = {}): Promise<GraphView
if (filters.depth) params.set('depth', String(filters.depth))
for (const rt of filters.relType ?? []) params.append('rel_type', rt)
if (filters.includeStatus) params.append('include', 'status')
const res = await fetch(`${API}/graph?${params}`)
const res = await fetchWithAuth(`${API}/graph?${params}`)
if (!res.ok) return null
return res.json()
}
@@ -492,14 +519,17 @@ export interface BlastRadiusItem {
}
export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> {
const res = await fetch(`${API}/entities/${id}/blast-radius`)
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/blast-radius`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
// id is commonly a slug, not a UUID (e.g. "document:infrastructure/network") —
// slugs can contain '/', which must be percent-encoded or it splits the path
// into extra segments the router won't match.
export async function fetchEntity(id: string): Promise<Entity | null> {
const res = await fetch(`${API}/entities/${id}`)
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}`)
if (!res.ok) return null
return res.json()
}
@@ -521,7 +551,7 @@ export interface MetricSeries {
export async function fetchMetrics(entityId: string): Promise<MetricSeries[]> {
const params = new URLSearchParams({ entity_id: entityId, rollup: 'auto' })
const res = await fetch(`${API}/metrics?${params}`)
const res = await fetchWithAuth(`${API}/metrics?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -559,21 +589,38 @@ export interface RecentKnowledge {
export async function fetchRecentKnowledge(source?: string): Promise<RecentKnowledge> {
const params = new URLSearchParams()
if (source) params.set('source', source)
const res = await fetch(`${API}/knowledge/recent?${params}`)
const res = await fetchWithAuth(`${API}/knowledge/recent?${params}`)
if (!res.ok) return { stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] }
return res.json()
}
export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeHit[]> {
const res = await fetch(`${API}/knowledge/${entityId}`)
const res = await fetchWithAuth(`${API}/knowledge/${entityId}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export interface KnowledgeContent {
title: string
content: string
source: string
tags: string[]
updated_at: string
}
// Full markdown body for a document/investigation/runbook entity — distinct
// from fetchEntityKnowledge, which returns knowledge that references OTHER
// entities, not this entity's own content.
export async function fetchKnowledgeContent(id: string): Promise<KnowledgeContent | null> {
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(id)}`)
if (!res.ok) return null
return res.json()
}
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
const res = await fetch(`${API}/events?${params}`)
const res = await fetchWithAuth(`${API}/events?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -581,7 +628,7 @@ export async function fetchEntityEvents(entityId: string): Promise<import('./sto
export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
const res = await fetch(`${API}/signals?${params}`)
const res = await fetchWithAuth(`${API}/signals?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -589,12 +636,50 @@ export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
export async function fetchEntityExecutions(entityId: string): Promise<Execution[]> {
const params = new URLSearchParams({ target: entityId, limit: '50' })
const res = await fetch(`${API}/executions?${params}`)
const res = await fetchWithAuth(`${API}/executions?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export interface EntityTask {
task: Entity
executionCount: number
}
// Executions aren't directly browsable from an entity in a useful way — what
// matters is which task/session acted on it, and how many times. There's no
// "incoming relationships" endpoint (blast_radius/graph only walks outgoing
// edges), so this composes it client-side: find executions targeting this
// entity, then check each task's own outgoing edges (task --involves--> entity
// directly, or task --involves--> execution) to attribute them. Cheap while
// the task count is small; would want a dedicated query if that changes.
export async function fetchEntityTasks(entity: Entity): Promise<EntityTask[]> {
const [executions, tasks] = await Promise.all([
fetchEntityExecutions(entity.id),
fetchEntities({ type: 'task' })
])
const executionIds = new Set(executions.map((e) => e.id))
const results = await Promise.all(
tasks.map(async (task): Promise<EntityTask | null> => {
const g = await fetchGraph({ root: task.slug, depth: 1 })
if (!g) return null
const involvesThisEntity = g.edges.some((e) => e.type === 'involves' && e.target === entity.slug)
const nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type]))
const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id]))
const executionCount = g.edges.filter((e) => {
if (e.type !== 'involves') return false
const targetId = idBySlug.get(e.target)
return targetId != null && nodeTypeById.get(targetId) === 'execution' && executionIds.has(targetId)
}).length
if (!involvesThisEntity && executionCount === 0) return null
return { task, executionCount }
})
)
return results.filter((r): r is EntityTask => r !== null)
}
export interface Check {
id: string
slug: string
@@ -611,16 +696,16 @@ export interface Check {
export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]> {
const params = new URLSearchParams({ target: targetSlug, limit: '50' })
const res = await fetch(`${API}/checks?${params}`)
const res = await fetchWithAuth(`${API}/checks?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise<Check | null> {
const res = await fetch(`${API}/checks/${id}`, {
const res = await fetchWithAuth(`${API}/checks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'If-Match': `"${version}"` },
headers: { 'If-Match': `"${version}"` },
body: JSON.stringify(patch)
})
if (!res.ok) return null
@@ -654,7 +739,7 @@ export async function fetchAgentActivity(filters: {
if (filters.activity_type) params.set('activity_type', filters.activity_type)
if (filters.entity_id) params.set('entity_id', filters.entity_id)
params.set('limit', String(filters.limit ?? 200))
const res = await fetch(`${API}/agent-activity?${params}`)
const res = await fetchWithAuth(`${API}/agent-activity?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -662,7 +747,7 @@ export async function fetchAgentActivity(filters: {
export async function searchKnowledge(q: string, limit = 50): Promise<KnowledgeHit[]> {
const params = new URLSearchParams({ q, limit: String(limit) })
const res = await fetch(`${API}/knowledge/search?${params}`)
const res = await fetchWithAuth(`${API}/knowledge/search?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -698,7 +783,7 @@ export async function fetchAudit(filters: {
if (filters.action) params.set('action', filters.action)
if (filters.correlation_id) params.set('correlation_id', filters.correlation_id)
params.set('limit', String(filters.limit ?? 200))
const res = await fetch(`${API}/audit?${params}`)
const res = await fetchWithAuth(`${API}/audit?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []

62
web/src/lib/categories.ts Normal file
View File

@@ -0,0 +1,62 @@
// Browsing categories for the Knowledge Base — a coarser, more useful axis
// than the ontology's own `layer` (infrastructure/governance/cognition),
// which lumps very different things (an LXC and a DNS record and a storage
// volume) into one "infrastructure" bucket. Built from the ontology's
// `domain` field instead, which already draws these lines; this just
// groups the 9 domains into 6 browsing-sized buckets.
import type { EntityFilters } from './api'
export type Category = 'network' | 'fleet' | 'services' | 'storage' | 'identity' | 'knowledge'
export const categories: { id: Category; label: string }[] = [
{ id: 'fleet', label: 'Fleet' },
{ id: 'network', label: 'Network' },
{ id: 'services', label: 'Services' },
{ id: 'storage', label: 'Storage' },
{ id: 'identity', label: 'Identity' },
{ id: 'knowledge', label: 'Knowledge' }
]
// entity_types.domain -> Category. `external` folds into Network (isp-link,
// domain-registration are network-adjacent); `physical` folds into Fleet
// (ups/sensor/site support compute, browsing them separately fragments
// "what's running where"). `meta` (the abstract root "entity" type) and
// `cognition` (see KNOWLEDGE_TYPES below) are handled outside this map.
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
network: 'network',
external: 'network',
compute: 'fleet',
physical: 'fleet',
software: 'services',
storage: 'storage',
identity: 'identity'
}
// `cognition` is not one thing: document/investigation/runbook are genuine
// long-form knowledge, but the domain also holds execution/check/task/
// signal/approval/pattern/skill/classification/feedback — operational
// telemetry with its own pages (Operations, Signals, Learning). Mapping the
// whole domain to Knowledge pulled in 245 execution + 25 check entities that
// fan out to a handful of compute nodes via `targets`/`checks` edges,
// flooding the graph. Only the true knowledge types get a category; the
// rest are excluded from Knowledge Base browsing entirely (returns
// undefined, same treatment as the abstract `entity` root type).
const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook'])
export function typeToCategory(type: string, domain: string): Category | undefined {
if (KNOWLEDGE_TYPES.has(type)) return 'knowledge'
if (domain === 'cognition') return undefined
return DOMAIN_TO_CATEGORY[domain]
}
// Filter sets to fetch and merge for a category's table view. Most
// categories are one or two `domain` values; Knowledge is a handful of
// specific `type`s carved out of the (otherwise excluded) cognition domain.
export function filtersForCategory(category: Category): EntityFilters[] {
if (category === 'knowledge') {
return Array.from(KNOWLEDGE_TYPES).map((type) => ({ type }))
}
return Object.entries(DOMAIN_TO_CATEGORY)
.filter(([, c]) => c === category)
.map(([domain]) => ({ domain }))
}

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import * as Collapsible from '$lib/components/ui/collapsible'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import type { Snippet } from 'svelte'
let {
title,
count,
defaultOpen,
children
}: {
title: string
count?: number
defaultOpen: boolean
children: Snippet
} = $props()
let open = $state(defaultOpen)
</script>
<Collapsible.Root bind:open class="rounded-md border bg-card">
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2.5 py-1.5 text-left hover:bg-muted/50">
<span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
<ChevronDownIcon
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
aria-hidden="true"
/>
</Collapsible.Trigger>
<Collapsible.Content class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in">
<div class="border-t px-2.5 py-2">
{@render children()}
</div>
</Collapsible.Content>
</Collapsible.Root>

View File

@@ -2,14 +2,17 @@
import { onMount, tick } from 'svelte'
import uPlot from 'uplot'
import 'uplot/dist/uPlot.min.css'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import {
fetchEntity,
fetchGraph,
fetchMetrics,
fetchEntityEvents,
fetchEntitySignals,
fetchEntityExecutions,
fetchEntityTasks,
fetchEntityKnowledge,
fetchKnowledgeContent,
fetchChecksForTarget,
fetchAgentActivity,
fetchAudit,
@@ -21,29 +24,33 @@
type Relationship,
type MetricSeries,
type Signal,
type Execution,
type EntityTask,
type KnowledgeHit,
type KnowledgeContent,
type Check,
type AgentActivity,
type AuditEntry
} from '$lib/api'
import { relativeTime } from '$lib/utils'
import { relativeTime, truncateMiddle } from '$lib/utils'
import type { OikosEvent } from '$lib/stores/events'
import * as Card from '$lib/components/ui/card'
import DetailSection from '$lib/components/DetailSection.svelte'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import { Skeleton } from '$lib/components/ui/skeleton'
import { toast } from 'svelte-sonner'
let { slug }: { slug: string } = $props()
const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook'])
let { slug, onSelectEntity }: { slug: string; onSelectEntity?: (slug: string) => void } = $props()
let entity = $state<Entity | null>(null)
let relations = $state<Relationship[]>([])
let metrics = $state<MetricSeries[]>([])
let events = $state<OikosEvent[]>([])
let signals = $state<Signal[]>([])
let executions = $state<Execution[]>([])
let tasks = $state<EntityTask[]>([])
let knowledge = $state<KnowledgeHit[]>([])
let ownContent = $state<KnowledgeContent | null>(null)
let checks = $state<Check[]>([])
let agentActivity = $state<AgentActivity[]>([])
let auditEntries = $state<AuditEntry[]>([])
@@ -58,13 +65,14 @@
loading = false
return
}
const [graphView, m, ev, sig, exec, kh, ch, aa, au] = await Promise.all([
const [graphView, m, ev, sig, tk, kh, oc, ch, aa, au] = await Promise.all([
fetchGraph({ root: entity.id, depth: 1 }),
fetchMetrics(entity.id),
fetchEntityEvents(entity.id),
fetchEntitySignals(entity.id),
fetchEntityExecutions(entity.id),
fetchEntityTasks(entity),
fetchEntityKnowledge(entity.id),
KNOWLEDGE_TYPES.has(entity.type) ? fetchKnowledgeContent(entity.id) : Promise.resolve(null),
fetchChecksForTarget(entity.slug),
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
fetchAudit({ entity_id: entity.id, limit: 50 })
@@ -73,8 +81,9 @@
metrics = m
events = ev
signals = sig
executions = exec
tasks = tk
knowledge = kh
ownContent = oc
checks = ch
agentActivity = aa
auditEntries = au
@@ -174,37 +183,117 @@
toast.error('Failed to update check')
}
}
// Attributes are freeform (no attribute_schema on most entity types), so
// the generic key/value list was truncating anything long — including a
// document's whole changelog — to an unreadable single line. Recognize a
// few common shapes and render them properly instead of hiding them.
interface ChangelogEntry {
date?: string
title?: string
body?: string
}
const LONG_TEXT_KEYS = new Set(['description', 'content', 'summary', 'notes', 'note', 'body', 'details'])
function isChangelog(value: unknown): value is ChangelogEntry[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v))
)
}
function isFlatObject(value: unknown): value is Record<string, unknown> {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.values(value as object).every((v) => v === null || typeof v !== 'object')
)
}
type AttributeRow =
| { key: string; kind: 'long-text'; value: string }
| { key: string; kind: 'changelog'; value: ChangelogEntry[] }
| { key: string; kind: 'flat-object'; value: Record<string, unknown> }
| { key: string; kind: 'simple'; value: unknown }
function renderMarkdown(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
function classifyAttributes(attrs: Record<string, unknown>): AttributeRow[] {
return Object.entries(attrs).map(([key, value]): AttributeRow => {
if (typeof value === 'string' && (LONG_TEXT_KEYS.has(key) || value.length > 120)) {
return { key, kind: 'long-text', value }
}
if (isChangelog(value)) return { key, kind: 'changelog', value }
if (isFlatObject(value)) return { key, kind: 'flat-object', value }
return { key, kind: 'simple', value }
})
}
</script>
<div class="@container flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
<div class="flex h-full flex-col gap-2 overflow-y-auto p-3 md:p-4">
{#if loading}
<Skeleton class="h-8 w-48" />
<div class="grid grid-cols-1 gap-4 @lg:grid-cols-2">
<Skeleton class="h-40 w-full" />
<Skeleton class="h-40 w-full" />
</div>
<Skeleton class="h-6 w-48" />
<Skeleton class="h-8 w-full" />
<Skeleton class="h-8 w-full" />
<Skeleton class="h-8 w-full" />
{:else if !entity}
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
{:else}
<div class="flex flex-wrap items-center gap-2">
<h1 class="font-mono text-lg font-semibold">{entity.slug}</h1>
<Badge variant="outline">{entity.type}</Badge>
{#if entity.state}<Badge>{entity.state}</Badge>{/if}
{#if entity.health}
<span class="flex items-center gap-1.5 text-xs text-muted-foreground" title="{entity.health} — checked {relativeTime(entity.last_check_at)}">
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
{entity.health} · checked {relativeTime(entity.last_check_at)}
</span>
{/if}
</div>
<h1 class="font-mono text-sm font-semibold">{entity.slug}</h1>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Monitoring ({checks.length})</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1.5">
{#snippet detailsContent()}
<dl class="flex flex-col gap-1 text-xs">
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Type</dt>
<dd><Badge variant="outline">{entity.type}</Badge></dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">State</dt>
<dd>{#if entity.state}<Badge>{entity.state}</Badge>{:else}<span class="text-muted-foreground"></span>{/if}</dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Health</dt>
<dd>
{#if entity.health}
<span class="flex items-center gap-1.5" title="checked {relativeTime(entity.last_check_at)}">
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
{entity.health} · checked {relativeTime(entity.last_check_at)}
</span>
{:else}
<span class="text-muted-foreground">not monitored</span>
{/if}
</dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Version</dt>
<dd>{entity.version}</dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Created</dt>
<dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
</div>
<div class="flex items-center justify-between gap-3 {entity.maintenance_until ? 'border-b pb-1' : ''}">
<dt class="shrink-0 text-muted-foreground">Updated</dt>
<dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
</div>
{#if entity.maintenance_until}
<div class="flex items-center justify-between gap-3">
<dt class="shrink-0 text-muted-foreground">Maintenance until</dt>
<dd>{new Date(entity.maintenance_until).toLocaleString()}</dd>
</div>
{/if}
</dl>
{/snippet}
{#snippet monitoringContent()}
<div class="flex flex-col gap-1">
{#each checks as check (check.id)}
<div class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs">
<div class="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-xs">
<div class="flex items-center gap-2">
<Badge variant="outline" class="font-mono">{check.kind}</Badge>
<span class="text-muted-foreground">every {check.interval_s}s</span>
@@ -221,202 +310,321 @@
{:else}
<p class="text-xs text-muted-foreground">No checks configured for this entity.</p>
{/each}
</Card.Content>
</Card.Root>
</div>
{/snippet}
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Attributes</Card.Title>
</Card.Header>
<Card.Content>
{#if entity.attributes && Object.keys(entity.attributes).length}
<dl class="flex flex-col gap-1.5 text-xs">
{#each Object.entries(entity.attributes) as [key, value]}
<div class="flex items-start justify-between gap-3 border-b pb-1.5 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
<dd class="min-w-0 flex-1 truncate text-right">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</dd>
{#snippet contentContent()}
{#if ownContent}
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
<div class="prose-chat max-w-none text-xs">{@html renderMarkdown(ownContent.content)}</div>
{:else}
<p class="text-xs text-muted-foreground">No content.</p>
{/if}
{/snippet}
{#snippet attributesContent()}
{#if entity.attributes && Object.keys(entity.attributes).length}
{@const rows = classifyAttributes(entity.attributes)}
<div class="flex flex-col gap-2 text-xs">
{#each rows as row (row.key)}
{#if row.kind === 'long-text'}
<div class="flex flex-col gap-0.5">
<p class="font-mono text-muted-foreground">{row.key}</p>
<p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">{row.value}</p>
</div>
{:else if row.kind === 'changelog'}
<div class="flex flex-col gap-0.5">
<p class="font-mono text-muted-foreground">{row.key} ({row.value.length})</p>
<div class="flex flex-col gap-1">
{#each row.value as entry}
<div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
<div class="flex items-baseline gap-1.5">
{#if entry.date}<span class="shrink-0 font-mono text-muted-foreground">{entry.date}</span>{/if}
{#if entry.title}<span class="font-medium">{entry.title}</span>{/if}
</div>
{#if entry.body}<p class="whitespace-pre-wrap break-words text-muted-foreground">{entry.body}</p>{/if}
</div>
{/each}
</div>
{/each}
</dl>
{:else}
<p class="text-xs text-muted-foreground">No attributes.</p>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Relations ({relations.length})</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1">
{#each relations as rel}
<div class="flex items-center gap-1 font-mono text-xs">
<span>{rel.source}</span>
<span class="text-muted-foreground">{rel.type}</span>
<span>{rel.target}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No direct relations.</p>
</div>
{:else if row.kind === 'flat-object'}
<div class="flex flex-col gap-0.5">
<p class="font-mono text-muted-foreground">{row.key}</p>
<dl class="flex flex-col gap-0.5 rounded-md bg-muted/40 p-1.5">
{#each Object.entries(row.value) as [subKey, subValue]}
<div class="flex items-start justify-between gap-3">
<dt class="shrink-0 font-mono text-muted-foreground">{subKey}</dt>
<dd class="min-w-0 flex-1 break-words text-right">{String(subValue)}</dd>
</div>
{/each}
</dl>
</div>
{:else}
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
<dd class="min-w-0 flex-1 break-words text-right">
{#if row.value !== null && typeof row.value === 'object'}
<pre class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(row.value, null, 2)}</pre>
{:else}
{String(row.value)}
{/if}
</dd>
</div>
{/if}
{/each}
</Card.Content>
</Card.Root>
</div>
</div>
{:else}
<p class="text-xs text-muted-foreground">No attributes.</p>
{/if}
{/snippet}
{#if metrics.length}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Metrics</Card.Title>
</Card.Header>
<Card.Content class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
{#snippet relationsContent()}
<div class="flex flex-col gap-1">
{#each relations as rel}
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
{#if onSelectEntity}
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
<span class="shrink-0 text-muted-foreground">{rel.type}</span>
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
{:else}
<span class="min-w-0 shrink truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
<span class="shrink-0 text-muted-foreground">{rel.type}</span>
<span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
{/if}
</div>
{:else}
<p class="text-xs text-muted-foreground">No direct relations.</p>
{/each}
</div>
{/snippet}
{#snippet metricsContent()}
{#if metrics.length}
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
{#each metrics as series (series.metric)}
<div>
<p class="mb-1 text-xs text-muted-foreground">{series.metric} ({series.rollup})</p>
<div bind:this={chartContainers[series.metric]}></div>
</div>
{/each}
</Card.Content>
</Card.Root>
{/if}
</div>
{:else}
<p class="text-xs text-muted-foreground">No metrics tracked.</p>
{/if}
{/snippet}
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Signals</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-2">
{#each signals as signal (signal.id)}
<div class="flex flex-col gap-1 border-b pb-2 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span>{signal.kind}</span>
<div class="flex items-center gap-1">
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
<Badge variant="outline">{signal.state}</Badge>
</div>
{#snippet signalsContent()}
<div class="flex flex-col gap-1.5">
{#each signals as signal (signal.id)}
<div class="flex flex-col gap-1 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span>{signal.kind}</span>
<div class="flex items-center gap-1">
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
<Badge variant="outline">{signal.state}</Badge>
</div>
{#if ['raised', 'acknowledged', 'acting'].includes(signal.state)}
<div class="flex justify-end gap-1.5">
{#if signal.state === 'raised'}
<Button
size="sm"
variant="outline"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => ackOpenSignal(signal.id)}>Ack</Button
>
{/if}
</div>
{#if ['raised', 'acknowledged', 'acting'].includes(signal.state)}
<div class="flex justify-end gap-1.5">
{#if signal.state === 'raised'}
<Button
size="sm"
variant="outline"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => muteOpenSignal(signal.id)}>Mute 1h</Button
onclick={() => ackOpenSignal(signal.id)}>Ack</Button
>
<Button
size="sm"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => resolveOpenSignal(signal.id)}>Resolve</Button
>
</div>
{/if}
<Button
size="sm"
variant="outline"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => muteOpenSignal(signal.id)}>Mute 1h</Button
>
<Button
size="sm"
class="h-6 px-2 text-xs"
disabled={actingSignal === signal.id}
onclick={() => resolveOpenSignal(signal.id)}>Resolve</Button
>
</div>
{/if}
</div>
{:else}
<p class="text-xs text-muted-foreground">None.</p>
{/each}
</div>
{/snippet}
{#snippet tasksContent()}
<div class="flex flex-col gap-1">
{#each tasks as { task, executionCount } (task.id)}
{@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
{@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
<div class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0">
{#if onSelectEntity}
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={title} onclick={() => onSelectEntity(task.slug)}>
{title}
</button>
{:else}
<span class="min-w-0 flex-1 truncate" title={title}>{title}</span>
{/if}
<div class="flex shrink-0 items-center gap-1">
{#if outcome}
<Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
{/if}
<Badge variant="outline">{executionCount} action{executionCount === 1 ? '' : 's'}</Badge>
</div>
{:else}
<p class="text-xs text-muted-foreground">None.</p>
{/each}
</Card.Content>
</Card.Root>
</div>
{:else}
<p class="text-xs text-muted-foreground">No tasks have acted on this entity.</p>
{/each}
</div>
{/snippet}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Executions</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1">
{#each executions as execution (execution.id)}
<div class="flex items-center justify-between text-xs">
<span>{execution.action}</span>
<Badge variant="outline">{execution.status}</Badge>
</div>
{:else}
<p class="text-xs text-muted-foreground">None.</p>
{/each}
</Card.Content>
</Card.Root>
{#snippet knowledgeContent()}
<div class="flex flex-col gap-1">
{#each knowledge as hit (hit.id)}
<div class="text-xs">
<Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
</div>
{:else}
<p class="text-xs text-muted-foreground">None linked.</p>
{/each}
</div>
{/snippet}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Knowledge</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col gap-1">
{#each knowledge as hit (hit.id)}
<div class="text-xs">
<Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
</div>
{:else}
<p class="text-xs text-muted-foreground">None linked.</p>
{/each}
</Card.Content>
</Card.Root>
</div>
{#snippet eventsContent()}
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
{#each events as ev (ev.id)}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
<span class="truncate">{ev.type}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No events yet.</p>
{/each}
</div>
{/snippet}
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Recent events</Card.Title>
</Card.Header>
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each events as ev (ev.id)}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
<span class="truncate">{ev.type}</span>
{#snippet agentActivityContent()}
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
{#each agentActivity as activity (activity.id)}
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
</div>
{:else}
<p class="text-xs text-muted-foreground">No events yet.</p>
{/each}
</Card.Content>
</Card.Root>
<span class="truncate text-muted-foreground"
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
>
</div>
{:else}
<p class="text-xs text-muted-foreground">No agent activity.</p>
{/each}
</div>
{/snippet}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Agent activity</Card.Title>
</Card.Header>
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each agentActivity as activity (activity.id)}
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
</div>
<span class="truncate text-muted-foreground"
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
>
{#snippet auditContent()}
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
{#each auditEntries as entry (entry.id)}
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
<Badge variant="outline">{entry.actor_type}</Badge>
</div>
{:else}
<p class="text-xs text-muted-foreground">No agent activity.</p>
{/each}
</Card.Content>
</Card.Root>
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No audit entries.</p>
{/each}
</div>
{/snippet}
<Card.Root>
<Card.Header>
<Card.Title class="text-sm">Audit trail</Card.Title>
</Card.Header>
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#each auditEntries as entry (entry.id)}
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
<Badge variant="outline">{entry.actor_type}</Badge>
</div>
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">No audit entries.</p>
{/each}
</Card.Content>
</Card.Root>
</div>
{@const sections = [
...(ownContent ? [{ key: 'content', title: 'Content', count: 1, content: contentContent }] : []),
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
{ key: 'relations', title: 'Relations', count: relations.length, content: relationsContent },
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
{ key: 'events', title: 'Recent events', count: events.length, content: eventsContent },
{ key: 'agentActivity', title: 'Agent activity', count: agentActivity.length, content: agentActivityContent },
{ key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent }
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
{#each sections as section (section.key)}
<DetailSection title={section.title} count={section.count} defaultOpen={section.count > 0}>
{@render section.content()}
</DetailSection>
{/each}
{/if}
</div>
<style>
/* Minimal markdown styling for document/investigation/runbook content —
mirrors Chat.svelte's .prose-chat (Svelte scopes styles per-component,
so it can't be shared directly). */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(li) {
margin-bottom: 0.125rem;
}
.prose-chat :global(code) {
background: var(--muted);
border-radius: 4px;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.625rem 0.75rem;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
}
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
}
</style>

View File

@@ -0,0 +1,550 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { typeToCategory, type Category } from '$lib/categories'
import { Skeleton } from '$lib/components/ui/skeleton'
export interface GraphInfo {
allNodeTypes: string[]
allRelTypes: string[]
relColors: Map<string, string>
visibleCount: number
truncated: boolean
zoomPct: number
}
let {
category,
selectedSlug = null,
onSelect,
root = $bindable(''),
depth,
search,
reloadToken,
resetToken,
activeNodeTypes = $bindable(new Set<string>()),
activeRelTypes = $bindable(new Set<string>()),
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
}: {
category: Category
selectedSlug?: string | null
onSelect: (slug: string | null) => void
root?: string
depth: number
search: string
// Bumped by the parent toolbar to request a data reload / view reset —
// these controls live in the shared page toolbar (not squeezed inside
// this resizable pane), so they can't call load()/resetView() directly.
reloadToken: number
resetToken: number
activeNodeTypes?: Set<string>
activeRelTypes?: Set<string>
info?: GraphInfo
} = $props()
interface Node extends Entity {
x?: number
y?: number
vx?: number
vy?: number
fx?: number | null
fy?: number | null
degree: number
}
interface Link {
source: string | Node
target: string | Node
type: string
}
let graph = $state<GraphView | null>(null)
let loading = $state(true)
let nodes = $state<Node[]>([])
let links = $state<Link[]>([])
let sim: Simulation<Node, Link> | null = null
// type → browsing category, so the graph can be scoped client-side (the
// graph endpoint itself has no category/domain param). Value is undefined
// for types deliberately excluded from every category (e.g. execution/
// check/task — see categories.ts); the key is still present so inCategory
// can tell "excluded on purpose" apart from "not in the ontology at all."
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
let hoveredId = $state<string | null>(null)
// viewport transform: translate(x, y) scale(k)
let view = $state({ x: 0, y: 0, k: 1 })
let svgEl = $state<SVGSVGElement | null>(null)
const width = 1200
const height = 800
const healthColor: Record<Health, string> = {
healthy: '#3fb950',
degraded: '#d29922',
down: '#f85149',
unknown: '#8b949e'
}
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
const relColorByType = $derived.by(() => {
const map = new Map<string, string>()
const types = Array.from(new Set(links.map((l) => l.type))).sort()
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
return map
})
function relColor(type: string): string {
return relColorByType.get(type) ?? '#30363d'
}
function markerId(type: string): string {
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
}
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
}
function endpointId(end: string | Node): string {
return typeof end === 'object' ? end.id : end
}
// Node belongs to the active category? Types the ontology never returned
// at all fall back to visible (so a missing entry never blanks the
// graph); types the ontology returned but categories.ts deliberately
// excludes (key present, value undefined) do not.
function inCategory(type: string): boolean {
if (!typeCategory.has(type)) return true
return typeCategory.get(type) === category
}
// 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
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
loading = false
if (!graph) return
const byId = new Map(nodes.map((n) => [n.id, n]))
const degree = new Map<string, number>()
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
for (const e of graph.edges) {
const s = idBySlug.get(e.source) ?? e.source
const t = idBySlug.get(e.target) ?? e.target
degree.set(s, (degree.get(s) ?? 0) + 1)
degree.set(t, (degree.get(t) ?? 0) + 1)
}
nodes = graph.nodes.map((n) => {
const prev = byId.get(n.id)
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
})
links = graph.edges.map((e) => ({
source: idBySlug.get(e.source) ?? e.source,
target: idBySlug.get(e.target) ?? e.target,
type: e.type
}))
// Default the node/edge-type toggles to the types present in the active category.
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
activeRelTypes = new Set(links.map((l) => l.type))
sim?.stop()
sim = forceSimulation(nodes)
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
.force('charge', forceManyBody().strength(-240).distanceMax(400))
.force('center', forceCenter(width / 2, height / 2))
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
.force('x', forceX(width / 2).strength(0.04))
.force('y', forceY(height / 2).strength(0.04))
.velocityDecay(0.32)
.alphaDecay(0.035)
.on('tick', () => {
nodes = [...nodes]
})
.on('end', () => {
if (fit) fitToView()
})
}
onMount(() => {
fetchEntityTypes().then((types) => {
typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
// Re-derive the active node types now that category membership is known.
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
})
load()
const unsubscribe = subscribeEvents()
return () => {
unsubscribe()
sim?.stop()
}
})
onDestroy(() => sim?.stop())
// When the category perspective changes, reset the node-type toggles to it.
$effect(() => {
category
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
})
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
load(false)
}
})
// Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
// typing freely doesn't refetch, only a committed change (Enter/blur in the
// parent's inputs, or the Reset button) bumps the token.
let lastReloadToken = $state(0)
$effect(() => {
if (reloadToken !== lastReloadToken) {
lastReloadToken = reloadToken
load()
}
})
let lastResetToken = $state(0)
$effect(() => {
if (resetToken !== lastResetToken) {
lastResetToken = resetToken
view = { x: 0, y: 0, k: 1 }
load()
}
})
function selectNode(node: Node) {
onSelect(node.slug)
}
function rerootTo(node: Node) {
root = node.slug
load()
}
function nodeColor(node: Node): string {
const h = graph?.health?.[node.id]
return h ? healthColor[h] : '#58a6ff'
}
function nodeRadius(node: Node): number {
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
}
// Only offer node-type toggles that live in the active category.
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort())
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
// Publish status/legend info up to the parent toolbar.
$effect(() => {
info = {
allNodeTypes,
allRelTypes,
relColors: relColorByType,
visibleCount: visibleNodeIds.size,
truncated: !!graph?.truncated,
zoomPct: Math.round(view.k * 100)
}
})
const matchedIds = $derived.by(() => {
if (!search.trim()) return null
const q = search.trim().toLowerCase()
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
})
// Focus = in the active category AND its node-type toggle is on — these
// are what the category tab is "about."
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
// Real infra relationships mostly cross category lines (a service sits on
// a network, uses storage, runs on an lxc — different categories under
// this taxonomy). Hard-hiding any edge whose other end isn't in-category
// left focus nodes looking like disconnected dots. Rooted views (the user
// is exploring out from one entity) pull in 1-hop neighbors of any
// category, dimmed, so the edges — and what they connect to — stay
// visible. Unscoped "browse the whole category" views (no root) skip
// this: with ~50 focus nodes that touch nearly everything, 1-hop
// expansion floods in most of the graph (measured: 417 of 479 total
// entities for an unrooted Fleet view) — worse than the isolated-dot
// problem it was meant to fix. There, same-category-only edges stay.
const neighborNodeIds = $derived.by(() => {
const neighbors = new Set<string>()
if (!root.trim()) return neighbors
for (const l of links) {
if (!activeRelTypes.has(l.type)) continue
const s = endpointId(l.source)
const t = endpointId(l.target)
if (focusNodeIds.has(s) && !focusNodeIds.has(t)) neighbors.add(t)
else if (focusNodeIds.has(t) && !focusNodeIds.has(s)) neighbors.add(s)
}
return neighbors
})
const visibleNodeIds = $derived(new Set([...focusNodeIds, ...neighborNodeIds]))
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
const adjacency = $derived.by(() => {
const adj = new Map<string, Set<string>>()
for (const l of links) {
const s = endpointId(l.source)
const t = endpointId(l.target)
if (!adj.has(s)) adj.set(s, new Set())
if (!adj.has(t)) adj.set(t, new Set())
adj.get(s)!.add(t)
adj.get(t)!.add(s)
}
return adj
})
const focusIds = $derived.by(() => {
const focus = hoveredId ?? selectedId
if (!focus) return null
const set = new Set<string>([focus])
for (const n of adjacency.get(focus) ?? []) set.add(n)
return set
})
function nodeOpacity(node: Node): number {
const base = focusNodeIds.has(node.id) ? 1 : 0.4
if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
if (focusIds !== null) return focusIds.has(node.id) ? 1 : Math.min(base, 0.15)
return base
}
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
const s = endpointId(link.source)
const t = endpointId(link.target)
const focus = hoveredId ?? selectedId
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
return { opacity: 0.45, emphasized: false }
}
// ─── pan / zoom / drag ───────────────────────────────────────────────
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
const rect = svgEl!.getBoundingClientRect()
return {
x: ((clientX - rect.left) / rect.width) * width,
y: ((clientY - rect.top) / rect.height) * height
}
}
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
const p = toViewBox(clientX, clientY)
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
}
function onWheel(e: WheelEvent) {
e.preventDefault()
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
const k = Math.min(6, Math.max(0.25, view.k * factor))
const p = toViewBox(e.clientX, e.clientY)
const wx = (p.x - view.x) / view.k
const wy = (p.y - view.y) / view.k
view = { k, x: p.x - wx * k, y: p.y - wy * k }
}
let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null)
let dragState: { node: Node; moved: boolean } | null = null
function onBackgroundPointerDown(e: PointerEvent) {
if (dragState) return
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const p = toViewBox(e.clientX, e.clientY)
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false }
}
function onNodePointerDown(e: PointerEvent, node: Node) {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
dragState = { node, moved: false }
sim?.alphaTarget(0.25).restart()
}
function onPointerMove(e: PointerEvent) {
if (dragState) {
const w = toWorld(e.clientX, e.clientY)
dragState.node.fx = w.x
dragState.node.fy = w.y
dragState.moved = true
return
}
if (panState) {
const p = toViewBox(e.clientX, e.clientY)
const dx = p.x - panState.startX
const dy = p.y - panState.startY
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true
view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy }
}
}
function onPointerUp(e: PointerEvent) {
if (dragState) {
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectNode(node)
return
}
if (panState && !panState.moved) {
// Plain click on empty background (not a drag-pan) — clear selection.
onSelect(null)
}
panState = null
}
</script>
{#if loading && !nodes.length}
<Skeleton class="h-full min-h-0" />
{:else}
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border bg-[radial-gradient(ellipse_at_center,rgba(88,166,255,0.04),transparent_70%)]">
<svg
bind:this={svgEl}
viewBox="0 0 {width} {height}"
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
role="application"
aria-label="Entity graph"
onwheel={onWheel}
onpointerdown={onBackgroundPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<defs>
{#each allRelTypes as type}
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
</marker>
{/each}
</defs>
<g transform="translate({view.x},{view.y}) scale({view.k})">
<g>
{#each links as link}
{@const s = endpoint(link.source)}
{@const t = endpoint(link.target)}
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)}
{@const vs = linkVisualState(link)}
{@const dx = t.x - s.x}
{@const dy = t.y - s.y}
{@const len = Math.max(Math.hypot(dx, dy), 1)}
{@const tr = nodeRadius(t) + 3}
{@const ex = t.x - (dx / len) * tr}
{@const ey = t.y - (dy / len) * tr}
<line
x1={s.x}
y1={s.y}
x2={ex}
y2={ey}
stroke={relColor(link.type)}
stroke-width={vs.emphasized ? 2 : 1.2}
opacity={vs.opacity}
marker-end="url(#{markerId(link.type)})"
>
<title>{link.type}</title>
</line>
{#if vs.emphasized && view.k >= 0.7}
<text
x={(s.x + ex) / 2}
y={(s.y + ey) / 2 - 4}
text-anchor="middle"
font-size={10 / view.k}
fill={relColor(link.type)}
opacity="0.95"
paint-order="stroke"
stroke="var(--background)"
stroke-width={3 / view.k}
>
{link.type}
</text>
{/if}
{/if}
{/each}
</g>
<g>
{#each nodes as node (node.id)}
{#if node.x != null && node.y != null && visibleNodeIds.has(node.id)}
{@const r = nodeRadius(node)}
{@const op = nodeOpacity(node)}
{@const isFocus = hoveredId === node.id || selectedId === node.id}
{@const isMatch = matchedIds !== null && matchedIds.has(node.id)}
<g
transform="translate({node.x},{node.y})"
opacity={op}
class="cursor-pointer"
role="button"
tabindex="0"
onpointerdown={(e) => onNodePointerDown(e, node)}
onpointerenter={() => (hoveredId = node.id)}
onpointerleave={() => (hoveredId = null)}
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
ondblclick={() => rerootTo(node)}
>
{#if isFocus || isMatch}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
{/if}
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? '#e6edf3' : '#0d1117'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
<text
y={r + 12}
text-anchor="middle"
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
fill={isFocus ? '#e6edf3' : '#8b949e'}
paint-order="stroke"
stroke="#0d1117"
stroke-width={3 / view.k}
class="pointer-events-none select-none"
>
{node.slug}
</text>
{/if}
</g>
{/if}
{/each}
</g>
</g>
</svg>
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
</div>
</div>
{/if}

View File

@@ -3,16 +3,26 @@
import * as Sheet from '$lib/components/ui/sheet'
let { slug, open = $bindable(false) }: { slug: string | null; open?: boolean } = $props()
// Lets a relation click inside the sheet drill into that entity in place,
// without closing/reopening. Resets to the externally-requested slug
// whenever the caller opens the sheet on a different entity.
let currentSlug = $state(slug)
$effect(() => {
currentSlug = slug
})
</script>
<Sheet.Root bind:open>
<Sheet.Content side="right" class="w-full p-0 sm:max-w-2xl">
<Sheet.Header class="sr-only">
<Sheet.Title>{slug ?? 'Entity detail'}</Sheet.Title>
<Sheet.Title>{currentSlug ?? 'Entity detail'}</Sheet.Title>
<Sheet.Description>Entity detail panel</Sheet.Description>
</Sheet.Header>
{#if slug}
<EntityDetailContent {slug} />
{#if currentSlug}
{#key currentSlug}
<EntityDetailContent slug={currentSlug} onSelectEntity={(s) => (currentSlug = s)} />
{/key}
{/if}
</Sheet.Content>
</Sheet.Root>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import type { Entity, EntityHealth } from '$lib/api'
import { relativeTime } from '$lib/utils'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
let {
entities,
loading,
selectedSlug = null,
onSelect
}: {
entities: Entity[]
loading: boolean
selectedSlug?: string | null
onSelect: (slug: string) => void
} = $props()
type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health'
let sortKey = $state<SortKey>('slug')
let sortDir = $state<'asc' | 'desc'>('asc')
function sortBy(key: SortKey) {
if (sortKey === key) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc'
} else {
sortKey = key
sortDir = 'asc'
}
}
const healthRank: Record<EntityHealth, number> = { down: 0, degraded: 1, stale: 2, unknown: 3, healthy: 4 }
function sortValue(entity: Entity, key: SortKey): string | number {
if (key === 'health') return entity.health ? healthRank[entity.health] : -1
return (entity[key] ?? '').toString().toLowerCase()
}
const sortedEntities = $derived.by(() => {
const sorted = [...entities].sort((a, b) => {
const av = sortValue(a, sortKey)
const bv = sortValue(b, sortKey)
if (av < bv) return -1
if (av > bv) return 1
return 0
})
if (sortDir === 'desc') sorted.reverse()
return sorted
})
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
if (!state) return 'outline'
if (state === 'active' || state === 'healthy') return 'default'
return 'secondary'
}
const healthDot: Record<EntityHealth, string> = {
healthy: 'bg-success',
degraded: 'bg-warning',
down: 'bg-destructive',
stale: 'bg-warning/50',
unknown: 'bg-muted-foreground/40'
}
function healthTitle(entity: Entity): string {
if (!entity.health) return 'not monitored'
if (entity.health === 'stale') return `stale — last checked ${relativeTime(entity.last_check_at)}`
return `${entity.health} — checked ${relativeTime(entity.last_check_at)}`
}
// Widths vary per row so the skeleton reads as text, not a stack of identical bars.
const skeletonSlugWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
const skeletonNameWidths = ['w-32', 'w-40', 'w-24', 'w-36', 'w-28', 'w-40', 'w-24', 'w-32']
</script>
{#if loading}
<div class="h-full min-h-0 overflow-hidden rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Slug</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>State</Table.Head>
<Table.Head>Health</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each skeletonSlugWidths as slugWidth, i}
<Table.Row class="hover:bg-transparent">
<Table.Cell><Skeleton class="h-4 {slugWidth}" /></Table.Cell>
<Table.Cell><Skeleton class="h-5 w-16 rounded-full" /></Table.Cell>
<Table.Cell><Skeleton class="h-4 {skeletonNameWidths[i]}" /></Table.Cell>
<Table.Cell><Skeleton class="h-5 w-14 rounded-full" /></Table.Cell>
<Table.Cell>
<div class="flex items-center gap-1.5">
<Skeleton class="size-2 shrink-0 rounded-full" />
<Skeleton class="h-4 w-12" />
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
{#snippet sortHead(key: SortKey, label: string)}
<Table.Head>
<button type="button" class="flex items-center gap-1 hover:text-foreground" onclick={() => sortBy(key)}>
{label}
{#if sortKey === key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 text-muted-foreground/50" />
{/if}
</button>
</Table.Head>
{/snippet}
<div class="h-full min-h-0 overflow-auto rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{@render sortHead('slug', 'Slug')}
{@render sortHead('type', 'Type')}
{@render sortHead('name', 'Name')}
{@render sortHead('state', 'State')}
{@render sortHead('health', 'Health')}
</Table.Row>
</Table.Header>
<Table.Body>
{#each sortedEntities as entity (entity.id)}
<Table.Row
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
role="button"
tabindex={0}
onclick={() => onSelect(entity.slug)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }}
>
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
<Table.Cell>{entity.name}</Table.Cell>
<Table.Cell>
{#if entity.state}
<Badge variant={stateVariant(entity.state)}>{entity.state}</Badge>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</Table.Cell>
<Table.Cell>
{#if entity.health}
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
</span>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}
</Table.Cell>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={5} class="text-center text-muted-foreground"
>No entities in this layer match the filter.</Table.Cell
>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import { onMount } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type Simulation } from 'd3-force'
import { fetchGraph, type Health } from '$lib/api'
import { mode } from 'mode-watcher'
// Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
// host places this behind the page with pointer-events:none, so it never
// steals clicks. The "alive" feeling comes entirely from the camera (slow
// autonomous drift + mouse parallax + per-node depth), NOT from a live force
// sim — we warm the layout up once, freeze it, then just pan a static field.
interface SimNode {
id: string
slug: string
degree: number
z: number // depth in [0,1] for parallax
x?: number
y?: number
fx?: number | null
fy?: number | null
}
interface SimLink {
source: string | SimNode
target: string | SimNode
}
let host = $state<HTMLDivElement | null>(null)
let canvas = $state<HTMLCanvasElement | null>(null)
let nodes: SimNode[] = []
let links: SimLink[] = []
let health: Record<string, Health> = {}
// World bounds the layout is centered in; camera pans within.
const WORLD = 1400
const MAX_NODES = 260
const healthColor: Record<Health, string> = {
healthy: '#3fb950',
degraded: '#d29922',
down: '#f85149',
unknown: '#8b949e'
}
function nodeRadius(n: SimNode): number {
return 3 + Math.min(Math.sqrt(n.degree) * 1.4, 7)
}
async function loadGraph() {
const graph = await fetchGraph({ depth: 3, includeStatus: true })
if (!graph) return
health = graph.health ?? {}
// degree by id, edges reference slugs
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
const degree = new Map<string, number>()
for (const e of graph.edges) {
const s = idBySlug.get(e.source) ?? e.source
const t = idBySlug.get(e.target) ?? e.target
degree.set(s, (degree.get(s) ?? 0) + 1)
degree.set(t, (degree.get(t) ?? 0) + 1)
}
let all: SimNode[] = graph.nodes.map((n) => ({
id: n.id,
slug: n.slug,
degree: degree.get(n.id) ?? 0,
z: Math.random()
}))
// Cap to the most-connected nodes so large graphs stay cheap.
if (all.length > MAX_NODES) {
all = [...all].sort((a, b) => b.degree - a.degree).slice(0, MAX_NODES)
}
const keep = new Set(all.map((n) => n.id))
nodes = all
links = graph.edges
.map((e) => ({ source: idBySlug.get(e.source) ?? e.source, target: idBySlug.get(e.target) ?? e.target }))
.filter((l) => keep.has(l.source as string) && keep.has(l.target as string))
warmUpLayout()
}
// Run the sim to a settled state without rendering each tick, then freeze.
function warmUpLayout() {
const sim: Simulation<SimNode, SimLink> = forceSimulation(nodes)
.force('link', forceLink<SimNode, SimLink>(links).id((n) => n.id).distance(60).strength(0.5))
.force('charge', forceManyBody().strength(-140).distanceMax(360))
.force('center', forceCenter(0, 0))
.force('collide', forceCollide<SimNode>((n) => nodeRadius(n) + 6))
.stop()
const ticks = Math.min(400, Math.max(120, nodes.length * 2))
for (let i = 0; i < ticks; i++) sim.tick()
sim.stop()
}
// ─── camera + render loop ───────────────────────────────────────────────
let cam = { x: 0, y: 0 } // eased mouse-parallax offset
let targetCam = { x: 0, y: 0 }
let timer: ReturnType<typeof setTimeout> | 0 = 0
let dpr = 1
let w = 0
let h = 0
function onPointerMove(e: PointerEvent) {
if (!host) return
const rect = host.getBoundingClientRect()
const nx = (e.clientX - rect.left) / rect.width - 0.5 // -0.5..0.5
const ny = (e.clientY - rect.top) / rect.height - 0.5
targetCam = { x: -nx * 90, y: -ny * 90 } // small parallax nudge
}
function resize() {
if (!host || !canvas) return
dpr = Math.min(window.devicePixelRatio || 1, 2)
w = host.clientWidth
h = host.clientHeight
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
}
function colorForNode(n: SimNode): string {
return healthColor[health[n.id] ?? 'unknown']
}
// Driven by setTimeout rather than requestAnimationFrame: some embedding
// contexts (iframed previews, backgrounded-but-visible panes) report
// document.hidden = true and browsers fully suspend rAF callbacks there,
// which would freeze this canvas forever. setTimeout keeps ticking
// regardless, and ~30fps is plenty for a slow ambient drift.
function draw(t: number) {
timer = setTimeout(() => draw(performance.now()), 33)
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// ease parallax toward target
cam.x += (targetCam.x - cam.x) * 0.05
cam.y += (targetCam.y - cam.y) * 0.05
// autonomous drift (Lissajous pan + breathing zoom)
const ts = t / 1000
const driftX = Math.sin(ts * 0.05) * 70 + Math.sin(ts * 0.017) * 40
const driftY = Math.cos(ts * 0.043) * 60 + Math.sin(ts * 0.023) * 30
const zoom = 0.82 + Math.sin(ts * 0.03) * 0.03
const dark = mode.current !== 'light'
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
const cx = w / 2
const cy = h / 2
// project a world point to screen, applying per-depth parallax
function project(px: number, py: number, z: number) {
const par = 0.5 + z // nearer nodes (higher z) move more
const ox = (driftX + cam.x) * par
const oy = (driftY + cam.y) * par
return { x: cx + (px + ox) * zoom, y: cy + (py + oy) * zoom }
}
// edges
ctx.lineWidth = 1
ctx.strokeStyle = dark ? 'rgba(140,175,230,0.28)' : 'rgba(60,90,140,0.22)'
ctx.beginPath()
for (const l of links) {
const s = l.source as SimNode
const tg = l.target as SimNode
if (s.x == null || tg.x == null) continue
const z = (s.z + tg.z) / 2
const a = project(s.x, s.y!, z)
const b = project(tg.x, tg.y!, z)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
// nodes (glow via radial gradient, cheap enough at this count)
for (const n of nodes) {
if (n.x == null || n.y == null) continue
const p = project(n.x, n.y, n.z)
const r = nodeRadius(n) * zoom * (0.7 + n.z * 0.6)
const col = colorForNode(n)
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 3.2)
glow.addColorStop(0, hexA(col, dark ? 0.45 : 0.32))
glow.addColorStop(1, hexA(col, 0))
ctx.fillStyle = glow
ctx.beginPath()
ctx.arc(p.x, p.y, r * 3.2, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = hexA(col, dark ? 0.7 : 0.55)
ctx.beginPath()
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
ctx.fill()
}
// legibility scrim: dim only the center band where the UI sits, taper to
// ~nothing at the edges so the graph (and its connections) stay visible
// in the margins instead of being crushed everywhere equally.
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
const base = dark ? '13,17,23' : '255,255,255'
scrim.addColorStop(0, `rgba(${base},0.68)`)
scrim.addColorStop(0.45, `rgba(${base},0.32)`)
scrim.addColorStop(1, `rgba(${base},0.02)`)
ctx.fillStyle = scrim
ctx.fillRect(0, 0, w, h)
}
// "#rrggbb" + alpha -> rgba()
function hexA(hex: string, a: number): string {
const n = parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
}
onMount(() => {
loadGraph()
resize()
const ro = new ResizeObserver(resize)
if (host) ro.observe(host)
window.addEventListener('pointermove', onPointerMove)
timer = setTimeout(() => draw(performance.now()), 33)
return () => {
clearTimeout(timer)
ro.disconnect()
window.removeEventListener('pointermove', onPointerMove)
}
})
</script>
<div bind:this={host} class="pointer-events-none absolute inset-0 overflow-hidden">
<canvas bind:this={canvas} class="h-full w-full"></canvas>
</div>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
import { Button } from '$lib/components/ui/button'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
let {
label,
options,
selected = $bindable(),
colorFor
}: {
label: string
options: string[]
selected: Set<string>
colorFor?: (option: string) => string
} = $props()
function toggle(opt: string) {
const next = new Set(selected)
if (next.has(opt)) next.delete(opt)
else next.add(opt)
selected = next
}
const allSelected = $derived(options.length > 0 && options.every((o) => selected.has(o)))
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="h-8 gap-1.5">
{label}
<span class="text-muted-foreground">{selected.size}/{options.length}</span>
<ChevronDownIcon class="size-3.5 text-muted-foreground" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="max-h-80 w-56 overflow-y-auto" align="start">
<DropdownMenu.Item
closeOnSelect={false}
onSelect={() => { selected = allSelected ? new Set() : new Set(options) }}
class="text-xs text-muted-foreground"
>
{allSelected ? 'Deselect all' : 'Select all'}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{#each options as opt}
<DropdownMenu.CheckboxItem
closeOnSelect={false}
checked={selected.has(opt)}
onCheckedChange={() => toggle(opt)}
class="text-xs"
>
{#if colorFor}
<span class="size-2 shrink-0 rounded-full" style="background: {colorFor(opt)}"></span>
{/if}
{opt}
</DropdownMenu.CheckboxItem>
{/each}
{#if options.length === 0}
<p class="px-2 py-1.5 text-xs text-muted-foreground">No types loaded yet.</p>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -7,11 +7,14 @@
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
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 wasActive = $state(active)
const bodyTools = $derived(unmatched ?? tools)
const inlineCount = $derived(tools.length - bodyTools.length)
$effect(() => {
if (active && !wasActive) {
open = true
@@ -22,18 +25,18 @@
wasActive = active
})
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
const names = $derived(tools.map((t) => t.name).join(', '))
const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
const names = $derived(bodyTools.map((t) => t.name).join(', '))
const runningTool = $derived(
active ? tools.find((t) => t.type === 'tool_use') : undefined
active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
)
const ariaLabel = $derived(
doneCount === tools.length
? `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} completed`
: `${doneCount}/${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} done`
doneCount === bodyTools.length
? `${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} completed`
: `${doneCount}/${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} done`
)
function toolSummary(args: unknown): string {
@@ -45,19 +48,19 @@
}
</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.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}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
{#if active && doneCount < bodyTools.length}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
{:else if hasError}
<XIcon class="size-3 shrink-0 text-destructive" />
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
{:else}
<CheckIcon class="size-3 shrink-0 text-success" />
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
{/if}
{#if active && doneCount < tools.length}
<span class="font-medium">{doneCount}/{tools.length}</span>
{#if active && doneCount < bodyTools.length}
<span class="font-medium">{doneCount}/{bodyTools.length}</span>
{#if runningTool}
<span class="max-w-48 truncate font-mono text-muted-foreground">
{runningTool.name}
@@ -67,7 +70,10 @@
<span class="animate-pulse text-muted-foreground">working…</span>
{/if}
{: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>
{/if}
@@ -78,16 +84,16 @@
</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">
<div class="flex flex-col divide-y border-t">
{#each tools as tool (tool.id)}
<div class="flex flex-col divide-y border-t" role="list" aria-label={ariaLabel}>
{#each bodyTools as tool (tool.id)}
<div class="p-2">
<div class="flex items-center gap-2">
{#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'}
<CheckIcon class="size-3 shrink-0 text-success" />
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
{: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}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>

106
web/src/lib/config.ts Normal file
View File

@@ -0,0 +1,106 @@
// Runtime configuration for the SPA — server URL + auth token. Every fetch
// call goes through fetchWithAuth/apiBase (used by api.ts) so the SPA works
// identically whether it's served same-origin (browser prod, Vite dev proxy)
// or cross-origin (Wails webview, remote access). See
// plans/2026-07-12-wails-desktop-app.md 0.2.
import { getToken, isOIDCConfigured } from './oidc'
export interface OikosConfig {
apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin
token?: string // bearer token for auth
isDesktop?: boolean // true when running inside the Wails desktop app
}
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 setConfig(next: OikosConfig) {
cfg = next
if (next.token) localStorage.setItem('oikos_token', next.token)
else localStorage.removeItem('oikos_token')
if (next.apiUrl) localStorage.setItem('oikos_api_url', next.apiUrl)
else localStorage.removeItem('oikos_api_url')
}
export function clearConfig() {
cfg = { apiUrl: '' }
localStorage.removeItem('oikos_token')
localStorage.removeItem('oikos_api_url')
}
export function isConfigured(): boolean {
return !!getConfig().token || isOIDCConfigured()
}
// 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).
export 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}`
}
// 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 ----
// Prepends the API base URL (absolute when configured, relative when unset
// for the Vite dev proxy / same-origin prod) and adds the Authorization
// header. Used by every fetch call in api.ts.
export 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 authH = resolveAuthHeader()
if (authH) {
headers['Authorization'] = authH
}
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's combinedAuth checks it alongside the
// Authorization header, only for this route).
export function sseUrl(path: string): string {
const url = apiBase(path)
const c = getConfig()
const oidcToken = getToken()
const token = oidcToken ?? c.token
if (!token) return url
const sep = url.includes('?') ? '&' : '?'
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,
})
}

Some files were not shown because too many files have changed in this diff Show More