58 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
0ed171507f Merge remote-tracking branch 'origin/main'
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-12 09:18:23 +02:00
e3cbaee534 fix(knowledge): populate hit id in search results
Search hits and entity-knowledge hits never selected an id column, so
every KnowledgeHit.Id defaulted to the zero UUID. The frontend's keyed
{#each results as hit (hit.id)} then had all-duplicate keys, which
silently broke Svelte 5's if-block branch swap for the results panel —
search would set searched=true (Clear button appeared) but the view
never switched away from "Recently learned". Select e.id in both
queries and key the each block on hit.slug (guaranteed unique) instead.
2026-07-12 09:16:03 +02:00
de126daf43 feat(web): fold Events/Agent/Audit into EntityDetail; tag agent_activity with entity_id
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Events, Agent, and Audit were standalone read-only pages that never
cross-referenced the entity they related to. Fold them into EntityDetail
as entity-scoped cards (Agent activity, Audit trail) alongside the
existing Signals/Executions/Knowledge cards, and give the Signals card
real Ack/Mute/Resolve actions. Signals stays a standalone page since
it's the only one with cross-entity triage value (badge count, actions).

Also fixes the underlying reason those new cards would've stayed empty:
agent_activity rows were never tagged with entity_id at insert time
(cmd/nomos/store.go, internal/mcp/server.go), even though the column
and the API filter both support it. Added a best-effort resolver that
checks common tool-arg keys (target, entity_slug, slug, ...) against
the entities table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 08:38:19 +02:00
c8b479d565 docs: close out task-completion safety net plan; fix stale relative links
Fixes 1-3 deployed and verified live: fresh trivial Q&A sessions now reach
done immediately, and a goal-bearing session that stalled was correctly
nudged by the idle sweep. Fix 4 (backfill) was replaced with deletion after
the operator's call — verified against the DB first that zero knowledge
notes were linked to or written by any of the 53 removed sessions, so
nothing was lost. Documents the pagination gap in listSessions (hardcoded
LIMIT 50, no total count) that hid 6 of those sessions from the original
audit.

Also fixes relative links in this plan and in the UI-review plan that broke
when both moved from plans/ to plans/done/ (one directory level deeper).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 00:14:15 +02:00
075ff93792 docs: mark task-completion safety net fixes 1-3 in progress
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:11:56 +02:00
3b9c75fa3f fix(agent): task completion safety net — stop tasks sticking at Running
Implements fixes 1-3 of plans/2026-07-11-task-completion-safety-net.md.
Confirmed live that 50/50 production sessions never reached a terminal
status because the model almost never calls complete_task, even for
trivial single-tool Q&A turns SOUL.md explicitly calls out as needing it.

- Inline safety net (agent.go): a session that never called set_goal never
  framed itself as a structured task, so its first plain-text turn-end IS
  the task ending — auto-complete it there instead of leaving status stuck
  at its creation default forever.
- Idle sweep (continue.go, new completion_nudges column): goal-bearing
  sessions that stall get one nudge, then auto-close with outcome=partial
  if the nudge goes unanswered, mirroring the pattern resumeSession already
  uses for a different stuck-session failure mode.

Fix 4 (backfill of the 50 already-stuck live sessions) is deliberately
separate — deferred until this is deployed and verified live, per the
plan's implementation order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:11:24 +02:00
e3850f6820 docs: task completion safety net — every live task stuck Running
Traced during UI-review verification: 50/50 live sessions are stuck
active/planning, never done/failed. Root cause confirmed against the
running DB — set_goal called once, propose_plan and complete_task
called zero times across all 50 sessions. The model consistently
skips the terminal complete_task call despite SOUL.md explicitly
instructing it to, especially for trivial single-tool Q&A turns.
Plan proposes an inline safety net for the common case plus an idle
sweep for structured goal/plan sessions that stall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:01:35 +02:00
fb4c76ba82 fix(ui): implement UI review findings — a11y, IA, and consistency fixes
Fixes the reviewed gaps: keyboard-inaccessible delete controls (SessionRail,
Entities row), case-sensitive entity filter, two competing entity-detail
navigation patterns (standardize on EntitySheet), non-clickable Overview KPI
cards, a bare button bypassing the shared Button component, inconsistent
blur-only vs live filtering, and an unenforced sanitization assumption on
search snippet HTML (now using the already-present dompurify dependency).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 21:52:59 +02:00
b72267bd72 docs: nomos agent code review — mark all fixes done except deferred C1
Every finding from the review is now implemented and verified live:
A1 (3919ec3), B1+B2 (c5ffaec), A3 (926969a), D1-D3 (76f7630), A2 (c390164),
B3 (6d4f6de), F1 (11c18e8). C1 (nomos gateway has no authentication) remains
explicitly deferred per operator instruction. Kept in plans/ (not moved to
done/) since C1 is still open, matching how other partially-complete plans
in this index are tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:32:29 +02:00
11c18e8956 perf(agent): cache the MCP tool list per client (F1)
Fix F1 of plans/2026-07-11-nomos-agent-code-review.md, the last item.
buildTools called listToolsFull (a tools/list MCP round-trip) at the start of
EVERY chat turn, including every auto-continuation resume — the tool list is
static for the lifetime of one MCP connection, changing only when the api
process re-registers tools (a restart, which this client already detects and
reacts to via reconnectLocked). Re-fetching it every single turn was
avoidable network+parsing work on the hot path.

mcpClient now caches the parsed tool list after its first fetch, guarded by
its own mutex (kept separate from the request-serializing mu so a cache
check never contends with an in-flight doRequest call). reconnectLocked
clears the cache — an api restart may have changed what's registered, so a
stale cache would be wrong, not just slow. fleetSnapshot's get_health_summary
call is deliberately left uncached — it's meant to be "as of now."

Since each session gets its own client (the per-session pool from the
concurrency work), this caches per-task-conversation rather than globally: a
task's FIRST turn still pays the round-trip, every turn after reuses the
cached list — which is exactly the case that mattered (long-running,
heavily-autonomous tasks with many auto-continuation resumes).

Verified live via the api's request log: a brand-new session's first turn
made 3 MCP calls (initialize, tools/list, get_health_summary); a second turn
on the SAME session made exactly 1 (only get_health_summary) — tools/list
correctly skipped.

This completes the implementation order in
plans/2026-07-11-nomos-agent-code-review.md — every A/B/D/E/F finding from
the review (excluding C1, explicitly deferred per operator instruction) is
now fixed, tested, and verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:30:07 +02:00
6d4f6de676 fix(agent): mark a task failed when its resume permanently gives up (B3)
Fix B3 of plans/2026-07-11-nomos-agent-code-review.md. resumeSession's retry
loop (used by both auto-continuation and panel-answered questions) already
retried once on a transient LLM failure, but if BOTH attempts came back
empty/erroring, the code just logged and returned — the task was left at
whatever status it already had (typically 'executing' or 'awaiting_input')
with no outcome, no operator-visible signal beyond an inert error line
buried in the transcript, and no way to tell a genuinely stuck task apart
from one quietly still working.

On permanent failure, now calls store.completeTask(outcome='failure', a
summary built from the error) so the task board reflects reality instead of
showing a task that looks perpetually in-progress. Uses context.Background()
for that write, matching resumeSession's own persistence pattern, since the
context that led to the failure may itself be in a bad state. This doesn't
prevent the operator from continuing to work the task via a fresh chat
message afterward — it only replaces silent hanging with a real status.

A full live induction of a permanent LLM outage would require breaking the
model/API-key config for the whole nomos container — too invasive for this
fix's priority. Verified instead that the new branch stays correctly dormant
on the happy path: ran a real ask_operator → panel-answer → resume cycle
end-to-end and confirmed the task landed at status='executing' with no
outcome set, proving the failure-handling code doesn't false-positive on a
normal successful resume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:25:21 +02:00
c3901641d1 fix(agent): bound conversation history replayed to the LLM (A2)
Fix A2 of plans/2026-07-11-nomos-agent-code-review.md. chatWith replayed a
session's ENTIRE message history into the LLM's context on EVERY turn, no
windowing, no token budget — confirmed against a documented production case
(a single turn with 70 tool calls, messages up to 106KB). Every subsequent
turn of a long-running or heavily-autonomous task re-sent that ever-growing
history in full — a real cost/latency/eventual-context-limit risk for
exactly the tasks this system runs longest (many auto-continuation cycles).

Design call (flagged in the review as needing one before implementation):
a fixed-size window for LLM replay specifically, not the UI's own transcript
view. Simplest option that still keeps roughly the current task's working
context; a token-aware trim or LLM-summarize-on-drop are documented as
stretch options if 30 proves insufficient in practice.

- store.go: new getRecentMessages(ctx, sessionID, limit) — last `limit`
  messages in chronological order, plus whether older ones were omitted.
  getMessages (used by the UI's GET /sessions/{id}) is untouched and stays
  unbounded — the operator should still see a task's full history regardless
  of length; only what gets sent to the model is bounded.
- agent.go: chatWith uses getRecentMessages(sessionID, historyWindowSize=30)
  instead of the unbounded getMessages. When truncated, injects a system
  note telling the model explicitly that older turns exist but aren't shown,
  so it checks upsert_knowledge/search_knowledge rather than assuming
  something wasn't done just because it isn't visible.

New cmd/nomos/store_test.go: real Postgres integration tests (mirroring
internal/db/integration_test.go's throwaway-database pattern, guarded by
OIKOS_TEST_DATABASE_URL). TestGetRecentMessages_Truncation is the direct
proof for this fix (35 messages → 30 returned, correctly ordered,
truncated=true; 5 messages → all 5, truncated=false) — both cases run
against a fully-migrated database, not mocked. Also added
TestProposePlan_AppendVsReplace, closing part of the review's test-coverage
finding (E) by permanently regression-testing the earlier append-vs-replace
plan fix (commit 5384499), which had only been verified manually until now.

Verified live: inflated a real session to 42 persisted messages via direct
SQL, then continued it with a real chat call — the turn proceeded normally
(multiple real tool-call iterations, no crash, no context-length error);
nomos stayed healthy throughout. A3's incremental persistence separately
confirmed to have caught the 7 real tool calls made before the client
connection was cut, cleanly closing out both fixes' interaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:22:30 +02:00
76f76308cc fix(agent): D1-D3 cleanups — dead code, N+1 query, unvalidated outcome enum
Fixes D1-D3 of plans/2026-07-11-nomos-agent-code-review.md:

- D1: deleted isTaskTool — defined, never called (dispatch already checks
  handleTaskTool's own `handled` return value).
- D2: recordTouched issued one SELECT per entity slug found in a tool call's
  args; batched into one `WHERE slug = ANY($1)` query. Verified live: a turn
  naming three separate entities recorded involves edges for all three via
  the single batched lookup.
- D3: complete_task's outcome had a declared enum (success|failure|partial)
  in its tool schema but nothing validated it — an out-of-enum value (model
  typo or a weaker model not respecting the schema) silently persisted as-is,
  with only "failure" special-cased (anything else became status='done'
  regardless of what the value actually said). Now validated in
  handleTaskTool: empty defaults to "success" (unchanged), a recognized value
  passes through, anything else defaults to "partial" (safer than silently
  treating an unrecognized value as success) with a warning logged. Verified
  live: instructed the agent to call complete_task with outcome="unclear" —
  persisted as outcome='partial', not the literal invalid string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:13:48 +02:00
926969a03f fix(agent): live chat turns persist incrementally, survive client disconnect
Fix A3 of plans/2026-07-11-nomos-agent-code-review.md. handleChat only ever
saved the assistant message ONCE, after a.chat(...) returned, using
ctx := r.Context() for that write — the same context that cancels the instant
the client disconnects (Stop button, tab close, network blip). A disconnect
mid-turn meant the final save ran with an already-cancelled context and its
error was never checked: the entire turn's tool-call history was silently
lost from the persisted transcript, even though real work (executions
launched, knowledge written) had already happened server-side.

Brought handleChat in line with resumeSession's existing pattern
(continue.go): insert a placeholder assistant row immediately, update the
SAME row after every tool call. The key fix is WHICH context the writes use —
a new pctx := context.Background() for every DB write in this handler
(session creation/touch, the user message, question auto-close, the
placeholder + incremental updates, the title update), while ctx/r.Context()
still gates the agent's own work (a.chat) and the SSE writes exactly as
before — a disconnect still correctly stops the agent from doing further
work, it just no longer also erases what it already did.

Verified live: sent a message requiring 6 tool calls (get_entity/
get_relations/get_blast_radius on two targets) and force-killed the client
connection mid-stream with curl -m 12 (confirmed via exit code 28). Before
this fix the persisted transcript would show 0 tool-call entries; after,
all 12 raw tool_use/tool_result entries (6 calls × 2) were present and
correctly attributed by tool name — proving both that progress survives an
abort and that the incremental writes aren't corrupting the data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:10:04 +02:00
c5ffaec85b fix(agent): panic recovery on every background goroutine (B1+B2)
Fixes B1 and B2 of plans/2026-07-11-nomos-agent-code-review.md together,
since the right granularity for B1 in the auto-continuation worker turned
out to require B2's restructuring anyway (see below).

B1: grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/ returned
nothing before this — every explicitly-spawned goroutine (continuation
worker, resumed chat turns, async execution dispatch, the SSE listener, two
duplicate sshExec implementations' output-collector goroutines) crashed the
whole process on an unhandled panic, not just that one goroutine. More
consequential post-concurrency: more simultaneous unattended background work
means more surface area for one bad input to end every running task.

New internal/safego package: Go(label, fn) launches fn in a goroutine with a
recover-and-log wrapper. Applied at every bare `go` spawn site across the
three packages. Two sites needed bespoke handling instead of the generic
helper because their callers block on a channel and a silent recover would
just make them hang until timeout: sshExec's output-collector goroutine (two
near-identical copies, internal/mcp/server.go and internal/httpapi/phase3.go)
and httpapi's ListenAndServe goroutine — both now recover AND send a
synthetic error result so the waiting select unblocks immediately instead of
waiting out the full timeout.

httpapi's sseListener got extra treatment: its per-notification handling was
extracted into handleNotification with its own recover, so a panic decoding
ONE malformed pg_notify payload can't kill the listener goroutine for every
connected SSE client — the outer goroutine spawn only needs to guard the
connection setup/reconnect code around it.

B2: cmd/nomos/continue.go's processContinuations used to run every pending
continuation SEQUENTIALLY in a plain for loop, in the SAME goroutine as the
ticker — meaning (a) task B's continuation waited for task A's full (up to
10-minute) resumed turn to finish first, undercutting this session's earlier
concurrency work on exactly the path autonomous tasks depend on most, and
(b) an unrecovered panic anywhere in that call chain didn't just crash the
process (B1) — even WITH B1's recovery wrapped only at the top-level worker
spawn, the panic would still unwind the ENTIRE ticker-loop goroutine,
silently ending auto-continuation for every task until nomos restarted.
Fixed by spawning each pending item via safego.Go individually: real
parallelism, and a bad item can now only ever take down its own goroutine.

Added internal/safego/safego_test.go: TestGo_RecoversPanic is the concrete
proof — a deliberate panic inside Go() that would otherwise crash the whole
test binary; reaching the assertion after it IS the evidence recovery works.

Verified live against the rebuilt containers: full chat turn round-tripped
correctly (hostname lookup, 2 iterations, normal completion) — no regression
from threading safego.Go through the tool-dispatch/continuation paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:05:19 +02:00
3919ec37d7 fix(agent): word-boundary matching + contracted negatives in chat-assent
Fix A1 of plans/2026-07-11-nomos-agent-code-review.md. isAssent and
isTypedConfirmation used a space-padded word-boundary check for negation
words but a bare strings.Contains for assent/confirm words — confirmed live
via test probes: isAssent("...maybe yesterday's logs...") returned true
("yes" matched inside "yesterday"), and isTypedConfirmation("I haven't
confirmed anything yet") returned true ("confirm" matched inside "confirmed",
and "haven't" wasn't in negationWords — only "don't"/"do not" were).
isTypedConfirmation is the sole gate for DESTRUCTIVE actions, so the second
case meant a message merely stating something hadn't been confirmed could
read as an explicit confirmation.

- Replaced the ad-hoc space-padding/prefix-check negation logic with proper
  tokenization (wordTokenRe) + containsPhrase, matching WHOLE tokens/phrases
  only — never a mid-word substring. Handles curly apostrophes too (a
  pre-existing gap: the old straight-quote-only check would have missed
  "don't" typed with a smart quote).
- Added contracted negatives (haven't, hasn't, isn't, wasn't, aren't, can't,
  cannot, won't, wouldn't, shouldn't, didn't, doesn't) to negationWords.
  Deliberately did NOT add a bare "not" — too broad, would false-negative
  ordinary assent like "go ahead, this is not risky".
- Added regression tests for both confirmed cases plus a couple of adjacent
  ones (eyesight/isn't, can't confirm) so a future change can't silently
  reintroduce either bug.

All existing assent/confirmation tests pass unchanged — this is a pure
robustness fix, not a behavior change for any previously-correct case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:51:57 +02:00
df393152f6 docs: nomos agent code review — gaps and improvement plan
Full read-through of cmd/nomos/ (agent.go, store.go, main.go, continue.go,
assent.go, tasks.go). Findings, ranked:

- A1 (confirmed via runnable probe): isAssent/isTypedConfirmation use
  unpadded substring matching for assent/confirm words while negation uses
  word-boundary checks — "yes" matches inside "yesterday", "confirm" matches
  inside "confirmed" with no negation word covering contracted negatives
  ("haven't"). isTypedConfirmation gates DESTRUCTIVE actions specifically.
- A2: chatWith replays a session's ENTIRE message history every turn, no
  windowing/token budget — confirmed unbounded against a documented
  production case (70 tool calls, 106KB messages).
- A3: a live turn's tool-call history is lost entirely if the client
  disconnects mid-stream (single end-of-turn save using the same
  connection-tied, possibly-cancelled context) — resumeSession already has
  the fix pattern (incremental placeholder+update), handleChat doesn't use it.
- B1: zero recover() anywhere in cmd/nomos/internal/mcp/internal/httpapi —
  every explicitly-spawned goroutine (continuation worker, resumeSession,
  executeApprovedViaAPI, sse listeners) crashes the whole process on panic.
- B2: auto-continuation processes its batch sequentially, one full LLM turn
  at a time, undercutting this session's own concurrency work on exactly the
  path autonomous tasks depend on most.
- B3: no terminal state for a permanently-failed auto-continuation.
- C1: nomos's own gateway (port 8092, directly published + mesh-reachable)
  has ZERO authentication on any endpoint — chat, session read/delete,
  chat-assent approval of gated executions, all open to anyone on the LAN.
- D1-D3: dead code (isTaskTool unused), N+1 query in recordTouched, no
  validation on complete_task's outcome enum.
- E: zero automated tests for agent.go/store.go/main.go/tasks.go — including
  today's new safety-critical logic (session-scoped windows, mcpClientPool,
  proposePlan's append-vs-replace), verified only by live manual testing.
- F1: tool list + fleet snapshot re-fetched every turn (minor).

Prioritized implementation order in the doc: A1 → C1 → B1 → B2 → A3 → D1-3 →
A2 → B3/F1, tests landing alongside each fix rather than as a deferred pass.

Also archives the now-fully-shipped concurrent-task-execution plan to done/
(all 3 required fixes deployed this session; fix 4 explicitly deferred per
its own recommendation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:47:17 +02:00
a4ea542f3e fix(concurrency): per-session MCP client pool — removes cross-task tool-call blocking
Fix 3 of plans/2026-07-11-concurrent-task-execution.md, the throughput one.
nomos held exactly one *mcpClient for the whole process, shared by every
/chat goroutine. Its mutex was held for the full duration of each tool
round-trip, and `run` executes its SSH command SYNCHRONOUSLY inside that
round-trip (capped at up to 10 minutes) — so while Task A was mid-`run`,
every other task's tool calls, even a trivial get_entity, queued behind that
single lock. Tasks could think (LLM calls) in parallel but never act in
parallel.

The MCP server has no per-connection state to protect (newServer returns one
shared *mcp.Server instance whose handlers close only over the DB
connection pool, already safe for concurrent use) — the mutex existed purely
because the client reused one stateful transport session. So the fix doesn't
touch the server at all:

- New mcpClientPool (cmd/nomos/main.go): one *mcpClient per session id,
  created lazily (a real MCP initialize handshake) on first use and cached;
  session-less traffic (the ephemeral no-DB-store path, the structured
  /query endpoint) gets its own fixed, reused key instead of a fresh
  connection per request. Idle clients (20 min past last use — long enough
  to outlive a single slow `run`) are evicted on a 5-minute sweep ticker.
- agent.go: `client *mcpClient` → `clients *mcpClientPool`; every call site
  (buildTools, fleetSnapshot, the tool-dispatch loop) now resolves its own
  session's client via clients.get(sessionID) instead of reaching for one
  shared field. A task's own tool calls stay sequential (already true — the
  agent loop calls tools one at a time within a turn) but no longer block
  anyone else's.
- main.go: handleQuery takes the pool instead of a client (keyed "query", a
  fixed non-session slot); shutdown calls pool.closeAll().

Verified live against the deployed stack: fired a slow-but-ungated command
(`ping -c 15 127.0.0.1`, read-only per policy's allowlist, no approval
needed) as Task A, then — 2s into A's run — a trivial hostname lookup as
Task B, both through the real /chat endpoint. Task A's ping genuinely ran
~14.3s (confirmed via its own execution record and the agent's reported
output). Task B returned in 6s total, well before A finished — proving it
was never queued behind A's connection. Before this fix, B would have been
forced to wait out A's entire ~14.3s hold on the single shared client.

This completes plans/2026-07-11-concurrent-task-execution.md's required
scope — only the explicitly optional/deferred Fix 4 (a concurrency/cost cap,
pending real usage data) remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:15:32 +02:00
6a8fb435ad fix(concurrency): per-session stream controllers, not one global slot
Closes the known gap flagged in the previous commit (9131559). A single
module-level `activeController` meant cancelStream()/newChat() always
aborted whichever stream was MOST RECENTLY STARTED, regardless of what the
operator was currently viewing: start Task A, switch to an already-loaded
Task B, click "New task" — the click's cancelStream() would silently abort
Task A's still-running turn, even though the operator was never looking at
it and never asked to cancel it.

- Replaced the single controller with activeControllers (Map<sessionID,
  AbortController>) plus pendingController for the brief pre-'session'-event
  window of a brand-new task. Registered immediately in sendMessage (keyed by
  the continuing session id right away, or held pending until the 'session'
  event assigns a new one) and cleaned up on completion.
- cancelStream() now looks up by $currentSession (falling back to
  pendingController when no session is assigned yet) — it can only ever
  touch the stream belonging to the view being left, never an unrelated
  background task's.
- newChat() unchanged in behavior (still calls cancelStream()), now correctly
  scoped through the above.

Verified live, reproducing the exact bug: started Task A (slow, 5 tool
calls), switched to an existing Task B, clicked "New task" while viewing
B — Task A was NOT aborted, ran to completion server-side with a full,
correct final summary (previously this exact sequence would have killed it).
Confirmed the positive path is unaffected: started a task, clicked Stop
while actively viewing it — input re-enabled, stream genuinely aborted
("BodyStreamBuffer was aborted"), turn stopped mid-flight as expected.

This closes out Fix 2's scope from
plans/2026-07-11-concurrent-task-execution.md; only Fix 3 (per-session MCP
client pool, throughput) and the optional Fix 4 (concurrency cap) remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:02:50 +02:00
9131559ebd fix(concurrency): guard chat.ts's stream callback against a stale session
Fix 2 of plans/2026-07-11-concurrent-task-execution.md. sendMessage's SSE
callback mutated the global messages/currentSession stores unconditionally,
assuming only one task's turn is ever in flight. It isn't — the backend runs
every /chat request as its own goroutine with no serialization. Switching to
a different task while a previous one was still streaming let that
background stream's later events (tool_use, text_delta, ..., and worst of
all 'done''s currentSession.set) get applied to whatever the operator is now
looking at: corrupting another task's transcript, or yanking the view back
to the one they left.

- Captures the session a stream belongs to (openedFor at call time, updated
  to the real id once the 'session' event assigns one) and checks
  $currentSession still matches before every messages/error/streaming
  mutation. The task keeps running server-side regardless — dropped events
  just mean the live view isn't watching it; navigating back re-hydrates via
  REST, same as already happens for auto-continuation.
- The 'session' event itself only claims currentSession if the operator
  hasn't already navigated elsewhere since the call started (comparing
  against openedFor, which is null for a brand-new task).
- loadSessionMessages/newChat now reset `streaming` to false unconditionally
  on navigation — needed so the new guard can't leave a DIFFERENT task's view
  stuck showing streaming=true (which would also silently stop startPolling's
  loop from ever applying updates, since it bails while $streaming is true).

Known residual gap, not fixed here (matches the plan's "contained fix, not a
rearchitecture" scope): activeController is still a single global slot, so
starting a new task while another is mid-stream, then clicking "New task"
again, aborts whichever stream that slot last pointed at rather than only
the one being left. A genuine multi-session controller/store is the
plan's deferred "stretch" fix, not required for correctness here.

Verified live: started Task A with a deliberately slow 4-tool-call turn,
switched to an existing Task B mid-stream — Task B's transcript stayed
correct with zero A-originated entries and the input was NOT stuck disabled.
Task A kept running and completed normally server-side (status=done, full
6-tool transcript, 5-entity graph); navigating back loaded its complete,
uncorrupted result via REST.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:34:02 +02:00
9ef1ba3702 fix(concurrency): scope assent/destructive windows to session, not just agent
Fix 1 of plans/2026-07-11-concurrent-task-execution.md — the safety-critical
one. The assent window (and destructive window) were keyed purely by agent
id ("assent_window.agent:<uuid>"). With one agent:nomos entity serving every
concurrent task, this meant approving Task A's plan opened a window that ANY
concurrently-running task's config-mutation/destructive actions could also
ride, auto-executing without their own approval.

- store.go / agent.go: assentWindowActive/openAssentWindow and
  destructiveWindowActive/openDestructiveWindow/destructiveWindowKey all gain
  a sessionID parameter; keys become
  "assent_window.agent:<id>.session:<sessionID>" and
  "destructive_window.agent:<id>.target:<slug>.session:<sessionID>". Missing
  session id fails closed (no window) rather than falling back to the old
  agent-wide key.
- continue.go: the auto-continuation worker's window check moved from once-
  per-batch to once-per-pending-item, scoped to that item's own session —
  it was previously checking ONE agent-wide window for a batch that can span
  multiple tasks.
- agent.go tool-dispatch: injects `_session_id` into a COPY of the wire args
  sent to the MCP server (never into the args used for the emitted/logged/
  persisted tool call, and never part of any tool's declared InputSchema —
  invisible to the model) so the gating checks on the OTHER side of the
  process boundary know which task is asking.
- internal/mcp/server.go: assentWindowActive/destructiveWindowActive/
  classifyAndGate gain the same sessionID parameter, read from
  args["_session_id"] at the three call sites (request_execution's
  apt_upgrade/pct_create branches, and the shared classifyAndGate used by
  restart/pct_exec/systemctl/run).

Verified against the live stack with the exact scenario from the plan: opened
an assent window for session A only, then called `run` with an identical
config-mutation command for session A (window open) and session B (same
agent, no window). A auto-ran (execution status completed); B correctly
queued for approval (pending_approval) instead of bleeding through — proven
at both the MCP response text and the executions table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:27:29 +02:00
6932eb5eed docs: plan concurrent task execution; archive completed tasks plan
New plan grounds three concurrency issues found by tracing the actual code
(not assumed): the assent/destructive windows are keyed by agent id only
(no session dimension), so an approved plan in one task can auto-run
unapproved actions in a concurrently-running task; nomos shares one
mutex-guarded MCP client across all sessions, so a single slow `run` call
serializes every other task's tool calls behind it; and chat.ts's SSE
callback has no session guard, so switching tasks mid-stream lets the
backgrounded task's events corrupt whatever's now displayed. Proposes
session-scoping the windows (critical/first), a frontend stream guard
(contained/second), a per-session MCP client pool (throughput/third), and
an optional concurrency cap (deferred pending real usage data).

Also archives the goal-oriented-chat-control-panel plan to done/ — all 7
phases shipped and are live in production (SHA e30813a) — fixing its
internal relative links for the new depth and pointing forward to the new
concurrency plan as follow-up hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:14:35 +02:00
e30813a43d feat(tasks): make research-first / knowledge-write-back-last explicit steps
Closes the gap that made the knowledge loop optional/implicit: every
non-trivial task now has an EXPLICIT first plan step (research) and last
plan step (write back), not just background behavior the model might skip.

New MCP tools (the agent had no way to do these before — only REST endpoints
existed, unexposed to it):
- update_entity_attributes(slug, attributes): shallow-merge new/changed facts
  into an entity (an IP, a version, a discovered port) so a future task
  doesn't have to rediscover them from scratch. No approval required — this
  updates the knowledge graph, not live infra.
- create_relationship(source, target, type): record a discovered edge
  (depends-on, hosts, provides, ...). Idempotent, FK-validated against the
  ontology's relationship_types, no approval required.

SOUL.md: restructured the task loop so step 1 is explicitly "gather
knowledge, not just status" (get_entity_knowledge, search_knowledge,
get_relations, get_blast_radius, http_get) and the last step before
complete_task is explicitly "write back" (update_entity_attributes,
create_relationship, upsert_knowledge) — both called out as real plan
entries the operator should see in propose_plan, not silent side-work. This
is what prevents the graph drifting from reality and is the concrete
mechanism behind "tasks compound."

propose_plan's tool description reinforces the same first-step/last-step
convention at the call site.

Verified against the live stack: both tools registered and callable via MCP;
update_entity_attributes merged an attribute correctly; create_relationship
rejected an invalid type (FK violation, clear error) and succeeded with a
valid type+direction, confirmed idempotent (2 calls, 1 row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:14:02 +02:00
5384499903 fix(tasks): plan panel showed only the latest step, not the full plan
Root cause: proposePlan unconditionally deleted and replaced the whole
session_plan_steps list on every call. The model isn't strictly held to
"call propose_plan once with the full list" — nothing stopped it (and
production evidence + live testing showed it happening) from calling
propose_plan once per step as it worked. Each such call wiped every
already-completed step, so the operator only ever saw the model's latest
single step ("1/1") instead of the real, growing plan.

Fix, two layers:
- store.go: proposePlan now only does a destructive replace when no step
  has left 'pending' yet (a genuine pre-execution revision). Once any step
  has started, a new call APPENDS after the current max seq instead of
  wiping — so the panel accumulates the full history regardless of how the
  model chooses to call the tool. plan.proposed now carries `appended` so
  the frontend knows whether to replace or append.
- workspace.ts: plan.proposed handler respects `appended` (update vs set).
- tasks.go / SOUL.md: strengthened the propose_plan description and task-
  loop guidance to call it ONCE with the complete step list end-to-end,
  using update_plan_step (not re-calling propose_plan) to advance — fixing
  the root behavioral cause, with the store-side append as a safety net
  that holds even if the model still calls it incrementally.

Verified: forced the exact incremental-call pattern (propose_plan with 1
step, mark it running, propose_plan again with 1 more step) — the second
call appended at seq 2 instead of erasing seq 1, and its plan.proposed
event carried appended=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:07:10 +02:00
991e7d0900 feat(tasks): phase 6 — live TaskContextPanel (goal, plan, question, entities)
Replaces the chat right rail's ad-hoc Digest+Graph stack with a single
TaskContextPanel that renders the task's live working state, driven by the
always-on events stream (not the per-turn chat SSE) so it keeps updating
during server-side auto-continuation/resume:

- GoalHeader: goal + status pill (planning/executing/awaiting_input/done/
  failed), sourced from the sessions list.
- PlanProgress: ordered steps with live status icons + progress bar, hydrated
  via new GET /sessions/{id}/plan; clicking a step with a target opens its
  EntitySheet (no fake "jump to transcript" — bits-ui Collapsible content
  isn't force-mounted, so a DOM-scroll jump would silently no-op for
  collapsed tool groups).
- OperatorQuestion: the pinned structured question card (prompt/why/entity
  chips/option buttons/free-text), hydrated via new GET /sessions/{id}/
  questions; answering POSTs to the existing answer endpoint.
- SessionGraph upgraded to a live entity panel: entity.touched pulses the
  node (animated ring) and shows "Now touching <slug>"; health.changed shows
  a transient diff badge for touched entities.
- SessionDigest gains a success/failure/partial outcome banner and now also
  refetches when the task's status changes, not just on session switch.

Two bugs found and fixed while wiring this up:
- workspace.ts's status-refresh trigger only covered goal.set/task.status;
  question.raised/answered didn't refresh the sessions list, so GoalHeader's
  pill went stale after answering via the panel (resumeSession runs entirely
  server-side — no client 'done' event to piggyback a refresh on). Now every
  status-affecting event triggers the (debounced) refetch.
- Forgot to rebuild the nomos container after adding the /plan and
  /questions endpoints, so they silently fell through to the old default GET
  handler — caught via a live curl diff against the running container,
  not a code read.

Verified end-to-end against the live stack: goal/plan/question all update
without a reload as the agent works; answering a question via the panel
resumes the agent and the header pill correctly flips to Executing;
entity.touched pulses the live graph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:36 +02:00
413bf54daf feat(tasks): task board UI — chat window becomes Tasks + card grid
Reframes the chat surface as tasks:
- New Tasks.svelte: a card grid of tasks, each showing status (Running /
  Needs input / Done / Failed), the goal as title, the outcome summary, and
  relative time; filterable by status with live counts; delete on hover;
  "New task" and per-card click open the conversation.
- Board updates LIVE off the events stream (goal.set / task.status /
  question.*) via an explicit liveEvents.subscribe with a debounced refetch —
  scanning all events newer than the last seen, since entity.touched bursts
  bury task events below index 0.
- App shell: primary nav "Chat" → "Tasks" (board is now the home route),
  "New chat" → "New task", conversation header gets a Tasks / Conversation
  breadcrumb. Removed the superseded Sessions page.
- api.ts Session type carries the task fields (goal/status/outcome/summary).

Also fixes a pre-existing SSE bug that blocked ALL live updates app-wide:
writeSSE emitted `event: <type>`, which EventSource only delivers to
addEventListener(type) handlers — but stores/events.ts (and every page reading
liveEvents) consumes via onmessage, which never fires for named events. So the
live stream delivered nothing to the UI. Dropped the event-name line; the type
is already in the JSON payload, and new event types now need zero client
changes. SSE test still green (it parses data: lines).

Verified in the browser against the live stack: the board renders 50 tasks
with correct status buckets; a goal-driven task appears and flips to a Done
card with its summary in real time without a reload; Events page confirms the
stream now delivers to onmessage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:22:27 +02:00
014e5c74e0 feat(tasks): phase 5 — ask_operator (structured question, pause, resume)
The last backend piece: when the agent hits a decision only the operator can
make, it surfaces a structured question instead of guessing or stalling.

- ask_operator(prompt, why?, options?, context_entities?): nomos-local tool
  that records a session_questions row, moves the task to awaiting_input, emits
  question.raised, and ENDS the turn (the agent loop returns after it, so the
  agent can't barrel past its own question). The prompt becomes the assistant's
  visible message so the question also shows inline in the transcript.
- Two resume paths, both close the question + emit question.answered + return
  the task to executing:
  - Panel: POST /sessions/{id}/questions/{qid}/answer → resumes the agent in the
    background with the answer injected (reusing the continuation machinery,
    refactored continueSession → resumeSession). Returns 202; the reply lands via
    message polling.
  - Chat reply: the next chat message on a task with an open question IS the
    answer — auto-closed in handleChat; the turn itself is the resume.

Verified end-to-end: forcing a decision paused the task at awaiting_input with
the structured question (prompt/why/options/entities); a panel answer resumed
the agent (it acknowledged host:strong and continued); a plain chat reply
auto-closed a second question. Cleanup + tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:00:15 +02:00
be3ce761d4 feat(tasks): phase 4 — structured plan steps (set_goal/propose_plan/update_plan_step)
Gives a task a legible, live-advancing plan via three more nomos-local tools:

- set_goal(goal): records the task goal, status → planning, emits goal.set.
- propose_plan(steps[]): persists ordered steps (clean replace for v1 — a
  revision starts a new list), status → executing, emits plan.proposed with
  the persisted steps (id+seq) so the panel can address them.
- update_plan_step(seq, status, execution_id?): advances a step, stamping
  started_at/finished_at, emits plan.step.started/finished. Anchors the event
  to the step's target entity when it has one.

Belt-and-suspenders: when an execution linked to a step reaches a terminal
state, the api auto-closes the step (closePlanStepForExecution in
emitExecutionEvent) and emits plan.step.finished — so the board stays honest
even if the agent forgets to close a step it started.

Verified end-to-end: a goal-driven task fired goal.set → plan.proposed →
2× step.started/finished → task.status on the SSE stream; both steps persisted
done with start/finish timestamps; status progressed planning→executing→done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:50:27 +02:00
532310bb4b feat(tasks): phase 3 — close the knowledge loop (complete_task + retrieval)
Adds the compounding knowledge loop the task model is built around:

- complete_task(outcome, summary): a nomos-LOCAL, session-scoped tool (the
  shared MCP server has no session id). Introduces the local-tool mechanism —
  buildTools appends task tools, the agent loop routes them to handleTaskTool
  instead of the MCP client. Sets the task's terminal status/outcome/summary,
  mirrors it onto the task entity, and emits task.status.
- Knowledge → task linkage: after a successful upsert_knowledge in a task,
  nomos links the note to the task entity (documents) and emits
  knowledge.recorded, so the task's outcome view shows what it learned. The
  note's about-link to the involved entity (written by upsert_knowledge) is the
  retrieval path future tasks use.
- SOUL: every chat is a task loop — retrieve prior knowledge FIRST
  (get_entity_knowledge on the target), plan, execute, record learnings, then
  complete_task. Scales down for trivial read-only tasks.
- deleteSession now cleans up the task entity, its relationships, and its
  task-scoped events (was orphaning them); the knowledge doc itself and its
  about-links survive, as knowledge should outlive the task.

Verified end-to-end: a task recorded a note and completed; task.status +
knowledge.recorded hit the SSE stream; status=done/outcome=success persisted;
the note linked to both lxc:caddy (retrieval) and the task; a future
get_entity_knowledge(lxc:caddy) surfaces it; delete cleaned edges+events (0/0/0)
while the knowledge survived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:43:29 +02:00
3dba2e550a feat(tasks): phase 2 — entity.touched events + task→entity involves edges
As the agent runs a task, record which entities each tool call references:
write an idempotent task —involves→ entity relationship and publish one
entity.touched event per entity (correlation_id = session, data {slug,tool}).
Extracted from tool ARGS only — never results — so a bulk fleet query can't
drag every entity into the task graph; bulk/no-slug tools stay silent.

Emitted from the nomos agent loop rather than the shared MCP wrapper, which
has no session id. The involves edges make a task's graph neighborhood its
involved-entity set (queryable via get_relations) — the substrate for the
knowledge loop; the events are the live pulse the context panel consumes in
phase 6.

Verified end-to-end on the local stack: a chat referencing lxc:caddy/lxc:gitea
produced entity.touched on the browser SSE stream with slug+tool+correlation,
and exactly one involves edge per entity despite repeated touches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:35:27 +02:00
72e9fe534e feat(tasks): phase 1 — elevate chat session to a task (schema + task entity)
Migration 018 adds goal/status/outcome/summary/entity_id to agent_sessions
and creates session_plan_steps + session_questions. Registers a 'task'
entity type and an 'involves' (task→entity) relationship in the ontology
so each session anchors its knowledge and involved-entity edges on the
existing relationships graph.

nomos createSession now mints a task:<session-id> entity (type task) and
links it via agent_sessions.entity_id — best-effort so chat never blocks on
it. listSessions/GET /sessions surface the new task fields.

No behaviour change yet; this is the data foundation for the task board and
live context panel. Verified end-to-end against the local stack: migration
applied, ontology ingested (60 types/47 rels), a new session mints a linked
task entity and the API returns status/goal/entity_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:25:28 +02:00
eed6e3b1c5 docs: task-centric chat plan (goal → single-approval → autonomous + knowledge loop)
Reframes the chat surface as a board of tasks: each task carries a goal,
a plan approved once, a lifecycle status, an outcome, and a knowledge
loop that links learnings to the involved entities (and the task entity
itself) via relationships so future tasks compound. Supersedes the
sidebar-only framing and the free-form chat portion of the control-room
web UI plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:17:36 +02:00
ef5a92269b docs: reconcile plans/ status against actual code state
Audited all 10 active plan docs against the codebase (not just commit
titles). 5 were fully shipped and stale-tagged "Planned"/"In Progress" —
moved to done/ with verification notes. The other 4 got corrected
Planned→In Progress status plus concrete remaining-gap notes so the next
pass doesn't re-derive what's already done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 11:42:26 +02:00
158 changed files with 11262 additions and 3610 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

@@ -24,6 +24,20 @@ import (
const maxIterations = 40
const maxLLMRetries = 1
// historyWindowSize bounds how many of a session's most recent persisted
// messages are replayed into the LLM's context on each turn — see
// store.go's getRecentMessages for why this exists (fix A2 of
// plans/2026-07-11-nomos-agent-code-review.md: unbounded history replay was
// a real, observed-in-production cost/latency/eventual-context-limit risk).
// 30 is a fixed-window choice, not token-budget-aware: simplest option that
// still keeps roughly the current task's working context, at the cost of
// occasionally dropping something a very long task still needed — the
// system note injected when truncation happens tells the model to check
// upsert_knowledge/search_knowledge rather than assume something didn't
// happen. A token-aware trim or LLM-summarize-on-drop are documented
// stretch options if a fixed window proves insufficient in practice.
const historyWindowSize = 30
var refusalDenylist = []string{
"我没有相关信息",
"您可以尝试问我其它问题",
@@ -33,7 +47,7 @@ var refusalDenylist = []string{
}
type agent struct {
client *mcpClient
clients *mcpClientPool // one MCP client PER SESSION, not shared — see mcpClientPool's doc comment
system string
provider *openai.Client
model string
@@ -41,10 +55,11 @@ 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
}
func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug string) (*agent, error) {
func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string) (*agent, error) {
system := loadSoul()
apiKey := os.Getenv("OPENROUTER_API_KEY")
model := os.Getenv("NOMOS_MODEL")
@@ -92,7 +107,7 @@ func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug st
}
return &agent{
client: mcpClient,
clients: clients,
system: system,
provider: &provider,
model: model,
@@ -100,6 +115,7 @@ func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug st
agentID: agentID,
reqOpts: reqOpts,
apiBase: apiBase,
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
httpClient: &http.Client{Timeout: 15 * time.Second},
}, nil
}
@@ -125,12 +141,15 @@ const assentWindowDuration = 30 * time.Minute
// openAssentWindow records an active assent window in autonomy_settings so
// the MCP run tool (separate process) can check it before requiring approval
// for config_mutation commands. Key is scoped to this agent's UUID.
func (a *agent) openAssentWindow(ctx context.Context) {
if a.store == nil || a.store.pool == nil || a.agentID == uuid.Nil {
// for config_mutation commands. Key is scoped to this agent's UUID AND this
// session/task — see store.go's assentWindowActive for why: without the
// session dimension, approving one task's plan would silently auto-run
// unapproved actions in any other concurrently-running task.
func (a *agent) openAssentWindow(ctx context.Context, sessionID string) {
if a.store == nil || a.store.pool == nil || a.agentID == uuid.Nil || sessionID == "" {
return
}
key := "assent_window.agent:" + a.agentID.String()
key := assentWindowKey(a.agentID, sessionID)
expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339)
_, err := a.store.pool.Exec(ctx,
`INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
@@ -138,7 +157,7 @@ func (a *agent) openAssentWindow(ctx context.Context) {
if err != nil {
slog.Warn("nomos: openAssentWindow", "error", err)
} else {
slog.Info("nomos: assent window opened", "agent", a.agentID, "expires", expires)
slog.Info("nomos: assent window opened", "agent", a.agentID, "session", sessionID, "expires", expires)
}
}
@@ -168,18 +187,36 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject string, emit func(agentEvent)) {
correlationID := uuid.New().String()
tools, err := a.buildTools()
tools, err := a.buildTools(sessionID)
if err != nil {
emit(agentEvent{Type: "error", Data: fmt.Sprintf("build tools: %v", err), SessionID: sessionID})
return
}
system := a.system
if snapshot := a.fleetSnapshot(); snapshot != "" {
if snapshot := a.fleetSnapshot(sessionID); snapshot != "" {
system += "\n\n" + snapshot
}
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
history, _ := a.store.getMessages(ctx, sessionID)
history, truncatedHistory, _ := a.store.getRecentMessages(ctx, sessionID, historyWindowSize)
if truncatedHistory {
// Tell the model explicitly rather than silently dropping older
// turns — otherwise it might assume something wasn't done just
// because it doesn't see the turn that did it.
messages = append(messages, openai.SystemMessage(fmt.Sprintf(
"[System: this task has been running long enough that only the most recent %d turns of its history are included above your context — earlier turns happened but aren't shown. If you need to know what was already tried or found, check search_knowledge/get_entity_knowledge (if you recorded it) rather than assuming it didn't happen.]",
historyWindowSize)))
}
// sawSetGoal / sawCompleteTask track whether this session has EVER framed
// itself as a structured task (set_goal) or already reached a terminal
// state (complete_task) — across both replayed history and this turn's
// own tool calls (updated again below as they happen live). Used by the
// end-of-turn safety net (plans/2026-07-11-task-completion-safety-net.md,
// fix 1): most sessions are a single trivial Q&A exchange that answers in
// text and never calls either tool, leaving agent_sessions.status stuck
// at its creation-time default forever. If a session never framed itself
// as a task, its first plain-text turn-end IS the task ending.
var sawSetGoal, sawCompleteTask bool
var lastAssistantCalls []persistedCall
for _, m := range history {
text := extractText(m.Content)
@@ -191,6 +228,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
messages = append(messages, assistantToolCallMessage(calls))
for _, c := range calls {
messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
switch c.name {
case "set_goal":
sawSetGoal = true
case "complete_task":
sawCompleteTask = true
}
}
lastAssistantCalls = calls
}
@@ -242,15 +285,15 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if p.destructive && typedConfirm {
if execUUID, perr := uuid.Parse(p.execID); perr == nil {
if target := a.store.executionTarget(ctx, execUUID); target != "" {
a.store.openDestructiveWindow(ctx, a.agentID, target)
slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target)
a.store.openDestructiveWindow(ctx, a.agentID, target, sessionID)
slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target, "session", sessionID)
}
}
}
}
}
if len(granted) > 0 {
a.openAssentWindow(ctx)
a.openAssentWindow(ctx, sessionID)
note := fmt.Sprintf("[System: the operator's message approved pending execution(s) %s via chat assent — they are now running. An assent window is now active for 30 minutes: config_mutation commands will auto-run without re-approval. Do not re-request or call request_execution/run again for these; check get_execution_status if you need the outcome. CONTINUE executing the full plan — do not stop and wait for 'continue' after each step. Only surface to the operator for destructive actions (need typed confirmation) or if you're genuinely stuck after trying alternatives.]", strings.Join(granted, ", "))
messages = append(messages, openai.SystemMessage(note))
}
@@ -266,7 +309,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
// the operator approved — go execute the plan now.
note := "[System: The operator approved your proposed plan. Execute it now — call request_execution or run to carry out the steps you described. Do not re-describe the plan or ask for confirmation again. The assent window is active: config_mutation commands will auto-run once you create them.]"
messages = append(messages, openai.SystemMessage(note))
a.openAssentWindow(ctx)
a.openAssentWindow(ctx, sessionID)
}
// Worker continuation: append the finished-execution note so the model
@@ -333,6 +376,9 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if len(msg.ToolCalls) == 0 {
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
if !sawSetGoal && !sawCompleteTask {
a.autoCompleteTrivialTask(ctx, sessionID, msg.Content)
}
emit(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID,
"usage": acc.Usage,
@@ -352,6 +398,13 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
args = map[string]any{}
}
switch tc.Function.Name {
case "set_goal":
sawSetGoal = true
case "complete_task":
sawCompleteTask = true
}
emit(agentEvent{
Type: "tool_use",
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
@@ -360,14 +413,38 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
})
start := time.Now()
result, callErr := a.client.callTool(tc.Function.Name, args)
// Session-scoped task tools are handled in-process; everything else
// is forwarded to the shared MCP server.
var result any
var callErr error
if localRes, handled := a.handleTaskTool(ctx, sessionID, tc.Function.Name, args); handled {
result = localRes
} else {
// _session_id rides along on the wire call only — never in
// `args` (which is what gets emitted/logged/persisted as the
// model's own tool call) — so the MCP-side assent/destructive
// window checks can scope to THIS task instead of bleeding
// across every concurrently-running one sharing this agent
// identity. Not part of any tool's declared InputSchema, so
// the model never sees or supplies it.
wireArgs := make(map[string]any, len(args)+1)
for k, v := range args {
wireArgs[k] = v
}
wireArgs["_session_id"] = sessionID
var client *mcpClient
client, callErr = a.clients.get(sessionID)
if callErr == nil {
result, callErr = client.callTool(tc.Function.Name, wireArgs)
}
}
elapsed := int(time.Since(start).Milliseconds())
inputJSON, _ := json.Marshal(args)
inputStr := string(inputJSON)
if callErr != nil {
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, callErr.Error(), elapsed, false, correlationID)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
emit(agentEvent{
Type: "tool_result",
@@ -381,7 +458,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
}
resultJSON, _ := json.Marshal(result)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, string(resultJSON), elapsed, true, correlationID)
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID)
// Link any execution this tool queued/started back to this
// session, so the auto-continuation worker can feed its result
@@ -392,6 +469,17 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
a.store.linkExecution(ctx, execID, sessionID)
}
// Record which entities this task touched (task —involves→ entity)
// and pulse them on the live context panel. Args only — never
// results — so a bulk query doesn't drag the whole fleet in.
a.store.recordTouched(ctx, sessionID, tc.Function.Name, args)
// When the agent records knowledge, link that note to this task so
// the task's outcome view shows what it learned (and pulse it live).
if tc.Function.Name == "upsert_knowledge" {
a.store.linkKnowledgeToTask(ctx, sessionID, string(resultJSON))
}
emit(agentEvent{
Type: "tool_result",
Data: map[string]any{"name": tc.Function.Name, "result": result, "id": tc.ID},
@@ -400,6 +488,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
})
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
// ask_operator pauses the task: the agent has posed a decision only
// the operator can make. End the turn here so it doesn't barrel past
// its own question — the answer (panel or chat reply) resumes it.
// The prompt becomes the assistant's visible message so the question
// also shows inline in the transcript.
if tc.Function.Name == "ask_operator" {
prompt, _ := args["prompt"].(string)
emit(agentEvent{Type: "text", Data: prompt, SessionID: sessionID})
emit(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID,
"correlation_id": correlationID,
"iteration": i + 1,
}, SessionID: sessionID})
return
}
}
}
@@ -545,8 +649,12 @@ func assistantToolCallMessage(calls []persistedCall) openai.ChatCompletionMessag
// of spending its first iteration rediscovering topology it already has
// tools to query. Best-effort: an empty string on any failure just means no
// snapshot, not an error for the turn.
func (a *agent) fleetSnapshot() string {
result, err := a.client.callTool("get_health_summary", map[string]any{})
func (a *agent) fleetSnapshot(sessionID string) string {
client, err := a.clients.get(sessionID)
if err != nil {
return ""
}
result, err := client.callTool("get_health_summary", map[string]any{})
if err != nil {
return ""
}
@@ -609,11 +717,18 @@ func isRefusalOrEmpty(text string) bool {
return false
}
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
defs, err := a.client.listToolsFull()
func (a *agent) buildTools(sessionID string) ([]openai.ChatCompletionToolParam, error) {
client, err := a.clients.get(sessionID)
if err != nil {
return nil, err
}
defs, err := client.listToolsFull()
if err != nil {
return nil, err
}
// Append nomos-local, session-scoped task tools (complete_task, …) to the
// MCP tool list. They're routed to handleTaskTool, not the MCP client.
defs = append(defs, taskToolDefs()...)
var tools []openai.ChatCompletionToolParam
for _, d := range defs {
@@ -634,7 +749,23 @@ func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
return tools, nil
}
// listToolsFull returns the MCP server's tool list, cached on this client
// after the first call (see mcpClient.toolsCache). Fix F1 of
// plans/2026-07-11-nomos-agent-code-review.md: buildTools calls this at the
// start of every chat turn, including every auto-continuation resume — the
// tool list is static for the lifetime of one MCP connection, so re-fetching
// it every single time was avoidable network+parsing work on the hot path.
// Cache invalidates on reconnectLocked (an api restart may change what's
// registered).
func (c *mcpClient) listToolsFull() ([]toolDef, error) {
c.toolsMu.Lock()
if c.toolsCache != nil {
cached := c.toolsCache
c.toolsMu.Unlock()
return cached, nil
}
c.toolsMu.Unlock()
resp, err := c.doRequest("tools/list", map[string]any{})
if err != nil {
return nil, err
@@ -657,5 +788,9 @@ func (c *mcpClient) listToolsFull() ([]toolDef, error) {
InputSchema: t.InputSchema,
}
}
c.toolsMu.Lock()
c.toolsCache = out
c.toolsMu.Unlock()
return out, nil
}

View File

@@ -53,10 +53,18 @@ func extractPendingApprovals(calls []persistedCall) []pendingApproval {
// don't restart it yet" contains neither "yes" nor "go ahead", but "wait"
// alone should also block a stray "yes" a sentence later — checking negation
// first and returning false errs toward re-confirming rather than assuming
// consent, per "when in doubt, escalate").
// consent, per "when in doubt, escalate"). Includes contracted negatives
// ("haven't", "isn't", ...) alongside "don't"/"do not" — found live: "I
// haven't confirmed anything yet" was reading as an explicit confirmation
// because none of the contracted forms were covered, only "don't"/"do not".
// Deliberately does NOT include a bare "not": that's broad enough to false-
// negative ordinary assent ("go ahead, this is not risky") — the specific
// contracted-verb forms below are unambiguous negation on their own.
var negationWords = []string{
"no", "nope", "don't", "do not", "stop", "wait", "hold on", "hold off",
"not yet", "cancel", "nevermind", "never mind", "actually don't", "skip that",
"haven't", "hasn't", "isn't", "wasn't", "aren't", "can't", "cannot",
"won't", "wouldn't", "shouldn't", "didn't", "doesn't",
}
// assentWords, checked only if no negation matched.
@@ -66,19 +74,56 @@ var assentWords = []string{
"lgtm", "run it", "execute", "ok go", "okay go", "please do",
}
// wordTokenRe splits a message into lowercase word tokens. Apostrophes
// (straight ' and curly ) stay attached to their word so "don't"/"haven't"
// tokenize as one token, not two.
var wordTokenRe = regexp.MustCompile(`[a-z0-9']+`)
func tokenize(msg string) []string {
return wordTokenRe.FindAllString(strings.ToLower(strings.ReplaceAll(msg, "", "'")), -1)
}
// containsPhrase reports whether phrase (one or more words) appears as a
// consecutive run of WHOLE tokens in tokens — never a mid-word substring
// match. This is the fix for a real false positive found live: the old
// substring check (`strings.Contains(m, "yes")`) matched "yes" inside
// "yesterday", and "confirm" inside "confirmed"/"unconfirmed" without regard
// for word boundaries. Negation already used a word-boundary check
// (space-padded); assent/confirm words didn't — this brings both onto the
// same, more robust tokenized comparison instead of ad-hoc string padding.
func containsPhrase(tokens []string, phrase string) bool {
words := strings.Fields(phrase)
if len(words) == 0 || len(words) > len(tokens) {
return false
}
for i := 0; i+len(words) <= len(tokens); i++ {
match := true
for j, w := range words {
if tokens[i+j] != w {
match = false
break
}
}
if match {
return true
}
}
return false
}
// isAssent reports whether msg is a plain-language authorization of a
// pending proposal. Deliberately simple and auditable: a fixed word list,
// not a model judgment call, so behavior is predictable and can't be
// prompt-injected via the pending action's own content.
func isAssent(msg string) bool {
m := " " + strings.ToLower(strings.TrimSpace(msg)) + " "
tokens := tokenize(msg)
for _, w := range negationWords {
if strings.Contains(m, " "+w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+",") {
if containsPhrase(tokens, w) {
return false
}
}
for _, w := range assentWords {
if strings.Contains(m, w) {
if containsPhrase(tokens, w) {
return true
}
}
@@ -93,13 +138,13 @@ func isAssent(msg string) bool {
// ("I confirm destroy 135"). Still negation-aware for the same reason as
// isAssent: "don't confirm yet" must not accidentally match.
func isTypedConfirmation(msg string) bool {
m := " " + strings.ToLower(strings.TrimSpace(msg)) + " "
tokens := tokenize(msg)
for _, w := range negationWords {
if strings.Contains(m, " "+w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+",") {
if containsPhrase(tokens, w) {
return false
}
}
return strings.Contains(m, "confirm")
return containsPhrase(tokens, "confirm") || containsPhrase(tokens, "confirmed")
}
// approveExecution grants (or denies) a pending execution via the same HTTP
@@ -119,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

@@ -45,6 +45,43 @@ func TestIsAssent_NegationBeatsAssentWord(t *testing.T) {
}
}
// TestIsAssent_WholeWordBoundary regression-tests a real false positive found
// live: the old substring check matched "yes" inside "yesterday" (and would
// equally match "confirm" inside "confirmed"/"unconfirmed" for
// isTypedConfirmation below) because only negation used a word-boundary
// check — assent/confirm words used a bare strings.Contains. Confirmed via a
// throwaway probe before being fixed; kept here permanently so a future
// change can't silently reintroduce it.
func TestIsAssent_WholeWordBoundary(t *testing.T) {
cases := []string{
"not sure, maybe yesterday's logs show something useful",
"my eyesight isn't great, what does that say",
}
for _, c := range cases {
if isAssent(c) {
t.Errorf("isAssent(%q) = true, want false (word-boundary: 'yes' must not match inside 'yesterday'/'eyesight')", c)
}
}
}
// TestIsTypedConfirmation_ContractedNegation regression-tests the other real
// false positive: isTypedConfirmation gates DESTRUCTIVE actions, and
// "confirm" matching inside "confirmed" combined with contracted negatives
// ("haven't") not being in negationWords meant a message that explicitly
// says the operator has NOT confirmed something could read as confirming it.
func TestIsTypedConfirmation_ContractedNegation(t *testing.T) {
cases := []string{
"I haven't confirmed anything yet, let me think",
"that isn't confirmed on my end",
"we can't confirm that until tomorrow",
}
for _, c := range cases {
if isTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = true, want false (contracted negation should block)", c)
}
}
}
func TestIsTypedConfirmation(t *testing.T) {
positive := []string{
"I confirm destroy 135 in strong",

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
)
@@ -31,6 +32,71 @@ func extractExecutionIDs(toolResult string) []uuid.UUID {
return out
}
// idleTaskThreshold is how long a goal-bearing session can sit non-terminal
// with no activity before the idle sweep nudges it, per
// plans/2026-07-11-task-completion-safety-net.md. Arbitrary starting point,
// not measured against real task durations — long enough that it won't fire
// mid-turn, short enough the board doesn't lie for hours.
const idleTaskThreshold = 15 * time.Minute
// runIdleSweepWorker is the safety net for case 2 of
// plans/2026-07-11-task-completion-safety-net.md: sessions that called
// set_goal (so the inline safety net in agent.go correctly left them alone,
// since they framed themselves as a real task) but then stalled without
// ever calling complete_task. Coarser than runContinuationWorker's 4s tick
// since "gone idle" is a much slower signal than "an execution just
// finished." Blocks until ctx is cancelled.
func (a *agent) runIdleSweepWorker(ctx context.Context) {
if a.store == nil {
slog.Warn("nomos: idle sweep worker disabled (no store)")
return
}
slog.Info("nomos: idle sweep worker started")
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.processIdleSweep(ctx)
}
}
}
// processIdleSweep nudges a stalled goal-bearing session once; if it's still
// non-terminal on the NEXT sweep (meaning the nudge itself went unanswered,
// not just that the model is still working), auto-closes it with a
// visible "auto-closed" outcome instead of leaving it stuck forever — same
// reasoning resumeSession already applies below for a different failure
// mode (a resume that produces no response at all).
func (a *agent) processIdleSweep(ctx context.Context) {
stale := a.store.staleGoalSessions(ctx, idleTaskThreshold, 5)
for _, s := range stale {
s := s
if s.CompletionNudges == 0 {
safego.Go("nomos:idle-nudge:"+s.ID, func() {
if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil {
slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err)
return
}
note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+
"If the goal is done (or can't be completed), call complete_task now with the outcome and a "+
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
s.Goal, idleTaskThreshold)
a.resumeSession(ctx, s.ID, note)
})
continue
}
safego.Go("nomos:idle-autoclose:"+s.ID, func() {
summary := fmt.Sprintf("Auto-closed after %s idle with no response to a completion nudge.", idleTaskThreshold)
if err := a.store.completeTask(ctx, s.ID, "partial", summary); err != nil {
slog.Error("nomos: idle auto-close failed", "session", s.ID, "error", err)
}
})
}
}
// runContinuationWorker is the event loop that replaces the human typing
// "continue". It polls for gated executions that (a) were initiated by a chat
// session and (b) have just finished, and — while that agent has an open assent
@@ -55,20 +121,36 @@ func (a *agent) runContinuationWorker(ctx context.Context) {
}
}
// processContinuations dispatches each pending item as its OWN goroutine
// (safego.Go, so a panic deep in one task's resumed turn — JSON parsing of
// model output, an unexpected nil in a tool result — is recovered and logged
// instead of taking down this whole function, which used to run every
// item sequentially in the SAME goroutine as the ticker loop. Two problems
// that fixed: (1) throughput — task B's continuation no longer waits for
// task A's full (up to 10-minute) resumed turn to finish first, the exact
// per-task blocking this session's earlier concurrency work removed from the
// live-chat path but had left in place here; (2) survivability — since Go
// panics unwind the goroutine they occur in, an unrecovered one here used to
// mean this call (and every future tick, since the whole ticker loop runs in
// one goroutine) would simply stop — auto-continuation for every task would
// silently die until nomos restarted. Now a single bad item can only ever
// take down its own goroutine.
func (a *agent) processContinuations(ctx context.Context) {
pending := a.store.pendingContinuations(ctx, 5)
windowOpen := a.store.assentWindowActive(ctx, a.agentID)
for _, p := range pending {
// Scope gate: only auto-continue while an approved plan is active.
// A finished one-off execution with no window is left as-is (marked
// continued so we don't re-check it forever) — the operator decides
// what happens next, as today.
if !windowOpen {
// Scope gate: only auto-continue while an approved plan is active FOR
// THIS SESSION. Checked per-item, not once for the whole batch — with
// multiple tasks in flight, one task's open window must never cover a
// pending continuation belonging to a different task.
if !a.store.assentWindowActive(ctx, a.agentID, p.SessionID) {
// A finished one-off execution with no window is left as-is
// (marked continued so we don't re-check it forever) — the
// operator decides what happens next, as today.
a.store.markContinued(ctx, p.ExecID)
continue
}
a.store.markContinued(ctx, p.ExecID) // stamp first: a failure here must not cause a re-continue loop
a.continueSession(ctx, p)
safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) })
}
}
@@ -82,17 +164,24 @@ func (a *agent) processContinuations(ctx context.Context) {
// complaint this exists to fix — polling alone only helps if there's
// something new to poll for.
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
note := buildContinuationNote(p)
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
a.resumeSession(ctx, p.SessionID, buildContinuationNote(p))
}
// resumeSession re-invokes the agent for a session with a system-injected note —
// a finished execution (continueSession) or an operator's answer to a question
// (handleAnswerQuestion) — persisting progress LIVE (a placeholder row updated
// in place as each tool call lands) so the frontend poller sees each step,
// instead of total silence until the whole resume concludes.
func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
placeholder, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": "",
"auto": true,
})
msgID, err := a.store.insertMessageReturningID(ctx, p.SessionID, "assistant", placeholder)
msgID, err := a.store.insertMessageReturningID(ctx, sessionID, "assistant", placeholder)
if err != nil {
slog.Error("nomos: continuation placeholder insert failed", "session", p.SessionID, "error", err)
slog.Error("nomos: resume placeholder insert failed", "session", sessionID, "error", err)
}
var toolCalls []map[string]any
@@ -141,17 +230,32 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
errText, _ = ev.Data.(string)
}
}
a.chatWith(cctx, p.SessionID, "", note, emit)
a.chatWith(cctx, sessionID, "", note, emit)
if finalText != "" || len(toolCalls) > 0 {
break
}
if attempt == 0 {
slog.Warn("nomos: auto-continuation produced nothing, retrying once", "session", p.SessionID, "execution", p.ExecID, "error", errText)
slog.Warn("nomos: resume produced nothing, retrying once", "session", sessionID, "error", errText)
}
}
if errText != "" && finalText == "" {
slog.Error("nomos: auto-continuation produced no response after retry", "session", p.SessionID, "execution", p.ExecID, "error", errText)
slog.Error("nomos: resume produced no response after retry", "session", sessionID, "error", errText)
// Give the task a real, operator-visible terminal state instead of
// leaving it silently stuck at whatever status it was in (typically
// 'executing' or 'awaiting_input') forever. Before this, a
// permanently-failed resume was invisible beyond a log line — the
// task board just showed a task that never changed, with nothing
// telling the operator it needed attention. Marking it failed here
// doesn't prevent the operator from continuing to work the task via
// a fresh chat message afterward; it just stops the silent hang.
summary := fmt.Sprintf("Auto-resume failed after retrying: %s", errText)
if len(summary) > 200 {
summary = summary[:200] + "…"
}
if cerr := a.store.completeTask(context.Background(), sessionID, "failure", summary); cerr != nil {
slog.Error("nomos: failed to mark task failed after resume gave up", "session", sessionID, "error", cerr)
}
}
persist() // final state — same row, updated one last time with the concluding text
}

View File

@@ -15,6 +15,9 @@ import (
"sync"
"syscall"
"time"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
)
func main() {
@@ -26,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 == "" {
@@ -42,10 +49,19 @@ func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
client, err := newMCPClient(mcpURL)
if err != nil {
// 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, 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, mcpToken); err != nil {
slog.Error("nomos: mcp connect", "url", mcpURL, "error", err)
os.Exit(1)
} else {
probe.close()
}
st, err := newStore(ctx, databaseURL)
@@ -57,7 +73,7 @@ func main() {
defer st.close()
}
nAgent, err := newAgent(ctx, client, st, agentSlug)
nAgent, err := newAgent(ctx, clientPool, st, agentSlug)
if err != nil {
slog.Error("nomos: agent init", "error", err)
os.Exit(1)
@@ -66,7 +82,25 @@ func main() {
// Event-driven auto-continuation: feed finished async executions back
// into the agent so an approved plan runs to completion (and recovers
// from failures) without the operator ticking it forward each step.
go nAgent.runContinuationWorker(ctx)
safego.Go("nomos:continuation-worker", func() { nAgent.runContinuationWorker(ctx) })
// Idle sweep for stalled goal-bearing tasks (fix 2+3 of
// plans/2026-07-11-task-completion-safety-net.md) — a coarser,
// slower-ticking counterpart to the continuation worker above.
safego.Go("nomos:idle-sweep-worker", func() { nAgent.runIdleSweepWorker(ctx) })
safego.Go("nomos:mcp-pool-sweeper", func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
clientPool.sweep()
}
}
})
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
@@ -74,7 +108,7 @@ func main() {
w.Write([]byte("ok"))
})
mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
handleQuery(w, r, client, agentSlug, mcpURL)
handleQuery(w, r, clientPool, agentSlug, mcpURL)
})
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
handleChat(w, r, nAgent, st)
@@ -83,7 +117,7 @@ func main() {
handleSessionsList(w, r, st)
})
mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) {
handleSessionDetail(w, r, st)
handleSessionDetail(w, r, st, nAgent)
})
addr := os.Getenv("NOMOS_LISTEN")
@@ -92,17 +126,17 @@ func main() {
}
srv := &http.Server{Addr: addr, Handler: mux}
go func() {
safego.Go("nomos:http-server", func() {
slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "")
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("nomos: serve", "error", err)
}
}()
})
<-ctx.Done()
slog.Info("nomos: shutting down")
srv.Shutdown(context.Background())
client.close()
clientPool.closeAll()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
@@ -149,9 +183,21 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
ctx := r.Context()
sessionID := req.SessionID
// pctx (persistence context) is deliberately context.Background(), not
// ctx/r.Context(), for every DB write in this handler — ctx cancels the
// instant the client disconnects (Stop button, tab close, network blip),
// and a write made with an already-cancelled context fails. Before this
// fix, the assistant message was only ever saved ONCE, at the very end,
// using ctx — so a disconnect mid-turn silently lost the ENTIRE turn's
// tool-call history from the persisted transcript, even though real work
// (executions launched, knowledge written) had already happened
// server-side. The agent's own work (a.chat below) still correctly stops
// when ctx cancels — this only changes what happens to persistence.
pctx := context.Background()
if sessionID == "" {
title := truncate(req.Message, 80)
sess, err := st.createSession(ctx, title)
sess, err := st.createSession(pctx, title)
if err != nil {
slog.Error("nomos: create session", "error", err)
sessionID = "ephemeral"
@@ -159,25 +205,55 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
sessionID = sess.ID
}
} else {
st.touchSession(ctx, sessionID)
st.touchSession(pctx, sessionID)
}
slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100))
userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message})
st.saveMessage(ctx, sessionID, "user", userMsg)
st.saveMessage(pctx, sessionID, "user", userMsg)
// If this task has a pending operator question, the incoming message IS the
// answer — close it so the panel clears. No separate resume needed: this
// chat turn is the resume, and the agent sees the question + answer in its
// replayed history.
if qid := st.openQuestionID(pctx, sessionID); qid != "" {
st.answerQuestion(pctx, sessionID, qid, req.Message)
}
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
toolCalls := []map[string]any{}
var finalText string
// Incremental persistence, mirroring resumeSession's existing
// placeholder+update pattern (continue.go): insert a placeholder now,
// update the SAME row after every tool call, so whatever happened before
// an abort is never lost — only what hadn't happened yet is.
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
msgID, err := st.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
if err != nil {
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
}
persist := func() {
if msgID == uuid.Nil {
return
}
body, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": finalText,
"tool_calls": toolCalls,
})
st.updateMessage(pctx, msgID, body)
}
a.chat(ctx, sessionID, req.Message, func(ev agentEvent) {
if ev.Type == "tool_use" || ev.Type == "tool_result" {
if m, ok := ev.Data.(map[string]any); ok {
m["type"] = ev.Type
toolCalls = append(toolCalls, m)
}
persist() // live: survives even if the client disconnects right after
}
if ev.Type == "text" {
finalText, _ = ev.Data.(string)
@@ -185,19 +261,14 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
sseEvent(w, flusher, ev)
})
assistantMsg, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": finalText,
"tool_calls": toolCalls,
})
st.saveMessage(ctx, sessionID, "assistant", assistantMsg)
persist() // final state — same row, updated one last time with the concluding text
// Generate a meaningful title from the assistant's first answer
// instead of reusing the raw user message for every session.
if finalText != "" && sessionID != "ephemeral" {
title := truncate(finalText, 80)
if title != "" {
st.updateSessionTitle(ctx, sessionID, title)
st.updateSessionTitle(pctx, sessionID, title)
}
}
}
@@ -222,18 +293,57 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
}
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) {
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) {
if st == nil {
http.Error(w, "not found", 404)
return
}
id := strings.TrimPrefix(r.URL.Path, "/sessions/")
rest := strings.TrimPrefix(r.URL.Path, "/sessions/")
parts := strings.Split(rest, "/")
id := parts[0]
if id == "" {
http.Error(w, "session id required", 400)
return
}
// POST /sessions/{id}/questions/{qid}/answer — the operator answers a
// pinned question from the context panel; resume the agent with the answer.
if len(parts) == 4 && parts[1] == "questions" && parts[3] == "answer" {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
handleAnswerQuestion(w, r, st, a, id, parts[2])
return
}
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
// the context panel when it first opens a task; live events carry deltas
// from there.
if len(parts) == 2 && r.Method == http.MethodGet {
switch parts[1] {
case "plan":
steps, err := st.getPlanSteps(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"steps": steps})
return
case "questions":
questions, err := st.getQuestions(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
return
}
}
switch r.Method {
case http.MethodDelete:
if err := st.deleteSession(r.Context(), id); err != nil {
@@ -256,7 +366,31 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) {
}
}
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {
// handleAnswerQuestion records the operator's answer to a pinned question and
// resumes the agent in the background with that answer injected. Returns 202 —
// the agent's response lands via the normal message-polling path, not this POST.
func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *agent, sessionID, questionID string) {
var req struct {
Answer string `json:"answer"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Answer) == "" {
http.Error(w, "answer is required", 400)
return
}
prompt, _, _ := st.getQuestion(r.Context(), questionID)
if err := st.answerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil {
http.Error(w, err.Error(), 500)
return
}
if a != nil {
note := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+
"Continue the task from here — do not re-ask.]", prompt, req.Answer)
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), sessionID, note) })
}
w.WriteHeader(202)
}
func handleQuery(w http.ResponseWriter, r *http.Request, pool *mcpClientPool, agentSlug, mcpURL string) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
@@ -272,6 +406,16 @@ func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agen
return
}
// The structured /query endpoint is stateless/session-less — "query" is a
// fixed pool key (not a real session id) so repeated calls reuse one
// dedicated connection instead of paying a fresh MCP handshake every time,
// while still never sharing a connection with an actual chat task.
client, err := pool.get("query")
if err != nil {
http.Error(w, "mcp unavailable: "+err.Error(), 502)
return
}
start := time.Now()
if req.Tool != "" {
@@ -342,15 +486,29 @@ 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
mu sync.Mutex // MCP is one stateful session; serialize concurrent calls
mu sync.Mutex // this client is one stateful MCP session; serialize ITS OWN calls
// toolsCache holds the last tools/list result. The tool list is static
// for the lifetime of one MCP connection — it only changes when the api
// process (re)registers tools, i.e. on a restart, which this client
// already detects and reacts to via reconnectLocked. Without this,
// buildTools (called at the start of EVERY chat turn, including every
// auto-continuation resume) paid a full tools/list round-trip every
// single time for a list that's almost always identical to the last one.
// Guarded separately from mu (not reused) so a cache check never
// contends with an in-flight doRequest call for a different method.
toolsMu sync.Mutex
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},
}
@@ -405,6 +563,12 @@ func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPC
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
func (c *mcpClient) reconnectLocked() error {
c.sessionID = ""
// A reconnect means the api process was restarted (or forgot us) — its
// tool registration may have changed, so the cached list is no longer
// trustworthy.
c.toolsMu.Lock()
c.toolsCache = nil
c.toolsMu.Unlock()
resp, err := c.send("initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
@@ -441,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 {
@@ -545,3 +712,107 @@ func (c *mcpClient) listTools() ([]string, error) {
func (c *mcpClient) close() {
}
// ─── Per-session MCP client pool ────────────────────────────────────────
//
// A single shared mcpClient serializes EVERY tool call across EVERY
// concurrently-running task through one mutex (see mcpClient.mu) — `run`
// executes its SSH command synchronously inside that lock and is capped at
// up to 10 minutes, so one task mid-`run` stalled every other task's tool
// calls, even trivial reads, behind it. The MCP *server* has no per-
// connection state to protect (newServer in internal/mcp/server.go returns
// one shared *mcp.Server instance whose tool handlers close only over the DB
// pool, which is already safe for concurrent use) — the mutex existed purely
// because the *client* reused one stateful transport session, not because
// the server needed it. Giving each task's own session its own client
// removes the cross-task serialization entirely: a task's own tool calls
// stay sequential (which they already are — the agent loop calls tools one
// 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
}
type pooledMCPClient struct {
client *mcpClient
lastUsed time.Time
}
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
// real MCP handshake) on first use. Session ids that don't identify a real
// persisted conversation ("" / "ephemeral", the no-DB-store path; "query",
// the structured /query endpoint) still get exactly one dedicated,
// reused client each via the same map — just keyed on a fixed string instead
// of a real session id — so that traffic doesn't pay a fresh handshake per
// request while still never sharing a connection with an actual task.
func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) {
key := sessionID
if key == "" {
key = "ephemeral"
}
p.mu.Lock()
if pc, ok := p.clients[key]; ok {
pc.lastUsed = time.Now()
p.mu.Unlock()
return pc.client, nil
}
p.mu.Unlock()
// 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, p.token)
if err != nil {
return nil, err
}
p.mu.Lock()
// Another goroutine may have created one for the same key while we were
// initializing (two of this session's tool calls racing on a cold
// start); keep whichever won, close out the loser's connection (a no-op
// today, but future-proof if mcpClient.close ever does real teardown).
if existing, ok := p.clients[key]; ok {
p.mu.Unlock()
c.close()
return existing.client, nil
}
p.clients[key] = &pooledMCPClient{client: c, lastUsed: time.Now()}
p.mu.Unlock()
return c, nil
}
// mcpClientIdleTimeout is how long an idle session's MCP client is kept
// before eviction — long enough to outlive a single slow `run` (capped at 10
// minutes server-side) plus normal think-time between a task's tool calls,
// short enough not to accumulate one abandoned connection per finished task
// forever.
const mcpClientIdleTimeout = 20 * time.Minute
// sweep evicts clients idle past mcpClientIdleTimeout. Call on a ticker.
func (p *mcpClientPool) sweep() {
cutoff := time.Now().Add(-mcpClientIdleTimeout)
p.mu.Lock()
defer p.mu.Unlock()
for key, pc := range p.clients {
if pc.lastUsed.Before(cutoff) {
pc.client.close()
delete(p.clients, key)
}
}
}
func (p *mcpClientPool) closeAll() {
p.mu.Lock()
defer p.mu.Unlock()
for key, pc := range p.clients {
pc.client.close()
delete(p.clients, key)
}
}

View File

@@ -4,8 +4,13 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -37,10 +42,18 @@ func (s *store) close() {
}
}
// session is a chat session elevated to a task: goal-structured work with a
// lifecycle status and an outcome (see migration 018 / the task-board plan).
// Outcome/Summary/EntityID are empty until set, hence omitempty.
type session struct {
ID string `json:"id"`
Title string `json:"title"`
Actor string `json:"actor"`
Goal string `json:"goal"`
Status string `json:"status"`
Outcome string `json:"outcome,omitempty"`
Summary string `json:"summary,omitempty"`
EntityID string `json:"entity_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
}
@@ -55,7 +68,7 @@ type message struct {
func (s *store) createSession(ctx context.Context, title string) (*session, error) {
if s == nil {
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos"}, nil
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos", Status: "active"}, nil
}
var id string
err := s.pool.QueryRow(ctx,
@@ -64,7 +77,38 @@ func (s *store) createSession(ctx context.Context, title string) (*session, erro
if err != nil {
return nil, err
}
return &session{ID: id, Title: title, Actor: "agent:nomos", CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
// Give the task its own entity so knowledge and involved-entity edges hang
// off the existing relationships graph. Best-effort: a failure here must not
// block the chat — the session is usable without a graph anchor.
entityID := s.createTaskEntity(ctx, id, title)
return &session{ID: id, Title: title, Actor: "agent:nomos", Status: "active",
EntityID: entityID, CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
}
// createTaskEntity creates (or reuses) the task:<session-id> entity that
// anchors this task's knowledge and involved-entity relationships, and records
// it on the session. Returns the entity id, or "" on failure — non-fatal, see
// caller. Requires the 'task' entity type (seeds/ontology.yaml).
func (s *store) createTaskEntity(ctx context.Context, sessionID, title string) string {
entityID, _ := uuid.NewV7()
slug := "task:" + sessionID
// name is UNIQUE(type,name) and chat titles collide ("hi" ×6), so key the
// name on the session id and keep the human title in attributes for display.
name := "task " + sessionID
attrs, _ := json.Marshal(map[string]any{"title": title})
if err := s.pool.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, attributes)
VALUES ($1, $2, 'task', $3, $4)
ON CONFLICT (slug) DO UPDATE SET updated_at = now()
RETURNING id`, entityID, slug, name, string(attrs)).Scan(&entityID); err != nil {
slog.Warn("nomos: could not create task entity", "session", sessionID, "error", err)
return ""
}
if _, err := s.pool.Exec(ctx,
`UPDATE agent_sessions SET entity_id = $1 WHERE id = $2`, entityID, sessionID); err != nil {
slog.Warn("nomos: could not link task entity", "session", sessionID, "error", err)
}
return entityID.String()
}
func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error {
@@ -151,7 +195,9 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
return nil, nil
}
rows, err := s.pool.Query(ctx,
`SELECT id, title, actor, created_at, last_active_at FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
`SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary,
COALESCE(entity_id::text, ''), created_at, last_active_at
FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
if err != nil {
return nil, err
}
@@ -160,7 +206,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
var out []session
for rows.Next() {
var sess session
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
return nil, err
}
out = append(out, sess)
@@ -168,6 +215,12 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
return out, rows.Err()
}
// getMessages returns a session's ENTIRE message history, unbounded — used
// for the UI's own transcript view (GET /sessions/{id}), where the operator
// should be able to see everything a task has done regardless of how long
// it's run. For LLM replay, see getRecentMessages: sending the operator's
// full transcript is fine; sending the model's full transcript on every
// single turn is not (see getRecentMessages's doc comment).
func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, error) {
if s == nil {
return nil, nil
@@ -191,16 +244,517 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
return out, rows.Err()
}
// getRecentMessages returns the most recent `limit` messages for sessionID,
// in chronological order, plus whether older messages exist beyond that
// window. Used specifically for LLM replay (chatWith): without a bound,
// every turn re-sent the ENTIRE session history into the model's context,
// unconditionally growing with every turn — a real, observed-in-production
// cost/latency/eventual-context-limit risk for exactly the long-running,
// heavily-autonomous tasks (many auto-continuation cycles) this system is
// built to run longest. Fetches limit+1 rows to detect "there's more"
// without a separate COUNT query.
func (s *store) getRecentMessages(ctx context.Context, sessionID string, limit int) (msgs []message, truncated bool, err error) {
if s == nil {
return nil, false, nil
}
rows, qerr := s.pool.Query(ctx,
`SELECT id, session_id, role, content, created_at FROM agent_messages
WHERE session_id=$1 ORDER BY created_at DESC LIMIT $2`,
sessionID, limit+1)
if qerr != nil {
return nil, false, qerr
}
defer rows.Close()
var out []message
for rows.Next() {
var m message
if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
return nil, false, err
}
out = append(out, m)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
truncated = len(out) > limit
if truncated {
out = out[:limit]
}
// Rows came back newest-first (for the LIMIT to bound the right end);
// reverse to chronological order for replay.
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, truncated, nil
}
func (s *store) deleteSession(ctx context.Context, id string) error {
if s == nil {
return nil
}
_, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id)
// Resolve the task entity so we can clean up its graph edges and events too
// — otherwise deleting a session orphans its task:<id> entity, its involves/
// documents relationships, and its task-scoped events.
var entID uuid.UUID
s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, id).Scan(&entID)
if _, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id); err != nil {
return err
}
// task.status / entity.touched / knowledge.recorded are all correlated by
// session id.
s.pool.Exec(ctx, `DELETE FROM events WHERE correlation_id = $1`, id)
if _, err := s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id); err != nil {
return err
}
if entID != uuid.Nil {
// relationships FK is ON DELETE RESTRICT, so drop the task's edges first.
s.pool.Exec(ctx, `DELETE FROM relationships WHERE source_id = $1 OR target_id = $1`, entID)
s.pool.Exec(ctx, `DELETE FROM entities WHERE id = $1`, entID)
}
return nil
}
// taskEntityPtr returns the task entity id for a session, or nil — used as the
// entity_id on task-scoped events so they anchor to the task in the graph.
func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID {
var id uuid.UUID
if err := s.pool.QueryRow(ctx,
`SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil {
return nil
}
return &id
}
// setGoal records the task's goal and moves it into planning. Emits goal.set.
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
if _, err := s.pool.Exec(ctx,
`UPDATE agent_sessions SET goal = $2, status = 'planning', last_active_at = now() WHERE id = $1`,
sessionID, goal); err != nil {
return err
}
_ = observability.Event(ctx, sqlcgen.New(s.pool), "goal.set", s.taskEntityPtr(ctx, sessionID),
"info", "nomos", sessionID, map[string]any{"goal": goal})
return nil
}
// planStepInput is one step as the agent proposes it.
type planStepInput struct {
Title string
Detail string
TargetSlug string
}
// proposePlan sets the task's plan and moves it into executing. Emits
// plan.proposed with the persisted steps (seq + id) so the panel can render
// and later address them by id.
//
// Two modes, chosen by whether any existing step has left 'pending':
// - Fresh/revise (no step started yet): full replace (delete + insert). This
// covers the first call, and a genuine re-plan before any work began.
// - Mid-flight (some step is running/done/failed/…): APPEND the new steps
// after the current max seq instead of wiping. The model is instructed to
// propose the whole plan in one call, but nothing stops it from calling
// propose_plan again per-step as it goes — a destructive replace in that
// case would erase every already-completed step, leaving the operator
// seeing only the most recent single step ("1/1") instead of real
// progress. Appending makes the panel's step history correct regardless
// of how the model chooses to call the tool.
func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planStepInput) ([]map[string]any, error) {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil, nil
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var startSeq int
var anyStarted bool
if err := tx.QueryRow(ctx, `
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status <> 'pending'), false)
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&startSeq, &anyStarted); err != nil {
return nil, err
}
if !anyStarted {
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1`, sessionID); err != nil {
return nil, err
}
startSeq = 0
}
out := make([]map[string]any, 0, len(steps))
for i, st := range steps {
var targetSlug *string
if st.TargetSlug != "" {
targetSlug = &st.TargetSlug
}
seq := startSeq + i + 1
var id uuid.UUID
if err := tx.QueryRow(ctx, `
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
sessionID, seq, st.Title, st.Detail, targetSlug).Scan(&id); err != nil {
return nil, err
}
out = append(out, map[string]any{
"id": id.String(), "seq": seq, "title": st.Title,
"detail": st.Detail, "target_slug": st.TargetSlug,
})
}
if _, err := tx.Exec(ctx,
`UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1`, sessionID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
// Event after commit so subscribers only ever see a persisted plan.
// appended=true tells the panel to add these steps to its existing list
// rather than replace it (mirrors the mid-flight append above).
_ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.proposed", s.taskEntityPtr(ctx, sessionID),
"info", "nomos", sessionID, map[string]any{"steps": out, "appended": anyStarted})
return out, nil
}
// updatePlanStep sets a step's status by seq, stamping started_at/finished_at
// and linking an execution if given. Emits plan.step.started (running) or
// plan.step.finished (terminal) so the panel advances live. The execution link
// is also what lets the api auto-close the step when the execution finishes
// (see closePlanStepForExecution).
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
stamp := ""
switch status {
case "running":
stamp = ", started_at = COALESCE(started_at, now())"
case "done", "failed", "skipped", "blocked":
stamp = ", finished_at = now()"
}
var execPtr *uuid.UUID
if id, err := uuid.Parse(execID); err == nil {
execPtr = &id
}
var stepID uuid.UUID
var targetSlug *string
// stamp is a fixed literal from the switch above — never user input.
if err := s.pool.QueryRow(ctx, `
UPDATE session_plan_steps
SET status = $3, execution_id = COALESCE($4, execution_id)`+stamp+`
WHERE session_id = $1 AND seq = $2
RETURNING id, target_slug`, sessionID, seq, status, execPtr).Scan(&stepID, &targetSlug); err != nil {
return err
}
// Anchor the event to the step's target entity when it has one, else the task.
entPtr := s.taskEntityPtr(ctx, sessionID)
if targetSlug != nil && *targetSlug != "" {
var tid uuid.UUID
if s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, *targetSlug).Scan(&tid) == nil {
entPtr = &tid
}
}
evType := "plan.step.finished"
if status == "running" {
evType = "plan.step.started"
}
data := map[string]any{"step_id": stepID.String(), "seq": seq, "status": status}
if execID != "" {
data["execution_id"] = execID
}
_ = observability.Event(ctx, sqlcgen.New(s.pool), evType, entPtr, "info", "nomos", sessionID, data)
return nil
}
// completeTask sets a task's terminal state, outcome, and one-line summary,
// mirrors the outcome onto the task entity's attributes (so the board/graph
// show it), and publishes task.status for the live context panel. outcome is
// success|failure|partial; status is derived (failure → failed, else done).
func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return nil
}
status := "done"
if outcome == "failure" {
status = "failed"
}
if _, err := s.pool.Exec(ctx, `
UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4, last_active_at = now()
WHERE id = $1`, sessionID, status, outcome, summary); err != nil {
return err
}
var entID uuid.UUID
s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&entID)
var entPtr *uuid.UUID
if entID != uuid.Nil {
attrs, _ := json.Marshal(map[string]any{"outcome": outcome, "status": status, "summary": summary})
s.pool.Exec(ctx, `UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() WHERE id = $1`,
entID, string(attrs))
entPtr = &entID
}
severity := "info"
if outcome == "failure" {
severity = "warning"
}
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
map[string]any{"status": status, "outcome": outcome, "summary": summary})
return nil
}
// staleGoalSession is a goal-bearing task that's gone idle without reaching
// a terminal state — the idle-sweep worker's work list (fix 2+3 of
// plans/2026-07-11-task-completion-safety-net.md).
type staleGoalSession struct {
ID string
Goal string
CompletionNudges int
}
// staleGoalSessions finds sessions that framed themselves as a real task
// (goal != '', so the inline safety net in agent.go intentionally left them
// alone) but have sat non-terminal past idleThreshold. completion_nudges
// tells the caller whether to nudge (0) or give up and auto-close (>=1) —
// see processIdleSweep in continue.go.
func (s *store) staleGoalSessions(ctx context.Context, idleThreshold time.Duration, limit int) []staleGoalSession {
if s == nil {
return nil
}
rows, err := s.pool.Query(ctx, `
SELECT id, goal, completion_nudges
FROM agent_sessions
WHERE goal <> ''
AND status IN ('active', 'planning', 'executing')
AND last_active_at < now() - ($1 * interval '1 second')
ORDER BY last_active_at
LIMIT $2`, idleThreshold.Seconds(), limit)
if err != nil {
return nil
}
defer rows.Close()
var out []staleGoalSession
for rows.Next() {
var s staleGoalSession
if err := rows.Scan(&s.ID, &s.Goal, &s.CompletionNudges); err == nil {
out = append(out, s)
}
}
return out
}
// bumpCompletionNudge records that the idle sweep nudged a stalled session,
// stamping last_active_at so it isn't picked up again until it's genuinely
// idle again (a fresh nudge shouldn't fire every tick while the model is
// mid-response to the previous one).
func (s *store) bumpCompletionNudge(ctx context.Context, sessionID string) error {
if s == nil {
return nil
}
_, err := s.pool.Exec(ctx, `
UPDATE agent_sessions SET completion_nudges = completion_nudges + 1, last_active_at = now()
WHERE id = $1`, sessionID)
return err
}
// planStep is a persisted plan step, as returned to the frontend for hydration
// (the panel otherwise only sees steps live via plan.proposed/plan.step.*).
type planStep struct {
ID string `json:"id"`
Seq int `json:"seq"`
Title string `json:"title"`
Detail string `json:"detail"`
Status string `json:"status"`
ExecutionID *string `json:"execution_id,omitempty"`
TargetSlug *string `json:"target_slug,omitempty"`
StartedAt *string `json:"started_at,omitempty"`
FinishedAt *string `json:"finished_at,omitempty"`
}
// getPlanSteps returns a task's plan in order — REST hydration for the context
// panel when it first opens a task (live events only carry deltas from then on).
func (s *store) getPlanSteps(ctx context.Context, sessionID string) ([]planStep, error) {
if s == nil {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT id::text, seq, title, detail, status,
execution_id::text, target_slug,
started_at::text, finished_at::text
FROM session_plan_steps WHERE session_id = $1 ORDER BY seq`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []planStep
for rows.Next() {
var st planStep
var execID, target, started, finished *string
if err := rows.Scan(&st.ID, &st.Seq, &st.Title, &st.Detail, &st.Status,
&execID, &target, &started, &finished); err != nil {
return nil, err
}
st.ExecutionID, st.TargetSlug, st.StartedAt, st.FinishedAt = execID, target, started, finished
out = append(out, st)
}
return out, rows.Err()
}
// sessionQuestion is a persisted question, as returned to the frontend.
type sessionQuestion struct {
ID string `json:"id"`
Prompt string `json:"prompt"`
Context map[string]any `json:"context"`
Status string `json:"status"`
Answer *string `json:"answer,omitempty"`
CreatedAt string `json:"created_at"`
AnsweredAt *string `json:"answered_at,omitempty"`
}
// getQuestions returns a task's questions (open and answered) newest-first —
// REST hydration for the context panel's pinned question card and history.
func (s *store) getQuestions(ctx context.Context, sessionID string) ([]sessionQuestion, error) {
if s == nil {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT id::text, prompt, context, status, answer, created_at::text, answered_at::text
FROM session_questions WHERE session_id = $1 ORDER BY created_at DESC`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []sessionQuestion
for rows.Next() {
var q sessionQuestion
var ctxJSON []byte
var answer, answeredAt *string
if err := rows.Scan(&q.ID, &q.Prompt, &ctxJSON, &q.Status, &answer, &q.CreatedAt, &answeredAt); err != nil {
return nil, err
}
json.Unmarshal(ctxJSON, &q.Context)
q.Answer, q.AnsweredAt = answer, answeredAt
out = append(out, q)
}
return out, rows.Err()
}
// askOperator records a structured decision the agent needs from the operator,
// moves the task to awaiting_input, and emits question.raised so the context
// panel pins it. qctx carries {why, options, entities}. Returns the question id.
func (s *store) askOperator(ctx context.Context, sessionID, prompt string, qctx map[string]any) (string, error) {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return "", nil
}
ctxJSON, _ := json.Marshal(qctx)
var qid uuid.UUID
if err := s.pool.QueryRow(ctx, `
INSERT INTO session_questions (session_id, prompt, context) VALUES ($1, $2, $3) RETURNING id`,
sessionID, prompt, string(ctxJSON)).Scan(&qid); err != nil {
return "", err
}
s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now() WHERE id = $1`, sessionID)
data := map[string]any{"question_id": qid.String(), "prompt": prompt}
for k, v := range qctx {
data[k] = v
}
_ = observability.Event(ctx, sqlcgen.New(s.pool), "question.raised", s.taskEntityPtr(ctx, sessionID),
"warning", "nomos", sessionID, data)
return qid.String(), nil
}
// openQuestionID returns the id of the session's open question, or "". Used to
// auto-close a pending question when the operator answers via a plain chat reply.
func (s *store) openQuestionID(ctx context.Context, sessionID string) string {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return ""
}
var qid string
s.pool.QueryRow(ctx, `SELECT id::text FROM session_questions
WHERE session_id = $1 AND status = 'open' ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&qid)
return qid
}
// getQuestion returns a question's prompt, answer, and session — used to build
// the resume note when the operator answers via the panel.
func (s *store) getQuestion(ctx context.Context, questionID string) (prompt, answer, sessionID string) {
if s == nil || questionID == "" {
return "", "", ""
}
qid, err := uuid.Parse(questionID)
if err != nil {
return "", "", ""
}
s.pool.QueryRow(ctx, `SELECT prompt, COALESCE(answer, ''), session_id::text
FROM session_questions WHERE id = $1`, qid).Scan(&prompt, &answer, &sessionID)
return
}
// answerQuestion records the operator's answer, returns the task to executing,
// and emits question.answered. It does NOT itself resume the agent — the caller
// decides: a chat reply IS the resuming turn, while a panel answer triggers a
// continuation.
func (s *store) answerQuestion(ctx context.Context, sessionID, questionID, answer string) error {
if s == nil || sessionID == "" || sessionID == "ephemeral" || questionID == "" {
return nil
}
qid, err := uuid.Parse(questionID)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id)
return err
if _, err := s.pool.Exec(ctx, `
UPDATE session_questions SET status = 'answered', answer = $2, answered_at = now()
WHERE id = $1 AND status = 'open'`, qid, answer); err != nil {
return err
}
s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1`, sessionID)
_ = observability.Event(ctx, sqlcgen.New(s.pool), "question.answered", s.taskEntityPtr(ctx, sessionID),
"info", "nomos", sessionID, map[string]any{"question_id": questionID, "answer": answer})
return nil
}
// knowledgeSlugRe matches a nomos knowledge doc slug (<kind>:nomos/<title>) as
// printed in upsert_knowledge's result text.
var knowledgeSlugRe = regexp.MustCompile(`[a-z]+:nomos/[a-z0-9-]+`)
// linkKnowledgeToTask runs after a successful upsert_knowledge call within a
// task: it links the created knowledge doc to the task entity (documents) so
// get_relations(task) surfaces what the task learned, and publishes
// knowledge.recorded for the live panel. Best-effort. The doc is ALSO linked to
// the entity it's "about" by upsert_knowledge itself — that about-link is the
// retrieval path future tasks use (get_entity_knowledge); this task-link is for
// the task's own outcome/knowledge view.
func (s *store) linkKnowledgeToTask(ctx context.Context, sessionID, resultText string) {
if s == nil || sessionID == "" || sessionID == "ephemeral" {
return
}
slug := knowledgeSlugRe.FindString(resultText)
if slug == "" {
return
}
var taskID, docID uuid.UUID
if err := s.pool.QueryRow(ctx,
`SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&taskID); err != nil || taskID == uuid.Nil {
return
}
if err := s.pool.QueryRow(ctx,
`SELECT id FROM entities WHERE slug = $1`, slug).Scan(&docID); err != nil {
return
}
s.pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`,
docID, taskID)
_ = observability.Event(ctx, sqlcgen.New(s.pool), "knowledge.recorded", &docID, "info", "nomos", sessionID,
map[string]any{"slug": slug})
}
func (s *store) updateSessionTitle(ctx context.Context, id, title string) error {
@@ -237,6 +791,106 @@ func (s *store) linkExecution(ctx context.Context, execID uuid.UUID, sessionID s
VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, execID, sessionID)
}
// taskSlugRe matches an entity slug: a lowercase type prefix then colon-
// separated segments (host:hubris, lxc:caddy, check:ping:8cf). Mirrors the
// frontend SessionGraph regex so the panel and the involves-graph agree on
// what counts as an entity reference.
var taskSlugRe = regexp.MustCompile(`[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*`)
// touchExcludedTypes are entity types too noisy to record as task involvement:
// a health question names dozens of check:… slugs, executions/tasks are
// bookkeeping, not things the task "worked on".
var touchExcludedTypes = map[string]bool{"check": true, "execution": true, "task": true}
// recordTouched links the task to every entity referenced in a tool call's
// args (task —involves→ entity) and publishes one entity.touched event per
// entity so the live context panel can pulse it. Best-effort: it never blocks
// or fails the tool call. Only args are inspected — what the agent chose to act
// on — never results, since a single bulk query result would otherwise pull the
// whole fleet into the task's graph.
func (s *store) recordTouched(ctx context.Context, sessionID, toolName string, args map[string]any) {
if s == nil || sessionID == "" || sessionID == "ephemeral" || len(args) == 0 {
return
}
slugs := map[string]struct{}{}
collectTaskSlugs(args, slugs)
if len(slugs) == 0 {
return
}
var taskEntityID uuid.UUID
if err := s.pool.QueryRow(ctx,
`SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&taskEntityID); err != nil || taskEntityID == uuid.Nil {
return // no task entity to anchor edges on
}
// One batched lookup instead of a SELECT per slug — a tool call naming
// several entities (e.g. a multi-target comparison) used to issue N
// round-trips here for N slugs found in its args.
slugList := make([]string, 0, len(slugs))
for slug := range slugs {
slugList = append(slugList, slug)
}
rows, err := s.pool.Query(ctx,
`SELECT id, type, slug FROM entities WHERE slug = ANY($1)`, slugList)
if err != nil {
return
}
type found struct {
id uuid.UUID
etype string
}
matched := make(map[string]found, len(slugList))
for rows.Next() {
var f found
var slug string
if rows.Scan(&f.id, &f.etype, &slug) == nil {
matched[slug] = f
}
}
rows.Close()
if err := rows.Err(); err != nil {
return
}
q := sqlcgen.New(s.pool)
for slug, f := range matched {
if touchExcludedTypes[f.etype] || f.id == taskEntityID {
continue
}
// Idempotent involves edge (task → entity), same guard as upsert_knowledge.
s.pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, 'involves', '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = 'involves' AND valid_to IS NULL)`,
taskEntityID, f.id)
// Live pulse for the panel. correlation_id = sessionID lets the frontend
// filter to the active task.
_ = observability.Event(ctx, q, "entity.touched", &f.id, "info", "nomos", sessionID,
map[string]any{"slug": slug, "tool": toolName})
}
}
// collectTaskSlugs recursively pulls entity slugs out of tool-call args,
// mirroring the frontend's collectSlugs so both sides see the same references.
func collectTaskSlugs(v any, out map[string]struct{}) {
switch t := v.(type) {
case string:
for _, m := range taskSlugRe.FindAllString(t, -1) {
out[strings.TrimRight(m, ".,;)]")] = struct{}{}
}
case []any:
for _, e := range t {
collectTaskSlugs(e, out)
}
case map[string]any:
for _, e := range t {
collectTaskSlugs(e, out)
}
}
}
// pendingContinuation is one finished execution whose result hasn't yet been
// fed back to its originating session.
type pendingContinuation struct {
@@ -286,59 +940,74 @@ func (s *store) markContinued(ctx context.Context, execID uuid.UUID) {
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now() WHERE execution_id = $1`, execID)
}
// assentWindowActive reports whether this agent currently has an open assent
// window — the scope gate for auto-continuation. We only auto-continue
// executions that are part of an approved plan, never stray one-off actions.
func (s *store) assentWindowActive(ctx context.Context, agentID uuid.UUID) bool {
if s == nil || agentID == uuid.Nil {
return false
// assentWindowActive reports whether THIS TASK currently has an open assent
// window — the scope gate for auto-continuation. Scoped by session, not just
// agent: with a single agent:nomos entity serving every concurrent task, an
// agent-only key would let approving Task A's plan silently auto-run
// unapproved config-mutation actions in a concurrently-running Task B. We
// only auto-continue executions that are part of THIS session's approved
// plan, never a stray action from another task riding the same window.
func (s *store) assentWindowActive(ctx context.Context, agentID uuid.UUID, sessionID string) bool {
if s == nil || agentID == uuid.Nil || sessionID == "" {
return false // fail closed: no session to scope to means no window
}
var expires time.Time
key := "assent_window.agent:" + agentID.String()
key := assentWindowKey(agentID, sessionID)
if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, key).Scan(&expires); err != nil {
return false
}
return time.Now().Before(expires)
}
// assentWindowKey scopes the grant to one agent AND one session/task — see
// assentWindowActive. Must match internal/mcp/server.go's copy (mirrored
// there, not shared, since the two are separate Go packages/binaries reading
// the same autonomy_settings row).
func assentWindowKey(agentID uuid.UUID, sessionID string) string {
return "assent_window.agent:" + agentID.String() + ".session:" + sessionID
}
// destructiveWindowDuration is intentionally shorter than the general assent
// window (30 min): it's a narrow, scoped grant for a multi-step DESTRUCTIVE
// recovery (e.g. "stop then destroy this specific half-provisioned
// container"), not a standing license to destroy things.
const destructiveWindowDuration = 15 * time.Minute
// destructiveWindowKey scopes the grant to one agent AND one target entity
// an explicit typed confirmation ("I confirm") for a destructive action on
// target X must never be read as authorizing a destructive action on target Y.
func destructiveWindowKey(agentID uuid.UUID, targetSlug string) string {
return "destructive_window.agent:" + agentID.String() + ".target:" + targetSlug
// destructiveWindowKey scopes the grant to one agent, one target entity, AND
// one session/task — an explicit typed confirmation ("I confirm") for a
// destructive action on target X in task A must never be read as authorizing
// a destructive action on target X from a DIFFERENT concurrently-running
// task B, even though both share the same agent identity.
func destructiveWindowKey(agentID uuid.UUID, targetSlug, sessionID string) string {
return "destructive_window.agent:" + agentID.String() + ".target:" + targetSlug + ".session:" + sessionID
}
// openDestructiveWindow records a short, target-scoped grant after an
// operator's EXPLICIT typed confirmation (never loose assent) authorized a
// destructive action. Real case this exists for: recovering a failed destroy
// took "stop" (destructive) then "destroy" (destructive) — same container,
// two separate typed-confirmation round trips, because each was gated
// independently. One explicit confirmation on a target should cover the
// short follow-up sequence needed to finish what was just confirmed.
func (s *store) openDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug string) {
if s == nil || agentID == uuid.Nil || targetSlug == "" {
// openDestructiveWindow records a short, target-and-session-scoped grant
// after an operator's EXPLICIT typed confirmation (never loose assent)
// authorized a destructive action. Real case this exists for: recovering a
// failed destroy took "stop" (destructive) then "destroy" (destructive) —
// same container, two separate typed-confirmation round trips, because each
// was gated independently. One explicit confirmation on a target should
// cover the short follow-up sequence needed to finish what was just
// confirmed — but only within the task that got the confirmation.
func (s *store) openDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) {
if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
return
}
expires := time.Now().Add(destructiveWindowDuration).UTC().Format(time.RFC3339)
s.pool.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2`, destructiveWindowKey(agentID, targetSlug), expires)
ON CONFLICT (key) DO UPDATE SET value = $2`, destructiveWindowKey(agentID, targetSlug, sessionID), expires)
}
// destructiveWindowActive reports whether target has a live, explicitly-
// confirmed destructive grant for this agent.
func (s *store) destructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug string) bool {
if s == nil || agentID == uuid.Nil || targetSlug == "" {
// confirmed destructive grant for this agent within this session/task.
func (s *store) destructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) bool {
if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
return false
}
var expires time.Time
if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`,
destructiveWindowKey(agentID, targetSlug)).Scan(&expires); err != nil {
destructiveWindowKey(agentID, targetSlug, sessionID)).Scan(&expires); err != nil {
return false
}
return time.Now().Before(expires)
@@ -358,18 +1027,59 @@ func (s *store) executionTarget(ctx context.Context, execID uuid.UUID) string {
return slug
}
// entityArgKeys lists tool-argument keys, in priority order, that commonly
// carry the target entity's slug or UUID. Tool input schemas aren't
// consistent about naming this (target, entity_slug, slug, service_slug,
// lxc_slug, entity_id all appear across the MCP tool registrations in
// internal/mcp/server.go), so this is a best-effort lookup used to tag
// agent_activity rows with the entity a tool call acted on.
var entityArgKeys = []string{
"target", "entity_slug", "slug", "slug_or_id",
"service_slug", "lxc_slug", "entity_id", "about",
}
// resolveArgEntityID best-effort resolves the entity a tool call acted on
// from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no
// key is present or none resolves to a known entity.
func (s *store) resolveArgEntityID(ctx context.Context, args map[string]any) uuid.UUID {
if s == nil {
return uuid.Nil
}
for _, key := range entityArgKeys {
v, _ := args[key].(string)
if v == "" {
continue
}
if u, err := uuid.Parse(v); err == nil {
return u
}
var id uuid.UUID
if err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil {
return id
}
}
return uuid.Nil
}
// logActivity records a tool call. agent_id is the agent entity UUID and is
// NOT NULL in the schema, so we skip logging when it can't be resolved.
// The (nullable) session_id column carries the conversation id.
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
// The (nullable) session_id column carries the conversation id. args is the
// tool call's own arguments, used to best-effort tag the row with the
// entity it acted on (see resolveArgEntityID).
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
if s == nil || agentID == uuid.Nil {
return
}
entityID := s.resolveArgEntityID(ctx, args)
var entityIDArg any
if entityID != uuid.Nil {
entityIDArg = entityID
}
s.pool.Exec(ctx, `
INSERT INTO agent_activity
(agent_id, session_id, activity_type, tool_name, input_summary, output_summary,
(agent_id, session_id, activity_type, tool_name, entity_id, input_summary, output_summary,
duration_ms, success, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
agentID, sessionID, "tool_call", toolName, inputSummary, outputSummary,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
durationMs, success, correlationID)
}

238
cmd/nomos/store_test.go Normal file
View File

@@ -0,0 +1,238 @@
package main
// Integration tests against a real Postgres, mirroring
// internal/db/integration_test.go's pattern: guarded by
// OIKOS_TEST_DATABASE_URL (skipped when unset), throwaway database per run,
// full migrations applied, dropped on cleanup. Run with:
//
// docker compose up -d postgres
// OIKOS_TEST_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" go test ./cmd/nomos/
import (
"context"
"fmt"
"math/rand"
"os"
"strings"
"testing"
"github.com/dtoro/oikos/internal/db"
"github.com/jackc/pgx/v5"
)
// newTestStore creates a throwaway, fully-migrated database and returns a
// *store connected to it, cleaned up (including a matching task:<session>
// entity type in the ontology, needed by createTaskEntity/proposePlan tests)
// via t.Cleanup.
func newTestStore(t *testing.T) *store {
t.Helper()
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
if baseURL == "" {
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
}
ctx := context.Background()
admin, err := pgx.Connect(ctx, baseURL)
if err != nil {
t.Fatalf("connect admin: %v", err)
}
dbName := fmt.Sprintf("oikos_test_nomos_%08x", rand.Int63())
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
admin.Close(ctx)
t.Fatalf("create test db: %v", err)
}
admin.Close(ctx)
testURL := swapTestDatabase(baseURL, dbName)
pool, err := db.New(ctx, testURL)
if err != nil {
t.Fatalf("connect test db: %v", err)
}
t.Cleanup(func() {
pool.Close()
admin, err := pgx.Connect(ctx, baseURL)
if err == nil {
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
admin.Close(ctx)
}
})
if err := pool.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
// session_plan_steps/session_questions tests don't need the ontology
// seed, but createTaskEntity's INSERT INTO entities (type='task') has an
// FK to entity_types — seed the minimal rows it needs directly rather
// than pulling in the full seeds/ontology.yaml ingest path.
if _, err := pool.Exec(ctx, `
INSERT INTO entity_types (name, domain, layer) VALUES ('entity', 'meta', 'meta')
ON CONFLICT DO NOTHING;
INSERT INTO entity_types (name, parent_type, domain, layer) VALUES ('task', 'entity', 'cognition', 'cognition')
ON CONFLICT DO NOTHING;`); err != nil {
t.Fatalf("seed minimal ontology: %v", err)
}
return &store{pool: pool.Pool}
}
func swapTestDatabase(url, dbName string) string {
qi := strings.Index(url, "?")
params, base := "", url
if qi >= 0 {
params = url[qi:]
base = url[:qi]
}
si := strings.LastIndex(base, "/")
return base[:si+1] + dbName + params
}
// TestGetRecentMessages_Truncation is the concrete proof for fix A2 of
// plans/2026-07-11-nomos-agent-code-review.md: chatWith used to replay a
// session's ENTIRE history on every turn with no bound. getRecentMessages
// caps that; this test checks both sides — under the limit, nothing is
// dropped and truncated=false; over it, only the most recent `limit` come
// back, in chronological order, with truncated=true.
func TestGetRecentMessages_Truncation(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "history window test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
const total = 35
const limit = 30
for i := 0; i < total; i++ {
role := "user"
if i%2 == 1 {
role = "assistant"
}
body := fmt.Appendf(nil, `{"role":%q,"text":"msg-%d"}`, role, i)
if err := s.saveMessage(ctx, sess.ID, role, body); err != nil {
t.Fatalf("saveMessage %d: %v", i, err)
}
}
msgs, truncated, err := s.getRecentMessages(ctx, sess.ID, limit)
if err != nil {
t.Fatalf("getRecentMessages: %v", err)
}
if !truncated {
t.Errorf("truncated = false, want true (%d messages > limit %d)", total, limit)
}
if len(msgs) != limit {
t.Fatalf("got %d messages, want %d", len(msgs), limit)
}
// Chronological order: the oldest of the RETAINED messages should be the
// (total-limit)-th one saved (msg-5, since msg-0..4 were dropped), and
// the last should be the most recently saved (msg-34).
wantFirst := fmt.Sprintf("msg-%d", total-limit)
wantLast := fmt.Sprintf("msg-%d", total-1)
if got := extractText(msgs[0].Content); got != wantFirst {
t.Errorf("first retained message = %q, want %q", got, wantFirst)
}
if got := extractText(msgs[len(msgs)-1].Content); got != wantLast {
t.Errorf("last retained message = %q, want %q", got, wantLast)
}
// Under the limit: nothing dropped.
sess2, err := s.createSession(ctx, "small session")
if err != nil {
t.Fatalf("createSession: %v", err)
}
for i := 0; i < 5; i++ {
body := fmt.Appendf(nil, `{"role":"user","text":"msg-%d"}`, i)
if err := s.saveMessage(ctx, sess2.ID, "user", body); err != nil {
t.Fatalf("saveMessage: %v", err)
}
}
msgs2, truncated2, err := s.getRecentMessages(ctx, sess2.ID, limit)
if err != nil {
t.Fatalf("getRecentMessages (small): %v", err)
}
if truncated2 {
t.Errorf("truncated = true for a 5-message session under a %d limit, want false", limit)
}
if len(msgs2) != 5 {
t.Errorf("got %d messages, want 5", len(msgs2))
}
}
// TestProposePlan_AppendVsReplace is the concrete proof for the plan-append
// fix (commit 5384499, "plan panel showed only the latest step, not the full
// plan"): proposePlan must REPLACE the step list only while every existing
// step is still 'pending' (a genuine pre-execution revision), and APPEND
// once any step has started — otherwise a model that calls propose_plan once
// per step (rather than once with the full list, as instructed) erases every
// already-completed step each time, and the operator only ever sees the
// latest single step instead of real progress.
func TestProposePlan_AppendVsReplace(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
sess, err := s.createSession(ctx, "plan append test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
// First call: no steps exist yet — must persist as-is (replace mode,
// trivially: nothing to replace).
out1, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step A"}})
if err != nil {
t.Fatalf("proposePlan #1: %v", err)
}
if len(out1) != 1 || out1[0]["seq"] != 1 {
t.Fatalf("proposePlan #1 = %+v, want one step at seq 1", out1)
}
// Mark step 1 as started.
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
t.Fatalf("updatePlanStep: %v", err)
}
// Second call, simulating a model that (against instructions) calls
// propose_plan again per-step instead of once with the full list: since
// step 1 has left 'pending', this MUST append, not replace.
out2, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step B"}})
if err != nil {
t.Fatalf("proposePlan #2: %v", err)
}
if len(out2) != 1 || out2[0]["seq"] != 2 {
t.Fatalf("proposePlan #2 = %+v, want one step at seq 2 (appended after the running step 1)", out2)
}
steps, err := s.getPlanSteps(ctx, sess.ID)
if err != nil {
t.Fatalf("getPlanSteps: %v", err)
}
if len(steps) != 2 {
t.Fatalf("got %d persisted steps, want 2 (step 1 must survive the second propose_plan call)", len(steps))
}
if steps[0].Title != "Step A" || steps[0].Status != "running" {
t.Errorf("step 1 = %+v, want Step A still running (not erased)", steps[0])
}
if steps[1].Title != "Step B" || steps[1].Status != "pending" {
t.Errorf("step 2 = %+v, want Step B pending", steps[1])
}
// Third call BEFORE anything runs on a fresh session: every step is
// still pending, so this must REPLACE, not append.
sess2, err := s.createSession(ctx, "plan replace test")
if err != nil {
t.Fatalf("createSession: %v", err)
}
if _, err := s.proposePlan(ctx, sess2.ID, []planStepInput{{Title: "Original"}}); err != nil {
t.Fatalf("proposePlan (initial): %v", err)
}
if _, err := s.proposePlan(ctx, sess2.ID, []planStepInput{{Title: "Revised"}}); err != nil {
t.Fatalf("proposePlan (revise before execution): %v", err)
}
revisedSteps, err := s.getPlanSteps(ctx, sess2.ID)
if err != nil {
t.Fatalf("getPlanSteps: %v", err)
}
if len(revisedSteps) != 1 || revisedSteps[0].Title != "Revised" {
t.Fatalf("got %+v, want a single 'Revised' step (pre-execution revise must replace, not append)", revisedSteps)
}
}

293
cmd/nomos/tasks.go Normal file
View File

@@ -0,0 +1,293 @@
package main
import (
"context"
"fmt"
"log/slog"
"strings"
)
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
// shared MCP server (api:8090/mcp) has no session id — so these are handled
// in-process by nomos, which knows the session/task and holds the store.
// buildTools appends these to the model's tool list; the agent loop routes a
// call whose name isTaskTool to handleTaskTool instead of the MCP client.
//
// Phase 3 ships complete_task; set_goal / propose_plan / update_plan_step /
// ask_operator land in later phases through the same mechanism.
func taskToolDefs() []toolDef {
return []toolDef{
{
Name: "set_goal",
Description: "State the goal of this task in one sentence, as early as you " +
"can. This is what the task is trying to achieve (e.g. 'Deploy TypeType " +
"as an LXC on strong'); it heads the task on the board and the context " +
"panel. Call it once you understand what the operator wants.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"goal": map[string]any{"type": "string", "description": "The task's goal, one sentence."},
},
"required": []string{"goal"},
},
},
{
Name: "propose_plan",
Description: "Lay out ALL the ordered steps you'll take to reach the goal, in ONE " +
"call, listing every step end-to-end — not just the next one. The operator " +
"sees the full list in the context panel and watches it progress; a plan " +
"with only 1 step looks broken to them even if you intend to add more later. " +
"Your FIRST step should be research (prior knowledge, relations, blast radius " +
"— not just this target's status) and your LAST step should be writing back " +
"what you learned (update_entity_attributes / create_relationship / " +
"upsert_knowledge) BEFORE complete_task — this is what keeps the knowledge " +
"graph from drifting out of date. " +
"Call this ONCE, before you start executing (after gathering what you need). " +
"As you work, call update_plan_step (not propose_plan again) to advance each " +
"step. Only re-call propose_plan if the plan itself has fundamentally changed " +
"(e.g. a new approach is needed) — in that case new steps are appended after " +
"whatever already ran, never erasing completed work.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"steps": map[string]any{
"type": "array",
"description": "Ordered steps, first to last.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"title": map[string]any{"type": "string", "description": "Short imperative step title (e.g. 'Create the LXC')."},
"detail": map[string]any{"type": "string", "description": "Optional one-line detail."},
"target_slug": map[string]any{"type": "string", "description": "Optional entity slug this step acts on (e.g. lxc:typetype)."},
},
"required": []string{"title"},
},
},
},
"required": []string{"steps"},
},
},
{
Name: "update_plan_step",
Description: "Advance a plan step as you work it. Set status to 'running' when " +
"you start it (pass execution_id if the step queued a gated action, so " +
"the board can auto-close it when that finishes), then 'done' / 'failed' " +
"/ 'skipped' / 'blocked' when it resolves. Keeps the operator's progress " +
"view honest.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"seq": map[string]any{"type": "integer", "description": "1-based step number from propose_plan."},
"status": map[string]any{"type": "string", "enum": []string{"running", "done", "failed", "skipped", "blocked"}, "description": "New status for the step."},
"execution_id": map[string]any{"type": "string", "description": "Optional execution UUID this step is running, so it auto-closes on completion."},
},
"required": []string{"seq", "status"},
},
},
{
Name: "ask_operator",
Description: "Ask the operator a question when you hit a real decision only " +
"they can make — an ambiguous target, a trade-off, missing information, " +
"or a destructive choice not already approved. This pins a structured " +
"question card in the context panel (with your options and the entities " +
"involved) and PAUSES the task until they answer; their answer resumes " +
"you automatically. Do NOT use it for things you can determine yourself " +
"with tools — only for genuine decisions.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"prompt": map[string]any{"type": "string", "description": "The question, stated plainly."},
"why": map[string]any{"type": "string", "description": "Why you're asking / what's at stake."},
"options": map[string]any{
"type": "array", "items": map[string]any{"type": "string"},
"description": "The choices, if it's a pick-one decision.",
},
"context_entities": map[string]any{
"type": "array", "items": map[string]any{"type": "string"},
"description": "Entity slugs relevant to the decision (shown as chips).",
},
},
"required": []string{"prompt"},
},
},
{
Name: "complete_task",
Description: "Mark the current task finished. Call this once the goal is " +
"verified done — or when you've genuinely failed or only partially " +
"succeeded. Sets the task's outcome and a one-line summary shown on the " +
"task board. Record what you learned with upsert_knowledge BEFORE " +
"completing, so future tasks on the same entities benefit.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"outcome": map[string]any{
"type": "string",
"enum": []string{"success", "failure", "partial"},
"description": "Did the task achieve its goal?",
},
"summary": map[string]any{
"type": "string",
"description": "One line describing the result (shown on the task card).",
},
},
"required": []string{"outcome", "summary"},
},
},
}
}
// toInt coerces a JSON tool-arg number (float64 after unmarshal) to int.
func toInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
default:
return 0
}
}
// toStringSlice coerces a JSON tool-arg array to a non-empty []string.
func toStringSlice(v any) []string {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
if s, ok := e.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, s)
}
}
return out
}
// handleTaskTool executes a nomos-local task tool. Returns (result, true) if it
// handled the call, or (nil, false) if name is not a local task tool (so the
// caller forwards it to the MCP client).
func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args map[string]any) (any, bool) {
switch name {
case "set_goal":
goal, _ := args["goal"].(string)
if strings.TrimSpace(goal) == "" {
return "error: set_goal needs a goal", true
}
if err := a.store.setGoal(ctx, sessionID, goal); err != nil {
return fmt.Sprintf("error setting goal: %v", err), true
}
return "Goal set: " + goal, true
case "propose_plan":
raw, _ := args["steps"].([]any)
var steps []planStepInput
for _, r := range raw {
m, ok := r.(map[string]any)
if !ok {
continue
}
title, _ := m["title"].(string)
if strings.TrimSpace(title) == "" {
continue
}
detail, _ := m["detail"].(string)
target, _ := m["target_slug"].(string)
steps = append(steps, planStepInput{Title: title, Detail: detail, TargetSlug: target})
}
if len(steps) == 0 {
return "error: propose_plan needs at least one step with a title", true
}
persisted, err := a.store.proposePlan(ctx, sessionID, steps)
if err != nil {
return fmt.Sprintf("error proposing plan: %v", err), true
}
return fmt.Sprintf("Plan set: %d step(s). Execute them now, marking each with update_plan_step as you go.", len(persisted)), true
case "update_plan_step":
seq := toInt(args["seq"])
status, _ := args["status"].(string)
execID, _ := args["execution_id"].(string)
if seq <= 0 || status == "" {
return "error: update_plan_step needs seq (>=1) and status", true
}
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID); err != nil {
return fmt.Sprintf("error updating step %d: %v", seq, err), true
}
return fmt.Sprintf("Step %d → %s", seq, status), true
case "ask_operator":
prompt, _ := args["prompt"].(string)
if strings.TrimSpace(prompt) == "" {
return "error: ask_operator needs a prompt", true
}
qctx := map[string]any{}
if why, _ := args["why"].(string); strings.TrimSpace(why) != "" {
qctx["why"] = why
}
if opts := toStringSlice(args["options"]); len(opts) > 0 {
qctx["options"] = opts
}
if ents := toStringSlice(args["context_entities"]); len(ents) > 0 {
qctx["entities"] = ents
}
if _, err := a.store.askOperator(ctx, sessionID, prompt, qctx); err != nil {
return fmt.Sprintf("error posting question: %v", err), true
}
return "Question posted to the operator; the task is paused until they answer. " +
"Do not continue or call more tools — end your turn now and wait for their answer.", true
case "complete_task":
outcome, _ := args["outcome"].(string)
summary, _ := args["summary"].(string)
switch outcome {
case "":
outcome = "success" // no outcome given at all — assume success, the common case
case "success", "failure", "partial":
// valid, use as-is
default:
// The tool schema declares an enum, but a weaker model (or a
// typo) can still send anything — an unrecognized value used to
// persist as-is, silently, with only "failure" special-cased
// (store.completeTask derives status='failed' from it; anything
// else became status='done' regardless of what the value
// actually said). Default to "partial" rather than silently
// treating an unrecognized value as "success" — safer to
// under-claim than over-claim a task's outcome.
slog.Warn("nomos: complete_task got an unrecognized outcome, defaulting to partial",
"session", sessionID, "outcome", outcome)
outcome = "partial"
}
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
return fmt.Sprintf("error completing task: %v", err), true
}
return fmt.Sprintf("Task marked %s: %s", outcome, summary), true
default:
return nil, false
}
}
// autoCompleteTrivialTask is the case-1 fix from
// plans/2026-07-11-task-completion-safety-net.md: a session that never
// called set_goal never framed itself as a structured task, so a turn that
// ends with a plain-text answer and no further tool calls IS the task
// ending — but the model consistently skips complete_task for exactly this
// case (confirmed live: 43/50 production sessions were a single trivial
// Q&A exchange, none of which ever reached a terminal status). Rather than
// leave agent_sessions.status stuck at its creation-time default forever,
// close it out mechanically here: no judgment call needed, since SOUL.md
// already treats a one-shot answered question as done by definition.
func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, responseText string) {
summary := strings.TrimSpace(responseText)
summary = strings.SplitN(summary, "\n", 2)[0] // first line only — the board shows one line
const maxLen = 120
if len(summary) > maxLen {
summary = summary[:maxLen] + "…"
}
if summary == "" {
summary = "Answered without further action needed."
}
if err := a.store.completeTask(ctx, sessionID, "success", summary); err != nil {
slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", 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,12 @@ 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"
)
// serveRecentKnowledge backs the Knowledge page's "what the system knows / has
@@ -106,12 +109,58 @@ 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)
rows, err := s.pool.Query(ctx, `
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags,
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags,
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
ts_headline('english', ke.content, plainto_tsquery('english', $1),
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
@@ -131,12 +180,13 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
items := []gen.KnowledgeHit{}
for rows.Next() {
var id uuid.UUID
var slug, eType, title, source string
var tags []string
var rank float32
var snippet *string
if err := rows.Scan(&slug, &eType, &title, &source, &tags, &rank, &snippet); err != nil {
if err := rows.Scan(&id, &slug, &eType, &title, &source, &tags, &rank, &snippet); err != nil {
return nil, err
}
@@ -149,6 +199,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
}
items = append(items, gen.KnowledgeHit{
Id: id,
Slug: slug,
Title: title,
Type: hitType,
@@ -172,7 +223,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
entitySlug := request.EntityId
rows, err := s.pool.Query(ctx, `
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
JOIN entity_types et ON et.name = e.type
@@ -182,7 +233,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
AND r.valid_to IS NULL
AND r.type IN ('documents', 'about')
UNION
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
JOIN entity_types et ON et.name = e.type
@@ -191,7 +242,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
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`,
ORDER BY 2`,
entitySlug)
if err != nil {
return nil, err
@@ -200,10 +251,11 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
items := []gen.KnowledgeHit{}
for rows.Next() {
var id uuid.UUID
var slug, eType, title, source string
var tags []string
if err := rows.Scan(&slug, &eType, &title, &source, &tags); err != nil {
if err := rows.Scan(&id, &slug, &eType, &title, &source, &tags); err != nil {
return nil, err
}
@@ -216,6 +268,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
}
items = append(items, gen.KnowledgeHit{
Id: id,
Slug: slug,
Title: title,
Type: hitType,

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

@@ -18,6 +18,7 @@ import (
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
@@ -117,6 +118,13 @@ func sshExec(ctx context.Context, host, user, command string) (string, error) {
}
done := make(chan result, 1)
go func() {
// See internal/mcp/server.go's sshExec for why this recovers rather
// than letting a rare SSH-library panic crash the whole api process.
defer func() {
if r := recover(); r != nil {
done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)}
}
}()
out, err := session.CombinedOutput(command)
done <- result{out, err}
}()
@@ -231,6 +239,32 @@ func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, st
severity = "warning"
}
_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail)
if status == "completed" || status == "failed" || status == "cancelled" {
closePlanStepForExecution(ctx, pool, execID, status)
}
}
// closePlanStepForExecution auto-closes a task plan step whose linked execution
// just reached a terminal state, so the task board advances even if the agent
// doesn't call update_plan_step itself (belt and suspenders — the agent links
// the step to the execution when it starts it; the api finishes it here). Emits
// plan.step.finished correlated to the step's session. No-op for the vast
// majority of executions, which aren't plan steps.
func closePlanStepForExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, execStatus string) {
stepStatus := "done"
if execStatus == "failed" || execStatus == "cancelled" {
stepStatus = "failed"
}
var stepID, sessionID string
var seq int
if err := pool.QueryRow(ctx, `
UPDATE session_plan_steps SET status = $2, finished_at = now()
WHERE execution_id = $1 AND status NOT IN ('done', 'failed', 'skipped')
RETURNING id::text, session_id::text, seq`, execID, stepStatus).Scan(&stepID, &sessionID, &seq); err != nil {
return // no matching open step
}
_ = observability.Event(ctx, sqlcgen.New(pool), "plan.step.finished", &execID, "info", "actuator", sessionID,
map[string]any{"step_id": stepID, "seq": seq, "status": stepStatus, "execution_id": execID.String()})
}
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
@@ -1427,7 +1461,9 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
// Resolve target entity slug from targetID.
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
go executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
safego.Go("httpapi:executeApprovedAction", func() {
executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
})
// Status only — risk_class was set correctly at request time
// (e.g. by policy.ClassifyCommand for `run`); overwriting it to
// a hardcoded 'config_mutation' here corrupted the audit ledger

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"
@@ -26,8 +27,10 @@ import (
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/httpapi/gen"
mcphandler "github.com/dtoro/oikos/internal/mcp"
"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"
)
@@ -69,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,
@@ -78,12 +81,21 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
}
// Start background SSE listener, tied to ctx for clean shutdown.
go s.sseListener(ctx)
// handleNotification (called per-message inside sseListener's loop) has
// its own recover for the common case; this outer one covers the
// connection-setup/reconnect code around it.
safego.Go("httpapi:sse-listener", func() { s.sseListener(ctx) })
r := chi.NewRouter()
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) {
@@ -116,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())
@@ -126,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())
},
@@ -137,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
@@ -166,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 != ""
@@ -213,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")
@@ -271,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
}
}
@@ -295,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 {
@@ -520,17 +541,143 @@ 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,
}
errCh := make(chan error, 1)
go func() {
// Recovers a panic in ListenAndServe (stdlib, so extremely unlikely,
// but an unrecovered panic here would crash the whole process rather
// than surfacing as a normal startup error) and reports it through
// errCh instead — the select below would otherwise just hang waiting
// for a value that never arrives.
defer func() {
if r := recover(); r != nil {
errCh <- fmt.Errorf("panic in ListenAndServe: %v", r)
}
}()
slog.Info("api listening", "addr", cfg.APIListen)
errCh <- srv.ListenAndServe()
}()

View File

@@ -146,41 +146,58 @@ func (s *Server) sseListener(ctx context.Context) {
continue
}
var p notifyPayload
if err := json.Unmarshal([]byte(nt.Payload), &p); err != nil {
slog.Error("sse listener unmarshal failed", "error", err)
continue
}
// Fetch full event from DB
q := sqlcgen.New(s.pool)
events, err := q.ListEventsAfter(ctx, sqlcgen.ListEventsAfterParams{
ID: p.ID - 1,
Limit: 1,
})
if err != nil || len(events) == 0 {
slog.Warn("sse listener event fetch failed", "id", p.ID, "error", err)
continue
}
ev := events[0]
// Push to broker
s.sseBroker.push(ev)
// Fan out to subscribers (non-blocking send)
s.sseMu.Lock()
for sub := range s.sseSubs {
select {
case sub.ch <- ev:
default:
// Subscriber too slow — drop event for them
// (they'll reconnect via Last-Event-ID)
}
}
s.sseMu.Unlock()
s.handleNotification(ctx, nt.Payload)
}
}
// handleNotification processes one pg_notify payload: decode, fetch the full
// event, push to the broker, fan out to live subscribers. Split out of
// sseListener's loop specifically so it can be wrapped in its own recover —
// a panic while handling ONE notification (a malformed payload, an
// unexpected nil somewhere in the fan-out) must not kill the whole listener
// goroutine, which would silently stop the live event stream for every
// connected client until the api process is restarted.
func (s *Server) handleNotification(ctx context.Context, payload string) {
defer func() {
if r := recover(); r != nil {
slog.Error("sse listener: panic recovered handling notification", "panic", r)
}
}()
var p notifyPayload
if err := json.Unmarshal([]byte(payload), &p); err != nil {
slog.Error("sse listener unmarshal failed", "error", err)
return
}
// Fetch full event from DB
q := sqlcgen.New(s.pool)
events, err := q.ListEventsAfter(ctx, sqlcgen.ListEventsAfterParams{
ID: p.ID - 1,
Limit: 1,
})
if err != nil || len(events) == 0 {
slog.Warn("sse listener event fetch failed", "id", p.ID, "error", err)
return
}
ev := events[0]
// Push to broker
s.sseBroker.push(ev)
// Fan out to subscribers (non-blocking send)
s.sseMu.Lock()
for sub := range s.sseSubs {
select {
case sub.ch <- ev:
default:
// Subscriber too slow — drop event for them
// (they'll reconnect via Last-Event-ID)
}
}
s.sseMu.Unlock()
}
// sqlcEventToGen converts a DB event row to the canonical wire shape so the
// SSE `data:` payload matches GET /events (snake_case keys, decoded data
// object) rather than leaking Go field names and base64-encoded JSONB.
@@ -209,12 +226,22 @@ func sqlcEventToGen(ev sqlcgen.Event) gen.Event {
// writeSSE writes a single Event as an SSE message. Returns false if the
// write failed (client disconnected). flusher may be nil (io.Pipe path,
// which has no separate flush step).
//
// We deliberately DO NOT set the SSE `event:` name field, even though every
// event has a type. A named SSE event is only delivered to a matching
// addEventListener(type) handler, NOT to EventSource.onmessage — and the whole
// frontend (stores/events.ts and every page that reads liveEvents) consumes the
// stream via onmessage, reading the type from the JSON payload's `type` field.
// Emitting `event: <type>` silently routed every event away from onmessage, so
// the live stream delivered nothing to the UI. Leaving the name off sends all
// events to onmessage; the type is already in `data`, and new event types need
// zero client changes. `id:` is kept for Last-Event-ID reconnection.
func writeSSE(w ioWriter, flusher http.Flusher, ev sqlcgen.Event) bool {
data, err := json.Marshal(sqlcEventToGen(ev))
if err != nil {
return true // skip un-serializable events
}
_, err = fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, data)
_, err = fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.ID, data)
if err != nil {
return false
}

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

@@ -24,6 +24,7 @@ import (
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/policy"
"github.com/dtoro/oikos/internal/safego"
"github.com/google/jsonschema-go/jsonschema"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -90,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",
@@ -148,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),
@@ -164,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
@@ -192,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."},
@@ -208,12 +221,75 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return upsertKnowledge(ctx, pool, args)
})
register(&mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
InputSchema: objSchema(
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
attrsStr, _ := args["attributes"].(string)
if slug == "" || attrsStr == "" {
return textResult("error: slug and attributes are required"), nil
}
var attrs map[string]any
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
}
attrsJSON, _ := json.Marshal(attrs)
ct, err := pool.Exec(ctx, `
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
WHERE slug = $1`, slug, string(attrsJSON))
if err != nil {
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
}
if ct.RowsAffected() == 0 {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
})
register(&mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
InputSchema: objSchema(
prop{"source", "string", "Source entity slug."},
prop{"target", "string", "Target entity slug."},
prop{"type", "string", "Relationship type name (must already exist in the ontology)."},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
source, _ := args["source"].(string)
target, _ := args["target"].(string)
relType, _ := args["type"].(string)
if source == "" || target == "" || relType == "" {
return textResult("error: source, target, and type are required"), nil
}
var sourceID, targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
}
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
}
_, err := pool.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`,
sourceID, targetID, relType)
if err != nil {
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
}
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
})
register(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
}, 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,
@@ -222,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 ──────────────────────────────────────────
@@ -293,6 +369,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
targetSlug, _ := args["target"].(string)
action, _ := args["action"].(string)
params, _ := args["params"].(string)
sessionID, _ := args["_session_id"].(string)
if targetSlug == "" || action == "" {
return textResult("error: target and action required"), nil
}
@@ -325,7 +402,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
cmd = fmt.Sprintf("systemctl %s %s; sleep 1; systemctl is-active %s", params, svc, svc)
purpose = "systemctl " + params + " " + svc
}
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, cmd, purpose, ""), nil
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, cmd, purpose, "", sessionID), nil
}
// Deduplicate: if a pending execution already exists for the same
@@ -363,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
@@ -388,7 +482,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return textResult("apt audit:\n" + out), nil
}
// During an active assent window, auto-approve.
if assentWindowActive(ctx, pool, agentID) {
if assentWindowActive(ctx, pool, agentID, sessionID) {
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation")
// Do NOT pre-flip approvals/executions status here (that was
@@ -410,7 +504,9 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
// tool call, cancelled the instant the chat turn's HTTP
// response completes (every normal turn) — a goroutine
// meant to outlive the request must not inherit its context.
go executeApprovedViaAPI(context.Background(), id, targetSlug, "apt_upgrade:"+params)
safego.Go("mcp:executeApprovedViaAPI:apt_upgrade", func() {
executeApprovedViaAPI(context.Background(), id, targetSlug, "apt_upgrade:"+params)
})
slog.Info("mcp: apt_upgrade auto-approved via assent window", "execution_id", id)
return textResult(fmt.Sprintf("apt_upgrade on %s auto-approved via assent window — execution %s running.", targetSlug, id)), nil
}
@@ -422,13 +518,15 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
case "pct_create":
// During an active assent window, auto-approve and execute
// instead of queuing — the operator already approved the plan.
if assentWindowActive(ctx, pool, agentID) {
if assentWindowActive(ctx, pool, agentID, sessionID) {
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation")
// See the apt_upgrade case above for why there's no
// pre-flip-status "autoApprove" step here anymore, and why
// this uses context.Background().
go executeApprovedViaAPI(context.Background(), id, targetSlug, "pct_create:"+params)
safego.Go("mcp:executeApprovedViaAPI:pct_create", func() {
executeApprovedViaAPI(context.Background(), id, targetSlug, "pct_create:"+params)
})
slog.Info("mcp: pct_create auto-approved via assent window", "execution_id", id)
return textResult(fmt.Sprintf("pct_create on %s auto-approved via assent window — execution %s running. The LXC is being provisioned now.", targetSlug, id)), nil
}
@@ -454,6 +552,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
command, _ := args["command"].(string)
purpose, _ := args["purpose"].(string)
declaredRisk, _ := args["declared_risk"].(string)
sessionID, _ := args["_session_id"].(string)
if targetSlug == "" || command == "" {
return textResult("error: target and command are required"), nil
}
@@ -463,7 +562,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
}
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk), nil
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
})
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
@@ -550,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 ──────────────────────────────
@@ -565,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",
@@ -712,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,
@@ -722,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",
@@ -733,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,
@@ -741,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",
@@ -779,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
@@ -787,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
@@ -803,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",
@@ -869,12 +968,18 @@ func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next
correlationID := uuid.New().String()
entityID := resolveArgEntityID(ctx, pool, argsMap(req))
var entityIDArg any
if entityID != uuid.Nil {
entityIDArg = entityID
}
_, logErr := pool.Exec(ctx, `
INSERT INTO agent_activity
(agent_id, activity_type, tool_name, input_summary, output_summary,
(agent_id, activity_type, tool_name, entity_id, input_summary, output_summary,
duration_ms, success, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
agentID, "tool_call", toolName, inputSummary, outputSummary,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
agentID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
duration, success, correlationID)
if logErr != nil {
slog.Warn("mcp: log agent_activity", "error", logErr)
@@ -884,6 +989,37 @@ func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next
}
}
// entityArgKeys lists tool-argument keys, in priority order, that commonly
// carry the target entity's slug or UUID. Tool input schemas aren't
// consistent about naming this (target, entity_slug, slug, service_slug,
// lxc_slug, entity_id all appear across server.go's tool registrations), so
// this is a best-effort lookup used to tag agent_activity rows with the
// entity a tool call acted on.
var entityArgKeys = []string{
"target", "entity_slug", "slug", "slug_or_id",
"service_slug", "lxc_slug", "entity_id", "about",
}
// resolveArgEntityID best-effort resolves the entity a tool call acted on
// from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no
// key is present or none resolves to a known entity.
func resolveArgEntityID(ctx context.Context, pool *db.Pool, args map[string]any) uuid.UUID {
for _, key := range entityArgKeys {
v, _ := args[key].(string)
if v == "" {
continue
}
if u, err := uuid.Parse(v); err == nil {
return u
}
var id uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil {
return id
}
}
return uuid.Nil
}
// ─── Helpers ──────────────────────────────────────────────────────────
func argsMap(req *mcp.CallToolRequest) map[string]any {
@@ -992,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 (
@@ -1067,6 +1223,21 @@ func sshExec(ctx context.Context, host, user, command string) (string, error) {
}
done := make(chan result, 1)
go func() {
// Recovers a panic in CombinedOutput (SSH library internals, rare but
// not impossible) and reports it as a failed command instead of
// crashing the whole api process — every gated action runs through
// this function, so an unrecovered panic here would take down every
// concurrently-running task's execution, not just this one. Without
// this, a panic would ALSO silently degrade to "wait out the full
// timeout" (done never receives, the select below falls through to
// its time.After case) rather than crashing outright — recovering
// and sending an immediate result is strictly better: the caller
// finds out now, not after sshExecTimeout.
defer func() {
if r := recover(); r != nil {
done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)}
}
}()
out, err := session.CombinedOutput(command)
done <- result{out, err}
}()
@@ -1245,7 +1416,7 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
// fleet's reverse proxy) executed instantly with no approval at all. Routing
// every mutating path through the same classifier + approval-queue logic
// closes that gap without special-casing each caller.
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk string) *mcp.CallToolResult {
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk, sessionID string) *mcp.CallToolResult {
riskClass := policy.ClassifyCommand(command, declaredRisk)
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
actionCol := "run:" + string(runParams)
@@ -1275,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)
@@ -1297,7 +1485,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
// operator approved the overall direction; individual config steps
// within the window don't each need a separate yes. Destructive
// commands never auto-run, regardless of window.
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID) {
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
if rerr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
@@ -1319,7 +1507,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
// recovery (e.g. a failed destroy needing stop, then destroy) so the
// operator isn't asked to re-type "I confirm" for every single command
// against the thing they just confirmed.
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug) {
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug, sessionID) {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
if rerr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
@@ -1387,18 +1575,25 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac
}
// assentWindowActive checks whether the operator has recently approved a plan
// in this agent's chat session. The agent sets an assent_window.agent:<uuid>
// key in autonomy_settings with an expiry timestamp when chat-assent grants
// a pending execution. While active, config_mutation commands auto-run
// without re-approval — the operator approved the overall plan, not each step.
func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID) bool {
if agentID == uuid.Nil {
return false
// in THIS TASK's chat session. The agent sets an
// assent_window.agent:<uuid>.session:<id> key in autonomy_settings with an
// expiry timestamp when chat-assent grants a pending execution. While
// active, config_mutation commands auto-run without re-approval — the
// operator approved the overall plan, not each step. Scoped by session, not
// just agent: with one agent:nomos entity serving every concurrent task, an
// agent-only key would let approving Task A's plan silently auto-run
// unapproved actions from a concurrently-running Task B. sessionID comes
// from the `_session_id` nomos injects into every tool call's wire args
// (never part of any tool's declared InputSchema, so the model never
// supplies or sees it) — see cmd/nomos/agent.go's tool dispatch loop.
func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, sessionID string) bool {
if agentID == uuid.Nil || sessionID == "" {
return false // fail closed: no session to scope to means no window
}
var expiresStr string
err := pool.QueryRow(ctx,
"SELECT value FROM autonomy_settings WHERE key = $1",
"assent_window.agent:"+agentID.String()).Scan(&expiresStr)
"assent_window.agent:"+agentID.String()+".session:"+sessionID).Scan(&expiresStr)
if err != nil {
return false
}
@@ -1410,20 +1605,21 @@ func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID) b
}
// destructiveWindowActive reports whether targetSlug has a live, explicitly-
// confirmed destructive grant for this agent. Key format
// ("destructive_window.agent:<id>.target:<slug>") must match
// cmd/nomos/store.go's openDestructiveWindow — both processes read/write the
// same autonomy_settings row. Scoped to one target so a typed confirmation
// for destroying container A can never be read as authorizing anything
// against container B.
func destructiveWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, targetSlug string) bool {
if agentID == uuid.Nil || targetSlug == "" {
// confirmed destructive grant for this agent WITHIN THIS SESSION/TASK. Key
// format ("destructive_window.agent:<id>.target:<slug>.session:<id>") must
// match cmd/nomos/store.go's openDestructiveWindow — both processes
// read/write the same autonomy_settings row. Scoped to one target AND one
// session so a typed confirmation for destroying container A in task X can
// never be read as authorizing anything against container A from a
// different, concurrently-running task Y.
func destructiveWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, targetSlug, sessionID string) bool {
if agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
return false
}
var expiresStr string
err := pool.QueryRow(ctx,
"SELECT value FROM autonomy_settings WHERE key = $1",
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug).Scan(&expiresStr)
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug+".session:"+sessionID).Scan(&expiresStr)
if err != nil {
return false
}

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 —

35
internal/safego/safego.go Normal file
View File

@@ -0,0 +1,35 @@
// Package safego provides a goroutine launcher that recovers panics instead
// of letting them crash the whole process.
//
// Go's default behavior for a panic in ANY goroutine — not just the one
// serving an HTTP request, which net/http recovers automatically per
// request — is to take down the entire process. This codebase runs several
// long-lived or unattended background goroutines (the nomos auto-
// continuation worker, resumed chat turns, async execution dispatch, the SSE
// event listener) that do real work — JSON parsing of model/tool output,
// map/slice indexing — with no operator watching. Before this package, a
// single edge case in any of them (a malformed tool result, an unexpected
// nil) would crash nomos or the api process outright, taking down every
// concurrently-running task or request, not just the one that hit it.
package safego
import (
"log/slog"
"runtime/debug"
)
// Go runs fn in a new goroutine. A panic inside fn is recovered and logged
// (with a stack trace) instead of crashing the process. label identifies the
// goroutine in logs — use something a reader can trace back to the call
// site, e.g. "nomos:continuation-worker" or "mcp:executeApprovedViaAPI".
func Go(label string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("panic recovered in background goroutine",
"goroutine", label, "panic", r, "stack", string(debug.Stack()))
}
}()
fn()
}()
}

View File

@@ -0,0 +1,39 @@
package safego
import (
"sync"
"testing"
)
// TestGo_RecoversPanic is the concrete proof for the B1 fix in
// plans/2026-07-11-nomos-agent-code-review.md: a panic inside a goroutine
// launched via Go must not crash the process (or, here, the test binary —
// the same guarantee). Before this package existed, every background
// goroutine in cmd/nomos/internal/mcp/internal/httpapi used a bare `go`
// statement; an unhandled panic in any of them takes down the entire Go
// process, not just that goroutine.
func TestGo_RecoversPanic(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
Go("test:deliberate-panic", func() {
defer wg.Done()
panic("this must be recovered, not crash the test binary")
})
// If the panic weren't recovered, the whole test binary would crash
// before ever reaching this line (a Go panic in any goroutine terminates
// the process, full stop) — Wait() returning normally IS the proof.
wg.Wait()
}
// TestGo_RunsFnNormally confirms the non-panic path still just runs fn.
func TestGo_RunsFnNormally(t *testing.T) {
done := make(chan bool, 1)
Go("test:normal", func() {
done <- true
})
if !<-done {
t.Fatal("fn did not run")
}
}

View File

@@ -0,0 +1,53 @@
-- 018_tasks.up.sql
-- Elevate a chat session into a "task": a goal-structured unit of work with a
-- lifecycle status, an outcome, and a one-line summary — the first-class object
-- the task board and the live context panel render. See
-- plans/2026-07-11-goal-oriented-chat-control-panel.md.
--
-- entity_id links the session to its OWN entity (type 'task', registered in
-- seeds/ontology.yaml) so knowledge notes and involved-entity edges hang off
-- the existing relationships graph unchanged — get_relations and
-- get_entity_knowledge just work. Intentionally no hard FK (mirrors 017's
-- decoupling): a race between task-entity creation and the session insert must
-- not be able to break the session.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS goal TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS outcome TEXT;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS summary TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS entity_id UUID;
-- Ordered plan steps. A step is a described unit of work that maps to a
-- run/request_execution call (no fixed step enum, per general-gated-execution).
-- execution_id is the gated action a step runs, if any; its terminal status
-- auto-closes the step server-side.
CREATE TABLE IF NOT EXISTS session_plan_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
seq INT NOT NULL,
title TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
-- pending | running | done | failed | skipped | blocked
execution_id UUID,
target_slug TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_plan_steps_session ON session_plan_steps(session_id, seq);
-- Structured decisions the agent surfaces to the operator mid-task. context
-- carries { entities:[], options:[], why:"" } for the pinned question card.
CREATE TABLE IF NOT EXISTS session_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
prompt TEXT NOT NULL,
context JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'open', -- open | answered | dismissed
answer TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
answered_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_questions_session_open
ON session_questions(session_id) WHERE status = 'open';

View File

@@ -0,0 +1,10 @@
-- 019_task_completion_nudges.up.sql
-- See plans/2026-07-11-task-completion-safety-net.md (fix 2+3): a
-- goal-bearing session (set_goal was called, so it's a real structured
-- task, not the trivial-Q&A case handled by the inline safety net) can
-- still stall without ever calling complete_task. completion_nudges tracks
-- how many times the idle sweep has already nudged a stalled session, so it
-- can tell "never nudged" (nudge it) from "nudged once already, still
-- stuck" (auto-close it) rather than nudging forever.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS completion_nudges INT NOT NULL DEFAULT 0;

View File

@@ -46,6 +46,68 @@ classifier will catch a genuinely dangerous command regardless, but be honest
about risk in your `purpose` text; the operator is trusting your description
of what a command does.
## Every chat is a task
Each conversation is a **task**: a goal the operator wants achieved, from
"install service X" to "give me the key status of Y". Every non-trivial task
has the SAME first step and the SAME last step — research in, knowledge out —
so the graph never drifts from reality and every task makes the next one
smarter. Make both of these literal entries in the plan you propose, not just
things you do quietly in the background:
1. **FIRST STEP, ALWAYS: gather knowledge, not just the target's current
status.** Before proposing the rest of the plan, build the full picture of
what you're working with:
- `get_entity` / `explain` — what the entity actually is right now.
- `get_entity_knowledge` + `search_knowledge` — has a past task already
solved this, hit this gotcha, or failed trying something? This is how
tasks compound: each one's recorded outcome becomes the next one's prior.
Don't skip it and rediscover a known problem.
- `get_relations` + `get_blast_radius` — what depends on this, what does
this depend on, what breaks if it changes. Never plan a mutation blind to
its neighborhood.
- `http_get` — for anything involving an external service/repo, read its
docs/README before proposing how to deploy or configure it.
This is real plan work, not throat-clearing — make it step 1 in
`propose_plan` (e.g. "Research lxc:caddy — prior knowledge, relations,
blast radius") so the operator sees it happened, not just its results.
2. **Plan, then execute.** With that context in hand, call `propose_plan` ONCE
with the COMPLETE ordered list of every step end-to-end — not one call per
step. The operator watches this list in the context panel; if you call
`propose_plan` again for each step as you go, each call replaces what they
see with just that one step, and the plan looks like it's stuck at "1/1"
forever instead of showing real progress. Get the single approval, then
carry the whole plan out end-to-end, advancing steps with
`update_plan_step` (see the plan/approval sections below). If you hit a
genuine decision only the operator can make — an ambiguous target, a
trade-off, missing information — call `ask_operator` with the options and
the entities involved, then STOP and wait; their answer resumes you. Don't
ask about things you can settle yourself with tools.
3. **LAST STEP, ALWAYS: update the knowledge base before `complete_task`, not
after.** Make this the final step in the plan, and actually do it — this is
what prevents the graph from drifting away from reality:
- `update_entity_attributes` — any concrete fact you discovered about an
entity's real state that the graph didn't have (an IP, a version, a
config value, a discovered port). Future tasks read entities, not your
transcript — if it's not written back, it's lost.
- `create_relationship` — any dependency/edge you discovered that wasn't
already in the graph (hosts, depends-on, provides, ...).
- `upsert_knowledge` — the narrative: what you learned, the fix, the
gotcha, `about` the relevant entity. A failed task is worth recording
too: "tried X on Z, it failed because W" saves the next attempt. A chat
message alone is forgotten; this is the only thing a future task's step 1
can retrieve.
Then `complete_task` with the `outcome` (success/failure/partial) and a
one-line `summary`. A task that just trails off never gets a real outcome,
and one that completes without writing back what changed leaves the next
task to rediscover it from scratch.
A trivial read-only task ("what's the status of Y?") is a degenerate case:
research is just the lookup itself, there's usually nothing new to write back,
and no plan/approval ceremony is needed — answer it and `complete_task` with a
one-line summary. Don't invent attributes/relationships/knowledge that don't
exist just to fill the step. The loop scales down; it doesn't disappear.
## Key MCP tools
- `list_lxcs` — all LXC containers with host, IP, health (use for fleet-wide questions)
@@ -249,9 +311,9 @@ must state the result plainly: what's now true, what you verified, what (if
anything) failed or remains. Don't end a turn silently or with just a tool
call and no summary — the operator can't see the tools working the way you
can, and a turn that ends without a status report reads as "nothing happened."
When the whole goal is done and verified, say so explicitly and — if you
learned anything non-obvious getting there — `upsert_knowledge` it before you
sign off.
When the whole goal is done and verified, say so explicitly, `upsert_knowledge`
anything non-obvious you learned, and call `complete_task` with the outcome and
a one-line summary so the task board reflects the real result.
## Skills

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

@@ -1,6 +1,20 @@
# 2026-07-08 — Control room web UI
**Status:** In Progress — N0-N3 (Nomos amendment: chat home + sessions), M1
**Status:** In Progress (audited 2026-07-11 — still accurate; remaining gaps:
`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
page with ack/resolve/mute, live nav badges), and M3 (graph explorer with

View File

@@ -1,6 +1,11 @@
# 2026-07-08 — Liveness, drift, and UX cohesion
**Status:** In Progress — Phases 14 code complete; not yet deployed. Phase 5 deferred.
**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
@@ -24,20 +29,19 @@
- **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,6 +1,24 @@
# 2026-07-08 — Oikos gaps, broken things, and improvements
**Status:** Planned
**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
@@ -70,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.
---
@@ -93,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
@@ -106,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,
@@ -167,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

@@ -1,6 +1,13 @@
# 2026-07-10 — General gated execution: from fixed actions to unlimited-but-gated
**Status:** Planned
**Status:** In Progress — audited 2026-07-11. Done: `ClassifyCommand` risk
classifier, general `run` MCP tool, chat-assent approval (no button
required), blast radius on approval cards, session digest, global activity
feed (`Ops.svelte` "Executions" tab, risk-badged), Learning view
(success-rate trend). Still open: retire the fixed `request_execution`
action enum (`restart, systemctl, pct_exec, apt_upgrade, pct_create` still
hard-coded alongside `run`), and revive auto-act — `internal/actuator/actuator.go:125`
is still a literal `{"success": true, "message": "stub execution"}` stub.
## Goal

View File

@@ -0,0 +1,347 @@
# 2026-07-11 — Nomos agent code review: gaps and improvement plan
**Status:** In Progress — 2026-07-11. Every finding except C1 (A1-A3, B1-B3,
D1-D3, E, F1) is fixed, tested, and verified live against the running stack.
C1 (unauthenticated nomos gateway) is explicitly deferred per operator
instruction ("leave auth out for these round of fixes") — the one item
keeping this out of `done/`.
- A1 `3919ec3`, B1+B2 `c5ffaec`, A3 `926969a`, D1-D3 `76f7630`,
A2 `c390164`, B3 `6d4f6de`, F1 `11c18e8`.
- New `internal/safego` package (B1) and `cmd/nomos/store_test.go` (A2, plus
a regression test for the earlier plan-append fix) are the first automated
tests for any of this package's core logic — closing part of finding E,
though full coverage of agent.go/main.go remains future work.
- C1 remains open — nomos's gateway (port 8092) still has no authentication.
Revisit separately.
## Scope
A full read-through of `cmd/nomos/` (agent.go, store.go, main.go, continue.go,
assent.go, tasks.go — 3,120 lines) plus targeted checks of its HTTP exposure,
goroutine safety, and test coverage. Every finding below is grounded in a
specific file:line or a runnable reproduction — two of the sharper ones
(A1, A2) were empirically confirmed with throwaway test probes before being
written up, not just read and assumed.
This is a review, not an implementation — findings are ranked by severity with
a proposed fix per item; nothing here has been changed yet.
---
## A. Correctness bugs (confirmed, not theoretical)
### A1. Chat-assent word matching has real substring false positives
[assent.go:73-103](../cmd/nomos/assent.go). `isAssent`/`isTypedConfirmation`
pad the message with spaces and word-boundary-check the **negation** list
(`strings.Contains(m, " "+w+" ")`), but the **assent**/**confirm** checks use
bare `strings.Contains(m, w)` — no word boundary at all. Confirmed live via a
test probe:
- `isAssent("not sure, maybe yesterday's logs show something useful")`
**`true`** (`"yes"` matches inside `"yesterday"`; `"not"` alone isn't in
`negationWords`, only the phrase `"not yet"` is).
- `isTypedConfirmation("I haven't confirmed anything yet, let me think")`
**`true`** (`"confirm"` matches inside `"confirmed"`; `"haven't"` isn't in
`negationWords`, which only has `"don't"`/`"do not"`, not other contracted
negatives).
The second one is the serious half: `isTypedConfirmation` is the **sole gate
for DESTRUCTIVE actions** ([agent.go:220-223](../cmd/nomos/agent.go)) — a
message that merely *mentions* not having confirmed something yet can read as
an explicit confirmation.
**Fix:** apply the same space-padded word-boundary check to the assent/confirm
word lists that negation already uses. Expand `negationWords` to cover
contracted negatives (`haven't`, `hasn't`, `isn't`, `wasn't`, `can't`,
`won't`, `not` as a standalone word, not just `"not yet"`). Add both
reproduced cases as permanent regression tests in `assent_test.go`.
### A2. Unbounded conversation history replay — no windowing, no token budget
[agent.go:185-207](../cmd/nomos/agent.go): every single turn (`chatWith`)
calls `a.store.getMessages(ctx, sessionID)` — [store.go:218-239](../cmd/nomos/store.go),
`SELECT ... WHERE session_id=$1 ORDER BY created_at ASC` with **no `LIMIT`,
no windowing, no summarization** — and replays the *entire* history into the
LLM call every time. `truncateToolResults` ([store.go:152-185](../cmd/nomos/store.go))
caps each individual tool **result** at 4KB, but caps nothing else: not tool
**args**, not the number of tool calls in one message, not the total message
count, not total tokens.
This isn't theoretical — an earlier production audit (see
[chat-sessions-improvements](done/2026-07-09-chat-sessions-improvements.md))
found a single turn with **70 tool calls** and messages up to **106KB**. Every
subsequent turn of a long-running or heavily-autonomous task (exactly what
auto-continuation is built for) re-sends that ever-growing history in full.
This is a real cost, latency, and eventual context-length-limit risk that
compounds specifically for the tasks the system is designed to run longest.
**Fix:** at minimum, cap replayed history to the most recent N messages or a
token budget, with older turns either dropped or collapsed into a short
system-message summary (`finalSummary`'s existing one-shot summarization
pattern, [agent.go:481-492](../cmd/nomos/agent.go), could be reused for this).
Needs a decision on where the cutoff lives (see open questions).
### A3. A live turn's tool-call history is lost entirely if the client disconnects mid-stream
[main.go handleChat](../cmd/nomos/main.go): `toolCalls`/`finalText` accumulate
only in local closure variables; `st.saveMessage(...)` runs exactly **once**,
after `a.chat(...)` returns, using `ctx := r.Context()` — the *same* context
that cancels the instant the client disconnects (Stop button, tab close,
network blip). If `a.chat` returns early because that context was cancelled,
the final `saveMessage` call runs with an already-cancelled context and its
error return is never checked — the whole turn's tool-call history (already
real: executions launched, knowledge possibly written) is silently lost from
the persisted transcript.
Contrast with `resumeSession`/`continueSession` ([continue.go:96-166](../cmd/nomos/continue.go)),
which insert a placeholder row immediately and update it after every single
tool call — exactly the incremental-persistence pattern `handleChat` lacks.
Verified live this session: my own Stop-button test showed the turn's actual
tool calls (6 of them) *were* visible in the UI only because the SSE stream
had already pushed them to the browser's in-memory store before the abort —
none of that would have survived a page reload, since nothing was persisted.
**Fix:** bring `handleChat` in line with `resumeSession`'s pattern — insert a
placeholder row before the turn starts, update it after each tool call using
a context *not* tied to the client connection for the write itself (or at
minimum, persist with `context.Background()` in a deferred cleanup so a
cancelled request context doesn't take the DB write down with it).
---
## B. Robustness
### B1. Zero panic recovery on any background goroutine
Every explicitly-spawned goroutine across the agent surface has no
`recover()`:
```
cmd/nomos/main.go:78 go nAgent.runContinuationWorker(ctx)
cmd/nomos/main.go:80 go func() { ...sweep ticker... }()
cmd/nomos/main.go:117 go func() { ...http server... }()
cmd/nomos/main.go:347 go a.resumeSession(context.Background(), sessionID, note)
internal/mcp/server.go:477,495 go executeApprovedViaAPI(...)
internal/mcp/server.go:1134 go func() { ... }()
internal/httpapi/phase3.go:119,1456
internal/httpapi/server.go:81,533
```
`grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/` returns
nothing. Go's default behavior for a panic in *any* goroutine — not just the
one handling an HTTP request, which the stdlib does recover — is to crash the
**entire process**. `runContinuationWorker` and `resumeSession` in particular
run complex, unattended agent logic (JSON unmarshaling of model output, tool
result parsing, map/slice indexing) with no operator watching; a single edge
case (a malformed tool result, an unexpected nil) takes down nomos for
**every concurrently-running task**, not just the one that hit it. This is
more consequential post-concurrency (today's work): more simultaneous
unattended goroutines running agent code means more surface area for one bad
input to end everyone's session.
**Fix:** wrap every explicitly-spawned goroutine body in a `defer func() {
if r := recover(); r != nil { slog.Error(...) } }()`. A small helper
(`safeGo(func())`) would make this consistent and hard to forget at new call
sites.
### B2. Auto-continuation processes its batch sequentially, one full turn at a time
[continue.go:58-75](../cmd/nomos/continue.go): `processContinuations` fetches
up to 5 pending items and runs `a.continueSession(ctx, p)` for each **in a
plain `for` loop**, in the single `runContinuationWorker` goroutine. Each
`continueSession` is a full LLM turn that can run for minutes (10-minute
timeout, [continue.go:134](../cmd/nomos/continue.go)). If 3 different tasks'
executions finish in the same 4-second tick, task #3's continuation waits for
#1 and #2 to *completely finish* first — undercutting today's whole
concurrency effort specifically on the auto-continuation path, which is the
mechanism autonomous multi-step tasks depend on most.
**Fix:** spawn each pending continuation as its own goroutine (with B1's
panic recovery), bounded by a small semaphore if unbounded parallelism here
is a concern.
### B3. No terminal state for a permanently-failed auto-continuation
[continue.go:162-165](../cmd/nomos/continue.go): if the resumed LLM call
errors on both the initial attempt and its one retry, the code logs an error
and returns — the task is left in whatever status it was in (typically
`executing`), with no outcome set and no operator-visible signal beyond an
inert message buried in the transcript. There's no give-up-after-N-retries or
dead-letter marking; the task just looks silently stuck.
**Fix:** on final failure, call the same path `complete_task` would use to set
`outcome='failure'` with a summary explaining the resume failed, so the task
board reflects reality instead of showing a task that looks perpetually
"executing."
---
## C. Security
### C1. Nomos's own HTTP gateway has zero authentication
[docker-compose.yml:144](../docker-compose.yml) publishes port 8092 directly
(`"8092:8092"`, comment: *"mesh-published"*) and
[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.
**Fix:** put nomos's gateway behind the same auth the `api` process uses
(shared bearer token check at minimum), or stop publishing 8092 directly and
route all traffic through the already-authenticated `api` proxy exclusively.
---
## D. Code quality
### D1. Dead code: `isTaskTool` is defined, never called
[tasks.go:139-146](../cmd/nomos/tasks.go). The actual dispatch in
[agent.go:370](../cmd/nomos/agent.go) calls `a.handleTaskTool(...)` directly
and checks its `handled` return value — `isTaskTool` is unused.
**Fix:** delete it, or use it in `buildTools`/dispatch if a cheaper
pre-check is actually wanted.
### D2. N+1 query in `recordTouched`
[store.go:720-742](../cmd/nomos/store.go): loops over every slug found in a
tool call's args and issues a separate `SELECT id, type FROM entities WHERE
slug = $1` per slug. Fine for the common case (1-3 slugs) but doesn't batch
for tool calls naming many entities.
**Fix:** one `SELECT id, slug, type FROM entities WHERE slug = ANY($1)` for
all collected slugs, then loop over the results in memory.
### D3. `complete_task`'s outcome isn't validated
[tasks.go:248-257](../cmd/nomos/tasks.go) declares an `enum` in the tool
schema (`success|failure|partial`) but [store.go:428-457](../cmd/nomos/store.go)
never checks it — an out-of-enum value (a model typo, or a weaker model not
respecting the schema) silently persists as-is; only `"failure"` is
special-cased (else `status="done"`), so a stray value still "completes" the
task but with a value the frontend's status/outcome rendering doesn't
recognize.
**Fix:** validate against the three allowed values in `handleTaskTool` before
calling `store.completeTask`, defaulting unrecognized values to `"partial"`
(safer than silently treating them as `"success"`).
---
## E. Test coverage
**Zero automated tests exist for `agent.go`, `store.go`, `main.go`, or
`tasks.go`.** Only `assent.go`'s and `continue.go`'s pure string-parsing
helpers have unit tests (`assent_test.go`, `continue_test.go`) — confirmed by
`grep -l "func Test" cmd/nomos/*.go` matching only those two files. This means
today's session added substantial new, safety-critical logic — session-scoped
assent/destructive windows, the `mcpClientPool`'s creation-race handling and
eviction sweep, `proposePlan`'s replace-vs-append branching — verified only by
live manual testing (curl + browser), with **no regression protection**
against a future change silently reintroducing the cross-task assent bleed or
breaking the pool's session isolation.
**Fix (highest-value additions first):**
1. `store_test.go`: `proposePlan`'s append-vs-replace branch (the exact bug
fixed earlier today) — needs a real DB (integration-style, matching
`internal/db/integration_test.go`'s pattern) or a query-mocking layer.
2. `main_test.go`: `mcpClientPool.get()`'s concurrent-creation race path (two
goroutines racing to create a client for the same new session id) and
`sweep()`'s eviction logic — these are pure in-memory logic, no DB needed,
straightforward to unit test.
3. `assent_test.go`: the two confirmed false-positive cases from A1.
---
## F. Efficiency (minor)
### F1. Tool list + fleet snapshot re-fetched every single turn
[agent.go:174,181](../cmd/nomos/agent.go): `buildTools` (`tools/list` MCP
round-trip) and `fleetSnapshot` (`get_health_summary` call) both run at the
start of **every** `chatWith` call — including auto-continuation resumes,
which can fire many times per task. The tool list changes only on an `api`
process restart; the fleet snapshot is a live "as of now" read, which is
arguably the point of it, but re-fetching the *tool list* every turn is
avoidable.
**Fix:** cache `buildTools`' result (e.g., in `mcpClientPool`, invalidated on
a client's re-initialize) — worth doing only if profiling shows it matters;
low priority relative to A-C.
---
## Implementation order
1. **A1** (assent false positives) — smallest, highest-severity-per-line-of-
code fix; ships with regression tests same-PR.
2. **C1** (unauthenticated gateway) — security-critical, independent of
everything else here.
3. **B1** (panic recovery) — cheap, broad safety net; do before B2 touches the
continuation worker's goroutine structure anyway.
4. **B2** (parallel auto-continuation) — natural follow-on to B1 since it's
restructuring the same goroutine.
5. **A3** (incremental persistence for live turns) — moderate effort, real
user-visible correctness gain.
6. **D1-D3** (small cleanups) — bundle together, low risk.
7. **A2** (history windowing) — needs a design decision (see below) before
implementation; largest single change.
8. **B3**, **F1** — lower urgency, do opportunistically.
9. **E** (tests) — ideally lands alongside each fix above (A1's tests with
A1, etc.) rather than as one giant deferred test-writing pass.
## Verification
- **A1**: the two probe cases (`isAssent` on the "yesterday" message,
`isTypedConfirmation` on the "haven't confirmed" message) become permanent
tests in `assent_test.go`, asserting `false` post-fix.
- **A2**: after adding windowing, replay a session with 70+ tool calls (the
documented production case) and confirm the message payload sent to the LLM
stays under a fixed token/byte ceiling regardless of session length.
- **A3**: reproduce the Stop-button-mid-turn scenario, reload the page, and
confirm the tool calls made before the abort are still present in the
persisted transcript (currently: they vanish).
- **B1**: inject a deliberate panic in a test build of `resumeSession` (or a
fault-injection flag), confirm the process survives and logs the recovered
panic instead of exiting.
- **C1**: confirm an unauthenticated `curl` to nomos's `/chat` from off-mesh
is rejected once auth lands (currently: succeeds).
- **D1-D3**: `go vet`/build clean, `complete_task` with a bogus outcome value
now rejected or defaulted rather than silently persisted.
## Open questions
- **A2's cutoff mechanism**: a fixed N-message window, a token-budget-aware
trim, or LLM-summarization of dropped history? Summarization preserves the
most context but costs an extra LLM call per trim; a fixed window is
simplest but could drop something the agent still needs mid-task. Leaning
fixed window + summarize-on-trim as a middle ground, but this needs a
decision before implementation, not during.
- **C1's auth mechanism**: reuse `api`'s existing static bearer token
(simplest, matches an existing pattern) or route everything through `api`'s
proxy and stop publishing 8092 at all (removes the surface entirely, but
changes the deploy topology)? Leaning the latter if nothing else on the LAN
legitimately needs to reach nomos directly — worth confirming with the
operator before picking.
- **B2's concurrency bound**: unbounded goroutines-per-tick vs. a small
semaphore? Given the continuation batch is already capped at 5 per tick
(`pendingContinuations(ctx, 5)`), unbounded is probably fine, but worth a
sanity check against real task-completion clustering patterns.

View File

@@ -1,6 +1,8 @@
# 2026-07-08 — Nomos resident agent (renames Hermes)
**Status:** In Progress — N0-N3 complete 2026-07-08
**Status:** Done — 2026-07-11. N0-N3 (rename, agent loop, sessions/streaming,
UI entry point) all verified in current code. N4 (Matrix bridge, proactive
sessions) was explicitly out of scope and remains unstarted.
## Goal

View File

@@ -1,6 +1,12 @@
# 2026-07-08 — Plan vs implementation cross-reference
**Status:** Planned
**Status:** Done — 2026-07-11. Every action this audit recommended has a
corresponding follow-up commit (consolidation `7660e56`, client lifecycle
`efa66c7`/`fcd9f23`/`28ab9b8`, comprehensive audit `43aaf2a`,
DB-as-source-of-truth `a3ebd12`, MCP tool surface `7c6cffb`, apps/105 webhook
cleanup `cefeba7`). Its own Prometheus finding (0% done) still matches the
current state — see [2026-07-05-oikos-prometheus-lxc.md](2026-07-05-oikos-prometheus-lxc.md),
still Planned.
## Goal

View File

@@ -1,6 +1,10 @@
# 2026-07-09 — Chat sessions: reliability, cost, and session-management fixes
**Status:** Planned
**Status:** Done — 2026-07-11. All 5 findings fixed on `main`
(`49c37fe fix: chat session reliability, cost, and hygiene`): empty/refusal
retry guard in `agent.go`, bulk-tool guidance in `SOUL.md`, tool-result
truncation in `store.go`, `get_state_snapshot` filtering, and session
delete + generated titles.
## Goal

View File

@@ -1,6 +1,11 @@
# 2026-07-09 — Session execution, UX, and learning improvements
**Status:** Planned
**Status:** Done — 2026-07-11. Hard blocker and all major items verified:
`pct_create` wired into `request_execution`, `ToolCallGroup` collapse +
live status, `InlineApproval` blast radius, `http_get` tool, `session-review`
skill. Two minor secondary items not implemented: `list_lxcs` CPU/mem
enrichment, and a dedicated `get_tools_summary` tool (SOUL.md has general
bulk-tool guidance instead).
## Goal

View File

@@ -1,6 +1,10 @@
# 2026-07-10 — Autonomous plan execution: close the observation gap
**Status:** Planned
**Status:** Done — 2026-07-11. Full scope shipped, including the Option B
stretch goal: atomic `pct_create` decomposition, robust assent-window
open/extend, SOUL persist-through-errors + `maxIterations=40`, and
event-driven auto-continuation (`cmd/nomos/continue.go`). Commits `233b5e4`,
`d2f749d`, `657e1a8`, `d529688`, `84ecb6b`.
## The real problem (not the one we kept fixing)

View File

@@ -0,0 +1,235 @@
# 2026-07-11 — Concurrent task execution: safety + throughput + frontend correctness
**Status:** Done — 2026-07-11. All three required fixes shipped and deployed:
session-scoped assent/destructive windows (commit `9ef1ba3`), the frontend
stream-corruption guard + per-session controllers (`9131559`, `6a8fb43`), and
the per-session MCP client pool (`a4ea542`). Fix 4 (concurrency/cost cap)
remains explicitly deferred pending real usage data, per this doc's own
recommendation.
## Goal
Multiple tasks already run "at the same time" at the HTTP/goroutine level —
nothing in nomos serializes whole turns. But tracing the actual code (not
assuming) surfaces three real gaps that make concurrent tasks unsafe or
broken today, in decreasing severity: a **cross-task authorization bleed**, a
**throughput bottleneck** that makes concurrency mostly illusory, and a
**frontend state-corruption bug**. This plan fixes all three.
## Findings (grounded)
### 1. CRITICAL — the assent window is scoped to the agent, not the task
[store.go:816](../../cmd/nomos/store.go), [agent.go:129-143](../../cmd/nomos/agent.go):
`openAssentWindow`/`assentWindowActive` key on `"assent_window.agent:" +
a.agentID.String()` — there is exactly one `agent:nomos` entity, so this key
is **global across every task**. It's checked at three MCP call sites
([internal/mcp/server.go:454](../../internal/mcp/server.go), :488, :1363) purely
as "does *the agent* currently have an open window," with no way to know
which task's tool call is asking.
**Concrete failure**: operator approves Task A's plan → 30-minute window
opens → operator starts Task B while that window is still open → Task B's
`run`/`request_execution` config-mutation calls **also auto-execute**,
because the check has no session dimension. The operator never approved
Task B's plan.
[store.go:313-345](../../cmd/nomos/store.go) `destructiveWindowKey`/
`openDestructiveWindow`/`destructiveWindowActive` have the same shape (keyed
`agent:<id>.target:<slug>`, no session) — narrower blast radius (needs a
second task hitting the *same target* within 15 minutes of an explicit typed
confirmation elsewhere) but the same class of bug.
### 2. Tool calls across ALL tasks funnel through one mutex — concurrency is mostly illusory
[main.go:45](../../cmd/nomos/main.go): nomos creates exactly **one**
`*mcpClient` at startup, shared by every `handleChat` goroutine. Its `mu
sync.Mutex` ([main.go:419](../../cmd/nomos/main.go)) is held for the full
duration of each `doRequest` round-trip. `run`'s MCP handler executes the SSH
command *synchronously inside that round-trip* and is capped at up to **10
minutes**. So while Task A is mid-`run`, every other task's tool calls —
even a trivial `get_entity` — queue behind that single mutex until it
returns. Tasks can think (LLM calls) in parallel, but cannot act in parallel;
one slow task stalls all others' progress.
The MCP *server* side has no session-scoped in-memory state to protect —
`newServer(pool, agentID)` returns one shared `*mcp.Server` instance whose
tool handlers close only over `pool` (safe for concurrent use — pgxpool is a
connection pool) and `agentID` ([internal/mcp/server.go:51-74](../../internal/mcp/server.go)).
The mutex exists purely because nomos's *client* reuses one stateful
transport session, not because the server needs it. This is fixable without
touching the server.
### 3. Frontend: the chat store is a global singleton — switching tasks mid-stream corrupts the view
[chat.ts:160-269](../../web/src/lib/stores/chat.ts) `sendMessage`'s SSE callback
mutates `messages`/`currentSession` by reaching for `ms[ms.length - 1]`
i.e. it assumes the array it's mutating still belongs to the task it was
opened for. Nothing in the callback checks that. [chat.ts:111-117](../../web/src/lib/stores/chat.ts)
`loadSessionMessages` (fired when you click a different task in the sidebar
or the board) does not cancel or otherwise account for a still-open stream
from the task you're leaving — it just calls `messages.set(...)` and
`currentSession.set(sessionId)`.
**Concrete failure**: start Task A, while it's still streaming click into
Task B from the Tasks board → `messages`/`currentSession` now reflect Task
B → Task A's still-open SSE stream delivers its next `tool_use`/`text_delta`
→ the callback appends it onto what is now *Task B's* last message, and on
`done` calls `currentSession.set(taskAId)`, flipping the app back to Task A
underneath the operator. This is a real bug independent of anything else in
this plan — it's why "switch away from a running task to start another"
currently looks broken even though the backend handles it fine.
(By contrast, [workspace.ts](../../web/src/lib/stores/workspace.ts)'s live
events are already correctly session-scoped — `applyEvent` checks
`ev.correlation_id !== sid` before doing anything — because that mechanism
was built for this from phase 6. The bug is confined to the older,
per-turn `chat.ts` streaming path.)
### Already fine, no change needed
- **DB access**: `pgxpool.Pool` is a connection pool; concurrent queries from
multiple task goroutines are its normal use case.
- **Auto-continuation worker** ([continue.go](../../cmd/nomos/continue.go)):
already scoped per session (`pendingContinuation.SessionID`) — processes
its poll batch sequentially (5/tick) but never mixes state across
sessions. Sequential processing is a throughput nit, not a correctness bug;
not in scope here.
- **Task board** ([Tasks.svelte](../../web/src/pages/Tasks.svelte)): event-driven
refresh already handles any number of concurrently-changing tasks correctly
— it re-lists, it doesn't hold per-task live state.
## Design
### Fix 1 — session-scope the assent and destructive windows
Thread `sessionID` through to the MCP call sites. nomos already knows the
session id when it calls a tool ([agent.go:363](../../cmd/nomos/agent.go)); the
MCP wire protocol doesn't restrict tool-call args to the declared schema
(`argsMap` just unmarshals whatever JSON object arrives), so nomos can inject
an internal `_session_id` into the args it sends over the wire — invisible to
the model (never in the tool's `InputSchema`, so it never appears in what the
LLM sees or is asked to supply) but readable server-side.
- `agent.go`: build a wire-args copy with `_session_id` added just before
`a.client.callTool(...)` (leave the args used for history/logging
unmodified — the model's own tool-call record shouldn't show an internal
field it never set).
- `internal/mcp/server.go`: `assentWindowActive`/`openAssentWindow`/
`destructiveWindow*` gain a `sessionID` parameter; the key becomes
`assent_window.agent:<id>.session:<sessionID>` (and similarly for the
destructive window). Every call site (`run`, `request_execution`, the
`apt_upgrade`/`pct_create` sub-cases) reads `_session_id` from `argsMap`
and passes it through.
- `agent.go` `openAssentWindow` gains the same `sessionID` param, called from
its two existing call sites ([agent.go:253](../../cmd/nomos/agent.go), :269),
which are already inside `chatWith` and have `sessionID` in scope.
- Fallback: if `_session_id` is missing (defensive — shouldn't happen since
nomos always sets it), treat as "no window" (fail closed, require
approval) rather than falling back to the old agent-wide key.
### Fix 2 — per-session MCP client (remove the throughput bottleneck)
Replace the single global `*mcpClient` with a small **map of clients keyed
by session id**, created lazily on first tool call for that session and
evicted after a period of inactivity (e.g. 10 minutes past the session's last
activity — long enough to outlive a slow `run`, short enough not to leak
connections for abandoned tasks). Guard the map itself with a mutex (cheap —
only held for map lookup/insert, not for the duration of a call); each
individual client keeps its own `mu` scoped to *its own* session's calls,
so Task A's slow `run` only serializes Task A's own tool calls (which are
already inherently sequential within one turn — the agent loop calls tools
one at a time) and never blocks Task B.
- New `mcpClientPool` type in `cmd/nomos`: `get(sessionID) *mcpClient`
(creates+initializes on miss), `sweep()` (evicts idle clients, called on a
ticker alongside the existing continuation-worker ticker).
`"ephemeral"`/`""` session ids (no persisted session) get their own
dedicated client, not pooled per-request, to avoid a connection-per-message
churn for the no-DB-store path.
- `agent` holds the pool instead of one `client`; `handleQuery` (the
structured `/query` endpoint, [main.go:330](../../cmd/nomos/main.go)) picks a
short-lived or dedicated client the same way.
- No server-side change needed (per finding 2's analysis — the server has no
per-connection state to protect).
### Fix 3 — frontend: don't let a background stream corrupt the active view
Minimal, contained fix (not a rearchitecture): capture the session id a
`sendMessage` stream belongs to, and have its callback check that
`currentSession` still matches before mutating `messages`/`streaming`. If the
operator has navigated away, the stream's events are silently dropped from
the UI (the task keeps running server-side regardless — the events are also
flowing on the global stream, and if the operator navigates back,
`loadSessionMessages`'s poll + REST hydration picks up whatever landed while
they were away, same as it already does for auto-continuation).
- `chat.ts` `sendMessage`: capture `const streamSessionID = ...` once the
`'session'` event assigns it; every subsequent branch of the callback
(`tool_use`, `tool_result`, `text_delta`, `text`, `done`, `error`) first
checks `get(currentSession) === streamSessionID` (or the pre-assignment
optimistic session) before touching `messages`.
- `loadSessionMessages`: no change needed once the above guard exists — it
already correctly sets `messages`/`currentSession` for the task being
opened; the guard just stops the *other* task's stream from clobbering it
afterward.
- Out of scope for this pass: a genuine multi-pane "watch two tasks stream
live side by side" UI. Not needed for correctness — the Tasks board already
shows live status for every task via `workspace.ts`'s correctly-scoped
events; only the single-focus Chat transcript view needs this guard.
### Fix 4 (optional) — a concurrency/cost guardrail
Nothing currently stops an operator from starting many tasks in a tight loop,
each spending real LLM API budget in parallel. Consider a simple semaphore in
nomos (`NOMOS_MAX_CONCURRENT_TASKS`, default e.g. 5) that `handleChat` acquires
before starting a turn and releases on completion; over the cap, queue or
reject with a clear "N tasks already running, try again shortly" rather than
letting an unbounded burst hit OpenRouter. This is an operational safeguard,
not a correctness fix — flagged as optional/lower priority.
## Implementation order
1. **Fix 1 (assent/destructive window session-scoping)** — the only one that's
a genuine safety bug (auto-running unapproved actions in another task);
ship first regardless of anything else.
2. **Fix 3 (frontend stream guard)** — small, contained, fixes a visibly
broken UX (switching tasks looks corrupted) independent of Fix 2.
3. **Fix 2 (per-session MCP client pool)** — the throughput fix; more moving
parts (lifecycle/eviction), ship after the safety fix lands and is
verified, since both touch the same call sites (`agent.go` tool dispatch).
4. **Fix 4 (concurrency cap)** — optional, only if real usage shows a need.
## Verification
- **Fix 1**: approve Task A's plan (open its window); concurrently start Task
B and have it attempt a config-mutation `run` command *without* approving
Task B's plan — confirm Task B's action is queued for approval (not
auto-run), while Task A's own subsequent steps keep auto-running.
`SELECT key FROM autonomy_settings WHERE key LIKE 'assent_window%'` should
show session-scoped keys.
- **Fix 2**: start Task A with a `run` step that sleeps ~60s; concurrently
start Task B with a trivial `get_entity` call; confirm Task B's tool result
returns immediately rather than waiting on Task A. Confirm the client map
evicts idle entries (`sweep()` logged, connection count doesn't grow
unbounded across many sequential tasks).
- **Fix 3**: start Task A, before it finishes click into Task B on the
board, confirm Task B's transcript stays correct (no Task-A tool calls
appended) and `currentSession` doesn't flip back to Task A when its stream
eventually completes in the background. Navigate back to Task A afterward
and confirm its full transcript (including what happened while unwatched)
loads correctly via REST.
## Open questions
- **Idle eviction window for Fix 2**: 10 minutes was a guess balancing
"outlive a slow `run`" against "don't leak connections." Worth checking
actual `run` durations in production (`executions.duration_ms`) before
picking a final number.
- **Fix 4's cap and behavior on overflow**: queue vs. reject vs. no cap at
all — depends on real usage patterns once concurrent tasks are actually
safe (Fix 1) and performant (Fix 2). Defer the decision until there's
data.
- **Destructive-window session-scoping**: bundle into Fix 1 (same shape, same
PR) or treat as a follow-up given its narrower blast radius? Leaning bundle
— it's the same three-line change pattern applied to one more function.

View File

@@ -0,0 +1,312 @@
# 2026-07-11 — Tasks: the chat page as goal-structured autonomous work
**Status:** Done — 2026-07-11. All 7 phases shipped and deployed (SHA
`e30813a`): task schema + entity anchor, `entity.touched`/`involves` live
tracking, the `complete_task`/knowledge-retrieval loop, structured
`propose_plan`/`update_plan_step` (with an append-not-replace fix for
mid-flight re-proposals), `ask_operator` pause/resume, the Tasks board, and
the live `TaskContextPanel`. Follow-up hardening tracked separately in
[concurrent-task-execution](../2026-07-11-concurrent-task-execution.md).
Supersedes the sidebar-only framing and the Chat portion of
[control-room-webui](../2026-07-08-control-room-webui.md), which described
chat as a free-form session list.
## The vision (operator, distilled)
> Structure the whole chat page as **tasks**. A task is a card — you see its
> status (running / completed / failed), its description. A task *is* a goal:
> "install the service", "give me the key status of X". The agent takes the
> goal, finds what it needs, **proposes a plan, the operator approves it once —
> that single approval is the only one needed — and the agent then executes the
> whole plan autonomously until the goal is achieved.** Every task has a
> completion status: successful or not, and its **learnings move to knowledge**,
> attached via **relationships** to the entities that were involved, so future
> tasks — successful or unsuccessful — make the agent better over time. Inside a
> task is the conversation (tools, thinking, questions if needed); the sidebar
> shows the live context: which entities the agent is exploring, the steps and
> their status, whether the task succeeded, and the knowledge it recorded — all
> populated in **real time** as the agent works.
Three pillars: **task as the unit**, **one approval → autonomous execution**,
**a knowledge loop that compounds**.
## The reframe
Today a "session" is a title + a flat message list
([migrations/015](../../migrations/015_agent_sessions.up.sql)); a "plan" is prose
the model types; there is no goal, status, outcome, or step object. We elevate
the session into a **task**:
- **A task = a session with a goal, a plan, a lifecycle status, and an
outcome.** One task per chat. The chat page becomes a **task board** of
status cards; opening a card shows the task: conversation in the center, live
context in the sidebar.
- **The plan is approved once.** Machinery already exists — the assent window +
event-driven auto-continuation shipped in
[autonomous-plan-execution](2026-07-10-autonomous-plan-execution.md)
([continue.go](../../cmd/nomos/continue.go), [assent.go](../../cmd/nomos/assent.go))
already turn a single approval into an autonomy grant the agent runs to
completion. This plan gives that flow a **structured surface**: the one thing
the operator approves is a named, stepped plan, and progress is visible.
- **On completion the task deposits knowledge**, linked by relationships to the
entities involved *and to the task itself*, tagged success/failure — and
**future tasks read it back at planning time.** The substrate exists:
`upsert_knowledge` writes a knowledge doc-entity and a `documents`
relationship ([server.go:1519](../../internal/mcp/server.go));
`get_entity_knowledge` reads it ([server.go:170](../../internal/mcp/server.go)).
We add task-linkage, an outcome flavor, and retrieval-at-planning.
## Builds on / aligns with
- [general-gated-execution](../2026-07-10-general-gated-execution.md) — the
classifier + `run` primitive is the execution substrate; a plan step is just
a described unit of work mapping to a `run`/`request_execution` call. **No
fixed step enum.**
- [autonomous-plan-execution](2026-07-10-autonomous-plan-execution.md) —
the single-approval autonomy window + auto-continuation loop.
- The knowledge tools + relationships graph (`upsert_knowledge`,
`get_entity_knowledge`, `get_relations`, the temporal `relationships` table).
## Data model (migration `018_tasks.up.sql`)
Elevate the session into a task; add plan steps, questions, and the
task→knowledge linkage.
```sql
ALTER TABLE agent_sessions
ADD COLUMN goal TEXT NOT NULL DEFAULT '',
ADD COLUMN status TEXT NOT NULL DEFAULT 'active',
-- active | planning | awaiting_approval | executing
-- | awaiting_input | done | failed | abandoned
ADD COLUMN outcome TEXT, -- success | failure | partial (NULL until done)
ADD COLUMN summary TEXT NOT NULL DEFAULT '', -- one-line result, shown on the card
ADD COLUMN entity_id UUID; -- the task's OWN entity (type 'task'), for
-- knowledge/relationship linkage (see below)
CREATE TABLE session_plan_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
seq INT NOT NULL,
title TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
-- pending | running | done | failed | skipped | blocked
execution_id UUID,
target_slug TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_plan_steps_session ON session_plan_steps(session_id, seq);
CREATE TABLE session_questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
prompt TEXT NOT NULL,
context JSONB NOT NULL DEFAULT '{}', -- { entities:[], options:[], why:"" }
status TEXT NOT NULL DEFAULT 'open', -- open | answered | dismissed
answer TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
answered_at TIMESTAMPTZ
);
CREATE INDEX idx_questions_session_open
ON session_questions(session_id) WHERE status = 'open';
```
**The task as an entity.** Each task gets a row in `entities` (type `task`,
slug `task:<short-id>`), stored in `agent_sessions.entity_id`. This is what
makes the knowledge loop use the *existing* graph machinery unchanged:
knowledge and involved-entity links hang off the task entity via
`relationships`, exactly like any other entity.
## Task lifecycle
```
created → planning → awaiting_approval → executing ⇄ awaiting_input → done
│ (outcome:
└──────────────→ failed success/
failure/
partial)
```
- **planning**: agent calls `get_entity_knowledge` on the target(s) first (prior
learnings), then `set_goal` + `propose_plan`.
- **awaiting_approval**: the plan is the single approval gate. Operator approves
→ opens the assent window (existing) → **executing**.
- **executing**: steps flip pending→running→done via `update_plan_step` and the
execution→step auto-close (below); the agent runs autonomously
(auto-continuation) with no per-step re-approval.
- **awaiting_input**: only when the agent hits a real decision → `ask_operator`;
answering resumes execution.
- **done/failed**: agent sets `outcome` + `summary` and deposits knowledge.
## Single approval → autonomous execution
Already the behaviour of the assent window + auto-continuation. This plan makes
the **approved object** a structured plan rather than an individual command:
approving the plan (one click / one "go ahead") authorizes every
read-only + config-mutation step in it. Destructive steps still require typed
confirmation *unless* named in the approved plan (the existing pre-authorized
destructive-step rule). Nothing new in the execution engine — we're giving it a
legible unit to approve and to show progress against.
## The knowledge loop (capture → link → retrieve)
**Capture (task end).** On `done`/`failed`, the agent (nudged by SOUL, enforced
by a server-side fallback) calls `upsert_knowledge` with the concrete learning —
what worked, what didn't, the gotcha — and we link the resulting knowledge
doc-entity to:
- the **entities involved** (already supported via `about`), and
- the **task entity** (`agent_sessions.entity_id`), via a new
`outcome_of` / `produced_by` relationship, tagged
`{"outcome":"success|failure"}`.
**Link.** Involved entities are captured cheaply: every `entity.touched` (below)
records a `relationships` edge `task —involved→ entity`. So a task's entity
neighborhood *is* its involved-entity set — queryable with the existing
`get_relations`.
**Retrieve (task start).** At **planning**, before proposing, the agent pulls
prior knowledge for the target entities (`get_entity_knowledge`) — which now
surfaces both successful and failed prior tasks (the outcome tag lets it weight
"last time `apt install docker.io` failed on Debian, used get.docker.com
instead"). This is the compounding: each task's outcome becomes the next task's
prior. SOUL makes this the first planning move.
## UI
### Task board (replaces the raw session rail / empty chat state)
[Sessions.svelte](../../web/src/pages/Sessions.svelte) /
[SessionRail.svelte](../../web/src/lib/components/SessionRail.svelte) become a
**board of task cards**. Each card:
- goal as the title, one-line `summary`,
- a **status pill** (running ◐ / awaiting you / done ✓ / failed ✗) with the
step progress (`4/6`),
- outcome color on completion, knowledge-count badge (♦ 2 learned),
- click → open the task.
Grouped/filterable by status (Running, Needs input, Done, Failed). "New task"
replaces "new chat" — the empty state asks for a goal.
### Task detail = conversation + live context sidebar
Center column: the existing chat transcript (tools, thinking, questions inline)
— unchanged rendering ([Chat.svelte](../../web/src/pages/Chat.svelte)).
Right sidebar becomes `TaskContextPanel.svelte`, populated **in real time**, top
to bottom:
1. **GoalHeader** — goal + status pill + outcome (once done); editable goal.
2. **PlanProgress** — ordered steps, live status icons, `4/6` bar, click a step
→ scroll chat to its tool call / open its execution output.
3. **OperatorQuestion** — pinned structured card when a question is open: prompt,
`why`, context-entity chips (→ EntitySheet), option buttons or free-text.
Answering POSTs the answer and resumes the agent. Same card also renders
inline in the transcript at the point it was raised. (The operator's
"structured component with relevant context.")
4. **LiveEntityPanel** — the [SessionGraph](../../web/src/lib/components/SessionGraph.svelte)
upgraded from passive to live: `entity.touched` → the node **pulses** +
"now touching `lxc:foo`"; `health.changed` → recolor + transient
`healthy→degraded` diff badge.
5. **Outcome & Knowledge** — on completion: success/failure banner, the
`summary`, and the knowledge notes recorded (links to the knowledge
entities), i.e. the [SessionDigest](../../web/src/lib/components/SessionDigest.svelte)
evolved into a task-outcome card.
## Real-time event contract (global `/events/stream`)
The panel is driven by the **always-on** [events stream](../../web/src/lib/stores/events.ts),
not the per-turn chat SSE — so it stays live during server-side
auto-continuation (when no chat turn is open) and survives a tab reload. New
`type`s, each carrying `correlation_id = session_id`:
| type | data |
| ---- | ---- |
| `task.status` | `{ status, outcome?, summary? }` |
| `goal.set` | `{ goal }` |
| `plan.proposed` | `{ steps:[{seq,title,detail,target_slug}] }` |
| `plan.step.started` / `plan.step.finished` | `{ step_id, seq, status, execution_id? }` |
| `question.raised` / `question.answered` | `{ question_id, prompt?, context?, answer? }` |
| `entity.touched` | `{ slug, tool }` |
| `knowledge.recorded` | `{ title, about, outcome }` |
`entity.touched` is emitted from the `withActivityLogging` wrapper
([server.go:832](../../internal/mcp/server.go)) — it wraps every tool call, so
touched-entity tracking needs **zero agent changes**; it also writes the
`task —involved→ entity` relationship. `health.changed` already exists.
## Agent surface (new MCP tools + SOUL)
Thin declarations that write the tables/relationships and publish the event
in-process (event and row commit together):
- `set_goal(goal)`
- `propose_plan(steps:[{title,detail?,target_slug?}])`
- `update_plan_step(seq,status,execution_id?)` — plus the execution's terminal
status **auto-closes** its linked step where
[phase3.go](../../internal/httpapi/phase3.go) finalizes executions (belt and
suspenders).
- `ask_operator(prompt,options?,context_entities?,why?)` — creates the question,
status→`awaiting_input`, ends the turn; answer resumes via the existing
assent/continuation path.
- `complete_task(outcome,summary)` — sets outcome/summary, status→done/failed;
server enforces "a completed task must have deposited ≥1 knowledge note"
(fallback: auto-summarize into one if the model forgot).
SOUL: "Every task has a goal. **First**, read prior knowledge for the target
entities (`get_entity_knowledge`) — learn from past tasks, successful or not.
Then `set_goal` + `propose_plan`. Execute autonomously after approval, marking
steps. Ask via `ask_operator` only for real decisions. When the goal is
verified, `complete_task` with the outcome and record what you learned."
## Implementation order
1. **Migration `018` + task-entity creation** (a `task` entity per session) +
store methods. Sessions gain goal/status/outcome/summary; no behaviour change.
2. **`entity.touched` + `task —involved→ entity`** from `withActivityLogging`
cheapest live win; graph starts pulsing, involved-set is captured for free.
3. **Knowledge loop close**: `complete_task` + retrieval-at-planning in SOUL +
outcome-tagged `outcome_of` link. Makes tasks compound.
4. **`set_goal`/`propose_plan`/`update_plan_step`** + execution→step auto-close.
5. **`ask_operator`** end-to-end (tool → question → pinned card inline+panel →
answer resumes).
6. **UI: TaskContextPanel** (GoalHeader, PlanProgress, OperatorQuestion,
LiveEntityPanel, Outcome/Knowledge) + `workspace.ts` store + REST hydration
(`GET /sessions/{id}/{plan,questions}`).
7. **UI: Task board** — session rail/list → status-card board, "new task" flow.
Each step ships value: 2 = live entity awareness; 3 = compounding knowledge;
4-5 = plan progress + interactive questions; 6-7 = the full task surface.
## Verification
- Run "deploy TypeType as an LXC on strong" as a task. Expect: at planning the
agent reads prior knowledge for `host:strong`; `propose_plan` renders steps;
operator approves **once**; steps flip live; the touched node pulses; a
mid-flow ambiguity surfaces as an `ask_operator` card answered in the panel;
on success `complete_task` sets outcome=success, deposits a knowledge note
linked to `lxc:typetype`, `host:strong`, and the task entity.
- Start a **second** task touching `host:strong`; confirm the first task's
knowledge surfaces at planning (`get_entity_knowledge`) — the compounding loop.
- Reload the tab mid-execution → panel rehydrates from REST and keeps updating
from the global stream (proves it isn't chat-SSE-bound).
- Board shows the task moving Running → Done with the right outcome color and
knowledge badge. `SELECT status, outcome FROM agent_sessions` shows a real
lifecycle, not all `active`.
- `get_relations` on the task entity returns its involved entities + produced
knowledge.
## Open questions
- **One task per session vs sequential tasks in a chat** — v1: one task = one
session (matches "each chat is a goal"). A new goal starts a new task.
Multi-task threads are a later extension.
- **Failure knowledge weighting** — do we just tag `outcome:failure` and let the
model judge, or add explicit "avoid this" surfacing at planning? Lean tag-only
first; revisit if the agent repeats known-failed approaches.
- **`complete_task` enforcement** — hard-require a knowledge note (block
completion) or soft (auto-generate a stub)? Lean soft, so a trivial "give me
status" task isn't forced to invent a learning.
- **Board vs thread** for very short tasks ("key status of X") — a status query
is a degenerate task (no plan, instant done). Render it as a lightweight card
that never shows an approval, so the board isn't cluttered with heavyweight
chrome for one-shot questions.

View File

@@ -0,0 +1,256 @@
# Task completion safety net: every live task is stuck "Running"
Status: Done — 2026-07-12. Fixes 1-3 implemented, built, tested
(`go build ./...`, `go test ./cmd/nomos/...`), committed (`3b9c75f`),
deployed, and verified live (see Verification below — fresh trivial Q&A
sessions now reach `done` immediately; a goal-bearing session that went
idle was correctly nudged and auto-resolved by the existing resume-failure
path). Fix 4 (backfill) was replaced with deletion — see "Fix 4, revised"
below; the original backfill-with-a-fabricated-outcome approach was never
run.
## Scope
Fix the root cause of a production-wide defect found while UI-testing
[`2026-07-11-ui-review-ia-usability.md`](2026-07-11-ui-review-ia-usability.md):
every session on the live task board shows as "Running" forever. Traced
through `cmd/nomos/` and confirmed against the running database — this is
not a frontend bug (the board correctly reflects real `agent_sessions.status`
values). It's an agent-behavior gap: the model almost never calls the
lifecycle tools (`set_goal` / `propose_plan` / `complete_task`) that the
task-board feature (shipped today,
[`done/2026-07-11-goal-oriented-chat-control-panel.md`](2026-07-11-goal-oriented-chat-control-panel.md))
depends on to know a task is finished.
## Evidence
Queried the live nomos API directly (`curl localhost:8092/sessions` and
per-session transcripts) against the running mac-mini stack:
- **50/50 live sessions**: 49 `active`, 1 `planning`. Zero have ever reached
`executing`, `awaiting_input`, `done`, or `failed`.
- Across all 50 sessions: **`set_goal` called once. `propose_plan` called
zero times. `complete_task` called zero times.**
- The dominant pattern (43/50 sessions, 2-message transcripts) is a single
quick exchange: operator asks something narrow ("what's the hostname of
lxc:caddy?"), the model runs one read tool (`run hostname`), answers in
plain text, and the turn ends — no lifecycle tool call at all. This is
exactly the case
[`nomos/SOUL.md:105-109`](../../nomos/SOUL.md#L105) calls out by name
("a trivial read-only task... is a degenerate case... answer it and
`complete_task` with a one-line summary") — the instruction exists and is
explicit, and the model skips it anyway, consistently.
- The one session that *did* call `set_goal` (a fleet health check) did
substantial real research (`get_health_summary`, `get_state_snapshot`,
`get_signal_history`, `list_lxcs`), gave the operator a full structured
answer, and then also just stopped — no `propose_plan`, no
`complete_task`. Status: stuck at `planning` since 2026-07-11T11:35, still
showing "Running" on the board.
This means the board's "N Running / 0 Done / 0 Failed" isn't a fluke or an
edge case — it's the default outcome for essentially every task the system
has ever run. The feature as designed (terminal state is 100% dependent on
the model remembering to call one specific tool) doesn't hold up against
real model behavior, even with an explicit prompt instruction already in
place.
## Where this lives in the code
`cmd/nomos/agent.go`'s `chatWith` has exactly one place a turn ends with a
plain-text answer and no tool calls:
```go
// agent.go:359-368
if len(msg.ToolCalls) == 0 {
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
emit(agentEvent{Type: "done", ...})
return
}
```
This is reached for the trivial-Q&A case (a turn that made zero or a few
read-only tool calls this iteration, then answered in text) and is where
43/50 of the stuck sessions are produced. There's a second, rarer exit at
the step-limit fallback (`agent.go:487-494`, `finalSummary`) with the same
gap.
Neither exit currently checks whether the session ever reached a terminal
state — the turn just ends, and `agent_sessions.status` is left wherever it
was (usually `active`, its creation-time default,
[`store.go:71,84`](../../cmd/nomos/store.go#L71)).
## Design
Two different failure shapes need two different fixes — collapsing them
into one heuristic would either auto-close genuinely in-progress structured
tasks or fail to catch the trivial-Q&A majority.
**1. Trivial/no-lifecycle-tool sessions (the 43/50 case) — auto-complete
inline, same turn.**
If a turn ends with a plain-text response (`len(msg.ToolCalls) == 0`, the
existing exit at `agent.go:359`) AND this session has never called
`set_goal` in its history, that's strong evidence this was never meant to
be a structured multi-step task — it's a one-shot question that got
answered. Call `store.completeTask` server-side right there, before the
`return`, with `outcome="success"` and a summary derived from the response
text (first ~120 chars, same truncation pattern
`buildContinuationNote` already uses at
[`continue.go:203-205`](../../cmd/nomos/continue.go#L203)). No LLM call needed
— this is a mechanical default, not a judgment call, matching the "trivial
task" case SOUL.md already describes.
If the session *has* called `set_goal` (meaning the model explicitly framed
this as a task, e.g. the fleet-health-check session), auto-completing on
the very next plain-text turn is riskier — the model may reasonably expect
to be asked something next. Skip the inline auto-complete for these; case 2
covers them.
**2. Structured (goal/plan set) sessions that stall — idle sweep, not
inline.**
Extend the existing `runContinuationWorker` ticker
([`continue.go:41-57`](../../cmd/nomos/continue.go#L41), already polling every
4s for a different purpose) with a second, coarser sweep — e.g. every 5
minutes — that finds sessions where:
- `status` is `active`, `planning`, or `executing` (not already terminal or
`awaiting_input`, which has its own resolution path), AND
- `set_goal` was called (this is a real task, not case 1), AND
- `last_active_at` is older than some idle threshold (start with 15
minutes — long enough that it's not still mid-turn, short enough that the
board doesn't lie for hours).
First idle hit: inject a system note next time nothing else touches the
session ("[System: this task has been idle for N minutes with no
`complete_task` call. If the goal is done, call it now with a summary. If
you're genuinely still working, ignore this.]") the same way
`buildContinuationNote` already injects notes into resumed sessions — reuse
`resumeSession`'s live-persist pattern
([`continue.go:106-196`](../../cmd/nomos/continue.go#L106)) so the nudge and
the model's response show up in the transcript, not silently.
If a second idle sweep finds the same session still not completed (i.e.
the nudge didn't take), auto-complete it directly with
`outcome="partial"` and a summary noting it was auto-closed after an
unanswered nudge — same reasoning as `resumeSession`'s existing
"give the task a real, operator-visible terminal state instead of leaving
it silently stuck forever" logic at
[`continue.go:179-193`](../../cmd/nomos/continue.go#L179), which already does
exactly this for a different failure mode (a resume that produces no
response). This is the same architectural pattern, applied to a session
that produces responses but never a terminal tool call.
**3. Leave `ask_operator` and gated-execution flows alone.** Those already
have real terminal signals (`awaiting_input` status, the continuation
worker's assent-window logic) — this plan only targets sessions that fall
through with no lifecycle signal at all.
## Fix plan
1. **Inline safety net (case 1)** — in `chatWith`'s plain-text exit
(`agent.go:359`), check `set_goal` was never called for this session
(cheap: track a bool while replaying `history` in the same function, no
extra query — the loop at `agent.go:209-226` already walks every
persisted message and could flag `sawSetGoal` while extracting tool
calls). If not sawSetGoal, call `completeTask` before returning.
2. **Idle sweep (case 2)** — new ticker in `continue.go` (or extend the
existing one with a slower secondary tick), a new store query
(`store.staleGoalSessions(ctx, idleThreshold)` mirroring
`pendingContinuations`'s shape), and reuse of `resumeSession`'s
live-persist injection for the nudge.
3. **Second-strike auto-close (case 2, continued)** — track nudge count (a
new `agent_sessions` column, e.g. `completion_nudges int default 0`, or
reuse the existing `summary`/attributes json instead of a schema change
if that's preferable) so the sweep can tell "never nudged" from "nudged
once already, still stuck."
4. **Backfill** — the 50 already-stuck live sessions won't get fixed by new
code alone (they're historical). One-time cleanup: run the same
case-1/case-2 classification against existing rows once the code ships,
so the board doesn't show 50 permanently-orphaned "Running" cards on top
of new correctly-terminating ones. This should be a script, not a manual
UPDATE — the classification logic will already exist in Go.
## Fix 4, revised: deletion instead of backfill
The plan as written proposed backfilling the 50 already-stuck sessions with
a mechanically-assigned outcome (`success` for case 1, `partial` for case
2). When it came time to execute that, the operator raised a better
question: these were overwhelmingly one-off test/smoke-test sessions
("hi", "what's the hostname of lxc:caddy?") with no lasting value —
assigning them a fabricated `success` outcome would make the task board
lie in the opposite direction (claiming verified success on things nobody
verified). The operator's call: delete them instead of backfilling a
guessed outcome, with one condition — don't lose any recorded knowledge.
Before deleting anything, verified directly against the database (not
assumed from reading the code):
- Zero `documents` relationship edges exist linking any of the candidate
sessions to any `knowledge_entities` row.
- Zero `upsert_knowledge` calls appear anywhere in the candidate sessions'
transcripts.
- Zero `knowledge_entities` rows exist system-wide mentioning the one
topic (`typetype`) the operator specifically asked to preserve.
`deleteSession` (`store.go:293`, already the live code path behind the
UI's "Delete task" button — reused as-is, not reimplemented) removes the
session, its messages, its own task entity, and that entity's relationship
edges — it never touches `knowledge_entities` rows or entities the task
merely referenced (e.g. `lxc:typetype` itself), only the provenance edges
back to the now-deleted task. Given the verification above, this was safe:
there was nothing to preserve because nothing had ever been recorded.
Executed in two batches, both via the same `DELETE /sessions/:id` route:
- **47 sessions** — the original candidate set from `curl
localhost:8092/sessions`, all non-`done`/`failed` at the time.
- **6 more sessions** — found *after* the first batch, when they surfaced
on the task board: `listSessions` (`store.go:193`) hardcodes
`ORDER BY last_active_at DESC LIMIT 50` with no pagination, so the
original audit's "50 sessions total" was actually "the 50 most
recently active" — it silently excluded 6 older stuck sessions from
2026-07-08 (predating the task-board feature entirely, same trivial
"hi"/smoke-test pattern). Worth knowing about `listSessions`'s cap for
any future audit of this table — a `count(*)` query directly against
the database is the only way to get a true total.
Final state: `agent_sessions` holds exactly 3 rows — the two `done` and
one `failed` sessions produced during live verification of fixes 1-3.
## Implementation order
1. Fix 1 (inline safety net) first — it's the highest-leverage, lowest-risk
change (self-contained, no schema change, covers 43/50 of the evidence).
2. Fix 2+3 (idle sweep + second-strike) — needs the schema decision
(new column vs. attribute) settled first; smaller blast radius than 1
but touches the ticker/worker machinery, deserves its own review pass.
3. Fix 4 (backfill) last, once 1-3 are deployed and verified live — running
it before the code ships would just recreate the same gap for new
sessions created in between.
## Verification
- After fix 1: start a few trivial one-shot chats against the live agent
(`hostname`-style questions), confirm each session reaches `status=done`
immediately after the answer, via `curl localhost:8092/sessions/:id` or
the task board.
- After fix 2+3: manually let a goal-bearing session go idle past the
threshold (or lower the threshold for a local test run), confirm the
nudge appears in the transcript, then confirm second-strike auto-close
fires if the nudge is ignored.
- Re-run the same audit query used to find this bug
(`curl localhost:8092/sessions` → status histogram) a day after deploy;
the "stuck active/planning forever" count should track only genuinely
in-flight tasks, not accumulate.
## Open questions
- **Outcome for case-1 auto-complete**: always `"success"`, or worth a
cheap heuristic (e.g. scan the final text for obvious failure language)?
Recommend starting with always-`"success"` — SOUL.md's own trivial-task
guidance doesn't distinguish, and a wrong "success" on a genuinely-failed
one-shot lookup is low-stakes (the transcript still shows the real
answer; nothing acts on the outcome besides the board's color).
- **Idle threshold (15 min) and nudge-to-close gap**: arbitrary starting
points, not measured against real task durations — worth revisiting after
a week of the new sessions' real timing data exists.
- **Schema change for nudge tracking**: a new column is simpler to query
than packing state into existing JSON, but adds a migration — worth
confirming that's acceptable before starting fix 2+3 (this plan defers
that call to whoever implements it, per Implementation order above).

View File

@@ -0,0 +1,262 @@
# UI review: information architecture, usability, and best practices
Status: Done — 2026-07-11. All fix-plan items implemented and verified live
except C2 (a11y lint enforcement — no ESLint/svelte-check is configured in
`web/` at all, so there's nothing to promote from warn to error; flagged
below instead of silently adding lint infra). Verification also surfaced an
unrelated pre-existing bug (Knowledge page search results never render) —
spun off as a separate task, not fixed here.
## Scope
Systematic review of `web/src/` (Svelte 5 + shadcn-svelte + Tailwind v4
control-room UI): all 13 pages, the 11 shared components, the sidebar/routing
shell (`App.svelte`), and cross-cutting patterns (filtering, loading/empty
states, live-event wiring, accessibility). Read in full, not sampled.
Grounded in what's actually in the code — no speculative "best practice"
items without a concrete file:line instance.
Not implementation. Findings and a proposed fix plan only, mirroring
[`2026-07-11-nomos-agent-code-review.md`](2026-07-11-nomos-agent-code-review.md)'s
structure — implement on a later "proceed."
## Findings
### A. Information architecture
**A1. Entity detail has two competing UI patterns for the same content.**
[`Entities.svelte:16-19,155`](../../web/src/pages/Entities.svelte) opens entity
detail as an in-page `EntitySheet` slide-over (no URL change, no sidebar
state change). [`Knowledge.svelte:57-59`](../../web/src/pages/Knowledge.svelte)
and [`Graph.svelte:464`](../../web/src/pages/Graph.svelte) instead navigate via
`location.hash = '#/entity/' + slug`, which `App.svelte`'s router resolves to
a full-page `EntityDetail` route — but `'entity'` isn't in `navItems`
([`App.svelte:68-79`](../../web/src/App.svelte)), so landing there leaves the
sidebar with nothing highlighted and the header showing the raw slug instead
of a section name. Same underlying view
(`EntityDetailContent.svelte`), three different entry points, two
different navigation models, one of which produces an orphaned page state.
A user who reaches an entity via Knowledge or Graph has no way back to
"where they were" via the sidebar — only browser back.
**A2. Two chat entry points with no visual link between them.**
The sidebar's "Tasks" section (board → `Chat.svelte` detail,
`isActive={page === 'tasks' || page === 'chat'}`,
[`App.svelte:127`](../../web/src/App.svelte)) and the footer's "Chat drawer"
button ([`App.svelte:160-163`](../../web/src/App.svelte), opens a `Sheet`
wrapping the same `Chat` component) are both valid, intentional ways to
reach chat — but nothing in the UI explains they're different modes (drawer
= overlay on current page, keeps your place; Tasks = full navigation). A
first-time user has no way to know which one preserves their current page.
Low-severity, but worth a tooltip/label distinction.
**A3. Overview's KPI cards don't drill down.**
[`Overview.svelte`](../../web/src/pages/Overview.svelte) shows "Pending
approvals," "Open signals," and fleet-health counts as static cards. The
header badges for the same data (`approvalsPending`, `openSignals`,
[`App.svelte:185-194`](../../web/src/App.svelte)) ARE clickable and navigate to
Ops/Signals — so the pattern exists in the app, just not on the page whose
entire purpose is summarizing this data. A dashboard card showing a count
that doesn't lead anywhere is a standard drill-down gap.
### B. Usability / interaction consistency
**B1. Table-row click targets lack keyboard/screen-reader support in one
place but not others.**
[`Entities.svelte:117-120`](../../web/src/pages/Entities.svelte) makes an
entire `Table.Row` clickable via a bare `onclick`, with no `role`,
`tabindex`, or `onkeydown` — unreachable and inoperable via keyboard, and
screen readers get no indication the row is interactive. This is a
regression against the codebase's own established pattern: `Tasks.svelte`
wraps its cards in real `<button>` elements
([`Tasks.svelte:172`](../../web/src/pages/Tasks.svelte)), `Events.svelte`'s
correlation-group headers are real `<button>`s
([`Events.svelte:110-114`](../../web/src/pages/Events.svelte)), and
`Graph.svelte`'s SVG nodes explicitly add `role="button"`, `tabindex="0"`,
and `onkeydown` ([`Graph.svelte:416-421`](../../web/src/pages/Graph.svelte)).
Entities is the outlier.
**B2. Filter inputs are inconsistently "live" vs. "apply-on-blur," with no
visual cue either way.**
`Entities.svelte`'s slug/name filter and `Graph.svelte`'s search box filter
as-you-type (bound to a `$derived`). But `Ops.svelte` (implicitly, no text
filters), `Audit.svelte`'s action/entity inputs
([`Audit.svelte:71-72`](../../web/src/pages/Audit.svelte)),
`Agent.svelte`'s agent_id input
([`Agent.svelte:59`](../../web/src/pages/Agent.svelte)), and `Events.svelte`'s
type/severity inputs ([`Events.svelte:92-93`](../../web/src/pages/Events.svelte))
all use `onchange`, which only fires on blur — a user typing a filter value
and watching the table sees nothing happen until they click or tab away, and
nothing in the UI (placeholder text, a debounce spinner, an "Enter to
apply" hint) tells them why. Three different pages share the same
`onchange`-only pattern, so it's a systemic choice, not an oversight — but
it reads as broken on first use.
**B3. Entity filter is case-sensitive; nothing else in the app is.**
[`Entities.svelte:50`](../../web/src/pages/Entities.svelte) matches with raw
`.includes()`, no `.toLowerCase()`. `Graph.svelte`'s equivalent search
normalizes both sides
([`Graph.svelte:175-176`](../../web/src/pages/Graph.svelte):
`n.slug.toLowerCase().includes(q)`). Slugs are lowercase by convention today,
which is why this hasn't bitten anyone yet, but entity *names* are
free text and can be mixed-case — a name filter that silently returns zero
results for a correctly-spelled but wrong-case query is a real trap, and the
one-line fix already has a working reference implementation three files
away.
**B4. `{@html}` on server-provided search snippets.**
[`Knowledge.svelte:120-121`](../../web/src/pages/Knowledge.svelte) renders
`hit.snippet` with `{@html}`, justified by a comment claiming the backend's
`ts_headline` output is pre-sanitized. That's true for Postgres
`ts_headline` today (it only wraps matched terms in `<b>` from a
parameterized query), but there's no client-side enforcement of that
invariant — if the search query or snippet source ever changes upstream,
this becomes a stored-XSS vector with no guard at the point of use. Not an
active vulnerability, but a fragile trust boundary worth tightening
defensively (e.g. a tiny allow-list sanitizer) rather than relying on a
comment to hold forever.
### C. Accessibility
**C1. `SessionRail.svelte`'s delete control is a `<span>`, not a button.**
[`SessionRail.svelte:54-64`](../../web/src/lib/components/SessionRail.svelte)
attaches `onclick` to a `<span>` for the per-session delete affordance, with
no `role`, `tabindex`, or keyboard handler — same defect class as B1, on a
destructive action this time (delete a chat session), which makes it a
notch more important: a keyboard-only user cannot delete a session from
this rail at all.
**C2. Same defect, lower stakes, elsewhere.**
Scan for the same "clickable non-interactive element" shape found in B1/C1
should be swept across `web/src/` once — these two are the ones a full read
surfaced, but the pattern (a `<div>`/`<span>` with `onclick` and no
keyboard path) is exactly the kind of thing that creeps back in per-PR
without a lint rule catching it. Worth checking whether
`eslint-plugin-svelte`'s `a11y_click_events_have_key_events` /
`a11y_no_static_element_interactions` rules are enabled and enforced in CI
(the prior summary noted these exist as warnings, not build failures — that
should be confirmed and possibly promoted to errors as part of implementing
C1/B1).
### D. Visual / component consistency
**D1. One page bypasses the shared `Button` component.**
`Agent.svelte`'s "Refresh" control is a bare
`<button class="rounded-md border px-3 py-1.5 text-xs">`
([`Agent.svelte:73`](../../web/src/pages/Agent.svelte)) instead of
`Button` (`variant="outline"`), which every other page's refresh/action
buttons use (`Ops.svelte`, `Signals.svelte`, `Audit.svelte`, `Events.svelte`
all use `<Button variant="outline">`). Cosmetically near-identical today
(both render as a bordered pill) but it'll drift the moment the design
tokens on `Button` change, since this one doesn't inherit them.
**D2. `formatEventLabel` is a needless indirection.**
[`Overview.svelte`](../../web/src/pages/Overview.svelte)'s
`formatEventLabel(ev)` returns `ev.type` verbatim — a one-line wrapper with
no formatting logic. Trivial, but noted since it reads as if formatting
were intended and never finished.
### E. Loading / empty states
No real findings — this is a strength worth naming rather than "fixing."
Every page reviewed (Overview, Entities, Ops, Signals, Events, Agent, Audit,
Knowledge, Learning, Graph, Tasks) has both a loading state (skeletons or an
implicit empty table) and an explicit, page-appropriate empty-state message
(not a generic "no data"). That consistency is worth preserving as new pages
get added — call it out in the PR template or a short frontend README note
rather than leaving it as tribal knowledge.
## Fix plan
Priority order, grounded in user impact:
1. **C1 (SessionRail delete button)** — highest priority: it's a destructive
action that's currently unreachable by keyboard at all. Swap the `<span>`
for a real `<button>` with `aria-label="Delete session"`, matching the
pattern `Tasks.svelte` already uses for its own delete affordance
([`Tasks.svelte:195-207`](../../web/src/pages/Tasks.svelte) — same feature,
done correctly, in the same codebase).
2. **B1 (Entities row click)** — wrap row content in a `<button>` (or add
`role="button" tabindex="0" onkeydown`) matching `Tasks.svelte` /
`Events.svelte`'s existing pattern.
3. **B3 (case-sensitive filter)** — one-line `.toLowerCase()` fix on both
sides of the `.includes()` calls in `Entities.svelte:50`.
4. **A1 (dual entity-detail navigation)** — pick one pattern. Recommend
standardizing on the `EntitySheet` (in-page, no navigation loss) and
changing `Knowledge.svelte`/`Graph.svelte`'s "View entity detail" actions
to open the sheet directly instead of hash-navigating to the orphaned
`#/entity/:slug` route. If the full-page route is kept for deep-linking
(a legitimate reason to keep it), then at minimum highlight the
originating section in the sidebar and give the header a real label
instead of the bare slug.
5. **A3 (Overview KPI cards not clickable)** — wrap the approvals/signals
cards in the same click-to-navigate pattern already used by the header
badges.
6. **D1 (Agent.svelte bare button)** — swap for `<Button variant="outline">`.
7. **B2 (inconsistent live-vs-blur filtering)** — standardize on
`oninput`-driven, debounced (~300ms) filtering across Audit/Agent/Events,
matching the already-live feel of Entities/Graph. Lower priority than the
above since it's a rough edge, not a defect.
8. **B4 (`{@html}` trust boundary)** — add a minimal sanitize step (strip
everything but the `<b>` tags `ts_headline` emits) at the point of
render, so the safety property doesn't depend on the backend never
changing.
9. **A2 (chat drawer vs. Tasks unlabeled)** and **D2 (`formatEventLabel`)**
cosmetic, do opportunistically or skip.
10. **C2 (a11y lint enforcement)** — checked: `web/` has no ESLint config and
no `lint`/`check` npm script at all (confirmed via `package.json` and
directory listing). The "a11y warnings" referenced in earlier session
notes were editor/IDE diagnostics, not a CI gate. There's nothing to
promote from warn to error because no lint infrastructure exists —
setting one up is a separate, larger decision (which rules, whether to
also add `svelte-check` for types) that wasn't part of this review's
scope. Not done; flagging for a separate decision rather than silently
bootstrapping tooling.
## Implementation notes (2026-07-11)
- C1, B1, B3, A1, A3, D1, D2, B2, B4, A2 all implemented and verified live
in the browser preview against the running stack (see Verification below).
- A1: standardized on `EntitySheet` per the plan's recommendation —
`Knowledge.svelte` and `Graph.svelte`'s "View entity detail" now open the
sheet instead of hash-navigating to the orphaned `#/entity/:slug` route.
The full-page `EntityDetail` route/component was left in place (not
deleted) as a harmless deep-link fallback — nothing internal navigates to
it anymore, but a bookmarked/shared URL still resolves.
- B4: used the `dompurify` package, already a `dependencies` entry in
`web/package.json` (unused until now) — no new dependency added.
- B2: added a small `debounce()` helper to `web/src/lib/utils.ts` and
switched Audit/Agent/Events' filter inputs from `onchange` (blur-only) to
debounced `oninput`.
- **Found during verification, not in the original fix list:** the
Knowledge page's search never actually renders results (the "Clear"
button appears, confirming `searched` flips to `true`, but the content
area stays on the "Recently learned" branch) despite the backend request
succeeding with real data. Confirmed via `git diff` this isn't caused by
anything touched here. Spun off as a separate follow-up rather than fixed
in this pass, since it's unrelated to any finding in this review.
## Verification
- After each interaction fix (C1, B1, A3): manual keyboard-only pass (Tab +
Enter/Space, no mouse) through the affected page in the browser preview.
- After B3: type a filter query in Entities with mixed case against a
known-mixed-case entity name; confirm it now matches.
- After A1: confirm both entry paths (Entities row click, Knowledge search
hit's linked entity, Graph node's "View entity detail") land on the same
UI pattern; confirm sidebar/header state is coherent from whichever page
the user started on.
- `cd web && npm run lint && npm run check` clean after all fixes.
- Visual: `npm run build` + spot-check each changed page in the browser
preview (light pass, not full regression).
## Open questions
- **A1's resolution direction** (sheet vs. full-page route) is a genuine
product call, not just a bug fix — needs a decision before implementing,
not just "proceed." Recommendation given above (standardize on the
sheet), but flagging it explicitly since it changes user-visible behavior
for Knowledge and Graph, not just Entities.
- Whether to promote a11y lint rules from warn to error (C2) is a policy
call for the repo, worth a one-line "yes/no" rather than silently doing
it.

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

@@ -8,15 +8,12 @@ went sideways, open an investigation.
| Date | Title | Status |
| ---- | ----- | ------ |
| 2026-07-05 | [Oikos Prometheus LXC](2026-07-05-oikos-prometheus-lxc.md) | Planned |
| 2026-07-08 | [Plan vs implementation cross-reference](2026-07-08-plan-implementation-audit.md) | Planned |
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | Planned |
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
| 2026-07-08 | [Nomos resident agent (renames Hermes)](2026-07-08-nomos-resident-agent.md) | In Progress |
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress |
| 2026-07-09 | [Chat sessions: reliability, cost, and session-management fixes](2026-07-09-chat-sessions-improvements.md) | Planned |
| 2026-07-09 | [Session execution, UX, and learning improvements](2026-07-09-session-execution-and-ux-fixes.md) | Planned |
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | Planned |
| 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 — 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 |
## Done
@@ -37,6 +34,17 @@ See [`done/`](done/) for executed plans:
| 2026-07-08 | [Fix MCP analysis tools](done/2026-07-08-fix-mcp-analysis-tools.md) |
| 2026-07-06 | [Consolidate Oikos control plane onto mac-mini](done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md) |
| 2026-07-08 | [Signal triggers: host health checks](done/2026-07-08-signal-triggers.md) |
| 2026-07-08 | [Plan vs implementation cross-reference](done/2026-07-08-plan-implementation-audit.md) |
| 2026-07-08 | [Nomos resident agent (renames Hermes)](done/2026-07-08-nomos-resident-agent.md) |
| 2026-07-09 | [Chat sessions: reliability, cost, and session-management fixes](done/2026-07-09-chat-sessions-improvements.md) |
| 2026-07-09 | [Session execution, UX, and learning improvements](done/2026-07-09-session-execution-and-ux-fixes.md) |
| 2026-07-10 | [Autonomous plan execution: close the observation gap](done/2026-07-10-autonomous-plan-execution.md) |
| 2026-07-11 | [Tasks: the chat page as goal-structured autonomous work](done/2026-07-11-goal-oriented-chat-control-panel.md) |
| 2026-07-11 | [Concurrent task execution: safety + throughput + frontend correctness](done/2026-07-11-concurrent-task-execution.md) |
| 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) |
| 2026-07-11 | [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

@@ -641,6 +641,14 @@ entity_types:
domain: cognition
layer: cognition
description: Recorded investigation/postmortem.
task:
parent: entity
domain: cognition
layer: cognition
description: A goal-structured unit of agent work — one chat/session elevated
to a task with a plan, lifecycle status, and outcome. Anchors the knowledge
and involved-entity relationships for the task so future tasks can learn
from it. Typed rows in agent_sessions.
# ─── Relationship types ────────────────────────────────────────────────
# cardinality is source→target: e.g. `hosts` one-to-many = one machine
@@ -928,6 +936,14 @@ relationship_types:
target: entity
cardinality: many-to-one
description: Document describes an entity.
involves:
inverse: involved-in
source: task
target: entity
cardinality: many-to-many
description: Task explored or acted on an entity (captured from its tool
calls). A task's involved-entity set is its graph neighborhood, so future
tasks on the same entities can surface this task's knowledge and outcome.
procedure-for:
inverse: has-procedure
source: runbook

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": {

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