From 6e18777d30f30b498def39d30016a697cabc4ea7 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:50:11 +0100 Subject: [PATCH] Preserve tmpfs mount evidence through collection and queries Docker can report tmpfs mounts only in HostConfig.Tmpfs. Preserve those entries in shared inventory and retain type, options and canonical write access in diagnostic queries. Configured size is not measured free space. Record the failed ordinary storage diagnosis and independently verified recovery without claiming autonomous or installed-collector qualification. --- .../PATROL_ASSISTANT_CUSTOMER_JOURNEY.md | 123 ++++++++++++++++++ docs/release-control/v6/internal/status.json | 2 +- .../v6/internal/subsystems/agent-lifecycle.md | 6 + .../v6/internal/subsystems/ai-runtime.md | 11 ++ .../v6/internal/subsystems/monitoring.md | 8 ++ .../v6/internal/subsystems/registry.json | 1 + frontend-modern/browser-verification.json | 45 +++++-- internal/ai/tools/data_types.go | 2 + .../ai/tools/tmpfs_mount_evidence_test.go | 87 +++++++++++++ internal/ai/tools/tools_query.go | 6 +- internal/dockeragent/collect.go | 31 +++++ internal/dockeragent/collect_tmpfs_test.go | 84 ++++++++++++ .../canonical_completion_guard_test.py | 1 + 13 files changed, 392 insertions(+), 15 deletions(-) create mode 100644 internal/ai/tools/tmpfs_mount_evidence_test.go create mode 100644 internal/dockeragent/collect_tmpfs_test.go diff --git a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md index 198b1d391..80de650f9 100644 --- a/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md +++ b/docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md @@ -1693,3 +1693,126 @@ This correction establishes the connection evidence contract, not reliable interpretation. Native configuration-read model use, storage-fault diagnosis, approved/rejected action outcomes and independent recovery remain open, as do the supported autonomous provider dependency and independent-environment gate. + + +### Ordinary Assistant storage fault and recovery, 2026-09-06 + +The command-connection correction passed the exact nine-file worker pre-commit +and was pushed as `f5f440dbad18d83557104d2cf6197d8319949e44` in PR #1935. +Required CI remains in progress. This is not a release or a completed goal. + +An owned DockerLab run used the checked-in storage-pressure manifest on Tower. +Independent observations established a healthy worker with 8,347,648 free bytes, +then a real ENOSPC fault with zero free bytes, a running/unhealthy worker and a +healthy control. Pulse collection converged to both states before the request. +The ordinary read-only Assistant used the configured subscription Opus 5 route. +It was not an autonomous Patrol request and did not retry the cached refusal. + +The diagnosis took 114.847 seconds and ten tool calls. It identified the worker's +unhealthy state, the control's current healthy state and a failed command-route +log read. It did not retry that unavailable capability. However, it falsely +ruled out resource pressure using low CPU, memory, network and disk-read values. +Filesystem capacity was absent from its evidence and was independently full. +This is a failed diagnosis, despite its otherwise useful uncertainty statement +and suggested diagnostic read. No model-directed mutation occurred. + +After the answer, the independent oracle still found zero available bytes and +an unhealthy worker. Removing only the owned fill file restored 8,220,672 bytes +and healthy status. Pulse collected recovery and resolved the health alert. +A follow-up in the same Assistant session took 91.358 seconds and five new reads. +It correctly identified current recovery and the resolved alert, distinguished +symptom recovery from an unknown cause and did not invent an intervention. +It overstated continuous control health and non-impact from sparse observations. +The recovery assessment is partial, not a complete incident explanation. +An independent post-answer check confirmed healthy worker/control, no container restart +and 7,639,040 free bytes. Both cleanup passes passed, with no second-pass work +and unchanged unrelated inventory. The disposable resources are removed. + +Playwright exercised `/patrol` and Assistant at 1440x1000 and 390x1000, all fifteen +retained tool input/output pairs, keyboard expansion/collapse, deepest output +scrolling, complete answers, Escape, reload and the same retained conversation. +Rendered inputs/outputs match persisted records. Pixel inspection covered both +answers and the failed-access result on desktop and mobile. Runtime and source +hashes remained unchanged across both requests, with binary +`bcaf748107211ee733a6dc0f4d17220d9b4d1ce1918c25bde27cf3d10c0d6379`. +The route warning was an artifact of blocking non-chat POSTs in the proof browser. +The original autonomous refusal timestamp remained `2026-09-05T19:46:39Z`. +Private fixtures, source bindings, observations, screenshots and assessments +are at workspace-relative `tmp/patrol-storage-fault-case/`. + +### Next canonical correction: tmpfs inventory + +Before implementation, source and native inspection establish a collection gap: +Docker reports the owned scratch mount in `HostConfig.Tmpfs`, while `Mounts` is +empty. `internal/dockeragent/collect.go` copies only `Mounts`, so shared resource +queries falsely present an empty mount inventory. Preserve these native tmpfs +entries through the existing report mount type. Keep destination, type and +reported options, derive read/write from those options, preserve authoritative +existing mount records and deterministic ordering. Do not infer used/free space +from a configured size. No enrollment, permission or production agent change is +part of this collection correction. + +Proof plan: reproduce the captured tmpfs-only inspect shape through the actual +collector, then cover existing mounts, overlapping representations, read-only +options and absent host configuration. Run targeted/full collector checks on +pulse-dev and verify the report through the existing shared projection. Browser +proof after the final change must exercise mount evidence in resource details +and Assistant tool results, desktop and mobile, including deepest expansion and +reload. A captured-result rendering check is not installed-agent or model +qualification. Leave those limits explicit until the new collector is exercised +through a supported installed path. + +The incident lookup also needs an identity audit: the canonical container ID +returned no incident recording while the observed health alert used its legacy +Docker resource ID. This is a concrete lookup discrepancy to investigate, not +yet proof that a recording exists. Model inference from unmeasured capacity and +sparse health history remains an open quality failure. Supported autonomous +provider, approved/rejected actions and independent environments remain open. + +Further shared-projection inspection before editing found that `MountInfo` drops +native mount type/options and that canonical app-container queries derive write +access from equality with the single string `ro`, misreporting compound read-only +options. The same slice must preserve type/options and the canonical `RW` boolean +through both canonical-provider and typed read-state query paths. Add a query +regression and capture its actual output for final Assistant browser proof. +This remains mount configuration evidence, not measured filesystem capacity. + + +### Tmpfs collection and query contract proof + +The captured tmpfs-only and mixed-mount regressions failed against the previous +collector, then passed after the collection correction. The full dockeragent +package passed in 18.883s and focused race proof in 1.030s. Existing monitor report +mount propagation and discovery mount regressions passed. Both query paths +failed because type/options were lost, then passed after projection correction. +The full tools package passed in 59.473s and focused race proof in 1.030s. +A final output-only capture rerun passed in 0.013s. All proof used Go1.26.8 and +GOMAXPROCS4 on pulse-dev. The final Pro cross-build passed and the installed +local binary SHA256 is +`0a21dca4106c2ddc6873a3aca3b23378dccef35383ca00d7e9292b966de7c200`. +The managed local backend restarted and `/api/health` reported healthy. +No production collector was replaced. + +Final Playwright proof used captured canonical resources at `/docker`, widths +1920, 1440, 900 and 390 with height 1080. It exercised mount summary/title, +keyboard row expansion/collapse, mobile row tapping, adjacent detail state, +mount-destination search, Escape and reloaded search state. The existing wide +mount column is truncated with a full title. Responsive details have no dedicated +mount section. This is an existing presentation limitation, not full mobile +mount inspection qualification. Assistant's complete mount evidence is readable +at `/patrol`, 1440x1000, 900x1000 and 390x1000. Both actual query projections +passed exact input/output comparison, hover/focus, keyboard expansion/collapse, +deepest scrolling, controlled session reload and reopening retained records. +Pixels were inspected on desktop and mobile. Source/binary hashes stayed fixed. +The original autonomous refusal timestamp is unchanged. + +These browser fixtures qualify rendering of the corrected shared fields. They +do not qualify an installed collector, actual model interpretation of tmpfs +configuration, or durable backend persistence of those fixture sessions. The +ordinary live diagnosis/recovery records above have real server persistence +and retain their failed/partial judgments. Exact scoped hook and landing remain +required. Required model/action qualification and independent environments are +still open. The typed compatibility get path also does not accept the canonical +ID returned by its list path, so its mount regression uses an existing accepted +name. That identity residual is recorded for modernization, not silently fixed +through this mount projection. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index fbb10c064..4f9e7377a 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 redesign plan and source-bound receipts remain in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. The goal is open. Model judgment owns diagnosis, with observations, hypotheses, proposals, executions and independently verified outcomes kept distinct. The recorded baseline has 127 paid installations, 71 with Patrol enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These are not representative success, false-alarm or missed-problem rates. Shared provenance, history, risk, missing-access and continuity corrections landed through PR1928/1929. Proposal-as-proof and proxy-driven diagnostic completion policy were removed. PR1934 merged the tool-ID and transcript-identity correction at 6b0abc3bee9ffa81f6ab298b5b67ee11369688a0 with all checks passing. Its captured-response browser replay preserves seven healthy and sixteen dependency tool records. The two ordinary subscription Assistant cases took 82.835s and 204.384s. Their primary decisions had useful evidence, but storage exclusion, recovery implications and config-not-found errors remain unqualified. Independent Docker fixtures prove injection, negative controls and deadline cleanup, not completed recovery or model/action competence. The current scoped Docker measurement correction preserves per-direction observation presence, separates corrected history from ambiguous legacy rows, removes image-layer ratios from capacity, and carries optional rates through resource/browser conversion. Legacy workload direction availability, Docker-host first-disk history and network presence remain distinct consumer/source follow-ups. The current configuration-read correction resolves identity and placement from canonical inventory, preserves explicit query restrictions, and distinguishes unavailable capability/provider observations from missing resources with the shared tool error bit. Fourteen contract cases, full tools package, targeted race checks and captured-result browser rendering pass. Both corrections are pushed to PR1935 at 355ac1f0a481d6dbc9a7ff3977bced0956711979 with exact worker hooks passing and remote checks pending. A fresh ordinary storage assessment took 177.869s and thirteen successful reads. It identified the actual capacity alert and preserved limited I/O history, but contradictory capacity assurance, unsupported artifact attribution and a misplaced CPU-peak time keep diagnosis unqualified. All thirteen rendered tool records match persistence. Desktop/mobile browser proof and its artificial route-warning limit are recorded in the plan. The current shared connection projection names command transport explicitly, omits unqueried connection and execution hints/counts from topology and Assistant inventory, and separates parent-node transport from direct guest connectivity. Four canonical connection/control cases, unobserved/empty topology, inventory context, full tools/chat packages, focused race checks and nine captured browser results pass. Scoped landing is pending. Unsupported filter handling remains a tool-context review item. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain intact. Ordinary Assistant is not autonomous qualification. Separate paid-provider approval remains pending and no paid request occurred. Reliable interpretation, real-model retest of config reads, storage/backup and approved/rejected action outcomes remain required local work. Independent volunteered Pro environments remain a separate wider-readiness gate.", + "summary": "The explicit redesign plan and source-bound receipts remain in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. The goal is open. Model judgment owns diagnosis, with observations, hypotheses, proposals, executions and independently verified outcomes kept distinct. The recorded baseline has 127 paid installations, 71 with Patrol enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These are not representative success, false-alarm or missed-problem rates. Shared provenance, history, risk, missing-access and continuity corrections landed through PR1928/1929. Proposal-as-proof and proxy-driven diagnostic completion policy were removed. PR1934 merged the tool-ID and transcript-identity correction at 6b0abc3bee9ffa81f6ab298b5b67ee11369688a0 with all checks passing. Its captured-response browser replay preserves seven healthy and sixteen dependency tool records. The two ordinary subscription Assistant cases took 82.835s and 204.384s. Their primary decisions had useful evidence, but storage exclusion, recovery implications and config-not-found errors remain unqualified. Independent Docker fixtures prove injection, negative controls and deadline cleanup, not completed recovery or model/action competence. The current scoped Docker measurement correction preserves per-direction observation presence, separates corrected history from ambiguous legacy rows, removes image-layer ratios from capacity, and carries optional rates through resource/browser conversion. Legacy workload direction availability, Docker-host first-disk history and network presence remain distinct consumer/source follow-ups. The current configuration-read correction resolves identity and placement from canonical inventory, preserves explicit query restrictions, and distinguishes unavailable capability/provider observations from missing resources with the shared tool error bit. Fourteen contract cases, full tools package, targeted race checks and captured-result browser rendering pass. Both corrections are pushed to PR1935 at 355ac1f0a481d6dbc9a7ff3977bced0956711979 with exact worker hooks passing and remote checks pending. A fresh ordinary storage assessment took 177.869s and thirteen successful reads. It identified the actual capacity alert and preserved limited I/O history, but contradictory capacity assurance, unsupported artifact attribution and a misplaced CPU-peak time keep diagnosis unqualified. All thirteen rendered tool records match persistence. Desktop/mobile browser proof and its artificial route-warning limit are recorded in the plan. The current shared connection projection names command transport explicitly, omits unqueried connection and execution hints/counts from topology and Assistant inventory, and separates parent-node transport from direct guest connectivity. Four canonical connection/control cases, unobserved/empty topology, inventory context, full tools/chat packages, focused race checks and nine captured browser results pass. The connection correction passed its exact worker hook and is pushed as f5f440dbad18d83557104d2cf6197d8319949e44 in PR1935, with CI ongoing. Unsupported filter handling remains a tool-context review item. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain intact. Ordinary Assistant is not autonomous qualification. Separate paid-provider approval remains pending and no paid request occurred. Reliable interpretation, real-model retest of config reads, storage/backup and approved/rejected action outcomes remain required local work. Independent volunteered Pro environments remain a separate wider-readiness gate. A new ordinary storage-fault diagnosis took 114.847s and ten calls, then same-session recovery took 91.358s and five calls. Independent baseline, ENOSPC, unchanged fault after answer, recovery, post-answer health and two-pass cleanup all passed. Diagnosis failed by ruling out resource pressure without filesystem capacity evidence. Recovery identified current health and alert resolution but overstated continuous control health from sparse observations. Fifteen rendered tool records match persisted history. The shared collector currently being corrected drops HostConfig.Tmpfs, while query projection loses mount type/options and misreads compound read-only options. The collector and shared query corrections pass focused/full package and race proof, and final source-bound Docker/Assistant captured-result browser checks. Exact scoped hook and landing remain pending. Installed-collector and model qualification remain unperformed, and the existing responsive Docker details omit a dedicated mounts section. No autonomous call, action execution, provider-policy bypass or paid request was part of the case. Incident-record identity lookup and typed compatibility canonical-ID lookup remain documented follow-ups.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-09-05", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 29d4b8f97..b99bc49c6 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -15,6 +15,12 @@ ## Purpose +Docker mount reports include tmpfs configuration from `HostConfig.Tmpfs` +through the existing optional mount array. This adds collection evidence only. +It does not change admission, enrollment, execution permissions or agent +lifecycle authority. Existing agents continue to report their existing mount +coverage. Deploying an updated collector is a separate installed-path proof. + Docker block-I/O report presence fields are optional measurement metadata. They preserve zero and omitted directions independently without changing report admission, enrollment, identity, command permission or agent lifecycle state. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index f7778928c..ed6cdf198 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -25,6 +25,17 @@ that same result. Successful reads retain their content and execution provenance ## Purpose +Shared app-container query mount evidence preserves native type, source, +destination, options and canonical read/write access. Compound options such as +`ro,noexec` cannot become writable through string equality heuristics. Both the +canonical provider and typed read-state projection preserve the same fields. +Configured size in mount options does not establish filesystem usage or free +space. `TestQueryPreservesMountConfigurationEvidence` covers these contracts. +The ordinary live storage case still failed diagnosis by excluding resource +pressure without capacity evidence. Mount fidelity alone does not qualify model +interpretation. Incident-record lookup across canonical and legacy Docker IDs +and typed compatibility lookup of canonical IDs remain explicit identity gaps. + Shared query projections name command transport explicitly through `command_agent_connected`, `node_command_agent_connected` and the corresponding topology counts. These observations do not establish monitoring freshness or diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index e9c89ae80..2533243f0 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -17,6 +17,14 @@ ## Purpose +Docker mount collection preserves both native `Mounts` records and entries +reported only in `HostConfig.Tmpfs`. Existing reported destinations remain +authoritative. Additional tmpfs destinations are ordered deterministically, +retain their options and read/write setting, and use the existing mount report +shape. Configured tmpfs size is configuration, not measured used/free space. +`TestCollectContainerPreservesTmpfsMounts` reproduces a live tmpfs-only inspect +shape and covers mixed mounts, read-only options, overlap and absent host config. + Docker collection records read and write counter presence independently, including explicit zero, in optional report fields. Older reports without those fields establish only positive counters. Container reports propagate this diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 31e433330..0d47d4b63 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -6069,6 +6069,7 @@ "internal/dockeragent/agent_cpu_test.go", "internal/dockeragent/agent_internal_test.go", "internal/dockeragent/blockio_presence_test.go", + "internal/dockeragent/collect_tmpfs_test.go", "internal/dockeragent/swarm_coverage_test.go" ] }, diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index cc0bbd649..40505e579 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,25 +1,44 @@ { "version": 1, - "base_sha": "4d302109cee0758a132ff150935630b50114cc05", - "verified_at": "2026-09-06T12:57:13.096Z", + "base_sha": "f5f440dbad18d83557104d2cf6197d8319949e44", + "verified_at": "2026-09-06T13:39:36.848659+00:00", "result": "passed", "changed_paths": [], "content_sha256": {}, "backend_content_sha256": { - "internal/ai/tools/data_types.go": "00200b1366ee4bf1e541e3acf6618f56993b05b05fa08122b91cd2db320969ec", - "internal/ai/tools/tools_query.go": "69bd79091bc8892043b900ca8475d1ee9d25c9613cf0ca86e36e41a0e468e2f8", - "internal/ai/chat/service.go": "7b4a7696eb0a3ca2a99427d98f991740ff362bfc0c738c36f45f80549d7c2744" + "internal/dockeragent/collect.go": "869eb906ac8d6fe57304a296bc81bc689f72a07b5a76267982b81228d147852d", + "internal/ai/tools/data_types.go": "91fff7a17735c9f9f4bf64c7fe42c7d4c5cf9e436af36ac07dec92feb952afc6", + "internal/ai/tools/tools_query.go": "5ba1f2c8ec7aadb21d594c967a5b2858db3471b02174889b7f78472e3a33235b" }, - "binary_sha256": "bcaf748107211ee733a6dc0f4d17220d9b4d1ce1918c25bde27cf3d10c0d6379", + "binary_sha256": "0a21dca4106c2ddc6873a3aca3b23378dccef35383ca00d7e9292b966de7c200", "rendering_content_sha256": { "frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c", "frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371", - "frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "cc7bd548a418c3863486f0fe987c5c3110c2f6cdfa70b630b2ec05fb794d60d6" + "frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "cc7bd548a418c3863486f0fe987c5c3110c2f6cdfa70b630b2ec05fb794d60d6", + "frontend-modern/src/features/docker/DockerContainersTable.tsx": "af58fb901b55b188be084105e78c71084af5a594d1a7dde2647959eb00cc6131", + "frontend-modern/src/features/docker/dockerContainerTableModel.ts": "48bf66fe7e3bacf6352c48d7ccd568dd337e9d27670da435f30c3caabccf79bc" }, "routes": [ + "/docker", "/patrol" ], "viewports": [ + { + "width": 1920, + "height": 1080 + }, + { + "width": 1440, + "height": 1080 + }, + { + "width": 900, + "height": 1080 + }, + { + "width": 390, + "height": 1080 + }, { "width": 1440, "height": 1000 @@ -34,13 +53,13 @@ } ], "states": [ - "Nine actual regression outputs: disconnected transport beside current workload metrics, connected transport under read-only control, distinct parent and guest connections, control-enabled transport, unqueried topology and an observed empty connection snapshot.", - "Each captured input and output is preserved by the Assistant stream renderer and restored through controlled session responses after reload. Unobserved fields remain absent and observed false/zero remains explicit. This is rendering proof, not native integration or model diagnosis.", - "Local Pro backend cross-built on pulse-dev with Go1.26.8. Source and binary hashes matched before and after Playwright. Browser interception blocks provider and infrastructure writes. Its selected-route warning does not qualify provider readiness." + "Captured canonical app-container and parent agent with tmpfs and bind mounts. Existing mount summary is a wide-table column, with no dedicated mount section in responsive detail views.", + "Two captured shared query results preserve native type, options, source, destination and RW. They establish mount configuration, not measured capacity.", + "Final Pro source/binary binding and original cached refusal preserved. No provider or production mutation in rendering proof." ], "interactions": [ - "Tool details open/closed, hover, keyboard focus, Enter and Space, exact complete input/output comparison, deepest output scrolling and pixel inspection at desktop, intermediate and narrow widths.", - "Escape, full reload, session picker selection, reopening all nine restored tool records with exact output and completed-state assertions. Controlled session fixtures do not qualify server persistence.", - "Private evidence: /Volumes/Development/pulse/tmp/patrol-command-context/browser, tool-evidence.json, runtime-binding.json and final.log." + "Docker mount summary/title hover, keyboard expansion/collapse, mobile row tap, adjacent detail state, mount destination search, Escape and reloaded search.", + "Assistant exact input/output comparison, hover/focus, Enter/Space, deepest output scrolling, Escape, full reload and controlled session selection/reopening.", + "Pixel inspection at desktop/mobile. Private receipts: tmp/patrol-storage-fault-case/browser and docker-browser. Captured REST/SSE fixtures do not qualify installed collector, model diagnosis or server persistence." ] } diff --git a/internal/ai/tools/data_types.go b/internal/ai/tools/data_types.go index 28062a5ef..8344ec73d 100644 --- a/internal/ai/tools/data_types.go +++ b/internal/ai/tools/data_types.go @@ -883,8 +883,10 @@ type PortInfo struct { // MountInfo describes a volume mount type MountInfo struct { + Type string `json:"type,omitempty"` Source string `json:"source"` Destination string `json:"destination"` + Mode string `json:"mode,omitempty"` ReadWrite bool `json:"rw"` } diff --git a/internal/ai/tools/tmpfs_mount_evidence_test.go b/internal/ai/tools/tmpfs_mount_evidence_test.go new file mode 100644 index 000000000..34615d657 --- /dev/null +++ b/internal/ai/tools/tmpfs_mount_evidence_test.go @@ -0,0 +1,87 @@ +package tools + +import ( + "context" + "encoding/json" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +func TestQueryPreservesMountConfigurationEvidence(t *testing.T) { + for _, provider := range []bool{false, true} { + name := "typed read state" + if provider { + name = "canonical provider" + } + t.Run(name, func(t *testing.T) { + snapshot := commandEvidenceSnapshot() + snapshot.DockerHosts[0].Containers[0].Mounts = []models.DockerContainerMount{ + {Type: "tmpfs", Destination: "/var/lib/service-cache", Mode: "rw,noexec,nosuid,nodev,size=8388608", RW: true}, + {Type: "tmpfs", Destination: "/readonly-cache", Mode: "ro,noexec", RW: false}, + {Type: "bind", Source: "/host/data", Destination: "/data", Mode: "", RW: false}, + } + registry := unifiedresources.NewRegistry(nil) + registry.IngestSnapshot(snapshot) + cfg := ExecutorConfig{ReadState: registry, ControlLevel: ControlLevelReadOnly} + if provider { + cfg.UnifiedResourceProvider = ®istryUnifiedQueryProvider{registry} + } + executor := NewPulseToolExecutor(cfg) + list, err := executor.executeQuery(context.Background(), map[string]interface{}{"action": "list", "type": "docker-hosts"}) + if err != nil || list.IsError { + t.Fatalf("list: %v %+v", err, list) + } + var hosts map[string]any + if err := json.Unmarshal([]byte(list.Content[0].Text), &hosts); err != nil { + t.Fatal(err) + } + id := hosts["docker_hosts"].([]any)[0].(map[string]any)["containers"].([]any)[0].(map[string]any)["id"] + if !provider { + // The typed compatibility path currently accepts provider IDs/names. + id = snapshot.DockerHosts[0].Containers[0].Name + } + args := map[string]interface{}{"action": "get", "resource_type": "app-container", "resource_id": id} + result, err := executor.executeQuery(context.Background(), args) + if err != nil || result.IsError { + t.Fatalf("get: %v %+v", err, result) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(result.Content[0].Text), &decoded); err != nil { + t.Fatal(err) + } + mounts, ok := decoded["mounts"].([]any) + if !ok { + t.Fatalf("missing mount projection: %+v", decoded) + } + if len(mounts) != 3 { + t.Fatalf("lost mounts: %+v", mounts) + } + for i, want := range snapshot.DockerHosts[0].Containers[0].Mounts { + got := mounts[i].(map[string]any) + if got["type"] != want.Type || got["source"] != want.Source || got["destination"] != want.Destination || got["rw"] != want.RW || (want.Mode != "" && got["mode"] != want.Mode) { + t.Fatalf("mount provenance/access changed: %+v, want %+v", got, want) + } + } + if _, exists := decoded["disk"]; exists { + t.Fatalf("mount configuration invented capacity: %+v", decoded["disk"]) + } + capture, _ := json.Marshal(map[string]any{"case": name, "input": args, "output": decoded}) + t.Logf("MOUNT_EVIDENCE %s", capture) + if provider { + resources := registry.ListByType(unifiedresources.ResourceTypeAppContainer) + for _, host := range registry.ListByType(unifiedresources.ResourceTypeAgent) { + if host.Docker != nil { + resources = append(resources, host) + } + } + encoded, err := json.Marshal(resources) + if err != nil { + t.Fatal(err) + } + t.Logf("MOUNT_RESOURCES %s", encoded) + } + }) + } +} diff --git a/internal/ai/tools/tools_query.go b/internal/ai/tools/tools_query.go index 65551b77c..53e63b504 100644 --- a/internal/ai/tools/tools_query.go +++ b/internal/ai/tools/tools_query.go @@ -5057,9 +5057,11 @@ func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[strin } for _, m := range resource.Docker.Mounts { response.Mounts = append(response.Mounts, MountInfo{ + Type: m.Type, Source: m.Source, Destination: m.Destination, - ReadWrite: !strings.EqualFold(strings.TrimSpace(m.Mode), "ro"), + Mode: m.Mode, + ReadWrite: m.RW, }) } } @@ -5164,8 +5166,10 @@ func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[strin for _, m := range container.Mounts() { response.Mounts = append(response.Mounts, MountInfo{ + Type: m.Type, Source: m.Source, Destination: m.Destination, + Mode: m.Mode, ReadWrite: m.RW, }) } diff --git a/internal/dockeragent/collect.go b/internal/dockeragent/collect.go index ebb8d0dfa..02223dc3a 100644 --- a/internal/dockeragent/collect.go +++ b/internal/dockeragent/collect.go @@ -10,6 +10,7 @@ import ( "net/netip" "net/url" "regexp" + "sort" "strconv" "strings" "time" @@ -698,6 +699,36 @@ func (a *Agent) collectContainer(ctx context.Context, summary containertypes.Sum }) } } + // Docker's --tmpfs mounts can exist only in HostConfig.Tmpfs. Preserve + // their configuration alongside inspected mounts without inventing usage. + if inspect.HostConfig != nil && len(inspect.HostConfig.Tmpfs) > 0 { + reported := make(map[string]bool, len(mounts)) + for _, mount := range mounts { + reported[mount.Destination] = true + } + destinations := make([]string, 0, len(inspect.HostConfig.Tmpfs)) + for destination := range inspect.HostConfig.Tmpfs { + if !reported[destination] { + destinations = append(destinations, destination) + } + } + sort.Strings(destinations) + for _, destination := range destinations { + options := inspect.HostConfig.Tmpfs[destination] + writable := true + for _, option := range strings.Split(options, ",") { + switch strings.TrimSpace(option) { + case "ro": + writable = false + case "rw": + writable = true + } + } + mounts = append(mounts, agentsdocker.ContainerMount{ + Type: "tmpfs", Destination: destination, Mode: options, RW: writable, + }) + } + } oomKilled := inspect.State.OOMKilled container := agentsdocker.Container{ diff --git a/internal/dockeragent/collect_tmpfs_test.go b/internal/dockeragent/collect_tmpfs_test.go new file mode 100644 index 000000000..c9b6340ad --- /dev/null +++ b/internal/dockeragent/collect_tmpfs_test.go @@ -0,0 +1,84 @@ +package dockeragent + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + containertypes "github.com/moby/moby/api/types/container" + agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" + "github.com/rs/zerolog" +) + +func TestCollectContainerPreservesTmpfsMounts(t *testing.T) { + for _, tc := range []struct { + name string + host *containertypes.HostConfig + mounts []containertypes.MountPoint + want []agentsdocker.ContainerMount + }{ + { + name: "native tmpfs only", + host: &containertypes.HostConfig{Tmpfs: map[string]string{ + "/var/lib/service-cache": "rw,noexec,nosuid,nodev,size=8388608", + }}, + want: []agentsdocker.ContainerMount{{Type: "tmpfs", Destination: "/var/lib/service-cache", Mode: "rw,noexec,nosuid,nodev,size=8388608", RW: true}}, + }, + { + name: "mixed mounts and stable tmpfs order", + host: &containertypes.HostConfig{Tmpfs: map[string]string{"/z-cache": "", "/a-cache": "ro,noexec"}}, + mounts: []containertypes.MountPoint{{Type: "bind", Source: "/host-data", Destination: "/data", RW: true}}, + want: []agentsdocker.ContainerMount{ + {Type: "bind", Source: "/host-data", Destination: "/data", RW: true}, + {Type: "tmpfs", Destination: "/a-cache", Mode: "ro,noexec", RW: false}, + {Type: "tmpfs", Destination: "/z-cache", RW: true}, + }, + }, + { + name: "reported mount is authoritative", + host: &containertypes.HostConfig{Tmpfs: map[string]string{"/cache": "rw,size=8388608"}}, + mounts: []containertypes.MountPoint{{Type: "tmpfs", Destination: "/cache", Mode: "ro", RW: false}}, + want: []agentsdocker.ContainerMount{{Type: "tmpfs", Destination: "/cache", Mode: "ro", RW: false}}, + }, + {name: "absent host config"}, + } { + t.Run(tc.name, func(t *testing.T) { + inspect := baseInspect() + inspect.HostConfig = tc.host + inspect.Mounts = tc.mounts + a := &Agent{ + logger: zerolog.Nop(), + runtime: RuntimeDocker, + prevContainerCPU: make(map[string]cpuSample), + docker: &fakeDockerClient{ + containerInspectWithRawFn: func(context.Context, string, bool) (containertypes.InspectResponse, []byte, error) { + return inspect, nil, nil + }, + containerStatsOneShotFn: func(context.Context, string) (dockerStatsResponseReader, error) { + return statsReader(t, containertypes.StatsResponse{}), nil + }, + }, + } + got, err := a.collectContainer(context.Background(), containertypes.Summary{ID: "owned-storage-container", Names: []string{"/worker"}, Image: "alpine:3.20", State: "running"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.Mounts, tc.want) { + t.Fatalf("mount inventory = %#v, want %#v", got.Mounts, tc.want) + } + encoded, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + var wire agentsdocker.Container + if err := json.Unmarshal(encoded, &wire); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(wire.Mounts, tc.want) { + t.Fatalf("report lost mounts: %#v", wire.Mounts) + } + t.Logf("TMPFS_COLLECTOR_REPORT %s", encoded) + }) + } +} diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 0697340f4..19a8e0c96 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -356,6 +356,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "internal/dockeragent/agent_cpu_test.go", "internal/dockeragent/agent_internal_test.go", "internal/dockeragent/blockio_presence_test.go", + "internal/dockeragent/collect_tmpfs_test.go", "internal/dockeragent/swarm_coverage_test.go", ], }