diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index 79ea603af..9cc0e4a5f 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -1129,3 +1129,86 @@ comparisons, gates and logs copied to `tmp/patrol-named-binding-final-proof/` at the workspace root. This establishes the selected local performance proof, not a completed remote CI pass. Final staged-hook qualification and landing remain pending. + + +### Missing diagnostic access preserves monitored identity + +The named-binding correction is committed as `1f41fa174d7d` and pushed to PR +#1928 after the exact five-file staged hook passed 163 tests in 127.438s. +This supersedes the pending-hook statement above. Remote landing remains open. + +The missing-access case reproduced a shared routing defect before any new +infrastructure fault was introduced. With no connected command agents, the +resolver returned before consulting monitoring topology. Known hosts, VMs and +system containers consequently lost their kind, parent and required transport. +The regression failed for all three known targets in the original source. + +The shared resolver now retains topology when no server or connection exists. +A known target cannot fall through to an unrelated agent with a colliding ID. +No-target routing still requires exactly one connected agent. The file-read, +file-write, file-append, read-only execution and retained legacy command handlers +return the existing NO_AGENT failure envelope, including known resource kind and +parent node when available. The requested operation did not run. Missing access +no longer produces a successful file-write result or unsupported installation +advice. Absence of a connection does not establish a policy denial, missing +installation, guest capability, fresh observation or healthy workload. + +This concerns diagnostic command access. Advertised Proxmox lifecycle actions +retain their canonical hypervisor action authority and do not acquire an +in-guest diagnostic prerequisite. Issue #1782's full body and two comments were +read as adjacent evidence of the customer harm caused by invented prerequisites. +Its requested reporter confirmation remains outstanding. No comment was sent. + +The final full tools package passes on pulse-dev with Go 1.26.8 in 59.437s. +Targeted local regression passes on Go 1.27.1 in 0.538s. Worker source hashes are +unchanged across the full-package proof. Private reproduction, source manifest +and browser-result export are under `tmp/patrol-access-routing-proof/` at the +workspace root. The export invokes actual current tool handlers with controlled +connection fixtures. Its initial connected mock lacked a GetConnectedAgents +expectation and failed before export. The corrected export passes in 0.513s. +Browser fixtures prove rendering only. They do not qualify model judgment or +real command-scope enforcement. Final browser, race and staged proofs follow. + + +Final focused race proof explicitly runs all five new/missing-read tests and +passes in 1.051s on Go 1.26.8. An earlier broader name pattern passed but omitted +three newly named routing tests, so it is not used to claim their race coverage. +Existing API regressions for implicit monitoring-token scope and real WebSocket +rejection without agent-exec scope also pass in 0.122s. These preserve the access +boundary. They are not a model-led missing-access investigation. + +Playwright and pixel inspection pass for known disconnected target, unknown +target and ordinary failed-read controls at `/patrol`, 1440x1000, 900x1000 and +390x1000. The 18 cases cover ordinary and mirrored findings, keyboard review, +completed-but-unresolved records, readable error evidence, expanded investigation +transcripts, collapse, linked Assistant explanation and deepest failed-tool +input/output, Escape and context-only reopen, plus reload without resubmission. +Nine chat requests are intercepted, with no infrastructure writes. The backend +results are serialized from actual final-source handlers with controlled +connection/command fixtures. Model conclusions and transport are scripted. +The generic Patrol toolbar opens context-only Assistant, so the proof checks +no automatic submission there rather than expecting a persisted issue session. +One permission-control pass exhausted a five-second wait while capabilities +were still loading. Its complete matrix passes with a twenty-second request +wait. This does not establish a latency SLO. Final receipts/screenshots are in +`tmp/patrol-access-browser-proof/{known-vm,unknown-target,permission-denied}/` +at the workspace root. No frontend runtime source changed. + +The final routing source hashes are: + +- `internal/ai/tools/tools_control.go`: `f6ac2e05542657a1d859196b06c3a68288e08b436db6dabf4f691ded744dc751` + +- `internal/ai/tools/tools_file.go`: `c42002e1bf012024e1b38aa64738e0a8d9e0609699a49362675e01e7891d763e` + +- `internal/ai/tools/tools_read.go`: `643a02f70432adff75355ef3083d84ab24f43c3b811bba7730cfca2ca4fd8de7` + +- `internal/ai/tools/strict_resolution_test.go`: `7f88e16933c0b91b1ef7754cb7df977501fdc7278540a6ec70c1c4283e08c62d` + +- `internal/ai/tools/file_docker_test.go`: `6bb8f5df1291175b9f7c52dd863293bfe89a4b189ef3ed79daeaf2f8d7adb45b` + + +The missing-access slice is ready for its exact staged hook. The broader goal +remains open for real-model diagnosis and approved/rejected action outcomes. +On head `1f41fa174d7d`, governance, all eight Core E2E shards and CodeQL pass. +Build and Test run `34015148620` is still pending with no jobs, so omitted PR +checks are not treated as success. PR #1928 remains open with auto-merge enabled. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index c9b78eda6..153587bc8 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10201,7 +10201,7 @@ }, { "id": "patrol-assistant-customer-outcome-qualification", - "summary": "The explicit Patrol/Assistant redesign goal and ordered source-bound evidence are recorded in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Observations, conclusions, proposals, execution and independently verified outcomes remain distinct, with Assistant continuing the same issue and governed action records. The 2026-09-05 telemetry review found 127 paid installs, 71 with Patrol enabled and 23 with Assistant calls, but fourteen verified resolutions came from one install and schema 17 outcome/provider/cost fields had no adoption. Activity does not establish useful linked tasks. Implemented corrections preserve canonical risk, evidence provenance, retained history, failed-read status and the original diagnostic conversation while removing proposal-as-root-cause promotion, duplicate causal inference, contextless evaluation passes, report quotas and call-count completion proxies. Full affected package and focused race proofs, real ordinary failed-read verification and linked-issue browser matrices are recorded in the plan. Integrated presentation passes 18 delivery-ordering and 12 Overview light/dark cases at 1440/900/390 widths, plus the Patrol/Assistant journey. The qualification schema accepts the implemented health_process_stop injector and all eleven manifests validate. Integration f779bf064ab4 passed all eight Core E2E shards but Build and Test run 34008823529 failed fifteen benchmark comparisons against exact main 3f74c0c27304. An initial performance correction still failed the raw-read comparison and was revised. The final reader reuses bounded SQL templates/current numbered bindings, avoids repeated absent-tier probes within the same snapshot and appends output series directly. Ten alternating worker samples of complete selected query, batch, fleet, API, chart and route benchmark families show no significant greater-than-10% time, byte or allocation regression against that exact base. Final full metrics/database proof passes (77.265s/0.272s), as does focused retained/binding/batch race proof (5.349s). Latency-based concurrent SLO tests pass normally but skip under race. A separate concurrent binding regression with eight scopes and a one-connection pool passes normally (0.043s) and under race (2.106s). Exact staged hook and remote landing remain pending. An earlier native SQLite binding fault has no established root cause. The latest ordinary model diagnosis still makes unsupported temporal claims and remains unqualified despite the successful failed-read contract check. Claude Max explicitly refused autonomous readiness, and cached refusal/API409 enforcement remains intact. Ordinary Assistant success does not qualify autonomy. Required healthy/unhealthy, dependency, missing-access, storage/backup and approved/rejected action outcome qualification remains incomplete. The live catalogue now has a bounded service-storage scenario with independent filesystem measurements. Full qualification/CLI proof passes (4.223s/0.005s), and a provider-free Docker oracle verifies ENOSPC, zero free space, unhealthy service/healthy control, recovery, refused overwrite and symlink writes, and unchanged pre-existing inventory (final live test 8.65s). This qualifies only the fault/oracle, not model diagnosis. Missing-access at the Pulse source/tool boundary and broader host/storage-pool or backup cases remain absent. Alternate paid-provider approval remains pending. No paid request or production mutation was performed. Only run-owned disposable worker containers were faulted and removed. The published schema now accepts equivalent summary-term groups used by the new and three existing action scenarios. All twelve manifests and five full-schema regression tests pass using the CI-pinned validator. The final shipped guide passes Playwright and pixel inspection at /docs/AI_PATROL_QUALIFICATION, 1440/900/390x1000, including catalogue/section/command/limits, horizontal command scrolling, reload and keyboard index/history navigation. Storage slice staged hook and landing remain pending. Volunteered Pro environments remain a separate wider-readiness gate. Storage slice 618700db5e is pushed to open PR #1928 after the exact staged hook passed 163 tests in 127.989s with fourteen unchanged file hashes. CI on f266 exposes a shared disk-probe completion race and another benchmark failure. A controlled regression reproduces result publication before registry retirement. The canonical fix makes retirement and publication atomic to later collectors. Twenty full hostmetrics runs pass (16.687s), as do three complete race runs (1.864s), with source hashes recorded in the plan. Benchmark comparison investigation and final disk-fix staged hook/landing remain pending. These checks do not qualify model diagnosis or action outcomes. Disk-probe correction b964eea767 is committed and pushed to open PR #1928 after the final six-file staged hook passed 163 tests in 128.317s. The f266 CI benchmark base is 3347f561ec7b. Its 500-node dashboard query has 24.73% more allocation events, 0.62% more bytes and no significant runtime change. Ten alternating Go 1.26.8 worker samples reproduce the failure. The pinned driver repeatedly converts ordinals to strings when matching numbered parameters. Alphabetic named bindings preserve current values and query-branch reuse while avoiding those conversions. A ten-sample experiment reduces allocation difference to +0.26% and bytes to -1.68%, with no significant runtime change, and passes the actual repository benchmark checker. Final source preserves resource/metric/window semantics, snapshot coverage and cache bounds. Full metrics/database tests pass 77.390s/0.203s and focused retained/tier/binding/batch race proof passes 7.707s, including a new 500-resource cached-scope test with parameter-like and SQL-like IDs. Final source hashes and experiment receipts are recorded in the plan. Broader final-source performance proof and the staged hook remain pending. Live-model/action qualification and the provider-refusal boundary remain unchanged. The final broader comparison now passes: ten paired 100ms rounds, baseline before candidate each round, against exact CI base 3347, covering query/batch/fleet/concurrent reads and history/chart APIs with a memory-fallback control. All forty invocations succeed, and both metrics/API comparisons pass the unchanged repository time/byte/allocation checker. The final 500-node result is +0.26% allocation events and -1.68% bytes versus base with no significant runtime change. Actual base/current/checker hashes are verified. The discarded invalid ./pkg/api setup is not evidence. Final raw logs and selected-scope limits are recorded in the plan. Staged hook and landing remain pending, and remote CI is not claimed complete.", + "summary": "The explicit Patrol/Assistant redesign goal, product contract and ordered source-bound proof remain in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Observations, hypotheses, proposals, executions and independently verified outcomes remain distinct. Model judgment owns diagnosis. Assistant continues the same issue and governed action records. The recorded 2026-09-05 baseline has 127 paid installs, 71 with Patrol enabled and 23 with Assistant calls. Fourteen verified resolutions came from one install, and schema 17 outcome/provider/cost fields had no adoption. Usage is not evidence of useful linked tasks. Implemented corrections preserve canonical risk, source/time/history evidence, failed-read status and diagnostic continuity while removing proposal-as-root-cause promotion, duplicate causal inference, contextless evaluations, quotas and call-count completion proxies. Named evidence and linked-issue browser matrices pass. The latest ordinary model diagnosis still makes unsupported temporal claims and remains unqualified. Claude Max explicitly refused autonomous readiness. Cached refusal and API409 enforcement remain intact. Ordinary Assistant success is not autonomy qualification. Alternate paid-provider approval remains pending, with no paid request or production mutation. The service-storage fault/oracle independently verifies bounded ENOSPC, filesystem exhaustion, unhealthy service with healthy control, recovery, refused overwrite/symlink writes and unchanged pre-existing inventory. This qualifies the oracle, not diagnosis. All twelve manifests and five full-schema regression tests pass, and the published qualification guide passes desktop/narrow browser proof. Missing-access, broader storage/backup and approved/rejected action outcomes still require full real-model qualification. Integrated CI exposed retained-query performance and disk-probe completion defects. The disk race fix b964eea767 and named-binding fix 1f41fa174d are committed and pushed to open PR #1928, each after a 163-test exact staged hook. Full affected packages and focused race proofs pass. Ten paired final-source worker benchmark rounds pass the unchanged time/bytes/allocation checker against exact base 3347, including history/chart APIs and a memory-fallback control. The 500-node read has +0.26% allocations and -1.68% bytes with no significant runtime difference. Remote CI and landing remain open. Current missing-access work reproduces topology loss with zero command connections and preserves known resource kind, parent and routing without dispatch. Reads and file mutations use one failed NO_AGENT envelope instead of installation speculation or successful write results. No-target cardinality and hostname collision boundaries have regression coverage. Full tools proof passes on Go 1.26.8 in 59.437s. The exact five-test race proof passes in 1.051s and existing monitoring-token/WebSocket execution-scope tests pass in 0.122s. The final 18-case scripted browser matrix passes at /patrol, 1440/900/390x1000, including current handler errors, investigation expansion, linked Assistant details, dismissal and reload without resubmission. This is rendering/access-boundary proof, not model diagnosis. Exact staged qualification remains pending. On head1f41, governance, all eight Core E2E shards and CodeQL pass, but Build and Test run34015148620 is pending with no jobs. Missing PR checks are not evidence of success. Existing diagnostic permission controls and advertised hypervisor action authority remain distinct. Exact receipts and historical failed/discarded runs are retained in the plan. Independent volunteered Pro environments remain a separate wider-readiness gate.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-09-05", diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 1d7e13c5a..396cc2a26 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -8030,3 +8030,16 @@ disallowed. Streaming DOM reconciliation preserves table region identity, focus and scroll position. The conversation itself must not acquire horizontal scroll from wide answer tables. Browser proof covers a persisted real answer and explicit renderer fixtures at 1440, 900 and 390 pixel widths. + + +### Monitoring identity without command access + +Command routing resolves monitored topology independently of agent connection +availability. A missing connection must preserve the known target kind and +parent node, cannot fall back to a different agent with a colliding name or ID, +and cannot dispatch a command. No-target routing requires exactly one connection. +Diagnostic reads and file mutations return the same NO_AGENT failure envelope +when no command connection is available. Failure is distinct from an established +permission denial. Do not infer an installation requirement or guest capability +from this absence. Advertised hypervisor lifecycle capabilities remain governed +by their canonical action path, independently of diagnostic command access. diff --git a/internal/ai/tools/file_docker_test.go b/internal/ai/tools/file_docker_test.go index 96152c7de..8e99fe639 100644 --- a/internal/ai/tools/file_docker_test.go +++ b/internal/ai/tools/file_docker_test.go @@ -151,7 +151,7 @@ func TestExecuteFileReadFailureStatus(t *testing.T) { result, err := executor.executeFileRead(context.Background(), "/proc/meminfo", "delly2", "") require.NoError(t, err) require.True(t, result.IsError, "missing agent must not count as a successful read") - assert.Contains(t, result.Content[0].Text, "No agent found") + assert.Contains(t, result.Content[0].Text, "No command connection is available") agent.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything) }) @@ -570,3 +570,62 @@ func TestExecuteFileEditDockerNestedRouting(t *testing.T) { mockAgent.AssertExpectations(t) }) } + +// Missing access is a failed operation, independent of whether monitoring knows +// the target. Exercise the actual tool handlers so writes cannot report success. +func TestCommandToolsPreserveMonitoringWhenAccessUnavailable(t *testing.T) { + t.Setenv("PULSE_STRICT_RESOLUTION", "false") + for _, target := range []string{"known-vm", "unknown-target"} { + for _, noServer := range []bool{false, true} { + for _, operation := range []string{"file read", "file write", "file append", "read exec", "legacy command"} { + t.Run(fmt.Sprintf("%s/noServer=%t/%s", target, noServer, operation), func(t *testing.T) { + server := &mockAgentServer{} + config := ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{ + VMs: []models.VM{{ID: "vm-101", VMID: 101, Name: "known-vm", Node: "known-node", Status: "running"}}, + }}} + if !noServer { + config.AgentServer = server + } + executor := NewPulseToolExecutor(config) + ctx := context.Background() + var result CallToolResult + var err error + switch operation { + case "file read": + result, err = executor.executeFileRead(ctx, "/proc/meminfo", target, "") + case "file write": + result, err = executor.executeFileWrite(ctx, "/tmp/access-proof", "value", target, "", nil) + case "file append": + result, err = executor.executeFileAppend(ctx, "/tmp/access-proof", "value", target, "", nil) + case "read exec": + result, err = executor.executeReadExec(ctx, map[string]interface{}{"command": "uptime", "target_host": target}) + case "legacy command": + result, err = executor.executeRunCommand(ctx, map[string]interface{}{"command": "uptime", "target_host": target}) + } + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + var response ToolResponse + require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &response)) + require.False(t, response.OK) + require.NotNil(t, response.Error) + require.Equal(t, ErrCodeNoAgent, response.Error.Code) + require.True(t, response.Error.Failed) + require.False(t, response.Error.Blocked, "an absent connection does not prove a policy denial") + require.Equal(t, target, response.Error.Details["target"]) + require.Contains(t, response.Error.Message, "did not run") + require.NotContains(t, response.Error.Message, "Install") + if target == "known-vm" { + require.Equal(t, "vm", response.Error.Details["resource_kind"]) + require.Equal(t, "known-node", response.Error.Details["parent_node"]) + require.Contains(t, response.Error.Message, "Pulse monitoring knows") + } else { + require.NotContains(t, response.Error.Details, "resource_kind") + require.NotContains(t, response.Error.Message, "Pulse monitoring knows") + } + server.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything) + }) + } + } + } +} diff --git a/internal/ai/tools/strict_resolution_test.go b/internal/ai/tools/strict_resolution_test.go index 0c5f459b7..10c97afa6 100644 --- a/internal/ai/tools/strict_resolution_test.go +++ b/internal/ai/tools/strict_resolution_test.go @@ -2135,3 +2135,69 @@ func TestExecutionIntent_TelemetryCategories(t *testing.T) { }) } } + +func TestCommandRoutingPreservesKnownTargetWithoutConnections(t *testing.T) { + state := models.StateSnapshot{ + Hosts: []models.Host{{ID: "reported-host", Hostname: "monitored-host", CommandsEnabled: false}}, + Nodes: []models.Node{{ID: "node-1", Name: "known-node"}}, + VMs: []models.VM{{ID: "vm-101", VMID: 101, Name: "known-vm", Node: "known-node", Status: "running"}}, + Containers: []models.Container{{ID: "lxc-102", VMID: 102, Name: "known-lxc", Node: "known-node", Status: "running"}}, + } + for _, unavailable := range []string{"no server", "no connections", "unrelated connection"} { + t.Run(unavailable, func(t *testing.T) { + config := ExecutorConfig{StateProvider: &mockStateProvider{state: state}} + if unavailable != "no server" { + server := &mockAgentServer{} + if unavailable == "unrelated connection" { + server.agents = []agentexec.ConnectedAgent{{AgentID: "unrelated", Hostname: "other-host"}} + } + config.AgentServer = server + } + executor := NewPulseToolExecutor(config) + for _, tc := range []struct{ target, kind, transport, node, id string }{ + {"monitored-host", "agent", "direct", "", ""}, + {"known-node", "node", "direct", "known-node", ""}, + {"known-vm", "vm", "qm_guest_exec", "known-node", "101"}, + {"known-lxc", "system-container", "pct_exec", "known-node", "102"}, + } { + t.Run(tc.target, func(t *testing.T) { + if loc := executor.resolveResourceLocation(tc.target); !loc.Found { + t.Fatal("fixture target is not known to monitoring") + } + got := executor.resolveTargetForCommandFull(tc.target) + if got.AgentID != "" { + t.Fatalf("unavailable connection admitted agent %q", got.AgentID) + } + if got.ResolvedKind != tc.kind || got.Transport != tc.transport || got.ResolvedNode != tc.node || got.TargetID != tc.id { + t.Fatalf("known target lost when command connection is absent: %+v", got) + } + }) + } + }) + } +} + +func TestCommandRoutingWithoutTargetRequiresOneConnection(t *testing.T) { + for _, count := range []int{0, 1, 2} { + server := &mockAgentServer{} + for i := 0; i < count; i++ { + server.agents = append(server.agents, agentexec.ConnectedAgent{AgentID: "available", Hostname: "host"}) + } + executor := NewPulseToolExecutor(ExecutorConfig{AgentServer: server}) + got := executor.resolveTargetForCommandFull("") + if (got.AgentID != "") != (count == 1) { + t.Fatalf("%d connections selected %+v", count, got) + } + } +} + +func TestCommandRoutingKnownHostDoesNotFallBackToCollidingID(t *testing.T) { + executor := NewPulseToolExecutor(ExecutorConfig{ + StateProvider: &mockStateProvider{state: models.StateSnapshot{Hosts: []models.Host{{ID: "reported-host", Hostname: "monitored-host"}}}}, + AgentServer: &mockAgentServer{agents: []agentexec.ConnectedAgent{{AgentID: "monitored-host", Hostname: "different-host"}}}, + }) + got := executor.resolveTargetForCommandFull("monitored-host") + if got.AgentID != "" || got.ResolvedKind != "agent" { + t.Fatalf("known disconnected host routed through colliding agent ID: %+v", got) + } +} diff --git a/internal/ai/tools/tools_control.go b/internal/ai/tools/tools_control.go index b0ff3d91d..81bfaa86f 100644 --- a/internal/ai/tools/tools_control.go +++ b/internal/ai/tools/tools_control.go @@ -359,23 +359,10 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri } } - // Execute via agent server - if e.agentServer == nil { - return NewErrorResult(fmt.Errorf("no agent server available")), nil - } - - // Resolve target to the correct agent and routing info (with full provenance) - // If targetHost is a container/VM name, this routes to the host node agent - // with the correct TargetType and TargetID for pct exec / qm guest exec + // Resolve topology even when no command connection is available. routing := e.resolveTargetForCommandFull(targetHost) if routing.AgentID == "" { - if targetHost != "" { - if routing.TargetType == "container" || routing.TargetType == "vm" { - return NewErrorResult(fmt.Errorf("'%s' is a %s but no agent is available on its host node; install Pulse Unified Agent on the node", targetHost, routing.TargetType)), nil - } - return NewErrorResult(fmt.Errorf("no agent available for target '%s'. %s", targetHost, formatAvailableAgentHosts(e.agentServer.GetConnectedAgents()))), nil - } - return NewErrorResult(fmt.Errorf("no agent available for target")), nil + return unavailableCommandConnection(targetHost, routing), nil } approvalTargetType, approvalTargetID, approvalTargetName := approvalTargetForCommand(targetHost, routing) @@ -743,18 +730,14 @@ func (e *PulseToolExecutor) resolveTargetForCommandFull(targetHost string) Comma Transport: "direct", } - if e.agentServer == nil { - return result - } - - agents := e.agentServer.GetConnectedAgents() - if len(agents) == 0 { - return result + var agents []agentexec.ConnectedAgent + if e.agentServer != nil { + agents = e.agentServer.GetConnectedAgents() } if targetHost == "" { // No target_host specified - require exactly one agent or fail - if len(agents) > 1 { + if len(agents) != 1 { return result } result.AgentID = agents[0].AgentID @@ -769,6 +752,7 @@ func (e *PulseToolExecutor) resolveTargetForCommandFull(targetHost string) Comma loc := e.resolveResourceLocation(targetHost) if loc.Found { + result.ResolvedKind = loc.ResourceType // Route based on resource type switch loc.ResourceType { case "agent": @@ -879,6 +863,8 @@ func (e *PulseToolExecutor) resolveTargetForCommandFull(targetHost string) Comma } } } + // A known resource must never fall through to a different hostname match. + return result } // STEP 2: FALLBACK — agent hostname match. @@ -896,6 +882,31 @@ func (e *PulseToolExecutor) resolveTargetForCommandFull(targetHost string) Comma return result } +// unavailableCommandConnection preserves monitoring context without inferring +// installation, permission, or guest capability from an absent connection. +func unavailableCommandConnection(target string, routing CommandRoutingResult) CallToolResult { + message := "No command connection is available. Specify a target with diagnostic access." + details := map[string]any{"target": target} + if target != "" { + message = fmt.Sprintf("No command connection is available for %q. Check its diagnostic access and connection status.", target) + } + if routing.ResolvedKind != "" { + details["resource_kind"] = routing.ResolvedKind + message = fmt.Sprintf("Pulse monitoring knows %q, but no command connection is available for this target.", target) + if routing.ResolvedNode != "" { + details["parent_node"] = routing.ResolvedNode + message += fmt.Sprintf(" Check diagnostic access and connection status on its node %q.", routing.ResolvedNode) + } else { + message += " Check its diagnostic access and connection status." + } + } + message += " The requested operation did not run." + return NewToolResponseResult(ToolResponse{ + OK: false, + Error: &ToolError{Code: ErrCodeNoAgent, Message: message, Failed: true, Details: details}, + }) +} + // resolveTargetForCommand resolves a target_host to the correct agent and routing info. // Uses the authoritative resolveResourceLocation function. // Returns: agentID, targetType ("agent", "container", or "vm"), targetID (vmid for LXC/VM) diff --git a/internal/ai/tools/tools_file.go b/internal/ai/tools/tools_file.go index 391220e17..798b6256c 100644 --- a/internal/ai/tools/tools_file.go +++ b/internal/ai/tools/tools_file.go @@ -153,9 +153,6 @@ func (e *PulseToolExecutor) executeFileEdit(ctx context.Context, args map[string // executeFileRead reads a file's contents func (e *PulseToolExecutor) executeFileRead(ctx context.Context, path, targetHost, dockerContainer string) (CallToolResult, error) { - if e.agentServer == nil { - return NewErrorResult(fmt.Errorf("no agent server available")), nil - } if blocked, reason := safety.IsSensitivePath(path); blocked { return NewToolResponseResult(NewToolBlockedError( @@ -179,10 +176,7 @@ func (e *PulseToolExecutor) executeFileRead(ctx context.Context, path, targetHos // Use full routing resolution - includes provenance for debugging routing := e.resolveTargetForCommandFull(targetHost) if routing.AgentID == "" { - if routing.TargetType == "container" || routing.TargetType == "vm" { - return NewErrorResult(fmt.Errorf("'%s' is a %s but no agent is available on its host node. Install Pulse Unified Agent on the node.", targetHost, routing.TargetType)), nil - } - return NewErrorResult(fmt.Errorf("No agent found for host '%s'. Check that the hostname is correct and an agent is connected.", targetHost)), nil + return unavailableCommandConnection(targetHost, routing), nil } var command string @@ -274,9 +268,6 @@ func (e *PulseToolExecutor) executeFileWrite(ctx context.Context, path, content, // blocking, routing + resolved-resource validation, approval gating, base64 // transfer, audited execution, and post-write verification. func (e *PulseToolExecutor) executeFileMutation(ctx context.Context, path, content, targetHost, dockerContainer string, args map[string]interface{}, spec fileMutationSpec) (CallToolResult, error) { - if e.agentServer == nil { - return NewErrorResult(fmt.Errorf("no agent server available")), nil - } approvalID := agentcapabilities.ApprovalArgument(args) if blocked, reason := safety.IsSensitivePath(path); blocked { @@ -310,10 +301,7 @@ func (e *PulseToolExecutor) executeFileMutation(ctx context.Context, path, conte // Use full routing resolution - includes provenance for debugging routing := e.resolveTargetForCommandFull(targetHost) if routing.AgentID == "" { - if routing.TargetType == "container" || routing.TargetType == "vm" { - return NewTextResult(fmt.Sprintf("'%s' is a %s but no agent is available on its host node. Install Pulse Unified Agent on the node.", targetHost, routing.TargetType)), nil - } - return NewTextResult(fmt.Sprintf("No agent found for host '%s'. Check that the hostname is correct and an agent is connected.", targetHost)), nil + return unavailableCommandConnection(targetHost, routing), nil } // INVARIANT: If the target resolves to a child resource (container/VM), writes MUST execute diff --git a/internal/ai/tools/tools_read.go b/internal/ai/tools/tools_read.go index 827b94f98..44b8cb2e6 100644 --- a/internal/ai/tools/tools_read.go +++ b/internal/ai/tools/tools_read.go @@ -188,17 +188,10 @@ func (e *PulseToolExecutor) executeReadExec(ctx context.Context, args map[string return NewToolResponseResult(validation.StrictError.ToToolResponse()), nil } - if e.agentServer == nil { - return NewErrorResult(fmt.Errorf("no agent server available")), nil - } - // Resolve target to the correct agent and routing info (with full provenance) routing := e.resolveTargetForCommandFull(targetHost) if routing.AgentID == "" { - if routing.TargetType == "container" || routing.TargetType == "vm" { - return NewErrorResult(fmt.Errorf("'%s' is a %s but no agent is available on its Proxmox host", targetHost, routing.TargetType)), nil - } - return NewErrorResult(fmt.Errorf("no agent available for target '%s'. %s", targetHost, formatAvailableAgentHosts(e.agentServer.GetConnectedAgents()))), nil + return unavailableCommandConnection(targetHost, routing), nil } // Build command (with optional Docker wrapper)