Add agent provisioning capabilities

This commit is contained in:
rcourtman 2026-05-28 16:25:40 +01:00
parent f7a40f3a22
commit aa4a5fa631
12 changed files with 697 additions and 28 deletions

View file

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

View file

@ -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) {

View file

@ -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}",

View file

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

View file

@ -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": [],

View file

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

View file

@ -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": "<stable_code>", "message": "<human>",
"details"?: {"<field>": "<reason>"}}` — 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

View file

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

View file

@ -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).",

View file

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

View file

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

View file

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