mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Add agent provisioning capabilities
This commit is contained in:
parent
f7a40f3a22
commit
aa4a5fa631
12 changed files with 697 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue