diff --git a/cmd/pulse-mcp/README.md b/cmd/pulse-mcp/README.md index adee54f84..ec0153d38 100644 --- a/cmd/pulse-mcp/README.md +++ b/cmd/pulse-mcp/README.md @@ -198,6 +198,33 @@ this writing, the published capabilities are: (`not_an_issue`, `expected_behavior`, `will_fix_later`). - `resolve_finding` manually marks a finding resolved. +**Actions (governed plan/approval/execute):** + +- `plan_action` validates an action request, records the plan, and + returns approval policy, blast radius, plan hash, and preflight + summary. +- `decide_action` records an approval or rejection for a planned + action. +- `execute_action` executes a previously planned and approved action + and returns the persisted audit result. + +**Provisioning (infrastructure onboarding):** + +- `discover_lan` scans a subnet, or returns cached scan results, to + find candidate infrastructure hosts before import. +- `list_nodes` returns configured Pulse infrastructure sources with + credential secret values redacted. +- `add_node` adds a Proxmox VE, Proxmox Backup Server, or Proxmox Mail + Gateway source. +- `update_node` updates a configured source without changing omitted + fields. +- `remove_node` removes a configured source. +- `test_node_credentials` validates proposed source credentials without + saving them. +- `test_node_connection` validates a saved source by id. +- `refresh_node_cluster_membership` re-detects Proxmox VE cluster + endpoint metadata for an existing source. + Run `tools/list` from your MCP client to see the live set. ## Stable error envelope diff --git a/cmd/pulse-mcp/main.go b/cmd/pulse-mcp/main.go index 563cf1d37..7e5d1097a 100644 --- a/cmd/pulse-mcp/main.go +++ b/cmd/pulse-mcp/main.go @@ -75,15 +75,16 @@ import ( // agentCapability mirrors Pulse's manifest wire shape — defined // inline so the adapter depends on nothing in the pulse module. type agentCapability struct { - Name string `json:"name"` - Description string `json:"description"` - Category string `json:"category"` - Method string `json:"method"` - Path string `json:"path"` - Scope string `json:"scope"` - ResponseShape string `json:"responseShape,omitempty"` - ErrorCodes []string `json:"errorCodes,omitempty"` - RequestBodyShape string `json:"requestBodyShape,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Method string `json:"method"` + Path string `json:"path"` + Scope string `json:"scope"` + ResponseShape string `json:"responseShape,omitempty"` + ErrorCodes []string `json:"errorCodes,omitempty"` + RequestBodyShape string `json:"requestBodyShape,omitempty"` + InputSchema json.RawMessage `json:"inputSchema,omitempty"` } type agentCapabilitiesManifest struct { @@ -418,6 +419,10 @@ func (s *mcpServer) writeJSON(out io.Writer, v any) { // MCP just needs enough shape so the agent knows which fields to // pass. func buildInputSchema(cap agentCapability) json.RawMessage { + if len(cap.InputSchema) > 0 { + return cap.InputSchema + } + properties := map[string]any{} required := []string{} for _, m := range pathPlaceholderRE.FindAllStringSubmatch(cap.Path, -1) { diff --git a/cmd/pulse-mcp/main_test.go b/cmd/pulse-mcp/main_test.go index 17cf6a1c3..ca86bb560 100644 --- a/cmd/pulse-mcp/main_test.go +++ b/cmd/pulse-mcp/main_test.go @@ -88,6 +88,36 @@ func TestBuildInputSchema_NonGetCapabilitiesAcceptBody(t *testing.T) { } } +func TestBuildInputSchema_UsesManifestProvidedSchema(t *testing.T) { + rawSchema := json.RawMessage(`{ + "type": "object", + "properties": { + "type": { "enum": ["pve", "pbs", "pmg"] }, + "host": { "type": "string" } + }, + "required": ["type", "host"], + "additionalProperties": false + }`) + cap := agentCapability{ + Name: "add_node", + Path: "/api/config/nodes", + Method: http.MethodPost, + InputSchema: rawSchema, + } + + got := buildInputSchema(cap) + var schema map[string]any + if err := json.Unmarshal(got, &schema); err != nil { + t.Fatalf("unmarshal schema: %v", err) + } + if schema["additionalProperties"] != false { + t.Errorf("manifest-provided schema must be preserved; got %v", schema) + } + if _, hasBody := schema["properties"].(map[string]any)["body"]; hasBody { + t.Errorf("manifest-provided schema should not be wrapped in body: %s", string(got)) + } +} + func TestSubstitutePathParams_FillsAllPlaceholders(t *testing.T) { got, err := substitutePathParams( "/api/agent/resource-context/{resourceId}", diff --git a/docs/release-control/control_plane.json b/docs/release-control/control_plane.json index 5ccdc8738..a1aeb65ca 100644 --- a/docs/release-control/control_plane.json +++ b/docs/release-control/control_plane.json @@ -63,7 +63,7 @@ "profile_id": "v6", "kind": "feature", "status": "active", - "summary": "Promote proactive Pulse Intelligence, policy-aware data governance, resource-change intelligence, action-governance, fleet-governance, and agent-ready operations contract proof only after the monitoring-first RC target is satisfied and the broader surfaced product case is proven; Patrol is the detection and investigation engine, Assistant is the contextual explanation, approval, and governed action surface, and agent-ready proof is API/CLI-first with MCP only as an adapter over canonical contracts.", + "summary": "Promote proactive Pulse Intelligence, policy-aware data governance, resource-change intelligence, action-governance, fleet-governance, agent-operable infrastructure onboarding, and agent-ready operations contract proof only after the monitoring-first RC target is satisfied and the broader surfaced product case is proven; Patrol is the detection and investigation engine, Assistant is the contextual explanation, approval, and governed action surface, and agent-ready proof is API/CLI-first with MCP only as an adapter over canonical contracts.", "completion_rule": "manual", "proof_scope": "none" } diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index f24d65288..9100d5b78 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -3583,6 +3583,80 @@ "kind": "file" } ] + }, + { + "id": "RA32", + "summary": "Pulse v6 must be agent-operable for infrastructure onboarding at the canonical API contract layer, not only through UI automation or an MCP-only shim: agents can discover LAN candidates, list configured sources, validate proposed credentials, add/update/remove/test/refresh Proxmox VE, Proxmox Backup Server, and Proxmox Mail Gateway sources through typed manifest schemas, and source reads must keep credential secrets redacted.", + "kind": "trust-gate", + "blocking_level": "repo-ready", + "proof_type": "automated", + "lane_ids": [ + "L6", + "L16", + "L20" + ], + "subsystem_ids": [ + "api-contracts" + ], + "release_gate_ids": [], + "proof_commands": [ + { + "id": "agent-provisioning-capability-contract", + "run": [ + "go", + "test", + "./internal/api", + "-run", + "TestHandleAgentCapabilitiesManifest|TestAgentCapabilitiesManifest|TestAgentSubstrate_NodeProvisioningCapabilitiesRouteThroughHTTPBoundary" + ] + }, + { + "id": "mcp-manifest-schema-projection", + "run": [ + "go", + "test", + "./cmd/pulse-mcp" + ] + } + ], + "evidence": [ + { + "repo": "pulse", + "path": "cmd/pulse-mcp/main.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "cmd/pulse-mcp/main_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "cmd/pulse-mcp/README.md", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "internal/api/agent_capabilities.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "internal/api/agent_capabilities_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "internal/api/agent_substrate_e2e_test.go", + "kind": "file", + "evidence_tier": "test-proof" + } + ] } ], "evidence_reference_policy": { @@ -5125,17 +5199,31 @@ "status": "partial", "completion": { "state": "bounded-residual", - "summary": "Action governance now has a first-class governed floor: tool capability declarations, approval-backed action plans, plan-level preflight and dry-run posture, fail-closed action-audit normalization, bounded lifecycle events, API-visible action audit preflight, and a resource-scoped action-history surface are owned by the shared AI, API, security, Patrol, and unified-resource contracts. Broader enterprise policy execution, richer dry-run providers, multi-actor approvals, and deeper safe-execution contracts remain a named post-RC hardening track.", + "summary": "Action governance now has a first-class governed floor: tool capability declarations include typed agent-operable infrastructure onboarding over the canonical node lifecycle and LAN-discovery APIs, approval-backed action plans, plan-level preflight and dry-run posture, fail-closed action-audit normalization, bounded lifecycle events, API-visible action audit preflight, and a resource-scoped action-history surface are owned by the shared AI, API, security, Patrol, and unified-resource contracts. Broader enterprise policy execution, richer dry-run providers, multi-actor approvals, full onboarding import-plan approvals, and deeper safe-execution contracts remain named post-RC hardening tracks.", "tracking": [ { "kind": "lane-followup", "id": "action-governance-auditability-post-rc-hardening" + }, + { + "kind": "lane-followup", + "id": "agent-operable-onboarding-import-planning" } ] }, "blockers": [], "subsystems": [], "evidence": [ + { + "repo": "pulse", + "path": "cmd/pulse-mcp/main.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "cmd/pulse-mcp/main_test.go", + "kind": "file" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/records/action-audit-resource-history-surface-2026-04-29.md", @@ -5226,6 +5314,21 @@ "path": "internal/ai/tools/action_audit.go", "kind": "file" }, + { + "repo": "pulse", + "path": "internal/api/agent_capabilities.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/agent_capabilities_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/agent_substrate_e2e_test.go", + "kind": "file" + }, { "repo": "pulse", "path": "internal/api/contract_test.go", @@ -6614,6 +6717,17 @@ "L19" ], "subsystem_ids": [] + }, + { + "id": "agent-operable-onboarding-import-planning", + "summary": "Track the remaining agent-operable onboarding hardening beyond the current typed node lifecycle and LAN-discovery manifest floor: candidate import plans, credential-grant handoffs, dry-run diffs, human approval before applying multi-source imports, and richer discovery-to-provisioning verification.", + "owner": "project-owner", + "status": "planned", + "recorded_at": "2026-05-28", + "lane_ids": [ + "L20" + ], + "subsystem_ids": [] } ], "coverage_gaps": [], diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index bfc9fd0dd..e67070739 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -182,6 +182,16 @@ API, but they must not widen install tokens, agent profiles, or command grants because a Discovery record exists. Mock-mode Discovery records are demo payloads for the same API contract and must not become a lifecycle-local fixture or an implicit permission to run commands. +Agent-facing provisioning capabilities declared by +`/api/agent/capabilities` are also API-owned projections over the canonical +node lifecycle and discovery APIs. Lifecycle and infrastructure onboarding +surfaces may use `discover_lan`, `list_nodes`, `add_node`, `update_node`, +`remove_node`, `test_node_credentials`, `test_node_connection`, and +`refresh_node_cluster_membership` to orchestrate source onboarding, but those +tools must preserve settings-scope auth, typed manifest schemas, and redacted +source reads. The manifest does not turn discovered hosts into command-agent +authority, install-token scope, fleet-command grants, or permission to bypass +human approval for multi-source imports. Native provider resource types exposed through shared `internal/api/` resource handlers are the same kind of read-only context for lifecycle surfaces. A TrueNAS `network-share` resource may appear in resource pickers, connection diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e761dad49..846e33db7 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1738,15 +1738,16 @@ without parsing human messages. `/api/agent/capabilities` is the discovery document for Pulse's agent surface. The manifest declares each agent-consumable capability with stable name (snake_case agent identifier), -description, category (`context` / `operator-state` / `finding`), -HTTP method + path, required auth scope, response shape name, and -the closed set of stable error codes the response may carry. Agents -fetch this once at startup to learn what's available; the -`cmd/pulse-mcp` adapter reads the manifest to register MCP tools, -and any future adapter (HTTP-API SDK, Claude-Code custom toolkit, -etc.) reads the same manifest the same way — manifest-driven -projection is the substrate's deliberate single source of truth -for "what can an agent do here?". The manifest +description, category (`context` / `provisioning` / +`operator-state` / `finding` / `action`), HTTP method + path, +required auth scope, response shape name, optional JSON Schema +`inputSchema`, and the closed set of stable error codes the +response may carry. Agents fetch this once at startup to learn +what's available; the `cmd/pulse-mcp` adapter reads the manifest +to register MCP tools, and any future adapter (HTTP-API SDK, +Claude-Code custom toolkit, etc.) reads the same manifest the +same way: manifest-driven projection is the substrate's deliberate +single source of truth for "what can an agent do here?". The manifest itself is unauthenticated and cacheable (`Cache-Control: public, max-age=300`) — declared in the router's `publicPaths` list so the global auth middleware does not gate it; the underlying @@ -1760,6 +1761,18 @@ decisions (which capabilities are agent-stable, what the stable error codes are, what category each belongs to) cannot drift behind code changes. +Infrastructure onboarding is part of that same manifest contract, +not an MCP-only shim. The provisioning category projects the +canonical `/api/config/nodes` node lifecycle and `/api/discover` +LAN-discovery routes as `list_nodes`, `add_node`, `update_node`, +`remove_node`, `test_node_credentials`, `test_node_connection`, +`refresh_node_cluster_membership`, and `discover_lan`. Those +capabilities must carry typed schemas for path/body arguments and +must preserve the underlying settings scopes. Listing configured +sources returns redacted credential state only; token or password +secret values may be accepted on write/test requests but must not +come back through `list_nodes` or any manifest metadata. + The agent surface uses one error-envelope shape across every endpoint — `{"error": "", "message": "", "details"?: {"": ""}}` — written via @@ -1822,11 +1835,13 @@ substrate as MCP tools so Claude Desktop, Claude Code, and other MCP-speaking clients can drive Pulse natively. The adapter is the test for whether the substrate's contracts were really cheap to project: each MCP tool is one entry in the hand-authored -manifest, and the input schema is auto-derived from the path -placeholders and method (path `{name}` segments become required -string properties; non-GET/DELETE tools accept a `body` object). -Adding a capability to the manifest automatically extends the MCP -tool surface without changes in the adapter. `subscribe_events` is +manifest. When the manifest supplies an `inputSchema`, MCP must +use it verbatim; otherwise the adapter falls back to deriving a +minimal schema from path placeholders and method (path `{name}` +segments become required string properties; non-GET/DELETE tools +accept a `body` object). Adding a capability to the manifest +automatically extends the MCP tool surface without changes in the +adapter. `subscribe_events` is intentionally excluded — SSE streaming doesn't fit the request/response tool shape; agents that need real-time push consume the SSE stream directly. The adapter speaks JSON-RPC 2.0 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 158564816..e3e62d0fb 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1117,6 +1117,14 @@ The agent capabilities manifest at `/api/agent/capabilities` is read-only and stateless — no persistence is involved. The manifest is hand-authored in `internal/api/agent_capabilities.go`; storage flows are not affected. +When that manifest exposes provisioning tools over `/api/discover` +and `/api/config/nodes`, storage and recovery may observe the resulting +configured sources and backup evidence only after the canonical node +lifecycle writes complete. Discovery candidates, credential test +payloads, token or password secrets, and add/update/remove source +commands remain API/agent-lifecycle onboarding concerns, not recovery +state, backup ownership, restore entitlement, or storage-local +credential material. The action governance loop endpoints (`/api/actions/plan`, `/api/actions/{id}/decision`, `/api/actions/{id}/execute`) joined diff --git a/internal/api/agent_capabilities.go b/internal/api/agent_capabilities.go index 782b60d80..12c5bf8b3 100644 --- a/internal/api/agent_capabilities.go +++ b/internal/api/agent_capabilities.go @@ -27,9 +27,12 @@ type AgentCapability struct { Description string `json:"description"` // Category groups capabilities for agent UIs. Stable values: - // "context" (read-only situated reads), "operator-state" - // (per-resource intent writes), "finding" (per-finding lifecycle - // actions). Agents can filter the manifest by category. + // "context" (read-only situated reads), "provisioning" + // (infrastructure onboarding and source lifecycle), + // "operator-state" (per-resource intent writes), "finding" + // (per-finding lifecycle actions), and "action" (governed + // plan/decision/execute operations). Agents can filter the + // manifest by category. Category string `json:"category"` // Method + Path describe the canonical REST surface. Path @@ -62,6 +65,11 @@ type AgentCapability struct { // request body shape for non-GET capabilities so agents can // validate before sending. RequestBodyShape string `json:"requestBodyShape,omitempty"` + + // InputSchema is an optional JSON Schema for agent-facing tool + // arguments. It is hand-authored for capabilities where prose + // body hints are not enough for reliable model use. + InputSchema map[string]any `json:"inputSchema,omitempty"` } // AgentCapabilitiesManifest is the discovery document for Pulse's @@ -81,6 +89,161 @@ type AgentCapabilitiesManifest struct { Capabilities []AgentCapability `json:"capabilities"` } +func agentObjectInputSchema(required []string, properties map[string]any) map[string]any { + schema := map[string]any{ + "type": "object", + "properties": properties, + "additionalProperties": false, + } + if len(required) > 0 { + schema["required"] = required + } + return schema +} + +func nodeIDInputSchema() map[string]any { + return agentObjectInputSchema([]string{"nodeId"}, map[string]any{ + "nodeId": map[string]any{ + "type": "string", + "description": "Configured node id from list_nodes, such as pve:lab or pve-0.", + }, + }) +} + +func discoverLANInputSchema() map[string]any { + return agentObjectInputSchema(nil, map[string]any{ + "subnet": map[string]any{ + "type": "string", + "description": "CIDR to scan, such as 192.168.1.0/24, or auto to let Pulse choose the local subnet.", + "default": "auto", + }, + "use_cache": map[string]any{ + "type": "boolean", + "description": "Return cached discovery results when available instead of starting a new scan.", + "default": false, + }, + }) +} + +func addNodeInputSchema() map[string]any { + schema := agentObjectInputSchema([]string{"type", "name", "host"}, nodeConfigInputProperties(false)) + schema["oneOf"] = []map[string]any{ + {"required": []string{"user", "password"}}, + {"required": []string{"tokenName", "tokenValue"}}, + } + return schema +} + +func testNodeCredentialsInputSchema() map[string]any { + schema := agentObjectInputSchema([]string{"type", "host"}, nodeConfigInputProperties(false)) + schema["oneOf"] = []map[string]any{ + {"required": []string{"user", "password"}}, + {"required": []string{"tokenName", "tokenValue"}}, + } + return schema +} + +func updateNodeInputSchema() map[string]any { + properties := nodeConfigInputProperties(true) + return agentObjectInputSchema([]string{"nodeId"}, properties) +} + +func nodeConfigInputProperties(includeNodeID bool) map[string]any { + properties := map[string]any{ + "type": map[string]any{ + "type": "string", + "enum": []string{"pve", "pbs", "pmg"}, + "description": "Infrastructure source type: pve, pbs, or pmg.", + }, + "name": map[string]any{ + "type": "string", + "description": "Human-readable source name to show in Pulse.", + }, + "host": map[string]any{ + "type": "string", + "description": "Source endpoint URL or host, including scheme and port when needed.", + }, + "guestURL": map[string]any{ + "type": "string", + "description": "Optional guest-accessible URL for navigation.", + }, + "user": map[string]any{ + "type": "string", + "description": "Username for password authentication, or the token owner when needed by the platform.", + }, + "password": map[string]any{ + "type": "string", + "description": "Password used only for setup or password-backed monitoring.", + }, + "tokenName": map[string]any{ + "type": "string", + "description": "API token id or name, such as root@pam!pulse-monitor.", + }, + "tokenValue": map[string]any{ + "type": "string", + "description": "API token secret value. Pulse stores this as a credential and never returns it from list_nodes.", + }, + "fingerprint": map[string]any{ + "type": "string", + "description": "Optional TLS certificate fingerprint for pinned self-signed endpoints.", + }, + "verifySSL": map[string]any{ + "type": "boolean", + "description": "Whether Pulse should require normal TLS certificate validation.", + }, + "monitorVMs": platformBool("PVE", "virtual machines"), + "monitorContainers": platformBool("PVE", "containers"), + "monitorStorage": platformBool("PVE", "storage"), + "monitorBackups": platformBool("PVE or PBS", "backups"), + "monitorPhysicalDisks": platformBool("PVE", "physical disks"), + "physicalDiskPollingMinutes": integerOption("PVE physical disk polling interval in minutes. Use 0 or omit for the default."), + "temperatureMonitoringEnabled": platformBool("All source types", "temperature monitoring"), + "monitorDatastores": platformBool("PBS", "datastores"), + "monitorSyncJobs": platformBool("PBS", "sync jobs"), + "monitorVerifyJobs": platformBool("PBS", "verify jobs"), + "monitorPruneJobs": platformBool("PBS", "prune jobs"), + "monitorGarbageJobs": platformBool("PBS", "garbage collection jobs"), + "monitorMailStats": platformBool("PMG", "mail statistics"), + "monitorQueues": platformBool("PMG", "mail queues"), + "monitorQuarantine": platformBool("PMG", "quarantine"), + "monitorDomainStats": platformBool("PMG", "domain statistics"), + "enabled": platformBool("All source types", "collection from this source"), + "excludeDatastores": stringArrayOption("PBS datastore names to exclude from monitoring."), + } + if includeNodeID { + properties["nodeId"] = map[string]any{ + "type": "string", + "description": "Configured node id from list_nodes, such as pve:lab or pve-0.", + } + } + return properties +} + +func platformBool(platform, subject string) map[string]any { + return map[string]any{ + "type": "boolean", + "description": platform + " option for " + subject + ".", + } +} + +func integerOption(description string) map[string]any { + return map[string]any{ + "type": "integer", + "minimum": 0, + "description": description, + } +} + +func stringArrayOption(description string) map[string]any { + return map[string]any{ + "type": "array", + "description": description, + "items": map[string]any{ + "type": "string", + }, + } +} + // agentCapabilitiesManifest is the v1 declaration of Pulse's // agent-consumable surface. Hand-authored rather than auto-generated // because the contract decisions (which capabilities are @@ -110,6 +273,89 @@ var agentCapabilitiesManifest = AgentCapabilitiesManifest{ Scope: "monitoring:read", ResponseShape: "AgentFleetContext", }, + { + Name: "list_nodes", + Description: "List configured infrastructure sources that Pulse can monitor or manage. Credential secret values are redacted; use the returned id with update_node, remove_node, test_node_connection, or refresh_node_cluster_membership.", + Category: "provisioning", + Method: http.MethodGet, + Path: "/api/config/nodes", + Scope: "settings:read", + ResponseShape: "NodeResponse[]", + }, + { + Name: "add_node", + Description: "Add a Proxmox VE, Proxmox Backup Server, or Proxmox Mail Gateway source to Pulse after credentials have been collected, generated, or approved.", + Category: "provisioning", + Method: http.MethodPost, + Path: "/api/config/nodes", + Scope: "settings:write", + RequestBodyShape: "NodeConfigRequest", + ResponseShape: "{ status: \"success\" }", + InputSchema: addNodeInputSchema(), + }, + { + Name: "update_node", + Description: "Update a configured infrastructure source. Omitted fields preserve the current value; tokenValue or password only changes when supplied.", + Category: "provisioning", + Method: http.MethodPut, + Path: "/api/config/nodes/{nodeId}", + Scope: "settings:write", + RequestBodyShape: "NodeConfigRequest", + ResponseShape: "{ status: \"success\" }", + InputSchema: updateNodeInputSchema(), + }, + { + Name: "remove_node", + Description: "Remove a configured infrastructure source from Pulse by node id.", + Category: "provisioning", + Method: http.MethodDelete, + Path: "/api/config/nodes/{nodeId}", + Scope: "settings:write", + ResponseShape: "{ status: \"success\" }", + InputSchema: nodeIDInputSchema(), + }, + { + Name: "test_node_credentials", + Description: "Validate proposed source credentials and connection details without saving them to Pulse.", + Category: "provisioning", + Method: http.MethodPost, + Path: "/api/config/nodes/test-config", + Scope: "settings:write", + RequestBodyShape: "NodeConfigRequest", + ResponseShape: "{ status: \"success\"|\"error\", message: string }", + InputSchema: testNodeCredentialsInputSchema(), + }, + { + Name: "test_node_connection", + Description: "Validate the saved connection for an existing configured infrastructure source.", + Category: "provisioning", + Method: http.MethodPost, + Path: "/api/config/nodes/{nodeId}/test", + Scope: "settings:write", + ResponseShape: "{ status: \"success\"|\"error\", message: string }", + InputSchema: nodeIDInputSchema(), + }, + { + Name: "refresh_node_cluster_membership", + Description: "Re-detect Proxmox VE cluster membership and endpoint metadata for a configured source.", + Category: "provisioning", + Method: http.MethodPost, + Path: "/api/config/nodes/{nodeId}/refresh-cluster", + Scope: "settings:write", + ResponseShape: "ClusterRefreshResponse", + InputSchema: nodeIDInputSchema(), + }, + { + Name: "discover_lan", + Description: "Scan a subnet, or return cached scan results, to find candidate infrastructure hosts before deciding which sources to add to Pulse.", + Category: "provisioning", + Method: http.MethodPost, + Path: "/api/discover", + Scope: "settings:write", + RequestBodyShape: "{ subnet?: string, use_cache?: boolean }", + ResponseShape: "ManualDiscoveryResult", + InputSchema: discoverLANInputSchema(), + }, { Name: "get_operator_state", Description: "Read the operator-set state for a resource (intentionally offline, never auto-remediate, maintenance window, criticality).", diff --git a/internal/api/agent_capabilities_test.go b/internal/api/agent_capabilities_test.go index 7651d10a2..0eb287457 100644 --- a/internal/api/agent_capabilities_test.go +++ b/internal/api/agent_capabilities_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -100,6 +101,7 @@ func TestAgentCapabilitiesManifest_CategoriesAreClosed(t *testing.T) { // two categories an agent might miss). allowed := map[string]bool{ "context": true, + "provisioning": true, "operator-state": true, "finding": true, "action": true, @@ -111,6 +113,78 @@ func TestAgentCapabilitiesManifest_CategoriesAreClosed(t *testing.T) { } } +func TestAgentCapabilitiesManifest_DeclaresNodeProvisioningSurface(t *testing.T) { + byName := map[string]AgentCapability{} + for _, cap := range agentCapabilitiesManifest.Capabilities { + byName[cap.Name] = cap + } + + required := map[string]struct { + method string + path string + scope string + }{ + "list_nodes": {http.MethodGet, "/api/config/nodes", "settings:read"}, + "add_node": {http.MethodPost, "/api/config/nodes", "settings:write"}, + "update_node": {http.MethodPut, "/api/config/nodes/{nodeId}", "settings:write"}, + "remove_node": {http.MethodDelete, "/api/config/nodes/{nodeId}", "settings:write"}, + "test_node_credentials": {http.MethodPost, "/api/config/nodes/test-config", "settings:write"}, + "test_node_connection": {http.MethodPost, "/api/config/nodes/{nodeId}/test", "settings:write"}, + "refresh_node_cluster_membership": {http.MethodPost, "/api/config/nodes/{nodeId}/refresh-cluster", "settings:write"}, + "discover_lan": {http.MethodPost, "/api/discover", "settings:write"}, + } + + for name, want := range required { + cap, ok := byName[name] + if !ok { + t.Fatalf("manifest missing node provisioning capability %q", name) + } + if cap.Category != "provisioning" { + t.Errorf("%s category = %q, want provisioning", name, cap.Category) + } + if cap.Method != want.method || cap.Path != want.path || cap.Scope != want.scope { + t.Errorf("%s method/path/scope = %s %s %s, want %s %s %s", + name, cap.Method, cap.Path, cap.Scope, want.method, want.path, want.scope) + } + } + + for _, name := range []string{"add_node", "update_node", "test_node_credentials", "discover_lan"} { + cap := byName[name] + if cap.InputSchema == nil { + t.Fatalf("%s must publish an inputSchema so agent clients get typed onboarding arguments", name) + } + raw, err := json.Marshal(cap.InputSchema) + if err != nil { + t.Fatalf("%s inputSchema marshal: %v", name, err) + } + text := string(raw) + for _, fragment := range []string{`"type":"object"`, `"additionalProperties":false`} { + if !strings.Contains(text, fragment) { + t.Errorf("%s inputSchema missing %s: %s", name, fragment, text) + } + } + } + + addSchema, _ := json.Marshal(byName["add_node"].InputSchema) + for _, fragment := range []string{`"enum":["pve","pbs","pmg"]`, `"required":["type","name","host"]`, `"tokenValue"`} { + if !strings.Contains(string(addSchema), fragment) { + t.Errorf("add_node inputSchema missing %s: %s", fragment, string(addSchema)) + } + } + + updateSchema, _ := json.Marshal(byName["update_node"].InputSchema) + if !strings.Contains(string(updateSchema), `"nodeId"`) { + t.Errorf("update_node inputSchema must include nodeId path argument: %s", string(updateSchema)) + } + + discoverSchema, _ := json.Marshal(byName["discover_lan"].InputSchema) + for _, fragment := range []string{`"subnet"`, `"use_cache"`} { + if !strings.Contains(string(discoverSchema), fragment) { + t.Errorf("discover_lan inputSchema missing %s: %s", fragment, string(discoverSchema)) + } + } +} + func TestAgentCapabilitiesManifest_CarriesStableErrorCodes(t *testing.T) { // The error-code surface is the agent-branching contract. The // codes I've shipped this session must appear on the diff --git a/internal/api/agent_substrate_e2e_test.go b/internal/api/agent_substrate_e2e_test.go index 0d648ba93..c6e76aae3 100644 --- a/internal/api/agent_substrate_e2e_test.go +++ b/internal/api/agent_substrate_e2e_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" ) // TestAgentSubstrate_DiscoveryToTriageToDepthFlowsThroughHTTPBoundary is @@ -216,6 +217,104 @@ func TestAgentSubstrate_DiscoveryToTriageToDepthFlowsThroughHTTPBoundary(t *test } } +func TestAgentSubstrate_NodeProvisioningCapabilitiesRouteThroughHTTPBoundary(t *testing.T) { + readToken := "agent-provisioning-read.12345678" + writeToken := "agent-provisioning-write.12345678" + cfg := newTestConfigWithTokens(t, + newTokenRecord(t, readToken, []string{config.ScopeSettingsRead}, nil), + newTokenRecord(t, writeToken, []string{config.ScopeSettingsWrite}, nil), + ) + monitor, err := monitoring.New(cfg) + if err != nil { + t.Fatalf("new monitor: %v", err) + } + defer monitor.Stop() + router := NewRouter(cfg, monitor, nil, nil, func() error { return nil }, "1.0.0") + + req := httptest.NewRequest(http.MethodGet, "/api/agent/capabilities", nil) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("capabilities GET: status = %d", rec.Code) + } + var manifest AgentCapabilitiesManifest + if err := json.NewDecoder(rec.Body).Decode(&manifest); err != nil { + t.Fatalf("decode manifest: %v", err) + } + byName := map[string]AgentCapability{} + for _, c := range manifest.Capabilities { + byName[c.Name] = c + } + + listCap := byName["list_nodes"] + addCap := byName["add_node"] + for name, cap := range map[string]AgentCapability{ + "list_nodes": listCap, + "add_node": addCap, + } { + if cap.Name == "" { + t.Fatalf("manifest missing %q capability", name) + } + if cap.Category != "provisioning" { + t.Fatalf("%s category = %q, want provisioning", name, cap.Category) + } + } + if listCap.Method != http.MethodGet || listCap.Scope != config.ScopeSettingsRead { + t.Fatalf("list_nodes method/scope = %s/%s, want GET/%s", listCap.Method, listCap.Scope, config.ScopeSettingsRead) + } + if addCap.Method != http.MethodPost || addCap.Scope != config.ScopeSettingsWrite || addCap.InputSchema == nil { + t.Fatalf("add_node method/scope/inputSchema = %s/%s/%v, want POST/%s/non-nil", + addCap.Method, addCap.Scope, addCap.InputSchema, config.ScopeSettingsWrite) + } + + req = httptest.NewRequest(http.MethodGet, listCap.Path, nil) + req.Header.Set("X-API-Token", readToken) + rec = httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list_nodes auth: status = %d, body = %s", rec.Code, rec.Body.String()) + } + if strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("new test config should start with no nodes, got %s", rec.Body.String()) + } + + addBody := strings.NewReader(`{ + "type": "pve", + "name": "test-agent-node", + "host": "http://192.168.77.50:8006", + "tokenName": "root@pam!pulse-agent-test", + "tokenValue": "secret-value", + "monitorVMs": true, + "monitorContainers": true + }`) + req = httptest.NewRequest(http.MethodPost, addCap.Path, addBody) + req.Header.Set("X-API-Token", writeToken) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("add_node auth: status = %d, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"status":"success"`) { + t.Fatalf("add_node response should be the existing success envelope, got %s", rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, listCap.Path, nil) + req.Header.Set("X-API-Token", readToken) + rec = httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list_nodes after add: status = %d, body = %s", rec.Code, rec.Body.String()) + } + listBody := rec.Body.String() + if !strings.Contains(listBody, `"name":"test-agent-node"`) || !strings.Contains(listBody, `"hasToken":true`) { + t.Fatalf("list_nodes should expose redacted configured source identity, got %s", listBody) + } + if strings.Contains(listBody, "secret-value") || strings.Contains(listBody, "tokenValue") { + t.Fatalf("list_nodes must not leak stored token values, got %s", listBody) + } +} + // TestAgentSubstrate_OperatorStateWriteRoundTripsThroughHTTPBoundary // is the end-to-end contract proof for the agent-paradigm write // substrate. The only write capability the manifest declares is the diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 4e667f425..cae602578 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -14290,6 +14290,47 @@ func TestContract_AgentCapabilitiesManifestVersionIsPinned(t *testing.T) { } } +// TestContract_AgentCapabilitiesManifestDeclaresProvisioningSurface pins the +// node-lifecycle onboarding surface in the public agent manifest. This is the +// backend API payload proof required when the hand-authored manifest starts +// projecting /api/config/nodes and /api/discover to external agents. +func TestContract_AgentCapabilitiesManifestDeclaresProvisioningSurface(t *testing.T) { + source, err := os.ReadFile("agent_capabilities.go") + if err != nil { + t.Fatalf("read agent_capabilities.go: %v", err) + } + src := string(source) + + required := []string{ + `"provisioning"`, + `"list_nodes"`, + `Path: "/api/config/nodes"`, + `Scope: "settings:read"`, + `"add_node"`, + `InputSchema: addNodeInputSchema()`, + `"update_node"`, + `InputSchema: updateNodeInputSchema()`, + `"remove_node"`, + `InputSchema: nodeIDInputSchema()`, + `"test_node_credentials"`, + `Path: "/api/config/nodes/test-config"`, + `"test_node_connection"`, + `Path: "/api/config/nodes/{nodeId}/test"`, + `"refresh_node_cluster_membership"`, + `Path: "/api/config/nodes/{nodeId}/refresh-cluster"`, + `"discover_lan"`, + `Path: "/api/discover"`, + `InputSchema: discoverLANInputSchema()`, + `"tokenValue"`, + `"additionalProperties": false`, + } + for _, fragment := range required { + if !strings.Contains(src, fragment) { + t.Errorf("agent capabilities manifest must declare provisioning contract fragment %s", fragment) + } + } +} + // TestContract_AgentResourceContextEndpointSurfacesStableShape pins // the agent-paradigm substrate contract: the bundled endpoint must // return arrays (never null) for activeFindings and recentActions so