diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 64bac65e2..699b48d82 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -313,6 +313,19 @@ prompt explain the same operator-facing priority. shortcut chip. 2. Route new top-level settings surfaces through the canonical settings shell instead of introducing page-local framing. + When a new operator-facing concern is closely related to an + existing tab's intent, prefer adding it as a sibling + `SettingsPanel` inside that tab's container component over + minting a new top-level tab. The agent-integrations surface + added in slice 59 follows this pattern: + `frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx` + ships under the existing API Access tab via + `APIAccessPanel.tsx`'s composition, not as its own tab. This + keeps the tab inventory bounded, avoids touching + `settingsNavigationModel.ts`, the registry, the loaders, and + the routing tests for additive sub-surfaces, and presents + "tokens + what those tokens unlock" as one operator-facing + story. Shared shells and primitives that need websocket or dark-mode context must consume `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx`, because `App.tsx` owns provider placement diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index adc92246f..f5c52f835 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -278,6 +278,22 @@ helper. Security surfaces may consume compatibility-normalized `platformType: 'truenas'` resources, but they must not reintroduce a separate `resource.type === 'truenas'` trust path when calculating token usage, revocation targets, or operator-facing token ownership. +The API Access tab now hosts an Agent Integrations section +(`frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx`) +alongside the existing API Token Manager. The section reads +`/api/agent/capabilities` at mount and renders the declared +agent surface (capabilities grouped by category, stable error +codes, scopes) plus an MCP config snippet generated from the +deployment's own origin so an operator wiring Claude Desktop or +Claude Code sees the right base URL automatically. The section +does NOT introduce a new token-mint flow or auth path: tokens +still flow through the API Token Manager, and the snippet +documents the `monitoring:read` / `monitoring:write` scopes the +agent surface requires. Keeping token minting and agent-surface +disclosure on the same tab means an operator gets a single +"machine-driven access" mental model rather than two surfaces +to reconcile. + That same token-management boundary also reserves token-owner identity for the server-authenticated principal. Token-minting helpers must derive `owner_user_id` from the authenticated session or caller token and reject any diff --git a/frontend-modern/src/components/Settings/APIAccessPanel.tsx b/frontend-modern/src/components/Settings/APIAccessPanel.tsx index 30e7102f5..69dca44e8 100644 --- a/frontend-modern/src/components/Settings/APIAccessPanel.tsx +++ b/frontend-modern/src/components/Settings/APIAccessPanel.tsx @@ -3,6 +3,7 @@ import SettingsPanel from '@/components/shared/SettingsPanel'; import { API_TOKEN_ACCESS_PANEL_DESCRIPTION } from '@/utils/apiTokenPresentation'; import { API_TOKEN_SCOPES_DOC_URL } from '@/utils/docsLinks'; import APITokenManager from './APITokenManager'; +import AgentIntegrationsPanel from './AgentIntegrationsPanel'; interface APIAccessPanelProps { currentTokenHint?: string; @@ -37,6 +38,8 @@ export const APIAccessPanel: Component = (props) => { refreshing={props.refreshing} canManage={props.canManage} /> + + ); }; diff --git a/frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx b/frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx new file mode 100644 index 000000000..3579fe39b --- /dev/null +++ b/frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx @@ -0,0 +1,255 @@ +import { Component, createMemo, createResource, createSignal, For, Show } from 'solid-js'; +import SettingsPanel from '@/components/shared/SettingsPanel'; +import CopyCommandBlock from './CopyCommandBlock'; + +// AgentCapability mirrors the wire shape of one entry in +// /api/agent/capabilities. Defined inline so this component does +// not depend on a shared API client; the manifest endpoint is +// public and stable. +interface AgentCapability { + name: string; + description: string; + category: string; + method: string; + path: string; + scope: string; + responseShape?: string; + errorCodes?: string[]; + requestBodyShape?: string; +} + +interface AgentCapabilitiesManifest { + version: string; + capabilities: AgentCapability[]; +} + +// Category presentation order on screen. Matches the closed set +// the backend pin enforces (see TestAgentCapabilitiesManifest_ +// CategoriesAreClosed). Capabilities with an unknown category are +// rendered last under a generic "other" heading so a future +// addition that the panel does not yet know about still renders. +const CATEGORY_ORDER: ReadonlyArray<{ id: string; label: string; description: string }> = [ + { + id: 'context', + label: 'Context', + description: 'Discovery and read-only situated reads. Agents start here.', + }, + { + id: 'operator-state', + label: 'Operator state', + description: 'Per-resource intent: intentionally-offline, never-auto-remediate, maintenance windows.', + }, + { + id: 'finding', + label: 'Patrol findings', + description: 'Acknowledge, snooze, dismiss, or resolve findings the patrol runtime raised.', + }, + { + id: 'action', + label: 'Action governance', + description: 'Plan, approve, and execute capability invocations against a resource.', + }, +]; + +async function fetchManifest(): Promise { + const response = await fetch('/api/agent/capabilities', { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`manifest fetch failed: ${response.status}`); + } + return (await response.json()) as AgentCapabilitiesManifest; +} + +// formatMcpConfig builds the JSON snippet an integrator drops into +// claude_desktop_config.json (or .mcp.json for Claude Code). The +// host is the deployment's own origin so the snippet is correct +// for whatever URL the operator is reading the panel from. +function formatMcpConfig(origin: string): string { + const config = { + mcpServers: { + pulse: { + command: 'pulse-mcp', + args: ['--base-url', origin], + env: { + PULSE_API_TOKEN: '', + }, + }, + }, + }; + return JSON.stringify(config, null, 2); +} + +const AGENT_PROBE_README = 'https://github.com/rcourtman/Pulse/blob/main/cmd/agent-probe/main.go'; +const PULSE_MCP_README = 'https://github.com/rcourtman/Pulse/blob/main/cmd/pulse-mcp/README.md'; +const SUBSTRATE_DOC = 'https://github.com/rcourtman/Pulse/blob/main/docs/AGENT_SUBSTRATE.md'; + +export const AgentIntegrationsPanel: Component = () => { + const [manifest] = createResource(fetchManifest); + const [copied, setCopied] = createSignal(null); + const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:7655'; + const mcpConfig = formatMcpConfig(origin); + + const grouped = createMemo(() => { + const m = manifest(); + if (!m) return []; + // Index capabilities by category, then return them in + // CATEGORY_ORDER. Unknown categories spill into a generic + // "other" bucket so the panel never silently drops a + // capability the operator should be able to see. + const byCategory = new Map(); + for (const cap of m.capabilities) { + const list = byCategory.get(cap.category) ?? []; + list.push(cap); + byCategory.set(cap.category, list); + } + const sections: Array<{ id: string; label: string; description?: string; entries: AgentCapability[] }> = []; + for (const known of CATEGORY_ORDER) { + const entries = byCategory.get(known.id); + if (entries && entries.length > 0) { + sections.push({ id: known.id, label: known.label, description: known.description, entries }); + byCategory.delete(known.id); + } + } + for (const [unknownCategory, entries] of byCategory) { + sections.push({ id: unknownCategory, label: unknownCategory, entries }); + } + return sections; + }); + + const handleCopySnippet = async (snippet: string) => { + await navigator.clipboard.writeText(snippet); + setCopied(snippet); + window.setTimeout(() => setCopied(null), 2000); + }; + + return ( + +
+
+

+ Pulse exposes a manifest-driven agent surface for MCP and HTTP clients. An external + agent (Claude Desktop, Claude Code, or a custom integration) discovers what it can do + via /api/agent/capabilities, then calls the + declared endpoints with an API token. +

+

+ The capabilities below are read live from this Pulse instance. Adding a capability on + the backend adds it here automatically; nothing in this panel is hardcoded. +

+
+ +
+
+

Claude Desktop / Claude Code config

+ + Copied to clipboard + +
+

+ Drop this block into{' '} + ~/Library/Application Support/Claude/claude_desktop_config.json{' '} + (Claude Desktop) or your project's .mcp.json (Claude + Code). Mint a token below with monitoring:read (and{' '} + monitoring:write for the operator-state write tools), then + replace <your-api-token>. +

+ +

+ See{' '} + + cmd/pulse-mcp/README.md + {' '} + for build instructions, the --emit-notifications flag, + and known limitations. The companion HTTP example is at{' '} + + cmd/agent-probe + + . Background and the substrate's design notes live in{' '} + + docs/AGENT_SUBSTRATE.md + + . +

+
+ + +

Loading capabilities…

+
+ + +

+ Could not load /api/agent/capabilities:{' '} + {String(manifest.error)} +

+
+ + 0}> +
+
+

+ Declared capabilities (manifest {manifest()?.version}) +

+ + {manifest()?.capabilities.length ?? 0} total + +
+ + {(section) => ( +
+
+

+ {section.label} +

+ +

{section.description}

+
+
+
    + + {(cap) => ( +
  • +
    + + {cap.name} + + + {cap.method} {cap.path} + + + {cap.scope} + +
    +

    {cap.description}

    + 0}> +

    + Stable error codes:{' '} + + {(code, idx) => ( + <> + {code} + {idx() < (cap.errorCodes!.length - 1) ? ', ' : ''} + + )} + +

    +
    +
  • + )} +
    +
+
+ )} +
+
+
+
+
+ ); +}; + +export default AgentIntegrationsPanel; diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 8f1367a43..3e2f450c6 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -635,6 +635,19 @@ describe('settings architecture guardrails', () => { ); }); + it('exposes the agent integrations panel under API Access without growing the settings tab inventory', () => { + // The agent integrations panel sits as a sibling section + // under the existing API Access tab rather than as its own + // navigation entry. This pin keeps the operator's mental + // model coherent (one tab for machine-driven access: + // tokens + what those tokens unlock) and prevents drift + // toward fragmenting the agent surface across tabs. + expect(apiAccessPanelSource).toContain( + "import AgentIntegrationsPanel from './AgentIntegrationsPanel';", + ); + expect(apiAccessPanelSource).toContain(''); + }); + it('keeps internal analytics off the user diagnostics boundary', () => { expect(diagnosticsResultsPanelSource).not.toContain('Commercial Funnel'); expect(diagnosticsResultsPanelSource).not.toContain('Infrastructure Onboarding');