From 98cd43a921cbf7886ed5b56e61fe0534c2dac55b Mon Sep 17 00:00:00 2001 From: Wojtek Date: Fri, 17 Apr 2026 15:53:02 -0400 Subject: [PATCH 1/2] docs: agent context visibility design for clawdash Design plan for surfacing agent contracts and live assembled context in clawdash via claw-api. Covers cllama snapshot capture, new API endpoints, credential redaction, dashboard principal, and scope model. Reviewed through two rounds of Codex review (9 findings addressed). --- ...6-04-17-agent-context-visibility-design.md | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 docs/plans/2026-04-17-agent-context-visibility-design.md diff --git a/docs/plans/2026-04-17-agent-context-visibility-design.md b/docs/plans/2026-04-17-agent-context-visibility-design.md new file mode 100644 index 0000000..8925f73 --- /dev/null +++ b/docs/plans/2026-04-17-agent-context-visibility-design.md @@ -0,0 +1,381 @@ +# Agent Context Visibility for clawdash + +**Date:** 2026-04-17 +**Status:** Draft — revised after Codex review (v3) + +## Problem + +Operators have no way to inspect what an agent actually sees at runtime. clawdash shows fleet health, topology, and schedules, but nothing about agent contracts, injected context, feeds, tools, memory, or model policy. Debugging agent behavior requires manually reading files under `.claw-runtime/context/` and guessing what cllama assembled on any given turn. + +## Goals + +1. **Static contract view** — inspect the compiled artifacts written by `claw up` for any agent: AGENTS.md, CLAWDAPUS.md, metadata, feeds manifest, tools manifest, memory config. +2. **Live assembled context** — see the exact system message, tools array, and memory recall block that cllama delivered to the provider on the most recent turn. This must use the same execution path as real requests so it validates actual output, not a reconstruction. +3. **Single API convention** — all observability queries go through `claw-api`. clawdash talks to one backend. + +## Non-goals + +- Per-turn history browser (session history already exists in `.claw-session-history/`; a full turn explorer is a separate feature). +- Modifying agent context at runtime (write-plane concern, out of scope). +- Replacing the cllama operator dashboard (it stays for provider key management; context visibility is an operator concern surfaced in clawdash). + +--- + +## Architecture + +### Data flow + +``` +claw up + │ + ├─ writes .claw-runtime/context//* (static artifacts) + │ mounted read-only into both cllama and claw-api + │ + └─ cllama (runtime) + │ + ├─ assembles system message per-turn (feeds + time + tools + memory recall) + ├─ holds last assembled context per agent in memory ← NEW + └─ exposes GET /internal/context//snapshot ← NEW + │ + │ (internal network, not operator-facing) + ▼ + claw-api + ├─ GET /agents ← NEW (index) + ├─ GET /agents//contract ← NEW (static) + └─ GET /agents//context ← NEW (live, proxied from cllama) + │ + ▼ + clawdash + └─ /agents page with contract + context views ← NEW +``` + +### Why this layering + +**claw-api as the single query surface.** clawdash already talks to claw-api for fleet status, logs, metrics, alerts, and schedule. Adding agent context queries to the same surface keeps one auth model, one base URL, one convention. The existing `CLAWDASH_CLLAMA_COSTS_URL` direct-to-cllama path is a legacy shortcut; this design does not add another one. + +**cllama holds the live snapshot.** The assembled system message only exists in cllama's process memory during request handling. A file-based or external approach would require reconstruction, violating the "same execution path" principle. cllama captures and retains the snapshot as a side effect of the real assembly, then serves it on an internal endpoint. claw-api proxies it. + +**Static artifacts served from disk by claw-api.** The context directory is already mounted into cllama; adding it to claw-api's mounts is a one-line change in `ClawAPIConfig`/`compose_emit.go`. claw-api reads the files directly — no proxying needed for the static view. + +--- + +## Detailed Design + +### 1. cllama: capture and serve last assembled context + +**What changes:** + +In `cllama/internal/proxy/handler.go`, after the full assembly sequence (memory recall → feeds → time → tools → model resolution) but before dispatch to the provider, capture a snapshot of the assembled payload. + +**Snapshot struct** (new file `cllama/internal/proxy/snapshot.go`): + +```go +type ContextSnapshot struct { + AgentID string `json:"agent_id"` + CapturedAt time.Time `json:"captured_at"` + Format string `json:"format"` // "openai" or "anthropic" + System any `json:"system"` // the full system content — string or []ContentBlock + Tools []any `json:"tools"` // tool schemas as sent to provider + RequestedModel string `json:"requested_model"` // what the agent asked for + ChosenRef string `json:"chosen_ref"` // normalized model ref after policy + Candidates []CandidateSnapshot `json:"candidates"` // ordered dispatch candidates + FeedBlocks []string `json:"feed_blocks"` // individual feed results (for UI breakdown) + MemoryRecall string `json:"memory_recall"` // formatted memory block, empty if none + TimeContext string `json:"time_context"` // the injected time line + Intervention string `json:"intervention"` // model policy intervention, empty if none + ManagedTool bool `json:"managed_tool"` // true if this was a managed-tool turn + TurnCount int `json:"turn_count"` // for managed-tool: how many internal rounds +} + +type CandidateSnapshot struct { + Provider string `json:"provider"` // e.g. "anthropic", "openrouter" + UpstreamModel string `json:"upstream_model"` // e.g. "claude-sonnet-4-20250514" +} +``` + +**Key type decisions:** + +- `System` is `any`, not `string`. Anthropic's system field can be a plain string OR a `[]ContentBlock` array (block-array form preserved by `feeds.InjectAnthropic` in `cllama/internal/feeds/inject.go:128`). Capturing as `any` preserves the actual shape sent to the provider. The clawdash UI renders either form — string as monospace text, block-array as structured blocks. + +- `RequestedModel` vs `Candidates`: the agent requests a model name (e.g., `"claude-sonnet"`), but `resolveOpenAIExecution` (`handler.go:217`) produces a `modelResolution` with a `Candidates []dispatchCandidate` list, not a single model. `dispatchCandidates` (`handler.go:322`) iterates through candidates, rewriting `payload["model"]` per attempt, with failover when a provider is exhausted. A pre-dispatch snapshot cannot know which candidate will succeed — only which ones are available. The snapshot therefore captures the full candidate list (`ChosenRef` + provider/model per candidate), not a single "resolved model." The operator sees the resolution policy (what was tried) rather than a potentially misleading single value. + +- `ManagedTool` and `TurnCount`: in managed-tool mode (`handleManagedOpenAI` in `toolmediation.go`), the upstream request mutates across internal rounds as tool results are appended. The initial snapshot captures the first-turn context (before any tool rounds). `TurnCount` is updated when the managed-tool loop completes, so the operator knows this was a multi-round interaction. A full replay of intermediate rounds is out of scope — that belongs in session history. + +**Storage:** A `sync.Map` keyed by agent ID, holding the most recent `ContextSnapshot`. One entry per agent, overwritten each turn. Memory cost is bounded by agent count (typically <20 per pod). + +**Why capture individual components in addition to the full system content:** The `system` field is the source of truth (what the provider actually sees), but operators also need to understand *which parts came from where* — was the feed stale? Did memory recall return anything? Was there an intervention? The breakdown fields enable this without requiring the operator to parse the assembled blob. + +**Capture is two-phase:** + +**Phase A — after all injection, after model resolution, before dispatch.** This is the primary snapshot point. At this point: +- Memory recall, feeds, time context, and tools are all injected into the payload +- `resolveOpenAIExecution` / `resolveAnthropicExecution` has run, so we have the resolved model and any intervention +- The payload is the exact first-turn request the provider will see + +For the **OpenAI flow**, this is after `resolveOpenAIExecution` (`handler.go:217`) returns `resolution`, right before the branch into managed-tool vs simple dispatch (~line 230). The snapshot reads `system` from `payload["messages"]` (first system message), tools from `payload["tools"]`, and model info from `resolution`. + +For the **Anthropic flow**, same position — after resolution, before dispatch. System read from `payload["system"]` (preserving string or block-array form). + +**Phase B — managed-tool completion (conditional).** If the request enters `handleManagedOpenAI`, the tool mediation loop may run multiple internal rounds. When the loop completes, update the existing snapshot's `TurnCount` field. This is a lightweight mutation (one int write to the sync.Map entry), not a full re-snapshot. The operator sees "this was a 4-round managed-tool turn" without needing the intermediate states. + +**Why not snapshot after every managed-tool round?** Each round appends tool results and assistant messages to the payload. Capturing all of them would mean a growing array of snapshots per turn, which is session-history territory. The snapshot's job is "what context did the agent start with" — the first turn captures that. + +**New internal endpoint** on the cllama UI handler (`cllama/internal/ui/handler.go`): + +``` +GET /internal/context//snapshot +``` + +Returns the `ContextSnapshot` JSON for the given agent. Returns 404 if no request has been processed for that agent since cllama startup. Auth: bearer check (same `CLLAMA_UI_TOKEN` as existing endpoints). + +This endpoint is internal — only claw-api calls it. It is not documented as an operator-facing surface. + +**New list endpoint:** + +``` +GET /internal/context +``` + +Returns `{"agents": ["agent-0", "agent-1", ...]}` — the set of agents that have at least one captured snapshot. claw-api uses this to annotate the agent index with "has live context" availability. + +### 2. claw-api: new agent context endpoints + +**New verb:** `agent.context` — added to `AllReadVerbs` in `internal/clawapi/principal.go`. This gates all three new endpoints. + +**Justification for a single verb:** The static contract and live context are both read-only observability of the same conceptual data (what an agent knows). Splitting into `agent.contract` and `agent.context` would add principal configuration burden with no meaningful security distinction — if you can see the contract, you can see the live context, and vice versa. + +**New config fields** in `ClawAPIConfig` (`internal/pod/compose_emit.go`): + +```go +ContextHostDir string // host path to .claw-runtime/context/ directory +CllamaAPIURL string // internal URL for cllama snapshot endpoint (e.g. http://cllama-passthrough:8081) +CllamaAPIToken string // CLLAMA_UI_TOKEN for authenticating against cllama's internal endpoints +``` + +**Mount:** `.claw-runtime/context/` mounted read-only at `/claw/context` in the claw-api container. Same directory already mounted into cllama — no new generation, just a second consumer. + +**Environment:** `CLAW_CONTEXT_ROOT=/claw/context`, `CLAW_CLLAMA_API_URL=http://:`, and `CLAW_CLLAMA_API_TOKEN=` injected into claw-api env by `compose_up.go`. + +**cllama auth for claw-api (CRITICAL):** cllama's UI handler enforces bearer auth on every route via `checkBearer()` (`cllama/internal/ui/handler.go:120`). The `CLLAMA_UI_TOKEN` is currently provisioned only into the proxy container env (`compose_up.go:648`). claw-api needs this token to call the snapshot endpoints. `compose_up.go` must pass the same `uiToken` value into `ClawAPIConfig` so `compose_emit.go` can inject it as `CLAW_CLLAMA_API_TOKEN`. claw-api sends it as `Authorization: Bearer ` when proxying to cllama. This is the same token, not a new credential — claw-api acts as a trusted internal consumer of cllama's UI surface. + +**Endpoints** (in `cmd/claw-api/handler.go`): + +#### `GET /agents` + +Returns an index of all agents in the pod. + +```json +{ + "agents": [ + { + "claw_id": "analyst-0", + "service": "analyst", + "claw_type": "openclaw", + "has_live_context": true + } + ] +} +``` + +**Implementation:** Scan `/claw/context/` for subdirectories (each is an agent ID). Enrich with service name and claw type from the pod manifest. `has_live_context` is populated by calling cllama's `GET /internal/context` list endpoint (cached with short TTL, e.g. 5s). + +**Scope filtering:** The agent list is filtered by the calling principal's scope, consistent with how `handleStatus` and `handleMetrics` work today (`handler.go:496-553`). + +**Important: `AllowsClawID` does not check service scope.** `AllowsClawID()` (`principal.go:132`) only checks `AllowsPod()` (needs `Pods` set) and explicit `ClawIDs` match. But `BuildSelfPrincipal()` (`principal.go:166`) only sets `Services`, not `Pods` or `ClawIDs`. A self principal for service `analyst` has `Services: ["analyst"]` but no `Pods` or `ClawIDs` — so `AllowsClawID("pod", "analyst-0")` returns false. + +**Fix:** The `/agents` endpoints use a **new scope check function** `allowsAgentContext(principal, podName, clawID, serviceName)` that accepts if ANY of: +- `principal.AllowsPod(podName)` — pod-scoped principals (master, dashboard) see everything +- `principal.AllowsClawID(podName, clawID)` — explicit claw_id scope +- `principal.AllowsService(podName, serviceName)` — service-scoped principals see their service's agents + +The handler resolves `serviceName` from the claw_id by reading `metadata.json` (which has the `service` field) or from the pod manifest. This way a self principal for `analyst` (which has `Services: ["analyst"]`) can inspect `analyst-0` and `analyst-1` but not `trader-0`. + +For `GET /agents`, the list is filtered using this function per entry. For `GET /agents//contract` and `GET /agents//context`, the target is checked before serving — 403 if out of scope. + +#### `GET /agents//contract` + +Returns the compiled artifacts for one agent. + +```json +{ + "claw_id": "analyst-0", + "agents_md": "# analyst\n\nYou are a market analyst...", + "clawdapus_md": "# CLAWDAPUS infrastructure context\n...", + "metadata": { "pod": "trading-desk", "service": "analyst", ... }, + "feeds": [ { "name": "channel-context", "source": "claw-wall", "ttl": 30, ... } ], + "tools": { "version": 1, "tools": [...], "policy": {...} }, + "memory": { "service": "mem-service", "base_url": "...", ... } +} +``` + +**Implementation:** Read files from `/claw/context//`. AGENTS.md and CLAWDAPUS.md returned as strings. JSON files (metadata, feeds, tools, memory) parsed and inlined. Missing optional files (feeds.json, tools.json, memory.json) returned as `null`. + +**Credential redaction (CRITICAL):** Multiple context artifacts carry bearer tokens that must not be exposed: + +| File | Field | Source | +|------|-------|--------| +| `metadata.json` | `token` | Agent's cllama bearer secret (`compose_up.go:572`) | +| `feeds.json` | `[].auth` | Feed bearer tokens (`compose_up.go:1269`) | +| `tools.json` | `tools[].execution.auth` | Tool endpoint auth (`ToolExecution.Auth` in `context.go:57`, populated at `compose_up.go:1333`) | +| `memory.json` | `auth` | Memory service auth (`MemoryManifestEntry.Auth` in `context.go:79`, populated at `compose_up.go:1371`) | +| `service-auth/*.json` | `token` | Per-service bearer tokens (`ServiceAuthEntry.Token` in `context.go:33`) | + +The contract handler applies a recursive redaction pass before serialization: any JSON key matching `token`, `auth`, or `secret` at any depth is replaced with `"[REDACTED]"` (for strings) or `{"type": "[REDACTED]"}` (for auth objects, preserving the `type` field so operators can see *what kind* of auth is configured without seeing the credential). This is a deny-list — new fields default to visible, and credential fields must be added explicitly when introduced. The redaction function is unit-tested against the actual context file schemas to catch drift. + +**Why return everything in one envelope:** The operator will always want the full picture. Splitting into sub-endpoints (`/contract/agents-md`, `/contract/metadata`, etc.) adds round trips for no benefit — these files are small. + +#### `GET /agents//context` + +Returns the last assembled turn context from cllama. + +**Implementation:** Proxies to cllama's `GET /internal/context//snapshot`. Returns the `ContextSnapshot` JSON directly. Returns `404` with `{"error": "no context captured yet"}` if the agent hasn't processed a request since cllama startup. + +**Why proxy instead of direct access:** Keeps the single-API convention. clawdash never needs to know cllama's address. The proxy is trivial (one HTTP call, pass through response). + +### 3. claw up: wiring changes + +**`compose_up.go`:** + +- Set `ClawAPIConfig.ContextHostDir` to the same `filepath.Join(runtimeDir, "context")` used for cllama proxy config. +- Set `ClawAPIConfig.CllamaAPIURL` to `http://:` (constructed from the proxy config, same pattern as `CLAWDASH_CLLAMA_COSTS_URL`). +- Set `ClawAPIConfig.CllamaAPIToken` to the same `uiToken` generated at line 635. This is the existing `CLLAMA_UI_TOKEN` — claw-api needs it to authenticate against cllama's internal endpoints. +- Change the clawdash API wiring conditional from `p.ClawAPI != nil && hasPodInvokeEntries(p)` to `p.ClawAPI != nil` so clawdash gets credentials on all pods that have claw-api. + +**`compose_emit.go`:** + +- In the claw-api volume list, add `ContextHostDir → /claw/context:ro`. +- In `clawAPIEnvironment()`, emit `CLAW_CONTEXT_ROOT`, `CLAW_CLLAMA_API_URL`, and `CLAW_CLLAMA_API_TOKEN`. + +**Principal generation (`prepareClawAPIRuntime`):** + +- Add `agent.context` to the master claw's verb set (master already gets `AllReadVerbs`; adding `agent.context` there is sufficient). +- Add `BuildDashboardPrincipal(podName)` call — always created when `p.ClawAPI != nil`, returns a principal with `AllReadVerbs` + `agent.context`, scoped to the pod. This is the auth identity for clawdash. +- The scheduler principal is unchanged — it keeps its narrow `schedule.read`/`schedule.control` verbs for invoke operations. + +### 4. clawdash: agent context UI + +**New route:** `GET /agents` → `h.renderAgents(w, r)` + +**Fleet page integration:** Add an "Agents" link in the nav bar alongside Fleet, Topology, Schedule. + +**Agents index page:** Card grid of agents (same visual pattern as fleet service cards). Each card shows: agent ID, service name, claw type, whether live context is available. Click through to detail. + +**Agent detail page:** `GET /agents/` + +Two-tab layout: + +**Tab 1 — Contract** (static, from `/agents//contract`): +- AGENTS.md rendered as formatted text (monospace block, or markdown if we add a renderer) +- CLAWDAPUS.md rendered similarly +- Metadata as a key-value table +- Feeds manifest as a table (name, source, TTL, URL) +- Tools manifest as collapsible list (name, description, schema expandable) +- Memory config as key-value pairs + +**Tab 2 — Live Context** (from `/agents//context`): +- "Last captured" timestamp with relative time +- Full system message in a scrollable monospace block (the source of truth) +- Breakdown panel: feed blocks listed individually, memory recall block, time context line, model policy intervention if any +- Tools array as collapsible schemas +- Resolved model name +- "No context yet" state if the agent hasn't made a request + +**Refresh:** Manual refresh button on the Live Context tab. No auto-polling — this is a debugging tool, not a monitoring dashboard. + +**Data fetching:** clawdash calls claw-api's `/agents` and `/agents//contract` or `/agents//context` endpoints using `CLAW_API_URL` + `CLAW_API_TOKEN`. + +**Wiring gap (CRITICAL):** Today, `CLAW_API_URL` and `CLAW_API_TOKEN` are only injected into clawdash when the pod has invoke entries (`compose_up.go:695`: `if p.ClawAPI != nil && hasPodInvokeEntries(p)`). The Agents UI needs credentials on *every* pod that has claw-api. + +The root problem is deeper than just the conditional: the token lookup calls `lookupClawAPIPrincipalToken(_, "claw-scheduler")`, but the `claw-scheduler` principal is only auto-created when invokes exist (`compose_up.go:1807`). On a master-only pod with no invokes, there is no scheduler principal to look up. + +**Fix:** Introduce a `claw-dashboard` principal alongside the existing auto-generated principals. `prepareClawAPIRuntime` always creates this principal when clawdash is injected (which is always — clawdash is unconditional). The principal gets `AllReadVerbs` + `agent.context` scoped to the pod. `compose_up.go` then: + +1. Always creates the `claw-dashboard` principal when `p.ClawAPI != nil` (in the auto-principal block at ~line 1800, after the master principal and before the self principals). +2. Changes the clawdash env injection conditional from `p.ClawAPI != nil && hasPodInvokeEntries(p)` to `p.ClawAPI != nil`. +3. Looks up `claw-dashboard` instead of `claw-scheduler` for the token. + +The scheduler principal continues to exist separately for invoke/schedule operations — it keeps its narrower verb set (`schedule.read`, `schedule.control`). The dashboard principal is the correct auth identity for clawdash's read-only observability role. + +For pods that have *neither* master nor invoke (no claw-api at all), clawdash won't have API credentials and the Agents nav link should be hidden. The template checks `{{ if .HasAPIAccess }}` before rendering the link. + +--- + +## Implementation Sequence + +Work is ordered to deliver value incrementally and allow each layer to be tested before the next depends on it. + +### Phase 1: cllama snapshot capture +1. Add `ContextSnapshot` struct and `sync.Map` store in `cllama/internal/proxy/` +2. Capture snapshot at the assembly-complete point in both OpenAI and Anthropic flows +3. Add `/internal/context` and `/internal/context//snapshot` endpoints to cllama UI handler +4. Unit test: mock request → verify snapshot captured with correct fields +5. Integration test: real cllama startup → send request → GET snapshot → verify content matches + +### Phase 2: claw-api context endpoints +1. Add `agent.context` verb to principal system +2. Add `ContextHostDir` and `CllamaAPIURL` to `ClawAPIConfig` +3. Mount context directory and inject env vars in `compose_emit.go` / `compose_up.go` +4. Implement `GET /agents`, `GET /agents//contract`, `GET /agents//context` handlers +5. Add principal wiring in `prepareClawAPIRuntime` +6. Unit test: handler tests with fixture context directory +7. Integration test: full `claw up` → verify mounts and env vars in generated compose + +### Phase 3: clawdash agents view +1. Add `/agents` route and index page +2. Add `/agents/` detail page with contract tab +3. Add live context tab +4. Add nav bar link +5. Manual testing against a running pod (quickstart or trading-desk example) + +--- + +## Codex Review Fixes + +### Round 1 (v2) + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| 1 | High | `metadata.json` contains agent bearer token (`compose_up.go:572`); raw `/contract` response would leak credentials | Contract endpoint redacts `token` from metadata, `auth` from feeds, `token` from service-auth entries. Explicit redaction list, not generic filter. | +| 2 | High | claw-api has no credential to call cllama's bearer-authenticated UI endpoints | `compose_up.go` passes the existing `CLLAMA_UI_TOKEN` into `ClawAPIConfig.CllamaAPIToken`; emitted as `CLAW_CLLAMA_API_TOKEN` env var. | +| 3 | High | Snapshot `SystemMessage string` doesn't cover Anthropic block-array; capture point is before model resolution; managed-tool rounds mutate payload | `System` field is `any` (preserves string or block-array). Capture moved to after resolution. Managed-tool turns get TurnCount update on completion. | +| 4 | Medium | `CLAW_API_URL`/`TOKEN` only wired into clawdash when pod has invoke entries (`compose_up.go:695`) | Conditional changed to `p.ClawAPI != nil` (drop `hasPodInvokeEntries` guard). Agents nav hidden when no API access. | +| 5 | Medium | `/agents` returns all agents regardless of principal scope | Agent list and detail endpoints filtered by claw_id scope via `AllowsClawID()`, consistent with `fleet.query_metrics`. | + +### Round 2 (v3) + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| 6 | High | Redaction missed `tools.json` (`ToolExecution.Auth` at `context.go:57`) and `memory.json` (`MemoryManifestEntry.Auth` at `context.go:79`) — both carry bearer tokens | Redaction is now recursive across all artifacts with a complete field table. Unit-tested against actual schemas. | +| 7 | High | Master-only pods have no `claw-scheduler` principal; `lookupClawAPIPrincipalToken("claw-scheduler")` fails when no invoke entries | Introduced dedicated `claw-dashboard` principal, always created when `p.ClawAPI != nil`. Decoupled from scheduler. | +| 8 | Medium | `AllowsClawID()` only checks pod scope and explicit ClawIDs; `BuildSelfPrincipal` only sets Services — self principals would 403 on all agent endpoints | New `allowsAgentContext()` function checks pod OR claw_id OR service scope. Self principals reach their service's agents via service match. | +| 9 | Medium | `ResolvedModel` described as "what the provider received" but resolution produces a candidate list with failover; pre-dispatch snapshot can't know which candidate succeeds | Replaced single `ResolvedModel` with `ChosenRef` + `[]CandidateSnapshot`. Snapshot shows the resolution policy, not a single potentially wrong value. | + +## Open Questions + +1. **Snapshot size budgeting.** A system message with multiple feeds and memory recall can be 10-50KB. The `sync.Map` holding one per agent is negligible for typical pods (<20 agents). Should we add a hard cap or TTL eviction? Leaning no — the memory cost is trivially bounded by agent count, and stale snapshots are still useful ("this is what it looked like on the last turn, 3 hours ago"). + +2. **Feed content in snapshot.** The `feed_blocks` breakdown includes actual feed content (channel messages, API data). This is useful for debugging but could be large. Should the snapshot truncate feed content? Leaning no for the snapshot itself — truncation should be a UI concern in clawdash if needed. + +3. **Multi-proxy pods.** The design assumes one cllama proxy. The existing runtime already fails fast on multi-proxy, so this isn't a new limitation, but worth noting for the `CllamaAPIURL` wiring. + +4. **Costs migration.** clawdash currently talks to cllama directly for costs via `CLAWDASH_CLLAMA_COSTS_URL`. Should we move costs behind claw-api in this work? Leaning no — costs are a working feature today, and migrating them adds scope without adding context visibility. File a separate issue. + +--- + +## Files Changed (estimated) + +| File | Change | +|------|--------| +| `cllama/internal/proxy/snapshot.go` | New — snapshot struct and sync.Map store | +| `cllama/internal/proxy/handler.go` | Capture snapshot after assembly (~10 lines per flow) | +| `cllama/internal/ui/handler.go` | Two new endpoint cases in ServeHTTP, two handler methods | +| `internal/clawapi/principal.go` | Add `VerbAgentContext`, update `AllReadVerbs`, add `BuildDashboardPrincipal()` | +| `internal/pod/compose_emit.go` | Add `ContextHostDir`, `CllamaAPIURL` to config; mount + env | +| `cmd/claw/compose_up.go` | Set new ClawAPIConfig fields; add dashboard principal; widen clawdash env conditional; pass cllama UI token | +| `cmd/claw-api/handler.go` | Three new route cases, three handler methods, cllama proxy helper | +| `cmd/claw-api/main.go` | Read `CLAW_CONTEXT_ROOT`, `CLAW_CLLAMA_API_URL`, `CLAW_CLLAMA_API_TOKEN` from env | +| `cmd/clawdash/handler.go` | New route, agent page rendering, claw-api client calls | +| `cmd/clawdash/templates/agents.html` | New — agent index page | +| `cmd/clawdash/templates/agent_detail.html` | New — agent detail page with tabs | +| `cmd/clawdash/templates/fleet.html` | Add "Agents" to nav bar | +| `internal/clawapi/skill.go` | Update skill descriptor with new endpoints | From 22904ff9cd4d7b2793c2f2d5958cb75f859bd67d Mon Sep 17 00:00:00 2001 From: Wojtek Date: Wed, 22 Apr 2026 15:46:53 -0400 Subject: [PATCH 2/2] feat: agent context visibility in clawdash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end agent context inspection: operators can see each agent's compiled contract (AGENTS.md, CLAWDAPUS.md, feeds, tools, memory) plus the live system message cllama assembled for the most recent turn. - cllama: bumps submodule to capture a ContextSnapshot per agent and expose it via two internal UI endpoints. - claw-api: new agent.context verb and three endpoints that read contract artifacts from the mounted context dir and proxy live snapshots from cllama. Credentials in metadata/feeds/tools/memory are recursively redacted before serving. - clawdash: new /agents index and /agents/ detail page with contract + live-context tabs. Minimalist live layout — the assembled system message is the hero, dynamic inputs render as a tagged ordered list, supporting data lives in
. - claw up: injects CLAW_CLLAMA_API_URL/TOKEN and the context-dir mount into claw-api; always creates a claw-dashboard principal so the Agents UI works on every pod with claw-api (previously only pods with invoke entries). Emits the dashboard URL on success. - audit: switches ParseReader to bufio.Reader so large context events no longer trip the 1 MiB Scanner token limit. Closes #172 --- README.md | 2 +- cllama | 2 +- cmd/claw-api/agent_context.go | 420 ++++++++++ cmd/claw-api/agent_context_test.go | 265 +++++++ cmd/claw-api/handler.go | 36 +- cmd/claw-api/main.go | 19 +- cmd/claw/compose_up.go | 20 +- cmd/claw/compose_up_test.go | 10 + cmd/claw/skill_data/SKILL.md | 15 +- cmd/clawdash/agent_context.go | 887 ++++++++++++++++++++++ cmd/clawdash/handler.go | 50 +- cmd/clawdash/handler_test.go | 198 +++++ cmd/clawdash/main.go | 3 +- cmd/clawdash/schedule_page.go | 2 + cmd/clawdash/static/app.css | 2 +- cmd/clawdash/tailwind.css | 113 +++ cmd/clawdash/templates/agent_detail.html | 305 ++++++++ cmd/clawdash/templates/agents.html | 93 +++ cmd/clawdash/templates/detail.html | 1 + cmd/clawdash/templates/fleet.html | 1 + cmd/clawdash/templates/schedule.html | 1 + cmd/clawdash/templates/topology.html | 1 + cmd/clawdash/topology.go | 1 + internal/audit/normalize.go | 62 +- internal/audit/normalize_test.go | 30 + internal/clawapi/principal.go | 17 +- internal/clawapi/principal_test.go | 26 + internal/clawapi/skill.go | 3 + internal/pod/compose_emit.go | 22 + internal/pod/compose_emit_clawapi_test.go | 52 ++ site/guide/cli.md | 2 +- site/guide/quickstart.md | 2 +- skills/clawdapus/SKILL.md | 15 +- 33 files changed, 2616 insertions(+), 62 deletions(-) create mode 100644 cmd/claw-api/agent_context.go create mode 100644 cmd/claw-api/agent_context_test.go create mode 100644 cmd/clawdash/agent_context.go create mode 100644 cmd/clawdash/templates/agent_detail.html create mode 100644 cmd/clawdash/templates/agents.html diff --git a/README.md b/README.md index 5e2d25c..8055c33 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ claw down The cllama governance proxy dashboard runs on port **8181** — every LLM call in real time: which agent, which model, token counts, cost. -The Clawdapus Dash fleet dashboard runs on port **8082** — live service health, topology wiring, and per-service drill-down. +The Clawdapus Dash fleet dashboard runs on port **8082** — live service health, topology wiring, per-service drill-down, and agent context inspection. The Agents view shows each claw's compiled `AGENTS.md`/`CLAWDAPUS.md`, redacted runtime manifests, and the latest live context snapshot captured by cllama. The operator surface is four verbs: - `claw pull` fetches pinned runtime infra and registry-backed pod services diff --git a/cllama b/cllama index 270976e..436df22 160000 --- a/cllama +++ b/cllama @@ -1 +1 @@ -Subproject commit 270976e1777ffb0bb8b1ebcaa9f5f9bb6beefcd9 +Subproject commit 436df22b63866fdb1b68c679b93ddcce85205625 diff --git a/cmd/claw-api/agent_context.go b/cmd/claw-api/agent_context.go new file mode 100644 index 0000000..db6c27f --- /dev/null +++ b/cmd/claw-api/agent_context.go @@ -0,0 +1,420 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/mostlydev/clawdapus/internal/clawapi" +) + +const maxContextSnapshotBytes = 8 * 1024 * 1024 + +type agentIndexEntry struct { + ClawID string `json:"claw_id"` + Service string `json:"service,omitempty"` + ClawType string `json:"claw_type,omitempty"` + HasLiveContext bool `json:"has_live_context"` +} + +type agentContractResponse struct { + ClawID string `json:"claw_id"` + AgentsMD string `json:"agents_md"` + ClawdapusMD string `json:"clawdapus_md"` + Metadata any `json:"metadata"` + Feeds any `json:"feeds"` + Tools any `json:"tools"` + Memory any `json:"memory"` + ServiceAuth map[string]any `json:"service_auth,omitempty"` +} + +type agentContextPath struct { + AgentID string + Action string +} + +func (h *apiHandler) handleAgentsList(w http.ResponseWriter, r *http.Request) { + principal, ok := h.authorize(w, r, clawapi.VerbAgentContext, "") + if !ok { + return + } + agents, err := h.listContextAgents() + if err != nil { + writeJSONError(w, http.StatusServiceUnavailable, err.Error()) + return + } + liveAgents := h.liveContextAgentSet(r.Context()) + out := make([]agentIndexEntry, 0, len(agents)) + for _, agent := range agents { + if !h.allowsAgentContext(principal, agent.ClawID, agent.Service) { + continue + } + agent.HasLiveContext = liveAgents[agent.ClawID] + out = append(out, agent) + } + writeJSON(w, http.StatusOK, map[string]any{"agents": out}) +} + +func (h *apiHandler) handleAgentDetail(w http.ResponseWriter, r *http.Request) { + parsed, ok := parseAgentContextPath(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + principal, authorized := h.authorize(w, r, clawapi.VerbAgentContext, parsed.AgentID) + if !authorized { + return + } + agent, err := h.readContextAgent(parsed.AgentID) + if err != nil { + if os.IsNotExist(err) { + writeJSONError(w, http.StatusNotFound, "agent context not found") + return + } + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + if !h.allowsAgentContext(principal, agent.ClawID, agent.Service) { + h.logDecision(principal.Name, clawapi.VerbAgentContext, parsed.AgentID, false, "agent out of scope") + writeJSONError(w, http.StatusForbidden, "agent is out of scope") + return + } + + switch parsed.Action { + case "contract": + h.handleAgentContract(w, parsed.AgentID) + case "context": + h.handleAgentLiveContext(w, r, parsed.AgentID) + default: + http.NotFound(w, r) + } +} + +func (h *apiHandler) handleAgentContract(w http.ResponseWriter, agentID string) { + agentDir, err := h.agentContextDir(agentID) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + agentsMD, err := os.ReadFile(filepath.Join(agentDir, "AGENTS.md")) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("read AGENTS.md: %v", err)) + return + } + clawdapusMD, err := os.ReadFile(filepath.Join(agentDir, "CLAWDAPUS.md")) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("read CLAWDAPUS.md: %v", err)) + return + } + metadata, err := readJSONArtifact(filepath.Join(agentDir, "metadata.json"), false) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + feeds, err := readJSONArtifact(filepath.Join(agentDir, "feeds.json"), true) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + tools, err := readJSONArtifact(filepath.Join(agentDir, "tools.json"), true) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + memory, err := readJSONArtifact(filepath.Join(agentDir, "memory.json"), true) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + serviceAuth, err := readServiceAuthArtifacts(filepath.Join(agentDir, "service-auth")) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, agentContractResponse{ + ClawID: agentID, + AgentsMD: string(agentsMD), + ClawdapusMD: string(clawdapusMD), + Metadata: redactJSONValue(metadata), + Feeds: redactJSONValue(feeds), + Tools: redactJSONValue(tools), + Memory: redactJSONValue(memory), + ServiceAuth: redactServiceAuthArtifacts(serviceAuth), + }) +} + +func (h *apiHandler) handleAgentLiveContext(w http.ResponseWriter, r *http.Request, agentID string) { + if strings.TrimSpace(h.cllamaAPIURL) == "" { + writeJSONError(w, http.StatusServiceUnavailable, "cllama context API is not configured") + return + } + target := strings.TrimRight(h.cllamaAPIURL, "/") + "/internal/context/" + url.PathEscape(agentID) + "/snapshot" + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + if h.cllamaAPIToken != "" { + req.Header.Set("Authorization", "Bearer "+h.cllamaAPIToken) + } + resp, err := h.httpClient.Do(req) + if err != nil { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("cllama context request failed: %v", err)) + return + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + writeJSONError(w, http.StatusNotFound, "no context captured yet") + return + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("cllama context request returned %s", resp.Status)) + return + } + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json; charset=utf-8" + } + w.Header().Set("Content-Type", contentType) + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, io.LimitReader(resp.Body, maxContextSnapshotBytes)) +} + +func (h *apiHandler) listContextAgents() ([]agentIndexEntry, error) { + root := strings.TrimSpace(h.contextRoot) + if root == "" { + return nil, fmt.Errorf("agent context root is not configured") + } + entries, err := os.ReadDir(root) + if err != nil { + return nil, fmt.Errorf("read agent context root: %w", err) + } + agents := make([]agentIndexEntry, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + agent, err := h.readContextAgent(entry.Name()) + if err != nil { + return nil, err + } + agents = append(agents, agent) + } + sort.Slice(agents, func(i, j int) bool { + return agents[i].ClawID < agents[j].ClawID + }) + return agents, nil +} + +func (h *apiHandler) readContextAgent(agentID string) (agentIndexEntry, error) { + agentDir, err := h.agentContextDir(agentID) + if err != nil { + return agentIndexEntry{}, err + } + raw, err := os.ReadFile(filepath.Join(agentDir, "metadata.json")) + if err != nil { + return agentIndexEntry{}, err + } + var metadata map[string]any + if err := json.Unmarshal(raw, &metadata); err != nil { + return agentIndexEntry{}, fmt.Errorf("parse metadata for %q: %w", agentID, err) + } + return agentIndexEntry{ + ClawID: agentID, + Service: stringValue(metadata["service"]), + ClawType: stringValue(metadata["type"]), + }, nil +} + +func (h *apiHandler) agentContextDir(agentID string) (string, error) { + agentID = strings.TrimSpace(agentID) + if err := validateGovernanceTarget(agentID); err != nil { + return "", err + } + root := strings.TrimSpace(h.contextRoot) + if root == "" { + return "", fmt.Errorf("agent context root is not configured") + } + return filepath.Join(root, agentID), nil +} + +func (h *apiHandler) allowsAgentContext(principal *clawapi.Principal, clawID, service string) bool { + if principal == nil || h == nil || h.manifest == nil { + return false + } + podName := h.manifest.PodName + return principal.AllowsPod(podName) || + principal.AllowsClawID(podName, clawID) || + (service != "" && principal.AllowsService(podName, service)) +} + +func (h *apiHandler) liveContextAgentSet(ctx context.Context) map[string]bool { + out := make(map[string]bool) + if strings.TrimSpace(h.cllamaAPIURL) == "" { + return out + } + reqCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, strings.TrimRight(h.cllamaAPIURL, "/")+"/internal/context", nil) + if err != nil { + return out + } + if h.cllamaAPIToken != "" { + req.Header.Set("Authorization", "Bearer "+h.cllamaAPIToken) + } + resp, err := h.httpClient.Do(req) + if err != nil { + return out + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return out + } + var decoded struct { + Agents []string `json:"agents"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&decoded); err != nil { + return out + } + for _, agentID := range decoded.Agents { + if strings.TrimSpace(agentID) != "" { + out[agentID] = true + } + } + return out +} + +func parseAgentContextPath(path string) (agentContextPath, bool) { + rest := strings.TrimPrefix(path, "/agents/") + if rest == path || rest == "" { + return agentContextPath{}, false + } + parts := strings.Split(rest, "/") + if len(parts) != 2 { + return agentContextPath{}, false + } + agentID, err := url.PathUnescape(parts[0]) + if err != nil || strings.TrimSpace(agentID) == "" { + return agentContextPath{}, false + } + action := strings.TrimSpace(parts[1]) + if action != "contract" && action != "context" { + return agentContextPath{}, false + } + return agentContextPath{AgentID: agentID, Action: action}, true +} + +func readJSONArtifact(path string, optional bool) (any, error) { + raw, err := os.ReadFile(path) + if err != nil { + if optional && os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read %s: %w", filepath.Base(path), err) + } + var out any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("parse %s: %w", filepath.Base(path), err) + } + return out, nil +} + +func readServiceAuthArtifacts(authDir string) (map[string]any, error) { + entries, err := os.ReadDir(authDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read service-auth: %w", err) + } + out := make(map[string]any) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + parsed, err := readJSONArtifact(filepath.Join(authDir, entry.Name()), false) + if err != nil { + return nil, err + } + name := strings.TrimSuffix(entry.Name(), ".json") + out[name] = parsed + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + +func redactServiceAuthArtifacts(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = redactJSONValue(v) + } + return out +} + +func redactJSONValue(v any) any { + switch typed := v.(type) { + case map[string]any: + out := make(map[string]any, len(typed)) + for k, child := range typed { + switch strings.ToLower(strings.TrimSpace(k)) { + case "token", "secret": + out[k] = "[REDACTED]" + case "auth": + out[k] = redactAuthValue(child) + default: + out[k] = redactJSONValue(child) + } + } + return out + case []any: + out := make([]any, len(typed)) + for i, child := range typed { + out[i] = redactJSONValue(child) + } + return out + default: + return v + } +} + +func redactAuthValue(v any) any { + switch typed := v.(type) { + case map[string]any: + out := map[string]any{} + if authType := stringValue(typed["type"]); authType != "" { + out["type"] = authType + } else { + out["type"] = "[REDACTED]" + } + return out + case string: + if typed == "" { + return "" + } + return "[REDACTED]" + default: + if v == nil { + return nil + } + return "[REDACTED]" + } +} + +func stringValue(v any) string { + s, _ := v.(string) + return strings.TrimSpace(s) +} diff --git a/cmd/claw-api/agent_context_test.go b/cmd/claw-api/agent_context_test.go new file mode 100644 index 0000000..3bcbbcb --- /dev/null +++ b/cmd/claw-api/agent_context_test.go @@ -0,0 +1,265 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mostlydev/clawdapus/internal/clawapi" + manifestpkg "github.com/mostlydev/clawdapus/internal/clawdash" +) + +func TestAgentsListFiltersByServiceScopeAndLiveContext(t *testing.T) { + contextRoot := t.TempDir() + writeAgentContextFixture(t, contextRoot, "trader-0", map[string]string{ + "service": "trader", + "type": "openclaw", + }) + writeAgentContextFixture(t, contextRoot, "analyst-0", map[string]string{ + "service": "analyst", + "type": "nanoclaw", + }) + + var sawAuth string + cllama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawAuth = r.Header.Get("Authorization") + if r.URL.Path != "/internal/context" { + t.Fatalf("unexpected cllama path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"agents":["trader-0"]}`)) + })) + defer cllama.Close() + + h := newHandler( + &manifestpkg.PodManifest{PodName: "ops"}, + nil, + nil, + nil, + &clawapi.Store{Principals: []clawapi.Principal{{ + Name: "trader-self", + Token: "capi_trader", + Verbs: []string{clawapi.VerbAgentContext}, + Services: []string{"trader"}, + }}}, + nil, + nil, + clawapi.DefaultThresholds(), + t.TempDir(), + withAgentContextConfig(contextRoot, cllama.URL, "ui-token"), + ) + + req := httptest.NewRequest(http.MethodGet, "/agents", nil) + req.Header.Set("Authorization", "Bearer capi_trader") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + if sawAuth != "Bearer ui-token" { + t.Fatalf("expected cllama bearer token, got %q", sawAuth) + } + var resp struct { + Agents []agentIndexEntry `json:"agents"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v body=%s", err, w.Body.String()) + } + if len(resp.Agents) != 1 { + t.Fatalf("expected 1 scoped agent, got %+v", resp.Agents) + } + got := resp.Agents[0] + if got.ClawID != "trader-0" || got.Service != "trader" || got.ClawType != "openclaw" || !got.HasLiveContext { + t.Fatalf("unexpected agent index entry: %+v", got) + } +} + +func TestAgentContractRedactsContextCredentials(t *testing.T) { + contextRoot := t.TempDir() + agentDir := writeAgentContextFixture(t, contextRoot, "trader-0", map[string]string{ + "service": "trader", + "type": "openclaw", + "token": "trader-0:secret", + }) + if err := os.WriteFile(filepath.Join(agentDir, "feeds.json"), []byte(`[{"name":"alerts","auth":"feed-token"}]`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "tools.json"), []byte(`{"tools":[{"name":"svc.tool","execution":{"auth":{"type":"bearer","token":"tool-token"}}}]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "memory.json"), []byte(`{"service":"mem","auth":{"type":"bearer","token":"memory-token"}}`), 0o644); err != nil { + t.Fatal(err) + } + authDir := filepath.Join(agentDir, "service-auth") + if err := os.MkdirAll(authDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(authDir, "claw-api.json"), []byte(`{"service":"claw-api","auth_type":"bearer","token":"api-token"}`), 0o600); err != nil { + t.Fatal(err) + } + + h := newAgentContextTestHandler(t, contextRoot, clawapi.Principal{ + Name: "dashboard", + Token: "capi_dash", + Verbs: []string{clawapi.VerbAgentContext}, + Pods: []string{"ops"}, + }) + req := httptest.NewRequest(http.MethodGet, "/agents/trader-0/contract", nil) + req.Header.Set("Authorization", "Bearer capi_dash") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "secret") || strings.Contains(w.Body.String(), "feed-token") || strings.Contains(w.Body.String(), "tool-token") || strings.Contains(w.Body.String(), "memory-token") || strings.Contains(w.Body.String(), "api-token") { + t.Fatalf("contract response leaked credential: %s", w.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + metadata := resp["metadata"].(map[string]any) + if metadata["token"] != "[REDACTED]" { + t.Fatalf("metadata token was not redacted: %+v", metadata) + } + feeds := resp["feeds"].([]any) + if feeds[0].(map[string]any)["auth"] != "[REDACTED]" { + t.Fatalf("feed auth was not redacted: %+v", feeds) + } + tools := resp["tools"].(map[string]any) + tool := tools["tools"].([]any)[0].(map[string]any) + execution := tool["execution"].(map[string]any) + auth := execution["auth"].(map[string]any) + if auth["type"] != "bearer" { + t.Fatalf("tool auth type should be preserved without token, got %+v", auth) + } + if _, ok := auth["token"]; ok { + t.Fatalf("tool auth token key should be removed, got %+v", auth) + } + serviceAuth := resp["service_auth"].(map[string]any) + clawAPIAuth := serviceAuth["claw-api"].(map[string]any) + if clawAPIAuth["token"] != "[REDACTED]" { + t.Fatalf("service-auth token was not redacted: %+v", clawAPIAuth) + } +} + +func TestAgentLiveContextProxiesCllamaSnapshot(t *testing.T) { + contextRoot := t.TempDir() + writeAgentContextFixture(t, contextRoot, "trader-0", map[string]string{ + "service": "trader", + "type": "openclaw", + }) + var sawAuth string + cllama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawAuth = r.Header.Get("Authorization") + if r.URL.Path != "/internal/context/trader-0/snapshot" { + t.Fatalf("unexpected cllama path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"agent_id":"trader-0","format":"openai","system":"ctx"}`)) + })) + defer cllama.Close() + + h := newHandler( + &manifestpkg.PodManifest{PodName: "ops"}, + nil, + nil, + nil, + &clawapi.Store{Principals: []clawapi.Principal{{ + Name: "dashboard", + Token: "capi_dash", + Verbs: []string{clawapi.VerbAgentContext}, + Pods: []string{"ops"}, + }}}, + nil, + nil, + clawapi.DefaultThresholds(), + t.TempDir(), + withAgentContextConfig(contextRoot, cllama.URL, "ui-token"), + ) + req := httptest.NewRequest(http.MethodGet, "/agents/trader-0/context", nil) + req.Header.Set("Authorization", "Bearer capi_dash") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + if sawAuth != "Bearer ui-token" { + t.Fatalf("expected cllama bearer token, got %q", sawAuth) + } + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal proxied response: %v", err) + } + if resp["agent_id"] != "trader-0" || resp["system"] != "ctx" { + t.Fatalf("unexpected proxied snapshot: %+v", resp) + } +} + +func TestAgentContractRejectsOutOfScopeServicePrincipal(t *testing.T) { + contextRoot := t.TempDir() + writeAgentContextFixture(t, contextRoot, "trader-0", map[string]string{ + "service": "trader", + "type": "openclaw", + }) + h := newAgentContextTestHandler(t, contextRoot, clawapi.Principal{ + Name: "analyst-self", + Token: "capi_analyst", + Verbs: []string{clawapi.VerbAgentContext}, + Services: []string{"analyst"}, + }) + + req := httptest.NewRequest(http.MethodGet, "/agents/trader-0/contract", nil) + req.Header.Set("Authorization", "Bearer capi_analyst") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d body=%s", w.Code, w.Body.String()) + } +} + +func newAgentContextTestHandler(t *testing.T, contextRoot string, principal clawapi.Principal) http.Handler { + t.Helper() + return newHandler( + &manifestpkg.PodManifest{PodName: "ops"}, + nil, + nil, + nil, + &clawapi.Store{Principals: []clawapi.Principal{principal}}, + nil, + nil, + clawapi.DefaultThresholds(), + t.TempDir(), + withAgentContextConfig(contextRoot, "", ""), + ) +} + +func writeAgentContextFixture(t *testing.T, root, agentID string, metadata map[string]string) string { + t.Helper() + agentDir := filepath.Join(root, agentID) + if err := os.MkdirAll(agentDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "AGENTS.md"), []byte("# Contract"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "CLAWDAPUS.md"), []byte("# Infra"), 0o644); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(metadata) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "metadata.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + return agentDir +} diff --git a/cmd/claw-api/handler.go b/cmd/claw-api/handler.go index 2495915..42b774f 100644 --- a/cmd/claw-api/handler.go +++ b/cmd/claw-api/handler.go @@ -47,6 +47,20 @@ type apiHandler struct { auditErr io.Writer thresholds clawapi.Thresholds governanceDir string + contextRoot string + cllamaAPIURL string + cllamaAPIToken string + httpClient *http.Client +} + +type handlerOption func(*apiHandler) + +func withAgentContextConfig(contextRoot, cllamaAPIURL, cllamaAPIToken string) handlerOption { + return func(h *apiHandler) { + h.contextRoot = strings.TrimSpace(contextRoot) + h.cllamaAPIURL = strings.TrimRight(strings.TrimSpace(cllamaAPIURL), "/") + h.cllamaAPIToken = strings.TrimSpace(cllamaAPIToken) + } } type serviceStatus struct { @@ -82,11 +96,11 @@ type scheduleFireRequest struct { BypassPause bool `json:"bypass_pause,omitempty"` } -func newHandler(manifest *manifestpkg.PodManifest, scheduleManifest *schedulepkg.Manifest, scheduleState *scheduleStateStore, scheduler *scheduler, store *clawapi.Store, docker *client.Client, auditWriter io.Writer, thresholds clawapi.Thresholds, governanceDir string) http.Handler { +func newHandler(manifest *manifestpkg.PodManifest, scheduleManifest *schedulepkg.Manifest, scheduleState *scheduleStateStore, scheduler *scheduler, store *clawapi.Store, docker *client.Client, auditWriter io.Writer, thresholds clawapi.Thresholds, governanceDir string, opts ...handlerOption) http.Handler { if auditWriter == nil { auditWriter = io.Discard } - return &apiHandler{ + h := &apiHandler{ manifest: manifest, scheduleManifest: scheduleManifest, scheduleState: scheduleState, @@ -97,7 +111,13 @@ func newHandler(manifest *manifestpkg.PodManifest, scheduleManifest *schedulepkg auditErr: os.Stderr, thresholds: thresholds, governanceDir: governanceDir, + contextRoot: "/claw/context", + httpClient: &http.Client{Timeout: 5 * time.Second}, + } + for _, opt := range opts { + opt(h) } + return h } func (h *apiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -132,6 +152,12 @@ func (h *apiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case r.Method == http.MethodGet && r.URL.Path == "/fleet/alerts": h.handleAlerts(w, r) return + case r.Method == http.MethodGet && r.URL.Path == "/agents": + h.handleAgentsList(w, r) + return + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/agents/"): + h.handleAgentDetail(w, r) + return case r.Method == http.MethodGet && r.URL.Path == "/schedule": h.handleScheduleList(w, r) return @@ -512,6 +538,12 @@ func (h *apiHandler) authorize(w http.ResponseWriter, r *http.Request, verb, tar writeJSONError(w, http.StatusForbidden, "principal is out of scope") return nil, false } + case clawapi.VerbAgentContext: + if !principal.AllowsPod(h.manifest.PodName) && len(principal.Services) == 0 && len(principal.ClawIDs) == 0 { + h.logDecision(principal.Name, verb, target, false, "scope denied") + writeJSONError(w, http.StatusForbidden, "principal is out of scope") + return nil, false + } case clawapi.VerbScheduleRead, clawapi.VerbScheduleControl: if target == "" { if !principal.AllowsPod(h.manifest.PodName) && len(principal.Services) == 0 { diff --git a/cmd/claw-api/main.go b/cmd/claw-api/main.go index 70bf3b0..e91ddf9 100644 --- a/cmd/claw-api/main.go +++ b/cmd/claw-api/main.go @@ -30,6 +30,9 @@ type config struct { SchedulePath string PrincipalsPath string GovernanceDir string + ContextRoot string + CllamaAPIURL string + CllamaAPIToken string } type quietExitError struct{} @@ -108,7 +111,18 @@ func run(args []string, stdout, stderr io.Writer) error { go scheduler.Run(runtimeCtx) } - handler := newHandler(manifest, scheduleManifest, scheduleState, scheduler, store, docker, stdout, clawapi.ThresholdsFromEnv(), cfg.GovernanceDir) + handler := newHandler( + manifest, + scheduleManifest, + scheduleState, + scheduler, + store, + docker, + stdout, + clawapi.ThresholdsFromEnv(), + cfg.GovernanceDir, + withAgentContextConfig(cfg.ContextRoot, cfg.CllamaAPIURL, cfg.CllamaAPIToken), + ) server := &http.Server{ Addr: cfg.Addr, Handler: handler, @@ -174,6 +188,9 @@ func configFromEnv() config { SchedulePath: envOr("CLAW_API_SCHEDULE_MANIFEST", ""), PrincipalsPath: envOr("CLAW_API_PRINCIPALS", "/claw/principals.json"), GovernanceDir: envOr("CLAW_GOVERNANCE_DIR", "/claw-governance"), + ContextRoot: envOr("CLAW_CONTEXT_ROOT", "/claw/context"), + CllamaAPIURL: envOr("CLAW_CLLAMA_API_URL", ""), + CllamaAPIToken: envOr("CLAW_CLLAMA_API_TOKEN", ""), } } diff --git a/cmd/claw/compose_up.go b/cmd/claw/compose_up.go index e44b6ee..f7862f1 100644 --- a/cmd/claw/compose_up.go +++ b/cmd/claw/compose_up.go @@ -665,6 +665,11 @@ func runComposeUp(podFile string) (err error) { PodName: p.Name, }) } + if p.ClawAPI != nil { + p.ClawAPI.ContextHostDir = filepath.Join(runtimeDir, "context") + p.ClawAPI.CllamaAPIURL = fmt.Sprintf("http://%s:8081", cllama.ProxyServiceName(proxyTypes[0])) + p.ClawAPI.CllamaAPIToken = uiToken + } fmt.Printf("[claw] cllama proxies enabled: %s (agents: %s)\n", strings.Join(proxyTypes, ", "), strings.Join(cllamaAgents, ", ")) } @@ -692,14 +697,14 @@ func runComposeUp(podFile string) (err error) { CllamaCostsURL: firstIf(cllamaEnabled, fmt.Sprintf("http://localhost:%s", cllamaDashboardPort)), PodName: p.Name, } - if p.ClawAPI != nil && hasPodInvokeEntries(p) { - schedulerToken, err := lookupClawAPIPrincipalToken(p.ClawAPI.PrincipalsHostPath, "claw-scheduler") + if p.ClawAPI != nil { + dashboardToken, err := lookupClawAPIPrincipalToken(p.ClawAPI.PrincipalsHostPath, "claw-dashboard") if err != nil { return err } p.Clawdash.Environment = map[string]string{ "CLAW_API_URL": fmt.Sprintf("http://claw-api:%s", clawAPIInternalPort(p.ClawAPI.Addr)), - "CLAW_API_TOKEN": schedulerToken, + "CLAW_API_TOKEN": dashboardToken, } } @@ -818,6 +823,9 @@ func runComposeUp(podFile string) (err error) { } fmt.Println("[claw] pod is up") + if p.Clawdash != nil { + fmt.Printf("[claw] dashboard: http://localhost:%s\n", pod.ClawdashHostPort(p.Clawdash.Addr)) + } return nil } @@ -1812,6 +1820,12 @@ func prepareClawAPIRuntime(runtimeDir string, p *pod.Pod, resolvedClaws map[stri auto = append(auto, schedulerPrincipal) } + dashboardPrincipal, err := clawapi.BuildDashboardPrincipal(p.Name) + if err != nil { + return nil, err + } + auto = append(auto, dashboardPrincipal) + // 2. Build self principals for services declaring claw-api: self. for name, svc := range p.Services { if name == p.Master || svc.Claw == nil || svc.Claw.ClawAPIMode != "self" { diff --git a/cmd/claw/compose_up_test.go b/cmd/claw/compose_up_test.go index ac31282..14d64ad 100644 --- a/cmd/claw/compose_up_test.go +++ b/cmd/claw/compose_up_test.go @@ -837,6 +837,13 @@ func TestPrepareClawAPIRuntimeWritesPrincipalsAndProjectsAuth(t *testing.T) { if _, err := os.Stat(p.ClawAPI.PrincipalsHostPath); err != nil { t.Fatalf("expected principals file to be written: %v", err) } + raw, err := os.ReadFile(p.ClawAPI.PrincipalsHostPath) + if err != nil { + t.Fatalf("read principals: %v", err) + } + if !strings.Contains(string(raw), "claw-dashboard") || !strings.Contains(string(raw), clawapi.VerbAgentContext) { + t.Fatalf("expected dashboard principal with agent context verb, got %s", string(raw)) + } } func TestPrepareClawAPIRuntimeUsesPostMergeMasterToken(t *testing.T) { @@ -923,6 +930,9 @@ func TestPrepareClawAPIRuntimeWithoutMasterWritesSchedulerPrincipal(t *testing.T if !strings.Contains(string(raw), "claw-scheduler") { t.Fatalf("expected scheduler principal in principals.json, got %s", string(raw)) } + if !strings.Contains(string(raw), "claw-dashboard") { + t.Fatalf("expected dashboard principal in principals.json, got %s", string(raw)) + } if !strings.Contains(string(raw), clawapi.VerbScheduleRead) || !strings.Contains(string(raw), clawapi.VerbScheduleControl) { t.Fatalf("expected schedule verbs in principals.json, got %s", string(raw)) } diff --git a/cmd/claw/skill_data/SKILL.md b/cmd/claw/skill_data/SKILL.md index 2b1db0e..2f42cd4 100644 --- a/cmd/claw/skill_data/SKILL.md +++ b/cmd/claw/skill_data/SKILL.md @@ -7,7 +7,7 @@ description: Use when working with the claw CLI, Clawfiles, claw-pod.yml, cllama Infrastructure-layer governance for AI agent containers. `claw` treats agents as untrusted workloads — reproducible, inspectable, diffable, killable. -**Mental model:** Clawfile is to Dockerfile what claw-pod.yml is to docker-compose.yml. Standard Docker directives pass through unchanged. Claw directives compile to labels + generated scripts. Eject anytime — you still have working Docker artifacts. +**Mental model:** Clawfile is to Dockerfile what claw-pod.yml is to docker-compose.yml. Standard Docker directives pass through unchanged. Claw directives compile into labels plus driver-specific runtime materialization. Eject anytime — you still have working Docker artifacts. ## CLI Commands @@ -91,7 +91,7 @@ SURFACE service://trading-api # infrastructure surface SURFACE volume://shared-research read-write SKILL policy/risk-limits.md # operator policy, mounted read-only -CONFIGURE openclaw config set key value # runs at container startup, NOT build time +CONFIGURE openclaw config set key value # driver-side config DSL, not arbitrary shell TRACK apt npm # mutation tracking wrappers PRIVILEGE worker root # privilege mode mapping @@ -111,10 +111,19 @@ PRIVILEGE runtime claw-user | `INVOKE ` | System cron in `/etc/cron.d/claw`. Bot cannot modify. | Baked into image | | `SURFACE :// [mode]` | Infrastructure boundary. See Surface Taxonomy. | Label -> compose wiring | | `SKILL ` | Reference markdown mounted read-only into runner skill directory. | Label -> host path validation + mount | -| `CONFIGURE ` | **Runs at startup** via `/claw/configure.sh`. For init-time config mutations. NOT build time. | Generates script | +| `CONFIGURE ` | Driver-specific config DSL. Use ` config set `, not arbitrary shell. | Parsed by Clawdapus, then projected into generated runtime config/artifacts | | `TRACK ` | Installs wrappers for `apt`, `pip`, `npm` to log mutations. | Build-time install | | `PRIVILEGE ` | Maps privilege modes to user specs. | Label -> Docker user/security | +### `CONFIGURE` Semantics + +- Treat `CONFIGURE` as driver-side config mutation DSL, not as a generic startup hook. +- The public contract is `CONFIGURE config set `. +- Values are JSON-decoded when possible. Leave booleans, numbers, arrays, and objects unquoted; quote strings. +- `CONFIGURE` applies after generated defaults, so it overrides what `HANDLE` and other driver defaults emitted. +- For `openclaw`, Clawdapus applies `CONFIGURE` while generating `openclaw.json` during materialization. Do not assume downstream `openclaw config set ...` shell behavior is the same contract. +- Dotted object paths are the supported shape today. Do not assume indexed list mutation like `agents.list[0].groupChat.mentionPatterns` is supported unless the code/docs explicitly say so. + ## Surface Taxonomy | Scheme | Enforcement | Notes | diff --git a/cmd/clawdash/agent_context.go b/cmd/clawdash/agent_context.go new file mode 100644 index 0000000..1b283e1 --- /dev/null +++ b/cmd/clawdash/agent_context.go @@ -0,0 +1,887 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +const maxAgentContextResponseBytes = 8 * 1024 * 1024 + +type agentContextSource interface { + List(ctx context.Context) ([]agentContextIndexEntry, error) + Contract(ctx context.Context, agentID string) (agentContractView, error) + LiveContext(ctx context.Context, agentID string) (any, error) +} + +type agentContextIndexEntry struct { + ClawID string `json:"claw_id"` + Service string `json:"service,omitempty"` + ClawType string `json:"claw_type,omitempty"` + HasLiveContext bool `json:"has_live_context"` + + DetailPath string + LiveLabel string + LiveTone string +} + +type agentContractView struct { + ClawID string `json:"claw_id"` + AgentsMD string `json:"agents_md"` + ClawdapusMD string `json:"clawdapus_md"` + Metadata any `json:"metadata"` + Feeds any `json:"feeds"` + Tools any `json:"tools"` + Memory any `json:"memory"` + ServiceAuth map[string]any `json:"service_auth,omitempty"` +} + +type agentContextHTTPClient struct { + baseURL string + token string + client *http.Client +} + +type agentContextListResponse struct { + Agents []agentContextIndexEntry `json:"agents"` +} + +type clawAPIError struct { + StatusCode int + Status string + Message string +} + +func (e *clawAPIError) Error() string { + if e == nil { + return "" + } + if strings.TrimSpace(e.Message) != "" { + return e.Message + } + return e.Status +} + +func newAgentContextHTTPClient(baseURL, token string) agentContextSource { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + token = strings.TrimSpace(token) + if baseURL == "" || token == "" { + return nil + } + return &agentContextHTTPClient{ + baseURL: baseURL, + token: token, + client: &http.Client{ + Timeout: 4 * time.Second, + }, + } +} + +func (c *agentContextHTTPClient) List(ctx context.Context) ([]agentContextIndexEntry, error) { + var payload agentContextListResponse + if err := c.do(ctx, http.MethodGet, "/agents", &payload); err != nil { + return nil, err + } + decorateAgentContextIndex(payload.Agents) + return payload.Agents, nil +} + +func (c *agentContextHTTPClient) Contract(ctx context.Context, agentID string) (agentContractView, error) { + var payload agentContractView + if err := c.do(ctx, http.MethodGet, agentContextAPIPath(agentID, "contract"), &payload); err != nil { + return agentContractView{}, err + } + return payload, nil +} + +func (c *agentContextHTTPClient) LiveContext(ctx context.Context, agentID string) (any, error) { + var payload any + if err := c.do(ctx, http.MethodGet, agentContextAPIPath(agentID, "context"), &payload); err != nil { + return nil, err + } + return payload, nil +} + +func (c *agentContextHTTPClient) do(ctx context.Context, method, path string, dst any) error { + if c == nil { + return fmt.Errorf("agent context client unavailable") + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode/100 != 2 { + return decodeClawAPIError(resp) + } + if dst == nil { + return nil + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxAgentContextResponseBytes)).Decode(dst); err != nil { + return fmt.Errorf("decode agent context response: %w", err) + } + return nil +} + +func agentContextAPIPath(agentID, action string) string { + return "/agents/" + url.PathEscape(strings.TrimSpace(agentID)) + "/" + strings.TrimSpace(action) +} + +func decodeClawAPIError(resp *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var payload struct { + Error string `json:"error"` + } + message := "" + if err := json.Unmarshal(body, &payload); err == nil { + message = strings.TrimSpace(payload.Error) + } + if message == "" { + message = strings.TrimSpace(string(body)) + } + return &clawAPIError{ + StatusCode: resp.StatusCode, + Status: resp.Status, + Message: message, + } +} + +type agentsPageData struct { + PodName string + ActiveTab string + HasSchedule bool + HasAgentContext bool + Summary []dashStat + Agents []agentContextIndexEntry + HasAgents bool + Error string + HasError bool +} + +type agentContextDetailPageData struct { + PodName string + ActiveTab string + HasSchedule bool + HasAgentContext bool + ContextTab string + IsContractTab bool + IsLiveTab bool + ContractPath string + LivePath string + ClawID string + Service string + ClawType string + Contract agentContractView + HasContract bool + LiveContext agentLiveContextView + LiveContextJSON string + HasLiveContext bool + ContractError string + LiveError string + HasContractErr bool + HasLiveError bool + MetadataJSON string + FeedsJSON string + ToolsJSON string + MemoryJSON string + ServiceAuthJSON string + HasMetadata bool + HasFeeds bool + HasTools bool + HasMemory bool + HasServiceAuth bool + MetadataRows []keyValueRow + FeedRows []feedManifestRow + ToolRows []toolManifestRow + MemoryRows []keyValueRow + ServiceAuthRows []serviceAuthRow + HasMetadataRows bool + HasFeedRows bool + HasToolRows bool + HasMemoryRows bool + HasServiceAuthRows bool +} + +type keyValueRow struct { + Key string + Value string +} + +type feedManifestRow struct { + Name string + Source string + Path string + URL string + TTL string + Description string +} + +type toolManifestRow struct { + Name string + Description string + Service string + Method string + Path string + Transport string + SchemaJSON string + HasSchema bool +} + +type serviceAuthRow struct { + Service string + AuthType string + Principal string + DetailJSON string +} + +type candidateContextRow struct { + Provider string + UpstreamModel string +} + +type contextPlacementRow struct { + Order int + Kind string + Label string + Carrier string + Position string + Occurrences int + Scope string + Relation string +} + +type contextCaptureRow struct { + Sequence int + CapturedAt string + Interval string + Format string + Model string + DynamicInputs int + FeedBlocks int + MemoryRecall bool + TimeContext bool + PlacementCount int + TurnCount int + ManagedTool bool +} + +type agentLiveContextView struct { + CapturedAt string + Format string + RequestedModel string + ChosenRef string + Candidates []candidateContextRow + Placements []contextPlacementRow + RecentCaptures []contextCaptureRow + FeedBlocks []string + MemoryRecall string + TimeContext string + Intervention string + ManagedTool bool + TurnCount int + SystemText string + SystemJSON string + ToolsJSON string + RawJSON string + HasCandidates bool + HasFeedBlocks bool + HasMemoryRecall bool + HasTimeContext bool + HasIntervention bool + HasSystem bool + HasSystemText bool + HasSystemJSON bool + HasTools bool + HasPlacements bool + HasRecentCaptures bool +} + +func (h *handler) renderAgents(w http.ResponseWriter, r *http.Request) { + if !h.hasAgentContext() { + http.NotFound(w, r) + return + } + agents, err := h.agentContextSource.List(r.Context()) + data := buildAgentsPageData( + h.manifest.PodName, + agents, + errString(err), + h.hasSchedule(), + h.hasAgentContext(), + ) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = h.tpl.ExecuteTemplate(w, "agents.html", data) +} + +func (h *handler) renderAgentContextDetail(w http.ResponseWriter, r *http.Request) { + if !h.hasAgentContext() { + http.NotFound(w, r) + return + } + agentID, ok := parseAgentDashboardPath(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + tab := normalizeAgentContextTab(r.URL.Query().Get("tab")) + + contract, contractErr := h.agentContextSource.Contract(r.Context(), agentID) + var apiErr *clawAPIError + if errors.As(contractErr, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + http.NotFound(w, r) + return + } + var liveContext any + var liveErr error + if tab == "live" { + liveContext, liveErr = h.agentContextSource.LiveContext(r.Context(), agentID) + if errors.As(liveErr, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + liveErr = nil + } + } + data := buildAgentContextDetailPageData( + h.manifest.PodName, + agentID, + tab, + contract, + contractErr, + liveContext, + liveErr, + h.hasSchedule(), + h.hasAgentContext(), + ) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = h.tpl.ExecuteTemplate(w, "agent_detail.html", data) +} + +func buildAgentsPageData(podName string, agents []agentContextIndexEntry, errMsg string, hasSchedule, hasAgentContext bool) agentsPageData { + agents = append([]agentContextIndexEntry(nil), agents...) + decorateAgentContextIndex(agents) + sort.Slice(agents, func(i, j int) bool { + if agents[i].Service != agents[j].Service { + return agents[i].Service < agents[j].Service + } + return agents[i].ClawID < agents[j].ClawID + }) + + live := 0 + serviceSet := map[string]struct{}{} + typeSet := map[string]struct{}{} + for _, agent := range agents { + if agent.HasLiveContext { + live++ + } + if strings.TrimSpace(agent.Service) != "" { + serviceSet[agent.Service] = struct{}{} + } + if strings.TrimSpace(agent.ClawType) != "" { + typeSet[agent.ClawType] = struct{}{} + } + } + + return agentsPageData{ + PodName: podName, + ActiveTab: "agents", + HasSchedule: hasSchedule, + HasAgentContext: hasAgentContext, + Summary: []dashStat{ + {Label: "Agents", Value: fmt.Sprintf("%d", len(agents)), Hint: "context directories in scope", Tone: "neutral"}, + {Label: "Live", Value: fmt.Sprintf("%d", live), Hint: "snapshots captured by cllama", Tone: toneForLiveContexts(live)}, + {Label: "Services", Value: fmt.Sprintf("%d", len(serviceSet)), Hint: "backing compose services", Tone: "neutral"}, + {Label: "Runtimes", Value: fmt.Sprintf("%d", len(typeSet)), Hint: "distinct claw types", Tone: "neutral"}, + }, + Agents: agents, + HasAgents: len(agents) > 0, + Error: errMsg, + HasError: strings.TrimSpace(errMsg) != "", + } +} + +func toneForLiveContexts(count int) string { + if count <= 0 { + return "neutral" + } + return "good" +} + +func buildAgentContextDetailPageData(podName, agentID, tab string, contract agentContractView, contractErr error, liveContext any, liveErr error, hasSchedule, hasAgentContext bool) agentContextDetailPageData { + if strings.TrimSpace(contract.ClawID) == "" { + contract.ClawID = agentID + } + tab = normalizeAgentContextTab(tab) + clawID := firstNonEmpty(contract.ClawID, agentID) + liveView := buildAgentLiveContextView(liveContext) + metadataRows := topLevelRows(contract.Metadata) + feedRows := feedManifestRows(contract.Feeds) + toolRows := toolManifestRows(contract.Tools) + memoryRows := topLevelRows(contract.Memory) + serviceAuthRows := serviceAuthRows(contract.ServiceAuth) + data := agentContextDetailPageData{ + PodName: podName, + ActiveTab: "agents", + HasSchedule: hasSchedule, + HasAgentContext: hasAgentContext, + ContextTab: tab, + IsContractTab: tab == "contract", + IsLiveTab: tab == "live", + ContractPath: "/agents/" + url.PathEscape(clawID) + "?tab=contract", + LivePath: "/agents/" + url.PathEscape(clawID) + "?tab=live", + ClawID: clawID, + Service: metadataString(contract.Metadata, "service"), + ClawType: metadataString(contract.Metadata, "type"), + Contract: contract, + HasContract: contractErr == nil, + LiveContext: liveView, + ContractError: errString(contractErr), + LiveError: errString(liveErr), + HasContractErr: contractErr != nil, + HasLiveError: tab == "live" && liveErr != nil, + MetadataJSON: prettyJSON(contract.Metadata), + FeedsJSON: prettyJSON(contract.Feeds), + ToolsJSON: prettyJSON(contract.Tools), + MemoryJSON: prettyJSON(contract.Memory), + ServiceAuthJSON: prettyJSON(contract.ServiceAuth), + LiveContextJSON: liveView.RawJSON, + MetadataRows: metadataRows, + FeedRows: feedRows, + ToolRows: toolRows, + MemoryRows: memoryRows, + ServiceAuthRows: serviceAuthRows, + } + data.HasMetadata = data.MetadataJSON != "" + data.HasFeeds = data.FeedsJSON != "" + data.HasTools = data.ToolsJSON != "" + data.HasMemory = data.MemoryJSON != "" + data.HasServiceAuth = data.ServiceAuthJSON != "" + data.HasLiveContext = data.LiveContextJSON != "" + data.HasMetadataRows = len(metadataRows) > 0 + data.HasFeedRows = len(feedRows) > 0 + data.HasToolRows = len(toolRows) > 0 + data.HasMemoryRows = len(memoryRows) > 0 + data.HasServiceAuthRows = len(serviceAuthRows) > 0 + return data +} + +func normalizeAgentContextTab(tab string) string { + switch strings.ToLower(strings.TrimSpace(tab)) { + case "live": + return "live" + default: + return "contract" + } +} + +func decorateAgentContextIndex(agents []agentContextIndexEntry) { + for i := range agents { + agents[i].ClawID = strings.TrimSpace(agents[i].ClawID) + agents[i].Service = strings.TrimSpace(agents[i].Service) + agents[i].ClawType = strings.TrimSpace(agents[i].ClawType) + agents[i].DetailPath = "/agents/" + url.PathEscape(agents[i].ClawID) + if agents[i].HasLiveContext { + agents[i].LiveLabel = "live snapshot" + agents[i].LiveTone = "tone-good" + } else { + agents[i].LiveLabel = "contract only" + agents[i].LiveTone = "tone-neutral" + } + } +} + +func parseAgentDashboardPath(path string) (string, bool) { + rest := strings.TrimPrefix(path, "/agents/") + if rest == path || strings.TrimSpace(rest) == "" || strings.Contains(rest, "/") { + return "", false + } + agentID, err := url.PathUnescape(rest) + if err != nil || strings.TrimSpace(agentID) == "" { + return "", false + } + return strings.TrimSpace(agentID), true +} + +func metadataString(metadata any, key string) string { + values, ok := metadata.(map[string]any) + if !ok { + return "" + } + return scalarString(values[key]) +} + +func scalarString(v any) string { + switch typed := v.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6f", typed), "0"), ".") + case bool: + if typed { + return "true" + } + return "false" + default: + return "" + } +} + +func scalarBool(v any) bool { + b, _ := v.(bool) + return b +} + +func scalarInt(v any) int { + switch typed := v.(type) { + case int: + return typed + case int64: + return int(typed) + case float64: + return int(typed) + case json.Number: + n, _ := typed.Int64() + return int(n) + default: + return 0 + } +} + +func displayValue(v any) string { + if s := scalarString(v); s != "" { + return s + } + if !hasJSONValue(v) { + return "" + } + return prettyJSON(v) +} + +func topLevelRows(v any) []keyValueRow { + m, ok := v.(map[string]any) + if !ok || len(m) == 0 { + return nil + } + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + rows := make([]keyValueRow, 0, len(keys)) + for _, key := range keys { + value := displayValue(m[key]) + if strings.TrimSpace(value) == "" { + continue + } + rows = append(rows, keyValueRow{Key: humanizeKey(key), Value: value}) + } + return rows +} + +func humanizeKey(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + words := strings.Fields(strings.ReplaceAll(key, "_", " ")) + for i, word := range words { + switch strings.ToLower(word) { + case "id": + words[i] = "ID" + case "url": + words[i] = "URL" + case "api": + words[i] = "API" + case "ttl": + words[i] = "TTL" + default: + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + word[1:] + } + } + } + return strings.Join(words, " ") +} + +func asManifestEntries(v any, field string) []any { + switch typed := v.(type) { + case []any: + return typed + case map[string]any: + if raw, ok := typed[field].([]any); ok { + return raw + } + } + return nil +} + +func feedManifestRows(v any) []feedManifestRow { + entries := asManifestEntries(v, "feeds") + rows := make([]feedManifestRow, 0, len(entries)) + for _, raw := range entries { + entry, _ := raw.(map[string]any) + if entry == nil { + continue + } + rows = append(rows, feedManifestRow{ + Name: firstNonEmpty(scalarString(entry["name"]), "-"), + Source: firstNonEmpty(scalarString(entry["source"]), "-"), + Path: firstNonEmpty(scalarString(entry["path"]), "-"), + URL: firstNonEmpty(scalarString(entry["url"]), "-"), + TTL: firstNonEmpty(scalarString(entry["ttl"]), "-"), + Description: scalarString(entry["description"]), + }) + } + return rows +} + +func toolManifestRows(v any) []toolManifestRow { + entries := asManifestEntries(v, "tools") + rows := make([]toolManifestRow, 0, len(entries)) + for _, raw := range entries { + entry, _ := raw.(map[string]any) + if entry == nil { + continue + } + execution, _ := entry["execution"].(map[string]any) + schemaJSON := prettyJSON(entry["inputSchema"]) + rows = append(rows, toolManifestRow{ + Name: firstNonEmpty(scalarString(entry["name"]), "-"), + Description: scalarString(entry["description"]), + Service: firstNonEmpty(scalarString(execution["service"]), "-"), + Method: firstNonEmpty(scalarString(execution["method"]), "-"), + Path: firstNonEmpty(scalarString(execution["path"]), "-"), + Transport: firstNonEmpty(scalarString(execution["transport"]), "-"), + SchemaJSON: schemaJSON, + HasSchema: schemaJSON != "", + }) + } + return rows +} + +func serviceAuthRows(v map[string]any) []serviceAuthRow { + if len(v) == 0 { + return nil + } + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + sort.Strings(keys) + rows := make([]serviceAuthRow, 0, len(keys)) + for _, key := range keys { + entry, _ := v[key].(map[string]any) + rows = append(rows, serviceAuthRow{ + Service: key, + AuthType: firstNonEmpty(scalarString(entry["auth_type"]), scalarString(entry["type"]), "-"), + Principal: firstNonEmpty(scalarString(entry["principal"]), "-"), + DetailJSON: prettyJSON(v[key]), + }) + } + return rows +} + +func buildAgentLiveContextView(v any) agentLiveContextView { + view := agentLiveContextView{RawJSON: prettyJSON(v)} + m, ok := v.(map[string]any) + if !ok || len(m) == 0 { + return view + } + view.CapturedAt = scalarString(m["captured_at"]) + view.Format = scalarString(m["format"]) + view.RequestedModel = scalarString(m["requested_model"]) + view.ChosenRef = scalarString(m["chosen_ref"]) + view.Candidates = candidateContextRows(m["candidates"]) + view.Placements = contextPlacementRows(m["placements"]) + view.RecentCaptures = contextCaptureRows(m["recent_captures"]) + view.FeedBlocks = stringSlice(m["feed_blocks"]) + view.MemoryRecall = scalarString(m["memory_recall"]) + view.TimeContext = scalarString(m["time_context"]) + view.Intervention = scalarString(m["intervention"]) + view.ManagedTool = scalarBool(m["managed_tool"]) + view.TurnCount = scalarInt(m["turn_count"]) + view.ToolsJSON = prettyJSON(m["tools"]) + + system := m["system"] + if systemText := scalarString(system); systemText != "" { + view.SystemText = systemText + } else { + view.SystemJSON = prettyJSON(system) + } + view.HasCandidates = len(view.Candidates) > 0 + view.HasFeedBlocks = len(view.FeedBlocks) > 0 + view.HasMemoryRecall = view.MemoryRecall != "" + view.HasTimeContext = view.TimeContext != "" + view.HasIntervention = view.Intervention != "" + view.HasSystemText = view.SystemText != "" + view.HasSystemJSON = view.SystemJSON != "" + view.HasSystem = view.HasSystemText || view.HasSystemJSON + view.HasTools = view.ToolsJSON != "" + view.HasPlacements = len(view.Placements) > 0 + view.HasRecentCaptures = len(view.RecentCaptures) > 0 + return view +} + +func candidateContextRows(v any) []candidateContextRow { + entries, _ := v.([]any) + rows := make([]candidateContextRow, 0, len(entries)) + for _, raw := range entries { + entry, _ := raw.(map[string]any) + if entry == nil { + continue + } + rows = append(rows, candidateContextRow{ + Provider: firstNonEmpty(scalarString(entry["provider"]), "-"), + UpstreamModel: firstNonEmpty(scalarString(entry["upstream_model"]), "-"), + }) + } + return rows +} + +func contextPlacementRows(v any) []contextPlacementRow { + entries, _ := v.([]any) + rows := make([]contextPlacementRow, 0, len(entries)) + for _, raw := range entries { + entry, _ := raw.(map[string]any) + if entry == nil { + continue + } + rows = append(rows, contextPlacementRow{ + Order: scalarInt(entry["order"]), + Kind: firstNonEmpty(humanizeKey(scalarString(entry["kind"])), "-"), + Label: firstNonEmpty(scalarString(entry["label"]), "-"), + Carrier: firstNonEmpty(scalarString(entry["carrier"]), "-"), + Position: placementPosition(entry), + Occurrences: scalarInt(entry["occurrences"]), + Scope: firstNonEmpty(humanizeKey(scalarString(entry["persistence"])), "-"), + Relation: firstNonEmpty(humanizeKey(scalarString(entry["relation"])), "-"), + }) + } + return rows +} + +func contextCaptureRows(v any) []contextCaptureRow { + entries, _ := v.([]any) + rows := make([]contextCaptureRow, 0, len(entries)) + var previous time.Time + for _, raw := range entries { + entry, _ := raw.(map[string]any) + if entry == nil { + continue + } + capturedText := scalarString(entry["captured_at"]) + capturedAt, hasCapturedAt := parseDashboardTime(capturedText) + interval := "first in buffer" + if hasCapturedAt && !previous.IsZero() { + diff := capturedAt.Sub(previous) + if diff < 0 { + diff = -diff + } + interval = formatRelativeDuration(diff) + } + if hasCapturedAt { + previous = capturedAt + } + rows = append(rows, contextCaptureRow{ + Sequence: scalarInt(entry["sequence"]), + CapturedAt: firstNonEmpty(capturedText, "-"), + Interval: interval, + Format: firstNonEmpty(scalarString(entry["format"]), "-"), + Model: firstNonEmpty(scalarString(entry["chosen_ref"]), scalarString(entry["requested_model"]), "-"), + DynamicInputs: scalarInt(entry["dynamic_inputs"]), + FeedBlocks: scalarInt(entry["feed_blocks"]), + MemoryRecall: scalarBool(entry["memory_recall"]), + TimeContext: scalarBool(entry["time_context"]), + PlacementCount: scalarInt(entry["placement_count"]), + TurnCount: scalarInt(entry["turn_count"]), + ManagedTool: scalarBool(entry["managed_tool"]), + }) + } + return rows +} + +func placementPosition(entry map[string]any) string { + messageIndex := scalarInt(entry["message_index"]) + blockIndex := scalarInt(entry["block_index"]) + start := scalarInt(entry["start_char"]) + end := scalarInt(entry["end_char"]) + parts := make([]string, 0, 3) + if messageIndex >= 0 { + parts = append(parts, fmt.Sprintf("message %d", messageIndex)) + } + if blockIndex >= 0 { + parts = append(parts, fmt.Sprintf("block %d", blockIndex)) + } + if start >= 0 && end >= 0 { + parts = append(parts, fmt.Sprintf("chars %d-%d", start, end)) + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, ", ") +} + +func parseDashboardTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + parsed, err := time.Parse(layout, value) + if err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +func stringSlice(v any) []string { + entries, _ := v.([]any) + out := make([]string, 0, len(entries)) + for _, entry := range entries { + if text := scalarString(entry); text != "" { + out = append(out, text) + } + } + return out +} + +func prettyJSON(v any) string { + if !hasJSONValue(v) { + return "" + } + raw, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(bytes.TrimSpace(raw)) +} + +func hasJSONValue(v any) bool { + switch typed := v.(type) { + case nil: + return false + case map[string]any: + return len(typed) > 0 + case []any: + return len(typed) > 0 + case string: + return strings.TrimSpace(typed) != "" + default: + return true + } +} diff --git a/cmd/clawdash/handler.go b/cmd/clawdash/handler.go index 816d8fd..02dd6f5 100644 --- a/cmd/clawdash/handler.go +++ b/cmd/clawdash/handler.go @@ -36,18 +36,27 @@ type statusSource interface { } type handler struct { - manifest *manifestpkg.PodManifest - statusSource statusSource - scheduleSource scheduleControlSource - cllamaCostsURL string - costLogFallback bool - httpClient *http.Client - now func() time.Time - tpl *template.Template - static http.Handler + manifest *manifestpkg.PodManifest + statusSource statusSource + scheduleSource scheduleControlSource + agentContextSource agentContextSource + cllamaCostsURL string + costLogFallback bool + httpClient *http.Client + now func() time.Time + tpl *template.Template + static http.Handler } -func newHandler(manifest *manifestpkg.PodManifest, source statusSource, scheduleSource scheduleControlSource, cllamaCostsURL string, costLogFallback bool) http.Handler { +type handlerOption func(*handler) + +func withAgentContextSource(source agentContextSource) handlerOption { + return func(h *handler) { + h.agentContextSource = source + } +} + +func newHandler(manifest *manifestpkg.PodManifest, source statusSource, scheduleSource scheduleControlSource, cllamaCostsURL string, costLogFallback bool, opts ...handlerOption) http.Handler { funcs := template.FuncMap{ "statusClass": statusClass, "pathEscape": url.PathEscape, @@ -62,7 +71,7 @@ func newHandler(manifest *manifestpkg.PodManifest, source statusSource, schedule if err != nil { panic(err) } - return &handler{ + h := &handler{ manifest: manifest, statusSource: source, scheduleSource: scheduleSource, @@ -75,6 +84,10 @@ func newHandler(manifest *manifestpkg.PodManifest, source statusSource, schedule tpl: tpl, static: http.StripPrefix("/static/", http.FileServerFS(staticFS)), } + for _, opt := range opts { + opt(h) + } + return h } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -94,6 +107,12 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/schedule/"): h.handleScheduleAction(w, r) return + case r.Method == http.MethodGet && r.URL.Path == "/agents": + h.renderAgents(w, r) + return + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/agents/"): + h.renderAgentContextDetail(w, r) + return case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/detail/"): h.renderDetail(w, r) return @@ -114,6 +133,7 @@ type fleetPageData struct { PodName string ActiveTab string HasSchedule bool + HasAgentContext bool Summary []dashStat Attention []dashAlert HasAttention bool @@ -260,6 +280,7 @@ func (h *handler) buildFleetPageData(ctx context.Context, statuses map[string]se PodName: h.manifest.PodName, ActiveTab: "fleet", HasSchedule: h.hasSchedule(), + HasAgentContext: h.hasAgentContext(), Summary: summary, Attention: attention, HasAttention: len(attention) > 0, @@ -281,6 +302,7 @@ type detailPageData struct { PodName string ActiveTab string HasSchedule bool + HasAgentContext bool ServiceName string RoleBadge string RoleClass string @@ -429,6 +451,7 @@ func (h *handler) buildDetailPageData(name string, statuses map[string]serviceSt PodName: h.manifest.PodName, ActiveTab: "detail", HasSchedule: h.hasSchedule(), + HasAgentContext: h.hasAgentContext(), ServiceName: name, RoleBadge: roleBadge, RoleClass: roleClass, @@ -457,6 +480,7 @@ func (h *handler) renderTopology(w http.ResponseWriter, r *http.Request) { statuses, statusErr := h.snapshot(r.Context()) data := buildTopologyPageData(h.manifest, statuses, statusErr) data.HasSchedule = h.hasSchedule() + data.HasAgentContext = h.hasAgentContext() w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = h.tpl.ExecuteTemplate(w, "topology.html", data) } @@ -465,6 +489,10 @@ func (h *handler) hasSchedule() bool { return h != nil && h.scheduleSource != nil } +func (h *handler) hasAgentContext() bool { + return h != nil && h.agentContextSource != nil +} + type apiStatusResponse struct { GeneratedAt string `json:"generatedAt"` Services map[string]serviceStatus `json:"services"` diff --git a/cmd/clawdash/handler_test.go b/cmd/clawdash/handler_test.go index 0b476e4..812fbfb 100644 --- a/cmd/clawdash/handler_test.go +++ b/cmd/clawdash/handler_test.go @@ -78,6 +78,36 @@ func (f *fakeScheduleSource) Fire(_ context.Context, id string, bypassWhen, bypa return f.err } +type fakeAgentContextSource struct { + agents []agentContextIndexEntry + contract agentContractView + liveContext any + listErr error + contractErr error + liveErr error +} + +func (f *fakeAgentContextSource) List(_ context.Context) ([]agentContextIndexEntry, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.agents, nil +} + +func (f *fakeAgentContextSource) Contract(_ context.Context, _ string) (agentContractView, error) { + if f.contractErr != nil { + return agentContractView{}, f.contractErr + } + return f.contract, nil +} + +func (f *fakeAgentContextSource) LiveContext(_ context.Context, _ string) (any, error) { + if f.liveErr != nil { + return nil, f.liveErr + } + return f.liveContext, nil +} + func testManifest() *manifestpkg.PodManifest { return &manifestpkg.PodManifest{ PodName: "fleet", @@ -336,6 +366,174 @@ func TestTopologyPageRenders(t *testing.T) { } } +func TestAgentsPageRenders(t *testing.T) { + source := &fakeAgentContextSource{ + agents: []agentContextIndexEntry{{ + ClawID: "bot-0", + Service: "bot", + ClawType: "openclaw", + HasLiveContext: true, + }}, + } + h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, nil, "http://localhost:8181", false, withAgentContextSource(source)) + req := httptest.NewRequest(http.MethodGet, "/agents", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, "Agent Context") || !strings.Contains(body, "bot-0") { + t.Fatalf("expected agent context list in body:\n%s", body) + } + if !strings.Contains(body, "live snapshot") { + t.Fatalf("expected live snapshot badge in body:\n%s", body) + } +} + +func TestAgentContextDetailRendersContractAndLiveSnapshot(t *testing.T) { + source := &fakeAgentContextSource{ + contract: agentContractView{ + ClawID: "bot-0", + AgentsMD: "# Contract", + ClawdapusMD: "# Infrastructure", + Metadata: map[string]any{ + "service": "bot", + "type": "openclaw", + }, + Feeds: map[string]any{"feeds": []any{"alerts"}}, + }, + liveContext: map[string]any{ + "agent_id": "bot-0", + "chosen_ref": "openrouter/anthropic/claude-sonnet-4", + "turn_count": float64(2), + "placements": []any{ + map[string]any{ + "order": float64(1), + "kind": "feed", + "label": "market-open", + "carrier": "openai.messages[0].content", + "message_index": float64(0), + "block_index": float64(-1), + "start_char": float64(12), + "end_char": float64(42), + "occurrences": float64(1), + "persistence": "per_request", + "relation": "system_context_before_conversation", + }, + }, + "recent_captures": []any{ + map[string]any{ + "sequence": float64(1), + "captured_at": "2026-04-17T20:00:00Z", + "chosen_ref": "openrouter/anthropic/claude-sonnet-4", + "dynamic_inputs": float64(2), + "feed_blocks": float64(1), + "memory_recall": true, + "time_context": true, + "placement_count": float64(2), + "turn_count": float64(1), + }, + map[string]any{ + "sequence": float64(2), + "captured_at": "2026-04-17T20:05:00Z", + "chosen_ref": "openrouter/anthropic/claude-sonnet-4", + "dynamic_inputs": float64(1), + "feed_blocks": float64(0), + "time_context": true, + "placement_count": float64(1), + "turn_count": float64(2), + "managed_tool": true, + }, + }, + }, + } + h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, nil, "http://localhost:8181", false, withAgentContextSource(source)) + req := httptest.NewRequest(http.MethodGet, "/agents/bot-0", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + body := w.Body.String() + for _, want := range []string{"# Contract", "# Infrastructure", "alerts", "openclaw", "Compiled manifests", "Runtime inputs"} { + if !strings.Contains(body, want) { + t.Fatalf("expected %q in body:\n%s", want, body) + } + } + if strings.Contains(body, "chosen_ref") { + t.Fatalf("did not expect live snapshot raw JSON on default contract tab") + } + + req = httptest.NewRequest(http.MethodGet, "/agents/bot-0?tab=live", nil) + w = httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for live tab, got %d body=%s", w.Code, w.Body.String()) + } + body = w.Body.String() + for _, want := range []string{"Live context", "chosen_ref", "openrouter/anthropic/claude-sonnet-4", "mediated", "Recent captures", "5m"} { + if !strings.Contains(body, want) { + t.Fatalf("expected %q in live tab body:\n%s", want, body) + } + } +} + +func TestAgentContextPageRequiresSource(t *testing.T) { + h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, nil, "http://localhost:8181", false) + req := httptest.NewRequest(http.MethodGet, "/agents", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestAgentContextHTTPClientUsesBearerAndPaths(t *testing.T) { + var paths []string + var auth []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + auth = append(auth, r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/agents": + _, _ = w.Write([]byte(`{"agents":[{"claw_id":"bot-0","service":"bot","claw_type":"openclaw","has_live_context":true}]}`)) + case "/agents/bot-0/contract": + _, _ = w.Write([]byte(`{"claw_id":"bot-0","agents_md":"# Contract","clawdapus_md":"# Infra","metadata":{"service":"bot","type":"openclaw"}}`)) + case "/agents/bot-0/context": + _, _ = w.Write([]byte(`{"agent_id":"bot-0","turn_count":1}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := newAgentContextHTTPClient(server.URL, "dash-token") + if _, err := client.List(context.Background()); err != nil { + t.Fatalf("list agents: %v", err) + } + if _, err := client.Contract(context.Background(), "bot-0"); err != nil { + t.Fatalf("contract: %v", err) + } + if _, err := client.LiveContext(context.Background(), "bot-0"); err != nil { + t.Fatalf("live context: %v", err) + } + + wantPaths := []string{"/agents", "/agents/bot-0/contract", "/agents/bot-0/context"} + if strings.Join(paths, ",") != strings.Join(wantPaths, ",") { + t.Fatalf("unexpected paths: got %v want %v", paths, wantPaths) + } + for _, got := range auth { + if got != "Bearer dash-token" { + t.Fatalf("expected bearer auth, got %q", got) + } + } +} + func TestAPIStatusJSON(t *testing.T) { h := newHandler(testManifest(), fakeStatusSource{statuses: testStatuses()}, nil, "http://localhost:8181", false) req := httptest.NewRequest(http.MethodGet, "/api/status", nil) diff --git a/cmd/clawdash/main.go b/cmd/clawdash/main.go index 1cb72ab..a6617f7 100644 --- a/cmd/clawdash/main.go +++ b/cmd/clawdash/main.go @@ -65,7 +65,8 @@ func run(cfg config) error { defer source.Close() scheduleSource := newScheduleHTTPClient(cfg.ClawAPIURL, cfg.ClawAPIToken) - h := newHandler(manifest, source, scheduleSource, cfg.CllamaCostsURL, cfg.CostLogFallback) + agentContextSource := newAgentContextHTTPClient(cfg.ClawAPIURL, cfg.ClawAPIToken) + h := newHandler(manifest, source, scheduleSource, cfg.CllamaCostsURL, cfg.CostLogFallback, withAgentContextSource(agentContextSource)) srv := &http.Server{ Addr: cfg.Addr, Handler: h, diff --git a/cmd/clawdash/schedule_page.go b/cmd/clawdash/schedule_page.go index 3da1262..d7d490f 100644 --- a/cmd/clawdash/schedule_page.go +++ b/cmd/clawdash/schedule_page.go @@ -17,6 +17,7 @@ type schedulePageData struct { PodName string ActiveTab string HasSchedule bool + HasAgentContext bool Summary []dashStat Cards []scheduleCard HasCards bool @@ -129,6 +130,7 @@ func (h *handler) renderSchedule(w http.ResponseWriter, r *http.Request) { firstNonEmpty(strings.TrimSpace(r.URL.Query().Get("error")), errString(err)), now, ) + data.HasAgentContext = h.hasAgentContext() w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = h.tpl.ExecuteTemplate(w, "schedule.html", data) diff --git a/cmd/clawdash/static/app.css b/cmd/clawdash/static/app.css index 304855e..b331102 100644 --- a/cmd/clawdash/static/app.css +++ b/cmd/clawdash/static/app.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Outfit,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Geist Mono,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark}body{--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));font-family:Outfit,sans-serif;--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;min-height:100vh;position:relative}body:after{content:"";position:fixed;inset:0;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.03) 0,rgba(0,0,0,.03) 4px);z-index:9999}a{color:inherit;text-decoration-line:none}.dash-shell{margin-left:auto;margin-right:auto;width:min(1080px,calc(100vw - 48px));padding-top:2rem;padding-bottom:2rem}.dash-topbar{display:flex;height:3rem;align-items:center;justify-content:space-between;gap:1rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(19 26 36/var(--tw-bg-opacity,1));padding-left:1.5rem;padding-right:1.5rem}.dash-brand{min-width:0;text-decoration-line:none}.dash-brand-kicker{letter-spacing:.08em}.dash-brand-kicker,.dash-brand-wordmark{font-family:Geist Mono,monospace;font-size:11px;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-brand-wordmark{letter-spacing:.06em}.dash-brand-wordmark span{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.dash-title{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.dash-nav{display:flex;align-items:center;gap:1rem}.dash-nav-link{font-family:Geist Mono,monospace;font-size:12px;font-weight:500;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.dash-nav-link-active,.dash-nav-link-active:before,.dash-nav-link:hover{--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.dash-nav-link-active:before{content:"• "}.dash-link{font-family:Geist Mono,monospace;font-size:12px;font-weight:500;--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.dash-link:hover{--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.dash-page-head{margin-bottom:1.25rem;margin-top:2rem}.dash-page-kicker{font-family:Geist Mono,monospace;font-size:12px;--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.dash-page-title{margin-top:.5rem;font-size:38px;font-weight:600;letter-spacing:-.04em;--tw-text-opacity:1;color:rgb(237 242 247/var(--tw-text-opacity,1))}.dash-page-copy{margin-top:.5rem;font-size:13px;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-page-meta{margin-top:1rem;display:flex;flex-wrap:wrap;align-items:center;gap:.5rem}.dash-panel{margin-bottom:1.25rem;overflow:hidden;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(19 26 36/var(--tw-bg-opacity,1))}.dash-section-head{display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));padding:14px 18px}.dash-section-title{margin:0;font-weight:600;text-transform:uppercase;letter-spacing:.06em;--tw-text-opacity:1}.dash-chip,.dash-section-title{font-family:Geist Mono,monospace;font-size:11px;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-chip{border-radius:.25rem;--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.125rem .5rem;--tw-text-opacity:1}.dash-banner{margin-bottom:1.25rem;border-radius:.5rem;border-width:1px;border-color:rgba(239,68,68,.3);background-color:rgba(239,68,68,.1);padding:.75rem 1rem;font-size:13px;--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.dash-pill{display:inline-flex;align-items:center;gap:.25rem;border-radius:.25rem;border-width:1px;padding:.125rem .5rem;font-family:Geist Mono,monospace;font-size:11px}.badge-cyan{border-color:rgba(34,211,238,.2);background-color:rgba(34,211,238,.08);--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.tone-good{border-color:rgba(52,211,153,.2);background-color:rgba(52,211,153,.08);--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.tone-warning{border-color:rgba(240,165,0,.2);background-color:rgba(240,165,0,.08);--tw-text-opacity:1;color:rgb(240 165 0/var(--tw-text-opacity,1))}.tone-neutral{--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-strip{margin-bottom:1.25rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(19 26 36/var(--tw-bg-opacity,1));padding:1rem 18px}.dash-strip-grid{display:flex;flex-wrap:wrap;gap:.75rem}.dash-metric{min-width:136px;flex:1 1 0%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.625rem .75rem}.dash-metric-label{font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-metric-label,.dash-metric-value{font-family:Geist Mono,monospace;--tw-text-opacity:1}.dash-metric-value{margin-top:.5rem;font-size:18px;font-weight:600;color:rgb(237 242 247/var(--tw-text-opacity,1))}.dash-metric-hint{margin-top:.25rem;font-size:11px;line-height:1.25rem;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-toolbar{margin-bottom:1.25rem;display:flex;flex-direction:column;gap:.75rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(19 26 36/var(--tw-bg-opacity,1));padding:1rem 18px}@media (min-width:768px){.dash-toolbar{flex-direction:row;align-items:center;justify-content:space-between}}.dash-input,.dash-select{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:13px;--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1));outline:2px solid transparent;outline-offset:2px}.dash-input::-moz-placeholder,.dash-select::-moz-placeholder{--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-input::placeholder,.dash-select::placeholder{--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-input:focus,.dash-select:focus{border-color:rgba(34,211,238,.4)}.dash-main-grid{display:grid;align-items:flex-start;gap:1.25rem}@media (min-width:1024px){.dash-main-grid{grid-template-columns:minmax(0,1.45fr) 320px}}.dash-stack{display:grid;gap:1.25rem}.dash-alert-list{display:grid;gap:.75rem;padding:18px}.dash-alert{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:1rem}.attention-summary{font-size:13px;line-height:1.5rem;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-list-head{display:none;align-items:center;gap:1rem;--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:10px 18px;font-family:Geist Mono,monospace;font-size:10px;text-transform:uppercase;letter-spacing:.06em;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}@media (min-width:1024px){.dash-list-head{display:grid;grid-template-columns:minmax(0,1.6fr) 140px 140px minmax(0,1fr) 120px}}.dash-service-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(31 45 61/var(--tw-divide-opacity,1))}.dash-service-row{display:grid;gap:.75rem;padding:.75rem 18px;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.dash-service-row:hover{background-color:rgba(34,211,238,.02)}@media (min-width:1024px){.dash-service-row{grid-template-columns:minmax(0,1.6fr) 140px 140px minmax(0,1fr) 120px;align-items:center}}.dash-service-name{font-family:Geist Mono,monospace;font-size:13px;font-weight:600;--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.dash-service-meta{margin-top:.25rem;font-size:12px;line-height:1.25rem;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-status-wrap{display:inline-flex;align-items:center;gap:.5rem;font-family:Geist Mono,monospace;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.status-dot{height:.375rem;width:.375rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(94 112 133/var(--tw-bg-opacity,1))}.status-healthy{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.status-dot.status-healthy,.status-healthy.status-dot{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.status-starting{--tw-text-opacity:1;color:rgb(240 165 0/var(--tw-text-opacity,1))}.status-dot.status-starting,.status-starting.status-dot{--tw-bg-opacity:1;background-color:rgb(240 165 0/var(--tw-bg-opacity,1))}.status-unhealthy{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.status-dot.status-unhealthy,.status-unhealthy.status-dot{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.dash-empty,.status-unknown{--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-empty{padding:2rem;text-align:center;font-size:13px}.dash-table{width:100%;border-collapse:collapse}.dash-table th{--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:10px 14px;text-align:left;font-family:Geist Mono,monospace;font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:.06em;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-table td{border-top-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));padding:11px 14px;font-size:13px;--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.dash-inline-list{display:flex;flex-wrap:wrap;gap:.5rem;padding:18px}.dash-inline-token{border-radius:.25rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.25rem .625rem;font-family:Geist Mono,monospace;font-size:11px;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-detail-grid{display:grid;gap:.75rem}@media (min-width:768px){.dash-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1280px){.dash-detail-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dash-detail-stat{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.625rem .75rem}.dash-grid-label{font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-detail-value,.dash-grid-label{font-family:Geist Mono,monospace;--tw-text-opacity:1}.dash-detail-value{margin-top:.5rem;overflow-wrap:break-word;font-size:13px;color:rgb(212 220 232/var(--tw-text-opacity,1))}.dash-guild{margin-top:.5rem;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.75rem;font-size:13px;line-height:1.5rem}.dash-legend{margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:.5rem}.dash-legend-pill{display:inline-flex;align-items:center;gap:.5rem;border-radius:.25rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.25rem .75rem;font-family:Geist Mono,monospace;font-size:11px;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-swatch{height:.5rem;width:.5rem;border-radius:9999px}.topology-stage{overflow-x:auto;padding:18px}.topology-frame{position:relative;overflow:hidden;background-color:rgb(12 16 23/var(--tw-bg-opacity,1))}.topology-frame,.topology-node{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1}.topology-node{position:absolute;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:.75rem;background-color:rgb(19 26 36/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-family:Geist Mono,monospace;font-size:11px;font-weight:600;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.topology-node:hover{border-color:rgba(34,211,238,.3)}.topology-edge{opacity:.78}.muted{opacity:.14!important}.absolute{position:absolute}.inset-0{inset:0}.isolate{isolation:isolate}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.inline-block{display:inline-block}.flex{display:flex}.table{display:table}.grid{display:grid}.h-full{height:100%}.w-full{width:100%}.min-w-\[240px\]{min-width:240px}.min-w-full{min-width:100%}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-0{gap:0}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bg-amber{--tw-bg-opacity:1;background-color:rgb(240 165 0/var(--tw-bg-opacity,1))}.bg-coral{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity,1))}.bg-green{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.bg-ink{--tw-bg-opacity:1;background-color:rgb(212 220 232/var(--tw-bg-opacity,1))}.bg-violet{--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1))}.p-5{padding:1.25rem}.px-0{padding-left:0;padding-right:0}.pl-5{padding-left:1.25rem}.pt-0{padding-top:0}.pt-4{padding-top:1rem}.font-mono{font-family:Geist Mono,monospace}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-6{line-height:1.5rem}.tracking-\[-0\.03em\]{letter-spacing:-.03em}.tracking-\[0\.16em\]{letter-spacing:.16em}.text-amber{--tw-text-opacity:1;color:rgb(240 165 0/var(--tw-text-opacity,1))}.text-coral{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.text-green{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.text-muted{--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.text-violet{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-opacity{transition-duration:.15s}@media not all and (min-width:768px){.max-md\:flex-col{flex-direction:column}.max-md\:items-start{align-items:flex-start}}@media (min-width:768px){.md\:flex-row{flex-direction:row}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Outfit,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Geist Mono,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark}body{--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));font-family:Outfit,sans-serif;--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;min-height:100vh;position:relative}body:after{content:"";position:fixed;inset:0;pointer-events:none;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.03) 0,rgba(0,0,0,.03) 4px);z-index:9999}a{color:inherit;text-decoration-line:none}.dash-shell{margin-left:auto;margin-right:auto;width:min(1080px,calc(100vw - 48px));padding-top:2rem;padding-bottom:2rem}.dash-topbar{display:flex;height:3rem;align-items:center;justify-content:space-between;gap:1rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(19 26 36/var(--tw-bg-opacity,1));padding-left:1.5rem;padding-right:1.5rem}.dash-brand{min-width:0;text-decoration-line:none}.dash-brand-kicker{letter-spacing:.08em}.dash-brand-kicker,.dash-brand-wordmark{font-family:Geist Mono,monospace;font-size:11px;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-brand-wordmark{letter-spacing:.06em}.dash-brand-wordmark span{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.dash-title{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.dash-nav{display:flex;align-items:center;gap:1rem}.dash-nav-link{font-family:Geist Mono,monospace;font-size:12px;font-weight:500;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.dash-nav-link-active,.dash-nav-link-active:before,.dash-nav-link:hover{--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.dash-nav-link-active:before{content:"• "}.dash-link{font-family:Geist Mono,monospace;font-size:12px;font-weight:500;--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.dash-link:hover{--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.dash-page-head{margin-bottom:1.25rem;margin-top:2rem}.dash-page-kicker{font-family:Geist Mono,monospace;font-size:12px;--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.dash-page-title{margin-top:.5rem;font-size:38px;font-weight:600;letter-spacing:-.04em;--tw-text-opacity:1;color:rgb(237 242 247/var(--tw-text-opacity,1))}.dash-page-copy{margin-top:.5rem;font-size:13px;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-page-meta{margin-top:1rem;display:flex;flex-wrap:wrap;align-items:center;gap:.5rem}.dash-panel{margin-bottom:1.25rem;overflow:hidden;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(19 26 36/var(--tw-bg-opacity,1))}.dash-section-head{display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));padding:14px 18px}.dash-section-title{margin:0;font-weight:600;text-transform:uppercase;letter-spacing:.06em;--tw-text-opacity:1}.dash-chip,.dash-section-title{font-family:Geist Mono,monospace;font-size:11px;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-chip{border-radius:.25rem;--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.125rem .5rem;--tw-text-opacity:1}.dash-banner{margin-bottom:1.25rem;border-radius:.5rem;border-width:1px;border-color:rgba(239,68,68,.3);background-color:rgba(239,68,68,.1);padding:.75rem 1rem;font-size:13px;--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.dash-pill{display:inline-flex;align-items:center;gap:.25rem;border-radius:.25rem;border-width:1px;padding:.125rem .5rem;font-family:Geist Mono,monospace;font-size:11px}.badge-cyan{border-color:rgba(34,211,238,.2);background-color:rgba(34,211,238,.08);--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.tone-good{border-color:rgba(52,211,153,.2);background-color:rgba(52,211,153,.08);--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.tone-warning{border-color:rgba(240,165,0,.2);background-color:rgba(240,165,0,.08);--tw-text-opacity:1;color:rgb(240 165 0/var(--tw-text-opacity,1))}.tone-neutral{--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-strip{margin-bottom:1.25rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(19 26 36/var(--tw-bg-opacity,1));padding:1rem 18px}.dash-strip-grid{display:flex;flex-wrap:wrap;gap:.75rem}.dash-metric{min-width:136px;flex:1 1 0%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.625rem .75rem}.dash-metric-label{font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-metric-label,.dash-metric-value{font-family:Geist Mono,monospace;--tw-text-opacity:1}.dash-metric-value{margin-top:.5rem;font-size:18px;font-weight:600;color:rgb(237 242 247/var(--tw-text-opacity,1))}.dash-metric-hint{margin-top:.25rem;font-size:11px;line-height:1.25rem;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-toolbar{margin-bottom:1.25rem;display:flex;flex-direction:column;gap:.75rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(19 26 36/var(--tw-bg-opacity,1));padding:1rem 18px}@media (min-width:768px){.dash-toolbar{flex-direction:row;align-items:center;justify-content:space-between}}.dash-input,.dash-select{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:13px;--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1));outline:2px solid transparent;outline-offset:2px}.dash-input::-moz-placeholder,.dash-select::-moz-placeholder{--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-input::placeholder,.dash-select::placeholder{--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-input:focus,.dash-select:focus{border-color:rgba(34,211,238,.4)}.dash-main-grid{display:grid;align-items:flex-start;gap:1.25rem}@media (min-width:1024px){.dash-main-grid{grid-template-columns:minmax(0,1.45fr) 320px}}.dash-stack{display:grid;gap:1.25rem}.dash-alert-list{display:grid;gap:.75rem;padding:18px}.dash-alert{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:1rem}.attention-summary{font-size:13px;line-height:1.5rem;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-list-head{display:none;align-items:center;gap:1rem;--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:10px 18px;font-family:Geist Mono,monospace;font-size:10px;text-transform:uppercase;letter-spacing:.06em;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}@media (min-width:1024px){.dash-list-head{display:grid;grid-template-columns:minmax(0,1.6fr) 140px 140px minmax(0,1fr) 120px}}.dash-service-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(31 45 61/var(--tw-divide-opacity,1))}.dash-service-row{display:grid;gap:.75rem;padding:.75rem 18px;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.dash-service-row:hover{background-color:rgba(34,211,238,.02)}@media (min-width:1024px){.dash-service-row{grid-template-columns:minmax(0,1.6fr) 140px 140px minmax(0,1fr) 120px;align-items:center}}.dash-service-name{font-family:Geist Mono,monospace;font-size:13px;font-weight:600;--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.dash-service-meta{margin-top:.25rem;font-size:12px;line-height:1.25rem;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-status-wrap{display:inline-flex;align-items:center;gap:.5rem;font-family:Geist Mono,monospace;font-size:11px;text-transform:uppercase;letter-spacing:.06em}.status-dot{height:.375rem;width:.375rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(94 112 133/var(--tw-bg-opacity,1))}.status-healthy{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.status-dot.status-healthy,.status-healthy.status-dot{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.status-starting{--tw-text-opacity:1;color:rgb(240 165 0/var(--tw-text-opacity,1))}.status-dot.status-starting,.status-starting.status-dot{--tw-bg-opacity:1;background-color:rgb(240 165 0/var(--tw-bg-opacity,1))}.status-unhealthy{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.status-dot.status-unhealthy,.status-unhealthy.status-dot{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.dash-empty,.status-unknown{--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-empty{padding:2rem;text-align:center;font-size:13px}.dash-table{width:100%;border-collapse:collapse}.dash-table th{--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:10px 14px;text-align:left;font-family:Geist Mono,monospace;font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:.06em;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-table td{border-top-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));padding:11px 14px;font-size:13px;--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.dash-inline-list{display:flex;flex-wrap:wrap;gap:.5rem;padding:18px}.dash-inline-token{border-radius:.25rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.25rem .625rem;font-family:Geist Mono,monospace;font-size:11px;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-detail-grid{display:grid;gap:.75rem}@media (min-width:768px){.dash-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1280px){.dash-detail-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.dash-detail-stat{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.625rem .75rem}.dash-grid-label{font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-detail-value,.dash-grid-label{font-family:Geist Mono,monospace;--tw-text-opacity:1}.dash-detail-value{margin-top:.5rem;overflow-wrap:break-word;font-size:13px;color:rgb(212 220 232/var(--tw-text-opacity,1))}.dash-guild{margin-top:.5rem;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.75rem;font-size:13px;line-height:1.5rem}.dash-legend{margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:.5rem}.dash-legend-pill{display:inline-flex;align-items:center;gap:.5rem;border-radius:.25rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(12 16 23/var(--tw-bg-opacity,1));padding:.25rem .75rem;font-family:Geist Mono,monospace;font-size:11px;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.dash-swatch{height:.5rem;width:.5rem;border-radius:9999px}.live-meta{display:flex;flex-wrap:wrap;align-items:center;-moz-column-gap:.75rem;column-gap:.75rem;row-gap:.25rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));padding:.75rem 18px;font-family:Geist Mono,monospace;font-size:12px;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.live-meta-k{color:rgba(94,112,133,.7)}.live-meta-v{--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.live-meta-sep{color:rgba(94,112,133,.4)}.live-block{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));padding:1.25rem 18px}.live-block:last-child{border-bottom-width:0}.live-kicker{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:rgb(94 112 133/var(--tw-text-opacity,1))}.live-kicker,.live-system-body{font-family:Geist Mono,monospace;--tw-text-opacity:1}.live-system-body{margin-top:.75rem;white-space:pre-wrap;overflow-wrap:break-word;font-size:13px;line-height:1.5rem;color:rgb(212 220 232/var(--tw-text-opacity,1))}.live-inj-list{margin-top:.75rem;list-style-type:none;border-left-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));padding-left:1rem}.live-inj{position:relative;padding-bottom:1rem}.live-inj:last-child{padding-bottom:0}.live-inj:before{content:"";position:absolute;left:-17px;top:7px;height:.375rem;width:.375rem;border-radius:9999px;background-color:rgba(34,211,238,.6)}.live-inj-tag{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:rgb(34 211 238/var(--tw-text-opacity,1))}.live-inj-body,.live-inj-tag{font-family:Geist Mono,monospace;--tw-text-opacity:1}.live-inj-body{margin-top:.25rem;white-space:pre-wrap;overflow-wrap:break-word;font-size:12px;line-height:1.5rem;color:rgb(212 220 232/var(--tw-text-opacity,1))}.live-inj-warn:before{background-color:rgba(240,165,0,.7)}.live-inj-warn .live-inj-tag{--tw-text-opacity:1;color:rgb(240 165 0/var(--tw-text-opacity,1))}.live-route-list{margin-top:.75rem}.live-route-list>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.live-route-list{font-family:Geist Mono,monospace;font-size:12px;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.live-route-list li{display:flex;align-items:center;gap:.5rem}.live-route-sep{color:rgba(94,112,133,.4)}.live-route-primary{--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.live-details{border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));padding:.75rem 18px;font-family:Geist Mono,monospace;font-size:12px;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.live-details:last-child{border-bottom-width:0}.live-details summary{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.live-details summary:hover,.live-details[open] summary{--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.live-details[open] summary{padding-bottom:.75rem}.live-details pre{margin-top:.5rem;white-space:pre-wrap;overflow-wrap:break-word;font-size:12px;line-height:1.5rem;--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.live-recent{margin-top:.75rem}.live-recent>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(31 45 61/var(--tw-divide-opacity,1))}.live-recent{border-top-width:1px;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));font-size:12px}.live-recent-row{display:grid;grid-template-columns:auto 1fr auto;align-items:baseline;-moz-column-gap:1rem;column-gap:1rem;padding:.5rem 0}.live-recent-seq{font-family:Geist Mono,monospace;font-size:11px;color:rgba(94,112,133,.7)}.live-recent-model{color:rgb(212 220 232/var(--tw-text-opacity,1))}.live-recent-meta,.live-recent-model{font-family:Geist Mono,monospace;--tw-text-opacity:1}.live-recent-meta{font-size:11px;color:rgb(94 112 133/var(--tw-text-opacity,1))}.topology-stage{overflow-x:auto;padding:18px}.topology-frame{position:relative;overflow:hidden;background-color:rgb(12 16 23/var(--tw-bg-opacity,1))}.topology-frame,.topology-node{border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(31 45 61/var(--tw-border-opacity,1));--tw-bg-opacity:1}.topology-node{position:absolute;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:.75rem;background-color:rgb(19 26 36/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-family:Geist Mono,monospace;font-size:11px;font-weight:600;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.topology-node:hover{border-color:rgba(34,211,238,.3)}.topology-edge{opacity:.78}.muted{opacity:.14!important}.visible{visibility:visible}.absolute{position:absolute}.inset-0{inset:0}.isolate{isolation:isolate}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.inline-block{display:inline-block}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-full{height:100%}.w-full{width:100%}.min-w-\[240px\]{min-width:240px}.min-w-full{min-width:100%}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-0{gap:0}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bg-amber{--tw-bg-opacity:1;background-color:rgb(240 165 0/var(--tw-bg-opacity,1))}.bg-coral{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity,1))}.bg-green{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.bg-ink{--tw-bg-opacity:1;background-color:rgb(212 220 232/var(--tw-bg-opacity,1))}.bg-violet{--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1))}.p-5{padding:1.25rem}.px-0{padding-left:0;padding-right:0}.pl-5{padding-left:1.25rem}.pt-0{padding-top:0}.pt-4{padding-top:1rem}.font-mono{font-family:Geist Mono,monospace}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-6{line-height:1.5rem}.tracking-\[-0\.03em\]{letter-spacing:-.03em}.tracking-\[0\.16em\]{letter-spacing:.16em}.text-amber{--tw-text-opacity:1;color:rgb(240 165 0/var(--tw-text-opacity,1))}.text-coral{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.text-green{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-ink{--tw-text-opacity:1;color:rgb(212 220 232/var(--tw-text-opacity,1))}.text-muted{--tw-text-opacity:1;color:rgb(94 112 133/var(--tw-text-opacity,1))}.text-violet{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-150,.transition-opacity{transition-duration:.15s}@media not all and (min-width:768px){.max-md\:flex-col{flex-direction:column}.max-md\:items-start{align-items:flex-start}}@media (min-width:768px){.md\:flex-row{flex-direction:row}} \ No newline at end of file diff --git a/cmd/clawdash/tailwind.css b/cmd/clawdash/tailwind.css index 3e8a07d..ec05228 100644 --- a/cmd/clawdash/tailwind.css +++ b/cmd/clawdash/tailwind.css @@ -323,6 +323,119 @@ @apply h-2 w-2 rounded-full; } + /* Live context — minimalist layout. + Relies on position + left-rail tags to imply order/source, + instead of stacking labeled boxes. */ + + .live-meta { + @apply flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-line px-[18px] py-3 font-mono text-[12px] text-muted; + } + + .live-meta-k { + @apply text-muted/70; + } + + .live-meta-v { + @apply text-ink; + } + + .live-meta-sep { + @apply text-muted/40; + } + + .live-block { + @apply border-b border-line px-[18px] py-5 last:border-b-0; + } + + .live-kicker { + @apply font-mono text-[10px] font-semibold uppercase tracking-[0.08em] text-muted; + } + + .live-system-body { + @apply mt-3 whitespace-pre-wrap break-words font-mono text-[13px] leading-6 text-ink; + } + + .live-inj-list { + @apply mt-3 list-none border-l border-line pl-4; + } + + .live-inj { + @apply relative pb-4 last:pb-0; + } + + .live-inj::before { + content: ""; + @apply absolute -left-[17px] top-[7px] h-1.5 w-1.5 rounded-full bg-cyan/60; + } + + .live-inj-tag { + @apply font-mono text-[10px] font-semibold uppercase tracking-[0.08em] text-cyan; + } + + .live-inj-body { + @apply mt-1 whitespace-pre-wrap break-words font-mono text-[12px] leading-6 text-ink; + } + + .live-inj-warn::before { + @apply bg-amber/70; + } + + .live-inj-warn .live-inj-tag { + @apply text-amber; + } + + .live-route-list { + @apply mt-3 space-y-1 font-mono text-[12px] text-muted; + } + + .live-route-list li { + @apply flex items-center gap-2; + } + + .live-route-sep { + @apply text-muted/40; + } + + .live-route-primary { + @apply text-ink; + } + + .live-details { + @apply border-b border-line px-[18px] py-3 font-mono text-[12px] text-muted last:border-b-0; + } + + .live-details summary { + @apply cursor-pointer select-none text-muted hover:text-ink; + } + + .live-details[open] summary { + @apply pb-3 text-ink; + } + + .live-details pre { + @apply mt-2 whitespace-pre-wrap break-words text-[12px] leading-6 text-ink; + } + + .live-recent { + @apply mt-3 divide-y divide-line border-y border-line text-[12px]; + } + + .live-recent-row { + @apply grid grid-cols-[auto_1fr_auto] items-baseline gap-x-4 px-0 py-2; + } + + .live-recent-seq { + @apply font-mono text-[11px] text-muted/70; + } + + .live-recent-model { + @apply font-mono text-ink; + } + + .live-recent-meta { + @apply font-mono text-[11px] text-muted; + } + .topology-stage { @apply overflow-x-auto p-[18px]; } diff --git a/cmd/clawdash/templates/agent_detail.html b/cmd/clawdash/templates/agent_detail.html new file mode 100644 index 0000000..02323a8 --- /dev/null +++ b/cmd/clawdash/templates/agent_detail.html @@ -0,0 +1,305 @@ + + + + + + clawdapus dash - agent context + + + + + + + + +
+
+ +
clawdash fleet
+
+ + + +
{{.PodName}}
+
+ +
+ Agents +
Context brief
+

{{.ClawID}}

+

What the agent is contracted to see at startup, plus the latest cllama-assembled live turn context.

+
+ {{if .ClawType}}{{.ClawType}}{{end}} + {{if .Service}}{{.Service}}{{end}} + {{if .IsLiveTab}} + {{if .HasLiveContext}}live snapshot{{else}}no live snapshot{{end}} + {{else}} + contract view + {{end}} +
+
+ + {{if .HasContractErr}} +
{{.ContractError}}
+ {{end}} + {{if .HasLiveError}} +
{{.LiveError}}
+ {{end}} + +
+ +
+ +
+ {{if .IsLiveTab}} +
+
+
+
Last captured turn
+

Live context

+
+ Refresh +
+ + {{if .HasLiveContext}} +
+ captured {{if .LiveContext.CapturedAt}}{{.LiveContext.CapturedAt}}{{else}}-{{end}} + · + format {{if .LiveContext.Format}}{{.LiveContext.Format}}{{else}}-{{end}} + · + {{if .LiveContext.RequestedModel}}{{.LiveContext.RequestedModel}}{{else}}-{{end}} + + {{if .LiveContext.ChosenRef}}{{.LiveContext.ChosenRef}}{{else}}-{{end}} + {{if .LiveContext.ManagedTool}} + · + mediated {{.LiveContext.TurnCount}} turn{{if ne .LiveContext.TurnCount 1}}s{{end}} + {{end}} +
+ +
+
System message sent to provider
+ {{if .LiveContext.HasSystemText}} +
{{.LiveContext.SystemText}}
+ {{else if .LiveContext.HasSystemJSON}} +
{{.LiveContext.SystemJSON}}
+ {{else}} +
-
+ {{end}} +
+ + {{if or .LiveContext.HasFeedBlocks .LiveContext.HasMemoryRecall .LiveContext.HasTimeContext .LiveContext.HasIntervention}} +
+
Injected into system context
+
    + {{range .LiveContext.FeedBlocks}} +
  1. +
    feed
    +
    {{.}}
    +
  2. + {{end}} + {{if .LiveContext.HasMemoryRecall}} +
  3. +
    memory
    +
    {{.LiveContext.MemoryRecall}}
    +
  4. + {{end}} + {{if .LiveContext.HasTimeContext}} +
  5. +
    time
    +
    {{.LiveContext.TimeContext}}
    +
  6. + {{end}} + {{if .LiveContext.HasIntervention}} +
  7. +
    intervention
    +
    {{.LiveContext.Intervention}}
    +
  8. + {{end}} +
+
+ {{end}} + + {{if .LiveContext.HasCandidates}} +
+
Routing — ordered candidates
+
    + {{range .LiveContext.Candidates}} +
  1. {{.Provider}}{{.UpstreamModel}}
  2. + {{end}} +
+
+ {{end}} + + {{if .LiveContext.HasRecentCaptures}} +
+ Recent captures · {{len .LiveContext.RecentCaptures}} +
+ {{range .LiveContext.RecentCaptures}} +
+ #{{.Sequence}} + {{.Model}} + {{.CapturedAt}}{{if .Interval}} · +{{.Interval}}{{end}} · {{.DynamicInputs}} in{{if .ManagedTool}} · mediated ({{.TurnCount}}){{end}} +
+ {{end}} +
+
+ {{end}} + + {{if .LiveContext.HasTools}} +
+ Tool schemas sent to provider +
{{.LiveContext.ToolsJSON}}
+
+ {{end}} + +
+ Raw snapshot JSON +
{{.LiveContextJSON}}
+
+ {{else if .HasLiveError}} +
{{.LiveError}}
+ {{else}} +
No live context has been captured for this agent yet.
+ {{end}} +
+ {{else if .HasContract}} +
+
+
+
Behavior contract
+

Agent instructions

+
+ AGENTS.md +
+
+
{{.Contract.AgentsMD}}
+
+
+ +
+
+
+
Infrastructure context
+

Compiled environment

+
+ CLAWDAPUS.md +
+
+
{{.Contract.ClawdapusMD}}
+
+
+ +
+
+
+
Compiled manifests
+

Runtime inputs

+
+
+
+ {{if .HasMetadataRows}} +
+
Identity and policy
+
+ + + {{range .MetadataRows}}{{end}} + +
{{.Key}}{{.Value}}
+
+
+ {{end}} + {{if .HasFeedRows}} +
+
Feeds
+
+ + + + {{range .FeedRows}} + + {{end}} + +
NameSourcePathTTLURL
{{.Name}}{{.Source}}{{.Path}}{{.TTL}}{{.URL}}
+
+
+ {{end}} + {{if .HasToolRows}} +
+
Managed tools
+
+ + + + {{range .ToolRows}} + + {{end}} + +
NameServiceMethodPathTransport
{{.Name}}{{.Service}}{{.Method}}{{.Path}}{{.Transport}}
+
+
+ {{end}} + {{if .HasMemoryRows}} +
+
Memory
+
+ + + {{range .MemoryRows}}{{end}} + +
{{.Key}}{{.Value}}
+
+
+ {{end}} + {{if .HasServiceAuthRows}} +
+
Service credentials
+
+ + + + {{range .ServiceAuthRows}} + + {{end}} + +
ServiceAuth typePrincipal
{{.Service}}{{.AuthType}}{{.Principal}}
+
+
+ {{end}} + {{if and (not .HasMetadata) (not .HasFeeds) (not .HasTools) (not .HasMemory) (not .HasServiceAuth)}} +
No generated JSON artifacts were returned for this agent.
+ {{end}} + {{if or .HasMetadata .HasFeeds .HasTools .HasMemory .HasServiceAuth}} +
+ Raw runtime JSON + {{if .HasMetadata}}
metadata.json
+{{.MetadataJSON}}
{{end}} + {{if .HasFeeds}}
feeds.json
+{{.FeedsJSON}}
{{end}} + {{if .HasTools}}
tools.json
+{{.ToolsJSON}}
{{end}} + {{if .HasMemory}}
memory.json
+{{.MemoryJSON}}
{{end}} + {{if .HasServiceAuth}}
service-auth
+{{.ServiceAuthJSON}}
{{end}} +
+ {{end}} +
+
+ {{else}} +
+
Contract context could not be loaded for this agent.
+
+ {{end}} +
+
+ + diff --git a/cmd/clawdash/templates/agents.html b/cmd/clawdash/templates/agents.html new file mode 100644 index 0000000..c6fa96c --- /dev/null +++ b/cmd/clawdash/templates/agents.html @@ -0,0 +1,93 @@ + + + + + + clawdapus dash - agents + + + + + + + + +
+
+ +
clawdash fleet
+
+ + + +
{{.PodName}}
+
+ +
+
{{.PodName}}
+

Agent Context

+

Compiled contracts and the latest proxy-captured prompt context for claws in this pod.

+
+ + {{if .HasError}} +
{{.Error}}
+ {{end}} + +
+
+ {{range .Summary}} +
+
{{.Label}}
+
{{.Value}}
+
{{.Hint}}
+
+ {{end}} +
+
+ +
+
+
+
Governed context
+

Agent registry

+
+ {{len .Agents}} visible +
+ +
+
Agent
+
Runtime
+
Service
+
Snapshot
+
Inspect
+
+ + +
+
+ + diff --git a/cmd/clawdash/templates/detail.html b/cmd/clawdash/templates/detail.html index 5c7b3ea..be3795e 100644 --- a/cmd/clawdash/templates/detail.html +++ b/cmd/clawdash/templates/detail.html @@ -21,6 +21,7 @@ diff --git a/cmd/clawdash/templates/fleet.html b/cmd/clawdash/templates/fleet.html index 38b1fc9..613b005 100644 --- a/cmd/clawdash/templates/fleet.html +++ b/cmd/clawdash/templates/fleet.html @@ -21,6 +21,7 @@ diff --git a/cmd/clawdash/templates/schedule.html b/cmd/clawdash/templates/schedule.html index b03278f..67548f7 100644 --- a/cmd/clawdash/templates/schedule.html +++ b/cmd/clawdash/templates/schedule.html @@ -22,6 +22,7 @@ diff --git a/cmd/clawdash/templates/topology.html b/cmd/clawdash/templates/topology.html index 663c179..80a90f7 100644 --- a/cmd/clawdash/templates/topology.html +++ b/cmd/clawdash/templates/topology.html @@ -21,6 +21,7 @@ diff --git a/cmd/clawdash/topology.go b/cmd/clawdash/topology.go index 431f60e..ad4c924 100644 --- a/cmd/clawdash/topology.go +++ b/cmd/clawdash/topology.go @@ -14,6 +14,7 @@ type topologyPageData struct { PodName string ActiveTab string HasSchedule bool + HasAgentContext bool Summary []dashStat Lanes []topologyLane CanvasWidth int diff --git a/internal/audit/normalize.go b/internal/audit/normalize.go index 1abb2ba..7d57d8a 100644 --- a/internal/audit/normalize.go +++ b/internal/audit/normalize.go @@ -9,8 +9,6 @@ import ( "time" ) -const maxScanTokenSize = 1024 * 1024 - func NormalizeLine(line []byte) (*Event, error) { var raw map[string]any if err := json.Unmarshal(line, &raw); err != nil { @@ -165,51 +163,51 @@ func NormalizeSessionHistoryLine(line []byte) ([]Event, error) { } func ParseReader(r io.Reader) ([]Event, int, error) { - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 0, 64*1024), maxScanTokenSize) + br := bufio.NewReader(r) events := make([]Event, 0) skipped := 0 - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue + for { + chunk, readErr := br.ReadBytes('\n') + if line := strings.TrimSpace(string(chunk)); line != "" { + event, err := NormalizeLine([]byte(line)) + if err != nil { + skipped++ + } else { + events = append(events, *event) + } } - event, err := NormalizeLine([]byte(line)) - if err != nil { - skipped++ - continue + if readErr != nil { + if readErr == io.EOF { + return events, skipped, nil + } + return events, skipped, readErr } - events = append(events, *event) - } - if err := scanner.Err(); err != nil { - return nil, skipped, err } - return events, skipped, nil } func ParseSessionHistoryReader(r io.Reader) ([]Event, int, error) { - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 0, 64*1024), maxScanTokenSize) + br := bufio.NewReader(r) events := make([]Event, 0) skipped := 0 - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue + for { + chunk, readErr := br.ReadBytes('\n') + if line := strings.TrimSpace(string(chunk)); line != "" { + normalized, err := NormalizeSessionHistoryLine([]byte(line)) + if err != nil { + skipped++ + } else { + events = append(events, normalized...) + } } - normalized, err := NormalizeSessionHistoryLine([]byte(line)) - if err != nil { - skipped++ - continue + if readErr != nil { + if readErr == io.EOF { + return events, skipped, nil + } + return events, skipped, readErr } - events = append(events, normalized...) - } - if err := scanner.Err(); err != nil { - return nil, skipped, err } - return events, skipped, nil } func parseRawTimestamp(raw map[string]any) (time.Time, error) { diff --git a/internal/audit/normalize_test.go b/internal/audit/normalize_test.go index 8126295..2c68ad5 100644 --- a/internal/audit/normalize_test.go +++ b/internal/audit/normalize_test.go @@ -190,6 +190,36 @@ func TestParseSessionHistoryReaderSkipsNonMediatedEntries(t *testing.T) { } } +func TestParseSessionHistoryReaderHandlesLargeLines(t *testing.T) { + filler := strings.Repeat("x", 2*1024*1024) + line := `{"version":1,"id":"hist1_big","status":"ok","ts":"2026-04-17T12:00:00Z","claw_id":"weston","status_code":200,"usage":{"total_rounds":1},"filler":"` + filler + `","tool_trace":[{"round":1,"tool_calls":[{"name":"svc.tool","service":"svc","status_code":200}]}]}` + "\n" + events, skipped, err := ParseSessionHistoryReader(strings.NewReader(line)) + if err != nil { + t.Fatalf("unexpected error on oversized line: %v", err) + } + if skipped != 0 { + t.Fatalf("expected 0 skipped lines, got %d", skipped) + } + if len(events) != 1 || events[0].ToolName != "svc.tool" { + t.Fatalf("unexpected events from oversized line: %+v", events) + } +} + +func TestParseReaderHandlesLargeLines(t *testing.T) { + filler := strings.Repeat("x", 2*1024*1024) + line := `{"ts":"2026-04-17T12:00:00Z","claw_id":"weston","type":"request","filler":"` + filler + `"}` + "\n" + events, skipped, err := ParseReader(strings.NewReader(line)) + if err != nil { + t.Fatalf("unexpected error on oversized line: %v", err) + } + if skipped != 0 { + t.Fatalf("expected 0 skipped lines, got %d", skipped) + } + if len(events) != 1 || events[0].Type != "request" { + t.Fatalf("unexpected events from oversized line: %+v", events) + } +} + func TestSummarizeCountsManagedToolCalls(t *testing.T) { events := []Event{ {ClawID: "weston", Type: "tool_call", ToolName: "svc.ok", FinalStatus: "ok"}, diff --git a/internal/clawapi/principal.go b/internal/clawapi/principal.go index 7bad2f8..f174584 100644 --- a/internal/clawapi/principal.go +++ b/internal/clawapi/principal.go @@ -19,6 +19,7 @@ const ( VerbFleetQueryMetrics = "fleet.query_metrics" VerbFleetAlerts = "fleet.alerts" VerbScheduleRead = "schedule.read" + VerbAgentContext = "agent.context" VerbFleetRestart = "fleet.restart" VerbFleetQuarantine = "fleet.quarantine" VerbFleetBudgetSet = "fleet.budget.set" @@ -26,7 +27,7 @@ const ( VerbScheduleControl = "schedule.control" ) -var AllReadVerbs = []string{VerbFleetStatus, VerbFleetLogs, VerbFleetQueryMetrics, VerbFleetAlerts, VerbScheduleRead} +var AllReadVerbs = []string{VerbFleetStatus, VerbFleetLogs, VerbFleetQueryMetrics, VerbFleetAlerts, VerbScheduleRead, VerbAgentContext} var AllWriteVerbs = []string{VerbFleetRestart, VerbFleetQuarantine, VerbFleetBudgetSet, VerbFleetModelRestrict, VerbScheduleControl} var AllVerbs = append(append([]string{}, AllReadVerbs...), AllWriteVerbs...) @@ -190,6 +191,20 @@ func BuildSchedulerPrincipal(podName string) (Principal, error) { }, nil } +func BuildDashboardPrincipal(podName string) (Principal, error) { + token, err := GenerateToken() + if err != nil { + return Principal{}, err + } + verbs := append([]string{}, AllReadVerbs...) + return Principal{ + Name: "claw-dashboard", + Token: token, + Verbs: verbs, + Pods: []string{podName}, + }, nil +} + func GenerateToken() (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { diff --git a/internal/clawapi/principal_test.go b/internal/clawapi/principal_test.go index 359af6c..f276bb9 100644 --- a/internal/clawapi/principal_test.go +++ b/internal/clawapi/principal_test.go @@ -284,6 +284,32 @@ func TestBuildSchedulerPrincipalIsScheduleScoped(t *testing.T) { } } +func TestBuildDashboardPrincipalIsReadOnlyAndPodScoped(t *testing.T) { + p, err := BuildDashboardPrincipal("trading-desk") + if err != nil { + t.Fatalf("BuildDashboardPrincipal: %v", err) + } + if p.Name != "claw-dashboard" { + t.Fatalf("unexpected name: %q", p.Name) + } + for _, v := range AllReadVerbs { + if !p.AllowsVerb(v) { + t.Fatalf("dashboard principal missing read verb %q", v) + } + } + for _, v := range AllWriteVerbs { + if p.AllowsVerb(v) { + t.Fatalf("dashboard principal must not have write verb %q", v) + } + } + if !p.AllowsPod("trading-desk") { + t.Fatalf("expected pod scope, got %+v", p) + } + if p.Token == "" || !strings.HasPrefix(p.Token, "capi_") { + t.Fatalf("expected capi_ token, got %q", p.Token) + } +} + func writeStoreFixture(t *testing.T, raw string) string { t.Helper() path := filepath.Join(t.TempDir(), "principals.json") diff --git a/internal/clawapi/skill.go b/internal/clawapi/skill.go index 41c8fc3..b1905d3 100644 --- a/internal/clawapi/skill.go +++ b/internal/clawapi/skill.go @@ -21,6 +21,9 @@ func GenerateServiceSkill(port string) string { "- `GET /fleet/metrics?claw_id=&since=` returns normalized telemetry for one claw\n"+ "- `GET /fleet/logs?service=&lines=` returns recent logs for one in-scope service\n"+ "- `GET /fleet/alerts?since=` returns anomaly summaries only\n"+ + "- `GET /agents` returns scoped agent context availability\n"+ + "- `GET /agents//contract` returns compiled AGENTS.md, CLAWDAPUS.md, and redacted context manifests\n"+ + "- `GET /agents//context` returns the last live context snapshot captured by cllama\n"+ "- `GET /schedule` returns current scheduled invocation state for in-scope services\n"+ "- `GET /schedule/` returns detail for one scheduled invocation\n\n"+ "## Control Operations\n"+ diff --git a/internal/pod/compose_emit.go b/internal/pod/compose_emit.go index d222d4a..ec6fb76 100644 --- a/internal/pod/compose_emit.go +++ b/internal/pod/compose_emit.go @@ -42,6 +42,9 @@ type ClawAPIConfig struct { PrincipalsHostPath string // host path to principals.json DockerSockHostPath string // host path to docker socket GovernanceHostPath string // host path to .claw-governance/ dir (write plane override files) + ContextHostDir string // host path to .claw-runtime/context/ + CllamaAPIURL string // internal cllama UI/API URL for context snapshots + CllamaAPIToken string // CLLAMA_UI_TOKEN for internal cllama snapshot calls PodName string Environment map[string]string // extra env vars (e.g. CLAW_ALERT_* thresholds) } @@ -372,6 +375,9 @@ func EmitCompose(p *Pod, results map[string]*driver.MaterializeResult, proxies . fmt.Sprintf("%s:/claw/principals.json:ro", p.ClawAPI.PrincipalsHostPath), fmt.Sprintf("%s:/var/run/docker.sock:ro", socketPath), } + if strings.TrimSpace(p.ClawAPI.ContextHostDir) != "" { + clawAPIVolumes = append(clawAPIVolumes, fmt.Sprintf("%s:/claw/context:ro", p.ClawAPI.ContextHostDir)) + } if strings.TrimSpace(p.ClawAPI.ScheduleHostPath) != "" { clawAPIVolumes = append(clawAPIVolumes, fmt.Sprintf("%s:/claw/schedule.json:ro", p.ClawAPI.ScheduleHostPath)) } @@ -567,6 +573,13 @@ func surfaceAccessMode(surface driver.ResolvedSurface) (string, error) { } } +// ClawdashHostPort returns the numeric host port derived from the given +// CLAWDASH_ADDR-style value (e.g. ":8082" or "0.0.0.0:9000"). It falls back +// to the default port when the value cannot be parsed. +func ClawdashHostPort(addr string) string { + return clawdashPort(addr) +} + func clawdashPort(addr string) string { port := strings.TrimSpace(addr) if strings.HasPrefix(addr, ":") { @@ -622,6 +635,15 @@ func clawAPIEnvironment(cfg *ClawAPIConfig, addr string) map[string]string { if strings.TrimSpace(cfg.ScheduleHostPath) != "" { env["CLAW_API_SCHEDULE_MANIFEST"] = "/claw/schedule.json" } + if strings.TrimSpace(cfg.ContextHostDir) != "" { + env["CLAW_CONTEXT_ROOT"] = "/claw/context" + } + if strings.TrimSpace(cfg.CllamaAPIURL) != "" { + env["CLAW_CLLAMA_API_URL"] = strings.TrimSpace(cfg.CllamaAPIURL) + } + if strings.TrimSpace(cfg.CllamaAPIToken) != "" { + env["CLAW_CLLAMA_API_TOKEN"] = strings.TrimSpace(cfg.CllamaAPIToken) + } for k, v := range cfg.Environment { env[k] = v } diff --git a/internal/pod/compose_emit_clawapi_test.go b/internal/pod/compose_emit_clawapi_test.go index 785051f..153019e 100644 --- a/internal/pod/compose_emit_clawapi_test.go +++ b/internal/pod/compose_emit_clawapi_test.go @@ -131,3 +131,55 @@ func TestEmitComposeInjectsClawAPIScheduleManifestWhenConfigured(t *testing.T) { t.Fatalf("expected schedule manifest env, got %v", clawAPISvc.Environment["CLAW_API_SCHEDULE_MANIFEST"]) } } + +func TestEmitComposeInjectsClawAPIContextMountAndCllamaCredentials(t *testing.T) { + p := &Pod{ + Name: "ops-pod", + Services: map[string]*Service{ + "octopus": {Image: "ghcr.io/example/octopus:latest", Claw: &ClawBlock{}}, + }, + ClawAPI: &ClawAPIConfig{ + Image: "ghcr.io/mostlydev/claw-api:latest", + Addr: ":8080", + ManifestHostPath: "/tmp/.claw-runtime/pod-manifest.json", + PrincipalsHostPath: "/tmp/.claw-runtime/claw-api/principals.json", + DockerSockHostPath: "/var/run/docker.sock", + ContextHostDir: "/tmp/.claw-runtime/context", + CllamaAPIURL: "http://cllama:8081", + CllamaAPIToken: "ui-token", + PodName: "ops-pod", + }, + } + + out, err := EmitCompose(p, map[string]*driver.MaterializeResult{ + "octopus": {ReadOnly: true, Restart: "on-failure"}, + }) + if err != nil { + t.Fatalf("EmitCompose returned error: %v", err) + } + + var cf struct { + Services map[string]struct { + Volumes []string `yaml:"volumes"` + Environment map[string]string `yaml:"environment"` + } `yaml:"services"` + } + if err := yaml.Unmarshal([]byte(out), &cf); err != nil { + t.Fatalf("parse compose yaml: %v", err) + } + + clawAPISvc := cf.Services["claw-api"] + joinedVolumes := strings.Join(clawAPISvc.Volumes, ",") + if !strings.Contains(joinedVolumes, "/tmp/.claw-runtime/context:/claw/context:ro") { + t.Fatalf("expected context mount, got %v", clawAPISvc.Volumes) + } + if clawAPISvc.Environment["CLAW_CONTEXT_ROOT"] != "/claw/context" { + t.Fatalf("expected CLAW_CONTEXT_ROOT env, got %v", clawAPISvc.Environment["CLAW_CONTEXT_ROOT"]) + } + if clawAPISvc.Environment["CLAW_CLLAMA_API_URL"] != "http://cllama:8081" { + t.Fatalf("expected CLAW_CLLAMA_API_URL env, got %v", clawAPISvc.Environment["CLAW_CLLAMA_API_URL"]) + } + if clawAPISvc.Environment["CLAW_CLLAMA_API_TOKEN"] != "ui-token" { + t.Fatalf("expected CLAW_CLLAMA_API_TOKEN env, got %v", clawAPISvc.Environment["CLAW_CLLAMA_API_TOKEN"]) + } +} diff --git a/site/guide/cli.md b/site/guide/cli.md index bd04907..6ba4617 100644 --- a/site/guide/cli.md +++ b/site/guide/cli.md @@ -99,7 +99,7 @@ The pod file can be specified as a positional argument or via `-f`. Defaults to 8. Calls `docker compose up` against the generated file. 9. For managed services (`-d` mode), runs post-apply health verification. -**Generated artifacts** are written to `.claw-runtime/` next to the pod file. Per-agent context lives at `.claw-runtime/context//`. These are generated outputs — inspect them for debugging, but do not hand-edit them. +**Generated artifacts** are written to `.claw-runtime/` next to the pod file. Per-agent context lives at `.claw-runtime/context//`. These are generated outputs — inspect them for debugging, but do not hand-edit them. When `claw-api` is present, Clawdapus Dash also exposes this context under its Agents view, including redacted runtime manifests and the latest live cllama context snapshot. `claw up` is strict by default. If an infra image is missing, it tells you to run `claw pull`. If a pod service image is not built, it tells you to run `claw build`. `claw up --fix` performs those remediation steps automatically. diff --git a/site/guide/quickstart.md b/site/guide/quickstart.md index 4c09313..69e49a9 100644 --- a/site/guide/quickstart.md +++ b/site/guide/quickstart.md @@ -103,7 +103,7 @@ claw down ## Dashboards - **cllama governance proxy** -- port **8181**. Every LLM call in real time: which agent, which model, token counts, cost. -- **Clawdapus Dash** -- port **8082**. Live service health, topology wiring, and per-service drill-down. +- **Clawdapus Dash** -- port **8082**. Live service health, topology wiring, per-service drill-down, and agent context inspection. The Agents view shows compiled contracts, redacted runtime manifests, and the latest live context snapshot captured by cllama. ## Alternative: Scaffold from Scratch diff --git a/skills/clawdapus/SKILL.md b/skills/clawdapus/SKILL.md index 2b1db0e..2f42cd4 100644 --- a/skills/clawdapus/SKILL.md +++ b/skills/clawdapus/SKILL.md @@ -7,7 +7,7 @@ description: Use when working with the claw CLI, Clawfiles, claw-pod.yml, cllama Infrastructure-layer governance for AI agent containers. `claw` treats agents as untrusted workloads — reproducible, inspectable, diffable, killable. -**Mental model:** Clawfile is to Dockerfile what claw-pod.yml is to docker-compose.yml. Standard Docker directives pass through unchanged. Claw directives compile to labels + generated scripts. Eject anytime — you still have working Docker artifacts. +**Mental model:** Clawfile is to Dockerfile what claw-pod.yml is to docker-compose.yml. Standard Docker directives pass through unchanged. Claw directives compile into labels plus driver-specific runtime materialization. Eject anytime — you still have working Docker artifacts. ## CLI Commands @@ -91,7 +91,7 @@ SURFACE service://trading-api # infrastructure surface SURFACE volume://shared-research read-write SKILL policy/risk-limits.md # operator policy, mounted read-only -CONFIGURE openclaw config set key value # runs at container startup, NOT build time +CONFIGURE openclaw config set key value # driver-side config DSL, not arbitrary shell TRACK apt npm # mutation tracking wrappers PRIVILEGE worker root # privilege mode mapping @@ -111,10 +111,19 @@ PRIVILEGE runtime claw-user | `INVOKE ` | System cron in `/etc/cron.d/claw`. Bot cannot modify. | Baked into image | | `SURFACE :// [mode]` | Infrastructure boundary. See Surface Taxonomy. | Label -> compose wiring | | `SKILL ` | Reference markdown mounted read-only into runner skill directory. | Label -> host path validation + mount | -| `CONFIGURE ` | **Runs at startup** via `/claw/configure.sh`. For init-time config mutations. NOT build time. | Generates script | +| `CONFIGURE ` | Driver-specific config DSL. Use ` config set `, not arbitrary shell. | Parsed by Clawdapus, then projected into generated runtime config/artifacts | | `TRACK ` | Installs wrappers for `apt`, `pip`, `npm` to log mutations. | Build-time install | | `PRIVILEGE ` | Maps privilege modes to user specs. | Label -> Docker user/security | +### `CONFIGURE` Semantics + +- Treat `CONFIGURE` as driver-side config mutation DSL, not as a generic startup hook. +- The public contract is `CONFIGURE config set `. +- Values are JSON-decoded when possible. Leave booleans, numbers, arrays, and objects unquoted; quote strings. +- `CONFIGURE` applies after generated defaults, so it overrides what `HANDLE` and other driver defaults emitted. +- For `openclaw`, Clawdapus applies `CONFIGURE` while generating `openclaw.json` during materialization. Do not assume downstream `openclaw config set ...` shell behavior is the same contract. +- Dotted object paths are the supported shape today. Do not assume indexed list mutation like `agents.list[0].groupChat.mentionPatterns` is supported unless the code/docs explicitly say so. + ## Surface Taxonomy | Scheme | Enforcement | Notes |