diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index 66dcaaf1..ff87d9f1 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -1043,6 +1043,42 @@ mod tests { assert!(serde_json::from_value::(wrong_target).is_err()); } + #[test] + fn version_five_tmux_evidence_requires_its_closed_slot_and_canonical_edge() { + let valid = serde_json::json!({ + "version": 5, + "id": "tmux_evidence_session_listing_opaque", + "provider": "tmux", + "kind": "tmux_session_listing", + "assertionKind": "observed", + "summary": "tmux listed a local session", + "subjectRef": "tmux_session_opaque", + "collectedAt": 42, + "providerRevision": "opaque-tmux-revision", + "providerSlot": "tmux", + "freshness": "stale" + }); + assert!(serde_json::from_value::(valid.clone()).is_ok()); + for (field, invalid) in [ + ("providerSlot", serde_json::json!("host_scoped")), + ("provider", serde_json::json!("cron")), + ("assertionKind", serde_json::json!("declared")), + ("kind", serde_json::json!("cron_schedule_declaration")), + ] { + let mut malformed = valid.clone(); + malformed[field] = invalid; + assert!(serde_json::from_value::(malformed).is_err()); + } + let edge = serde_json::json!({ + "source": "tmux_session_opaque", "target": "host_local", "relationship": "runs_on", + "metadata": {}, "evidenceRefs": [valid] + }); + assert!(serde_json::from_value::(edge.clone()).is_ok()); + let mut wrong_target = edge; + wrong_target["target"] = serde_json::json!("host_other"); + assert!(serde_json::from_value::(wrong_target).is_err()); + } + #[test] fn version_one_evidence_cannot_attest_a_different_runtime_edge() { let snapshot = mock_snapshot(); diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index 401ef79c..7481c56e 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -153,8 +153,11 @@ pub enum RuntimeMode { pub enum ProviderSlot { NetworkInfrastructure, HostScoped, + /// Tmux has an independent collector lifecycle. It must not inherit + /// host-node, listener, or PM2 freshness. + Tmux, /// Cron has an independent collector lifecycle. It must not inherit - /// host-node, listener, PM2, or tmux freshness. + /// host-node, listener, or PM2 freshness. Cron, /// systemd has an independent collector lifecycle. It must not inherit /// freshness from the broader host-scoped observation slot. @@ -823,9 +826,9 @@ pub struct RuntimeMapNode { pub package: Option, } -/// Evidence providers are deliberately closed. Version two adds systemd only -/// after it received its own scheduler slot; it cannot inherit a broader host -/// collection's freshness or revision. +/// Evidence providers are deliberately closed. Every host provider enters only +/// after it receives its own scheduler slot, so it cannot inherit a broader +/// host collection's freshness or revision. #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RuntimeEvidenceProvider { @@ -833,6 +836,7 @@ pub enum RuntimeEvidenceProvider { Systemd, Npm, Cron, + Tmux, } /// Evidence assertion semantics are deliberately closed. A declaration says @@ -872,6 +876,9 @@ pub enum RuntimeEvidenceKind { NpmPackageManifestDependency, /// A parsed cron declaration. This does not claim the command ran. CronScheduleDeclaration, + /// A fixed tmux session listing. This does not claim that the session is + /// attached, active, executing work, or reachable. + TmuxSessionListing, } /// A compact, versioned reference to the bounded fact supporting a runtime @@ -882,7 +889,7 @@ pub enum RuntimeEvidenceKind { pub struct RuntimeEvidenceRef { /// Version of this closed evidence representation, not a provider API /// version. It lets future additions remain explicit and reviewable. - #[schemars(range(min = 1, max = 4))] + #[schemars(range(min = 1, max = 5))] pub version: u8, #[schemars(length(min = 1, max = 259))] pub id: String, @@ -972,6 +979,15 @@ impl RuntimeEvidenceRef { | RuntimeEvidenceFreshness::Stale | RuntimeEvidenceFreshness::TimedOut, Some(ProviderSlot::Cron), + ) | ( + 5, + RuntimeEvidenceProvider::Tmux, + RuntimeEvidenceKind::TmuxSessionListing, + RuntimeEvidenceAssertionKind::Observed, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Tmux), ) ) } @@ -1181,6 +1197,21 @@ impl RuntimeMapEdge { && self.target == "host_local" && self.source != self.target } + ( + 5, + RuntimeEvidenceProvider::Tmux, + RuntimeEvidenceKind::TmuxSessionListing, + RuntimeEvidenceAssertionKind::Observed, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Tmux), + ) => { + self.relationship == RuntimeRelationshipKind::RunsOn + && self.source.starts_with("tmux_session_") + && self.target == "host_local" + && self.source != self.target + } ( 2, RuntimeEvidenceProvider::Systemd, @@ -1322,7 +1353,7 @@ pub struct RuntimeMap { #[schemars(length(min = 1))] pub model_revision: String, #[serde(rename = "providerStates")] - #[schemars(length(min = 7, max = 7))] + #[schemars(length(min = 8, max = 8))] pub provider_states: Vec, /// ACTUAL source of these bytes: "docker" or "mock" (#85 A3). Stamped by /// the daemon route layer from the cache's runtime mode. diff --git a/crates/dockermap-core/src/schema_baseline.rs b/crates/dockermap-core/src/schema_baseline.rs index be5ea895..472c2fa0 100644 --- a/crates/dockermap-core/src/schema_baseline.rs +++ b/crates/dockermap-core/src/schema_baseline.rs @@ -162,16 +162,16 @@ mod tests { .expect("provider state property exists"); assert_eq!( states.get("minItems").and_then(|value| value.as_u64()), - Some(7) + Some(8) ); assert_eq!( states.get("maxItems").and_then(|value| value.as_u64()), - Some(7) + Some(8) ); } #[test] - fn runtime_evidence_schema_admits_the_closed_version_four_cron_shape() { + fn runtime_evidence_schema_admits_the_closed_version_five_tmux_shape() { let schema = DAEMON_SCHEMA_NAMES .iter() .zip(daemon_schema_documents()) @@ -184,7 +184,7 @@ mod tests { evidence .pointer("/properties/version/maximum") .and_then(|value| value.as_u64()), - Some(4), + Some(5), "generated schema must not reject the newest closed evidence version" ); assert!( diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 8ae43169..609e1610 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -394,7 +394,8 @@ fn docker_runtime_evidence( | RuntimeEvidenceKind::SystemdWants | RuntimeEvidenceKind::SystemdPartOf | RuntimeEvidenceKind::NpmPackageManifestDependency - | RuntimeEvidenceKind::CronScheduleDeclaration => { + | RuntimeEvidenceKind::CronScheduleDeclaration + | RuntimeEvidenceKind::TmuxSessionListing => { unreachable!("Docker evidence helper only accepts Docker evidence kinds") } }; @@ -414,7 +415,8 @@ fn docker_runtime_evidence( | RuntimeEvidenceKind::SystemdWants | RuntimeEvidenceKind::SystemdPartOf | RuntimeEvidenceKind::NpmPackageManifestDependency - | RuntimeEvidenceKind::CronScheduleDeclaration => { + | RuntimeEvidenceKind::CronScheduleDeclaration + | RuntimeEvidenceKind::TmuxSessionListing => { unreachable!("Docker evidence helper only accepts Docker evidence kinds") } }; diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index f7c52038..39fa7da6 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -14,6 +14,7 @@ use crate::{ SYSTEMD_EVIDENCE_KIND_MARKER, SYSTEMD_EVIDENCE_PART_OF, SYSTEMD_EVIDENCE_REQUIRES, SYSTEMD_EVIDENCE_WANTS, }, + providers::tmux::TMUX_EVIDENCE_SESSION_LISTING_MARKER, publication::{publish_docker_snapshot, redact_health_response, redact_runtime_map}, runtime_collection::{ collect_provider_slot_bounded, runtime_map_from_collection, slot_interval, @@ -310,6 +311,7 @@ impl SlotDataRevision { pub(crate) struct ProviderSlotFlights { network: Arc, host: Arc, + tmux: Arc, cron: Arc, systemd: Arc, python: Arc, @@ -322,6 +324,7 @@ impl Default for ProviderSlotFlights { Self { network: Arc::new(AtomicBool::new(false)), host: Arc::new(AtomicBool::new(false)), + tmux: Arc::new(AtomicBool::new(false)), cron: Arc::new(AtomicBool::new(false)), systemd: Arc::new(AtomicBool::new(false)), python: Arc::new(AtomicBool::new(false)), @@ -339,6 +342,7 @@ impl ProviderSlotFlights { match slot { ProviderSlot::NetworkInfrastructure => self.network.clone(), ProviderSlot::HostScoped => self.host.clone(), + ProviderSlot::Tmux => self.tmux.clone(), ProviderSlot::Cron => self.cron.clone(), ProviderSlot::Systemd => self.systemd.clone(), ProviderSlot::PythonProcesses => self.python.clone(), @@ -351,6 +355,7 @@ impl ProviderSlotFlights { [ &self.network, &self.host, + &self.tmux, &self.cron, &self.systemd, &self.python, @@ -888,6 +893,8 @@ fn runtime_map_for_snapshot( bind_npm_evidence(&mut edges, slot_state); } else if slot == ProviderSlot::Cron { bind_cron_evidence(&mut edges, slot_state); + } else if slot == ProviderSlot::Tmux { + bind_tmux_evidence(&mut edges, slot_state); } let (target_nodes, target_edges, target_diagnostics) = combined.parts_mut(); target_nodes.extend(nodes); @@ -902,22 +909,48 @@ fn runtime_map_for_snapshot( }); } } - // Cron's declaration target is canonical only when the independently - // retained HostScoped observation supplied `host_local`. Startup and host - // refresh ordering can otherwise leave a dangling relationship; omit it - // rather than publishing an unverifiable target or borrowing host state. - let has_canonical_host = combined.nodes().iter().any(|node| { - node.id == "host_local" - && node.provider == RuntimeProviderKind::Host - && node.kind == dockermap_core::RuntimeNodeKind::Host + // Cron and Tmux target the canonical local host only when the independent + // HostScoped observation supplied exactly one such node. Startup, reset, + // and collision ordering can otherwise leave a dangling or ambiguous + // relationship; omit it rather than borrowing host state. Tmux has the + // additional source gate because a duplicate generated session identity + // cannot safely attest which listing record produced the relationship. + let canonical_host_nodes = combined + .nodes() + .iter() + .filter(|node| node.id == "host_local") + .collect::>(); + let has_exactly_one_canonical_host = canonical_host_nodes.len() == 1 + && canonical_host_nodes[0].provider == RuntimeProviderKind::Host + && canonical_host_nodes[0].kind == dockermap_core::RuntimeNodeKind::Host; + let tmux_source_identities = combined.nodes().iter().fold( + BTreeMap::::new(), + |mut counts, node| { + let entry = counts.entry(node.id.clone()).or_insert((0, false)); + entry.0 += 1; + entry.1 |= node.provider == RuntimeProviderKind::Tmux + && node.kind == dockermap_core::RuntimeNodeKind::TmuxSession; + counts + }, + ); + combined.parts_mut().1.retain(|edge| { + if edge.target != "host_local" + || edge.relationship != dockermap_core::RuntimeRelationshipKind::RunsOn + { + return true; + } + if edge.source.starts_with("scheduled_job_") { + return has_exactly_one_canonical_host; + } + if edge.source.starts_with("tmux_session_") { + return *mode == RuntimeMode::Docker + && has_exactly_one_canonical_host + && tmux_source_identities + .get(&edge.source) + .is_some_and(|(count, is_tmux_session)| *count == 1 && *is_tmux_session); + } + true }); - if !has_canonical_host { - combined.parts_mut().1.retain(|edge| { - !(edge.source.starts_with("scheduled_job_") - && edge.target == "host_local" - && edge.relationship == dockermap_core::RuntimeRelationshipKind::RunsOn) - }); - } let mut runtime_map = runtime_map_from_collection(snapshot, &combined, docker_observation_revision, mode); runtime_map.provider_states = provider_states_for(slots); @@ -1003,6 +1036,85 @@ fn clear_cron_evidence(edges: &mut [RuntimeMapEdge]) { } } +/// Bind a fixed tmux session listing only to the independently scheduled +/// Tmux slot. The private marker is removed in every path. The final runtime +/// map separately verifies that the source session and local-host target are +/// both unique before it retains the relationship. +fn bind_tmux_evidence(edges: &mut [RuntimeMapEdge], state: &SlotRuntimeState) { + let disabled = retained_collection(&state.observation) + .as_ref() + .is_some_and(|collection| { + collection.states().iter().any(|candidate| { + candidate.slot == ProviderSlot::Tmux + && candidate.state == ProviderStateKind::Disabled + }) + }); + let freshness = match &state.observation { + RuntimeProviderState::Fresh(_) => RuntimeEvidenceFreshness::Fresh, + RuntimeProviderState::Collecting(Some(_)) | RuntimeProviderState::Degraded(Some(_)) => { + RuntimeEvidenceFreshness::Stale + } + RuntimeProviderState::TimedOut(Some(_)) => RuntimeEvidenceFreshness::TimedOut, + RuntimeProviderState::Unavailable + | RuntimeProviderState::Collecting(None) + | RuntimeProviderState::Degraded(None) + | RuntimeProviderState::TimedOut(None) => { + clear_tmux_evidence(edges); + return; + } + }; + let (Some(revision), Some(collected_at)) = ( + state + .freshness + .data_revision + .as_ref() + .map(SlotDataRevision::public), + state.freshness.last_success_ms, + ) else { + clear_tmux_evidence(edges); + return; + }; + if disabled { + clear_tmux_evidence(edges); + return; + } + for edge in edges.iter_mut() { + let marker = edge.metadata.remove(TMUX_EVIDENCE_SESSION_LISTING_MARKER); + if marker.as_deref() != Some("observed") + || edge.relationship != dockermap_core::RuntimeRelationshipKind::RunsOn + || !edge.source.starts_with("tmux_session_") + || edge.target != "host_local" + || edge.source == edge.target + { + edge.evidence_refs.clear(); + continue; + } + edge.evidence_refs = vec![RuntimeEvidenceRef { + version: 5, + id: format!( + "tmux_evidence_session_listing_{}", + collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target)) + ), + provider: RuntimeEvidenceProvider::Tmux, + kind: RuntimeEvidenceKind::TmuxSessionListing, + assertion_kind: RuntimeEvidenceAssertionKind::Observed, + summary: "tmux listed a local session".into(), + subject_ref: edge.source.clone(), + collected_at, + provider_revision: revision.clone(), + provider_slot: Some(ProviderSlot::Tmux), + freshness, + }]; + } +} + +fn clear_tmux_evidence(edges: &mut [RuntimeMapEdge]) { + for edge in edges.iter_mut() { + edge.metadata.remove(TMUX_EVIDENCE_SESSION_LISTING_MARKER); + edge.evidence_refs.clear(); + } +} + /// Convert the private NPM manifest marker into public evidence only after /// this exact ProjectNpm slot has a sanitized opaque revision and successful /// collection timestamp. Retention is explicit: stale/timed-out observations @@ -1485,6 +1597,36 @@ mod scheduler_tests { collection } + fn marked_tmux_session() -> ProviderCollection { + let mut collection = ProviderCollection::default(); + collection.set_state(ProviderSlot::Tmux, ProviderStateKind::Fresh); + collection.nodes_mut().push(RuntimeMapNode { + id: "tmux_session_opaque".into(), + provider: RuntimeProviderKind::Tmux, + kind: RuntimeNodeKind::TmuxSession, + label: "DOCKERMAP_TEST_TMUX_SESSION_NAME_SECRET".into(), + status: Some("attached".into()), + layer: Some(RuntimeNodeLayer::Session), + metadata: BTreeMap::from([( + "sessionId".into(), + "DOCKERMAP_TEST_TMUX_SESSION_METADATA_SECRET".into(), + )]), + service: None, + package: None, + }); + collection.parts_mut().1.push(RuntimeMapEdge { + source: "tmux_session_opaque".into(), + target: "host_local".into(), + relationship: dockermap_core::RuntimeRelationshipKind::RunsOn, + metadata: BTreeMap::from([( + TMUX_EVIDENCE_SESSION_LISTING_MARKER.into(), + "observed".into(), + )]), + evidence_refs: Vec::new(), + }); + collection + } + fn host_collection() -> ProviderCollection { let mut collection = ProviderCollection::default(); collection.set_state(ProviderSlot::HostScoped, ProviderStateKind::Fresh); @@ -1566,6 +1708,241 @@ mod scheduler_tests { } } + #[test] + fn tmux_session_listing_evidence_is_slot_bound_target_gated_and_redacted() { + for (observation, expected) in [ + ( + RuntimeProviderState::Fresh(marked_tmux_session()), + RuntimeEvidenceFreshness::Fresh, + ), + ( + RuntimeProviderState::Degraded(Some(marked_tmux_session())), + RuntimeEvidenceFreshness::Stale, + ), + ( + RuntimeProviderState::TimedOut(Some(marked_tmux_session())), + RuntimeEvidenceFreshness::TimedOut, + ), + ] { + let mut provider_slots = slots(); + let tmux = provider_slots.get_mut(&ProviderSlot::Tmux).unwrap(); + tmux.observation = observation; + tmux.freshness.data_revision = Some(SlotDataRevision::first()); + tmux.freshness.last_success_ms = Some(42); + // A completed Tmux pass alone must not publish a dangling edge. + let no_host = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &provider_slots, + "docker-observation", + ); + assert!(no_host + .edges + .iter() + .all(|edge| edge.source != "tmux_session_opaque")); + + let host = provider_slots.get_mut(&ProviderSlot::HostScoped).unwrap(); + host.observation = RuntimeProviderState::Fresh(host_collection()); + host.freshness.data_revision = Some(SlotDataRevision::first()); + host.freshness.last_success_ms = Some(42); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &provider_slots, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "tmux_session_opaque") + .expect("canonical host admits tmux edge"); + assert!(edge.metadata.is_empty()); + assert_eq!(edge.evidence_refs.len(), 1); + let evidence = &edge.evidence_refs[0]; + assert_eq!(evidence.version, 5); + assert_eq!(evidence.provider, RuntimeEvidenceProvider::Tmux); + assert_eq!(evidence.kind, RuntimeEvidenceKind::TmuxSessionListing); + assert_eq!( + evidence.assertion_kind, + RuntimeEvidenceAssertionKind::Observed + ); + assert_eq!(evidence.summary, "tmux listed a local session"); + assert_eq!(evidence.provider_slot, Some(ProviderSlot::Tmux)); + assert_eq!(evidence.collected_at, 42); + assert_eq!(evidence.freshness, expected); + assert!(!evidence.provider_revision.is_empty()); + let serialized = serde_json::to_string(evidence).expect("evidence serializes"); + for sentinel in [ + "DOCKERMAP_TEST_TMUX_SESSION_NAME_SECRET", + "DOCKERMAP_TEST_TMUX_SESSION_METADATA_SECRET", + ] { + assert!( + !serialized.contains(sentinel), + "session data must never enter tmux evidence" + ); + } + } + } + + #[test] + fn tmux_evidence_fails_closed_for_ambiguous_source_missing_lifecycle_reset_and_mock() { + let mut slots_without_revision = slots(); + let tmux = slots_without_revision.get_mut(&ProviderSlot::Tmux).unwrap(); + tmux.observation = RuntimeProviderState::Fresh(marked_tmux_session()); + slots_without_revision + .get_mut(&ProviderSlot::HostScoped) + .unwrap() + .observation = RuntimeProviderState::Fresh(host_collection()); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &slots_without_revision, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "tmux_session_opaque") + .expect("identity is visible without an attesting lifecycle"); + assert!(edge.evidence_refs.is_empty() && edge.metadata.is_empty()); + + let mut disabled = slots(); + let mut collection = marked_tmux_session(); + collection.set_state(ProviderSlot::Tmux, ProviderStateKind::Disabled); + let tmux = disabled.get_mut(&ProviderSlot::Tmux).unwrap(); + tmux.observation = RuntimeProviderState::Fresh(collection); + tmux.freshness.data_revision = Some(SlotDataRevision::first()); + tmux.freshness.last_success_ms = Some(42); + disabled + .get_mut(&ProviderSlot::HostScoped) + .unwrap() + .observation = RuntimeProviderState::Fresh(host_collection()); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &disabled, + "docker-observation", + ); + assert!(map + .edges + .iter() + .find(|edge| edge.source == "tmux_session_opaque") + .expect("disabled tmux identity remains visible without evidence") + .evidence_refs + .is_empty()); + + let mut ambiguous = slots(); + let mut collection = marked_tmux_session(); + collection.nodes_mut().push(RuntimeMapNode { + id: "tmux_session_opaque".into(), + provider: RuntimeProviderKind::Process, + kind: RuntimeNodeKind::Process, + label: "duplicate".into(), + status: None, + layer: Some(RuntimeNodeLayer::Session), + metadata: BTreeMap::new(), + service: None, + package: None, + }); + let tmux = ambiguous.get_mut(&ProviderSlot::Tmux).unwrap(); + tmux.observation = RuntimeProviderState::Fresh(collection); + tmux.freshness.data_revision = Some(SlotDataRevision::first()); + tmux.freshness.last_success_ms = Some(42); + ambiguous + .get_mut(&ProviderSlot::HostScoped) + .unwrap() + .observation = RuntimeProviderState::Fresh(host_collection()); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &ambiguous, + "docker-observation", + ); + assert!(map + .edges + .iter() + .all(|edge| edge.source != "tmux_session_opaque")); + + // The canonical host target must be unique and have the closed Host shape. + // A colliding or misclassified `host_local` must not make the session edge + // routable or attestable. + for host in [ + { + let mut host = host_collection(); + host.nodes_mut().push(RuntimeMapNode { + id: "host_local".into(), + provider: RuntimeProviderKind::Host, + kind: RuntimeNodeKind::Host, + label: "duplicate host".into(), + status: Some("online".into()), + layer: Some(RuntimeNodeLayer::Host), + metadata: BTreeMap::new(), + service: None, + package: None, + }); + host + }, + { + let mut host = host_collection(); + let node = host.nodes_mut().first_mut().expect("host node"); + node.provider = RuntimeProviderKind::Tmux; + node.kind = RuntimeNodeKind::TmuxSession; + host + }, + ] { + let mut invalid_host = slots(); + let tmux = invalid_host.get_mut(&ProviderSlot::Tmux).unwrap(); + tmux.observation = RuntimeProviderState::Fresh(marked_tmux_session()); + tmux.freshness.data_revision = Some(SlotDataRevision::first()); + tmux.freshness.last_success_ms = Some(42); + let host_slot = invalid_host.get_mut(&ProviderSlot::HostScoped).unwrap(); + host_slot.observation = RuntimeProviderState::Fresh(host); + host_slot.freshness.data_revision = Some(SlotDataRevision::first()); + host_slot.freshness.last_success_ms = Some(42); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &invalid_host, + "docker-observation", + ); + assert!(map + .edges + .iter() + .all(|edge| edge.source != "tmux_session_opaque")); + } + + let mut reset = source_reset_provider_slots(); + reset.get_mut(&ProviderSlot::Tmux).unwrap().observation = RuntimeProviderState::Unavailable; + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &reset, + "docker-observation", + ); + assert!(map + .edges + .iter() + .all(|edge| edge.source != "tmux_session_opaque")); + + let mut mock = slots(); + let tmux = mock.get_mut(&ProviderSlot::Tmux).unwrap(); + tmux.observation = RuntimeProviderState::Fresh(marked_tmux_session()); + tmux.freshness.data_revision = Some(SlotDataRevision::first()); + tmux.freshness.last_success_ms = Some(42); + mock.get_mut(&ProviderSlot::HostScoped).unwrap().observation = + RuntimeProviderState::Fresh(host_collection()); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Mock, + &mock, + "docker-observation", + ); + assert!(map + .edges + .iter() + .all(|edge| edge.source != "tmux_session_opaque")); + } + #[test] fn cron_marker_never_publishes_without_lifecycle_or_after_reset() { let mut provider_slots = slots(); @@ -2011,6 +2388,7 @@ mod scheduler_tests { slot_interval(ProviderSlot::HostScoped), Duration::from_secs(15) ); + assert_eq!(slot_interval(ProviderSlot::Tmux), Duration::from_secs(15)); assert_eq!(slot_interval(ProviderSlot::Cron), Duration::from_secs(15)); assert_eq!( slot_interval(ProviderSlot::Systemd), @@ -2060,6 +2438,7 @@ mod scheduler_tests { let invocations = |slot| 1 + window.as_secs() / slot_interval(slot).as_secs(); assert_eq!(invocations(ProviderSlot::NetworkInfrastructure), 7); assert_eq!(invocations(ProviderSlot::HostScoped), 5); + assert_eq!(invocations(ProviderSlot::Tmux), 5); assert_eq!(invocations(ProviderSlot::Cron), 5); assert_eq!(invocations(ProviderSlot::Systemd), 5); assert_eq!(invocations(ProviderSlot::PythonProcesses), 7); @@ -2127,12 +2506,13 @@ mod scheduler_tests { assert_eq!(publications, 31); assert_eq!(starts[&ProviderSlot::NetworkInfrastructure], 7); assert_eq!(starts[&ProviderSlot::HostScoped], 5); + assert_eq!(starts[&ProviderSlot::Tmux], 5); assert_eq!(starts[&ProviderSlot::Cron], 5); assert_eq!(starts[&ProviderSlot::Systemd], 5); assert_eq!(starts[&ProviderSlot::PythonProcesses], 7); assert_eq!(starts[&ProviderSlot::NativeProcesses], 7); assert_eq!(starts[&ProviderSlot::ProjectNpm], 2); - assert_eq!(starts.values().sum::(), 38); + assert_eq!(starts.values().sum::(), 43); assert!(maximum_live_workers <= MAX_CONCURRENT_PROVIDER_SLOTS); // Before Systemd became independently schedulable, one aggregate // host-scoped pass covered it alongside the four other fixed bundles. @@ -2165,7 +2545,7 @@ mod scheduler_tests { .block_on(run_real_collector_churn_trace(&profile)); match profile.as_str() { "full-host" => { - assert_eq!(starts.values().sum::(), 38); + assert_eq!(starts.values().sum::(), 43); // The old whole-runtime pass had five aggregate bundles; // systemd was part of host-scoped collection, not a sixth // independently scheduled unit. @@ -2178,8 +2558,9 @@ mod scheduler_tests { ); } "restricted" => { - assert_eq!(starts.values().sum::(), 14); + assert_eq!(starts.values().sum::(), 15); assert_eq!(starts[&ProviderSlot::HostScoped], 1); + assert_eq!(starts[&ProviderSlot::Tmux], 1); assert_eq!(starts[&ProviderSlot::Cron], 1); assert_eq!(starts[&ProviderSlot::Systemd], 1); assert_eq!(starts[&ProviderSlot::PythonProcesses], 1); @@ -2389,6 +2770,7 @@ mod scheduler_tests { if profile == "restricted" { for slot in [ ProviderSlot::HostScoped, + ProviderSlot::Tmux, ProviderSlot::Cron, ProviderSlot::PythonProcesses, ProviderSlot::NativeProcesses, @@ -2402,6 +2784,7 @@ mod scheduler_tests { } else { assert_eq!(starts[&ProviderSlot::NetworkInfrastructure], 7); assert_eq!(starts[&ProviderSlot::HostScoped], 5); + assert_eq!(starts[&ProviderSlot::Tmux], 5); assert_eq!(starts[&ProviderSlot::Cron], 5); assert_eq!(starts[&ProviderSlot::Systemd], 5); assert_eq!(starts[&ProviderSlot::PythonProcesses], 7); diff --git a/crates/dockermap-daemon/src/providers/tmux.rs b/crates/dockermap-daemon/src/providers/tmux.rs index dc43451f..f239b588 100644 --- a/crates/dockermap-daemon/src/providers/tmux.rs +++ b/crates/dockermap-daemon/src/providers/tmux.rs @@ -7,14 +7,20 @@ use crate::process_runner::{run_command_with_timeout, PROVIDER_COMMAND_TIMEOUT}; use crate::{push_provider_diagnostic, safe_runtime_id_component}; use dockermap_core::{ - service_entity_kind_name, DiagnosticSeverity, RuntimeMapDiagnostic, RuntimeMapNode, - RuntimeNodeKind, RuntimeNodeLayer, RuntimeProviderKind, ServiceEntityKind, + service_entity_kind_name, DiagnosticSeverity, RuntimeMapDiagnostic, RuntimeMapEdge, + RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, RuntimeProviderKind, + RuntimeRelationshipKind, ServiceEntityKind, }; use std::{collections::BTreeMap, process::Command}; +/// Private handoff marker. Cache refresh removes it on every path and creates +/// public evidence only after this exact Tmux slot owns a successful revision. +pub(crate) const TMUX_EVIDENCE_SESSION_LISTING_MARKER: &str = "__dockermapTmuxSessionListing"; + /// Collect tmux sessions using its documented, fixed read-only listing form. pub(crate) fn collect_tmux_sessions( nodes: &mut Vec, + edges: &mut Vec, diagnostics: &mut Vec, ) { let output = match run_command_with_timeout( @@ -45,9 +51,30 @@ pub(crate) fn collect_tmux_sessions( return; } - nodes.extend(tmux_session_nodes_from_output(&String::from_utf8_lossy( - &output.stdout, - ))); + let sessions = tmux_session_nodes_from_output(&String::from_utf8_lossy(&output.stdout)); + edges.extend(tmux_session_listing_edges(&sessions)); + nodes.extend(sessions); +} + +fn tmux_session_listing_edges(sessions: &[RuntimeMapNode]) -> Vec { + sessions + .iter() + .filter(|session| { + session.provider == RuntimeProviderKind::Tmux + && session.kind == RuntimeNodeKind::TmuxSession + && session.id.starts_with("tmux_session_") + }) + .map(|session| RuntimeMapEdge { + source: session.id.clone(), + target: "host_local".into(), + relationship: RuntimeRelationshipKind::RunsOn, + metadata: BTreeMap::from([( + TMUX_EVIDENCE_SESSION_LISTING_MARKER.into(), + "observed".into(), + )]), + evidence_refs: Vec::new(), + }) + .collect() } fn tmux_session_nodes_from_output(value: &str) -> Vec { @@ -91,7 +118,10 @@ fn tmux_session_nodes_from_output(value: &str) -> Vec { #[cfg(test)] mod tests { - use super::tmux_session_nodes_from_output; + use super::{ + tmux_session_listing_edges, tmux_session_nodes_from_output, + TMUX_EVIDENCE_SESSION_LISTING_MARKER, + }; use crate::{redact_runtime_node, REDACTED_VALUE}; use dockermap_core::RuntimeNodeLayer; @@ -139,4 +169,22 @@ mod tests { assert_eq!(nodes[2].status.as_deref(), Some("attached")); assert_eq!(nodes[0].layer, Some(RuntimeNodeLayer::Session)); } + + #[test] + fn session_listing_fixture_produces_only_private_observation_markers() { + let nodes = tmux_session_nodes_from_output(include_str!( + "../../../../tests/fixtures/providers/parser/tmux-sessions.txt" + )); + let edges = tmux_session_listing_edges(&nodes); + + assert_eq!(edges.len(), nodes.len()); + assert!(edges.iter().all(|edge| { + edge.source.starts_with("tmux_session_") + && edge.target == "host_local" + && edge.relationship == dockermap_core::RuntimeRelationshipKind::RunsOn + && edge.metadata.get(TMUX_EVIDENCE_SESSION_LISTING_MARKER) + == Some(&"observed".into()) + && edge.evidence_refs.is_empty() + })); + } } diff --git a/crates/dockermap-daemon/src/runtime_collection.rs b/crates/dockermap-daemon/src/runtime_collection.rs index 534e99e0..5a9c96e9 100644 --- a/crates/dockermap-daemon/src/runtime_collection.rs +++ b/crates/dockermap-daemon/src/runtime_collection.rs @@ -48,6 +48,7 @@ pub(crate) type StaticProviderSlot = ProviderSlot; pub(crate) const STATIC_PROVIDER_SLOTS: &[StaticProviderSlot] = &[ StaticProviderSlot::NetworkInfrastructure, StaticProviderSlot::HostScoped, + StaticProviderSlot::Tmux, StaticProviderSlot::Cron, StaticProviderSlot::Systemd, StaticProviderSlot::PythonProcesses, @@ -61,6 +62,7 @@ pub(crate) fn slot_interval(slot: StaticProviderSlot) -> Duration { match slot { StaticProviderSlot::NetworkInfrastructure => Duration::from_secs(10), StaticProviderSlot::HostScoped => Duration::from_secs(15), + StaticProviderSlot::Tmux => Duration::from_secs(15), StaticProviderSlot::Cron => Duration::from_secs(15), StaticProviderSlot::Systemd => Duration::from_secs(15), StaticProviderSlot::PythonProcesses => Duration::from_secs(10), @@ -182,6 +184,17 @@ fn collect_provider_slot( }, ); } + StaticProviderSlot::Tmux => { + collect_tmux_runtime_provider(pid_namespace, &mut collection); + collection.set_state( + slot, + if pid_namespace.is_restricted() { + ProviderStateKind::Disabled + } else { + ProviderStateKind::Fresh + }, + ); + } StaticProviderSlot::Cron => { collect_cron_runtime_provider(pid_namespace, &mut collection); collection.set_state( @@ -270,7 +283,7 @@ fn collect_host_node(project_root: Option<&StdPath>, nodes: &mut Vec