diff --git a/crates/fractal-tui/README.md b/crates/fractal-tui/README.md index eae90a615a1..0bf3cd4f73d 100644 --- a/crates/fractal-tui/README.md +++ b/crates/fractal-tui/README.md @@ -318,3 +318,86 @@ budget left over the period. The rows come from `slo.list` and the chart from `s read from this member's daemon: the list is read when Usage is shown and again when a limits reading changes, never on a timer, and the chart once for each selected row and period. A member without the routes shows that its daemon needs an update. + +## Isolated client control + +The optional `--client-control-profile PATH` consumes the existing `commands` +collection, not a terminal-input protocol. It is restricted to +`--instance client-control-fixture`, `person/client-control-fixture`, and an +explicit `--endpoint` equal to that instance's configured **client** Unix socket. +The privileged socket, normal paired profile, mixed-member profiles and message +authority are refused. Normal TUI startup does not subscribe to commands. + +### Ready-to-enroll packet + +Source preparation does not authorize setup, pairing, enrollment or a granted +journey. Before bootstrap, bind the approved fixture setup receipt, exact release +commit, instance client socket and dedicated profile path into the enrollment +consent packet. The packet names kind `tui`, instance `client-control-fixture`, +the selected host, and the native consent ask. No runtime device, session, grant, +run or message IDs are precomputed. + +Read-only observer identity is available without opening the interface: +`fractal tui client-control identity --profile DEDICATED_PROFILE --json`. +It refuses the normal profile and requires exactly one fixture command pairing. +The JSON contains `public_key` (canonical `p256:`), `fingerprint` (lowercase +SHA-256 of the 65-byte uncompressed SEC1 point), `device_id`, `session_actor`, +and `profile`. The latter preserves the full on-disk JSON except that +`signing_key.pkcs8` and `session.credential` become +`{"redacted":"[REDACTED]","sha256":"..."}`; each digest covers the exact UTF-8 +bytes of the original string value. The helper never saves or changes the +profile, pairs a device, enrolls it, or contacts the daemon. + +After the separate fixture setup and TUI enrollment approvals, use the existing +`pairing.begin` operation with scopes exactly `read.projections` and +`control.commands`. Complete its manually supplied code and independently pinned +person-root fingerprint with `devices complete unix:/ABSOLUTE/CLIENT/SOCKET +PAIRING_ID --algorithm p256 --profile DEDICATED_PROFILE --fingerprint SHA256`. +The client generates and privately stores its P-256 key; the local enrollment +operation binds that existing device, pairing session and public key. It does +not create keys, pair a normal device, or grant `control.messages`. + +Launch `fractal --instance client-control-fixture --endpoint +unix:/ABSOLUTE/CLIENT/SOCKET tui --client-control-profile DEDICATED_PROFILE`. +The status line names the active run and `ctrl+alt+x` revokes enrollment. +The key stops command execution locally before requesting revocation; a failed +network request remains visibly pending and does not resume control. Re-enrollment +requires fresh consent and a new launch. Grant expiry, ended/revoked lifecycle +events and a disconnected control subscription stop execution/signing. Only +`phone.inspect` and `phone.navigate` to `NowRoot` and a native ask's `Attention` +screen are supported. Conversation and all answer/mutation commands produce no result. +The existing commands feed's lifecycle resource drives the indicator and +termination only. Commands must match an active non-terminal lifecycle grant; +screens, item binding, expiry and command budget are checked against that +command's `control_grant`. Duplicate or out-of-order starts cannot replace +newer context, and ended or revoked grant IDs can never restart. +Read receipts are disabled for the entire isolated control session, not just +while a grant is active. +The shared journey opens an open native ask (`person-step`, presentation kind +`request`) whose question carries the seeded content. The ask title and question +body are independently captured on its Attention card. Never press answer, +Yes/No, Nothing to do, close, Mark read or send controls during this journey; +navigation and inspection never invoke them. +The approved title and question are each printable ASCII, at most 60 characters, +one paragraph, with no Markdown syntax, JSON, leading/trailing whitespace or +repeated whitespace. The body digest is lowercase hex SHA-256 of the exact +question bytes. A grant's optional `item_id` binds Attention navigation and +signed inspection to that ask; another ask produces no result. +Lifecycle and command item bindings must agree, including absent or null values. +NowRoot may be unbound; Attention always requires a nonempty bound item. + +### Independent terminal observation + +In a separate observer process, capture the actual terminal pane with +`tmux capture-pane -p -J -t PANE`, retaining the unmodified output as evidence. +Make the pane large enough to show the full seeded title/body. Run +`python3 crates/fractal-tui/tests/client_control_capture.py CAPTURE --title +'client-control fixture 0001' --body-sha256 APPROVED_SEED_DIGEST +--body-rectangle FIRST_ROW LAST_ROW FIRST_COLUMN LAST_COLUMN`. +Coordinates are one-based and inclusive; select only the rendered body, excluding +borders. The helper hashes those terminal cells and checks the separately known +title and approved digest, never the client's signed report. It rejects missing, +truncated or different content. Preserve the capture digest, source commit, +instance identity and rectangle alongside each independently verified receipt. +This content check does not replace fixture payload/manifest/canary checks or +before/after read-state and normal-pairing verification. diff --git a/crates/fractal-tui/src/client_control.rs b/crates/fractal-tui/src/client_control.rs new file mode 100644 index 00000000000..a6a34f8f73a --- /dev/null +++ b/crates/fractal-tui/src/client_control.rs @@ -0,0 +1,504 @@ +//! The TUI adapter for the existing session-scoped device-commands collection. +use anyhow::{Result, ensure}; +use chrono::{DateTime, Utc}; +use fractal_client::{Client, device::Device}; +use fractal_schema::client_control::{ClientResultBinding, ClientVisibleState}; +use serde_json::Value; +use std::{collections::{BTreeMap, BTreeSet}, sync::mpsc}; + +pub(crate) struct Control { + pub device: Device, + pub grant: Option, + pub stopped: bool, + used: BTreeSet, + sequences: BTreeMap, + terminal: BTreeSet, +} + +pub(crate) fn validate_device(device: &Device, client_socket: &std::path::Path) -> Result<()> { + ensure!(device.session.person_id == "person/client-control-fixture" + && device.endpoint == format!("unix:{}", client_socket.display()) + && device.session.scopes.iter().all(|scope| matches!(scope.as_str(), "read.projections" | "control.commands")) + && device.session.scopes.iter().any(|scope| scope == "read.projections") + && device.session.scopes.iter().any(|scope| scope == "control.commands") + && device.signing_key.as_ref().is_some_and(|key| key.algorithm == fractal_client::device::KeyAlgorithm::P256), + "Fixture pairing must have its own P-256 key and read plus control.commands only"); + Ok(()) +} + +pub(crate) fn load_fixture_device(path: &std::path::Path, normal: &std::path::Path) -> Result { + use anyhow::Context; + ensure!(path != normal && path.canonicalize()? != normal.canonicalize().unwrap_or_else(|_| normal.to_owned()), + "Client control must not use the normal paired profile"); + let profile = fractal_client::device::Profile::load(path)? + .context("Pair the isolated TUI profile first")?; + ensure!(profile.devices.len() == 1, "Fixture profile must contain exactly one pairing"); + let device = profile.devices.into_iter().next().unwrap(); + let socket = device.endpoint.strip_prefix("unix:").context("Fixture pairing requires a Unix endpoint")?; + validate_device(&device, std::path::Path::new(socket))?; + Ok(device) +} + +/// Read-only observer output. Never serialize the unredacted device or profile. +pub(crate) fn identity(path: &std::path::Path, normal: &std::path::Path) -> Result { + use anyhow::Context; + use base64::Engine; + use sha2::{Digest, Sha256}; + let device = load_fixture_device(path, normal)?; + let public_key = device.signing_key.as_ref().context("Fixture key missing")?.public_key()?; + let point = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode( + public_key.strip_prefix("p256:").context("Fixture key must be P-256")?)?; + ensure!(point.len() == 65 && point[0] == 4, "Fixture key must be uncompressed P-256"); + let mut snapshot: Value = serde_json::from_slice(&std::fs::read(path)?)?; + let devices = snapshot["devices"].as_array_mut().context("Invalid fixture profile")?; + ensure!(devices.len() == 1, "Fixture profile must contain exactly one pairing"); + for device in devices { + for (section, field) in [("signing_key", "pkcs8"), ("session", "credential")] { + let secret = device[section][field].as_str().context("Missing fixture secret")?; + let digest = format!("{:x}", Sha256::digest(secret.as_bytes())); + device[section][field] = serde_json::json!({"redacted":"[REDACTED]","sha256":digest}); + } + } + Ok(serde_json::json!({ + "public_key":public_key, + "fingerprint":format!("{:x}", Sha256::digest(&point)), + "device_id":device.session.device_id, + "session_actor":device.session.session_actor, + "profile":snapshot, + })) +} + +fn fresh(value: &Value) -> bool { + value.as_str().is_some_and(fresh_timestamp) +} + +fn fresh_timestamp(stamp: &str) -> bool { + DateTime::parse_from_rfc3339(stamp).ok().is_some_and(|expiry| expiry > Utc::now()) +} + +impl Control { + pub fn new(device: Device) -> Self { + Self { device, grant: None, stopped: false, used: BTreeSet::new(), + sequences: BTreeMap::new(), terminal: BTreeSet::new() } + } + + pub fn update_lifecycle(&mut self, frame: Value) { + use fractal_schema::client_control::ClientControlGrantLifecycleState as State; + let Ok(frame) = serde_json::from_value::(frame) else { return; }; + if self.stopped || frame.device != self.device.session.device_id + || frame.session != self.device.session.session_actor + || frame.sequence == 0 || frame.grant_id.is_empty() || frame.run_id.is_empty() { return; } + if self.grant.as_ref().is_some_and(|active| active.grant_id == frame.grant_id + && (active.run_id != frame.run_id || active.item_id != frame.item_id)) { return; } + match frame.state { + State::Ended | State::Revoked => { + self.terminal.insert(frame.grant_id.clone()); + if self.grant.as_ref().is_some_and(|active| active.grant_id == frame.grant_id) { + self.grant = None; + } + } + State::Started if !self.terminal.contains(&frame.grant_id) + && self.sequences.get(&frame.grant_id).is_none_or(|last| frame.sequence > *last) => { + self.sequences.insert(frame.grant_id.clone(), frame.sequence); + if self.grant.as_ref().is_none_or(|old| old.grant_id != frame.grant_id) { + self.used.clear(); + } + self.grant = Some(frame); + } + _ => {} + } + } + + pub fn ingest(&mut self, frame: &Value) -> Vec { + if frame["id"] != "commands" { return Vec::new(); } + let items = if frame["kind"] == "snapshot" { + frame["items"].as_array() + } else if frame["kind"] == "changes" { + if self.grant.as_ref().is_some_and(|grant| frame["removes"].as_array() + .is_some_and(|removed| removed.iter().any(|id| id.as_str() == Some(grant.header.id.as_str())))) { + if let Some(grant) = &self.grant { + self.terminal.insert(grant.grant_id.clone()); + } + self.grant = None; + } + frame["upserts"].as_array() + } else { return Vec::new(); }; + let Some(items) = items else { return Vec::new(); }; + if frame["kind"] == "snapshot" && self.grant.as_ref().is_some_and(|active| + !items.iter().any(|item| item["id"].as_str() == Some(active.header.id.as_str()))) { + self.grant = None; + } + for item in items.iter().filter(|item| item["kind"] == "client-control-grant-lifecycle") { + self.update_lifecycle(item.clone()); + } + items.iter().filter(|item| item["kind"] == "device-command").take(8).cloned().collect() + } + + pub fn active(&self) -> Option<&fractal_client::ClientControlGrantLifecycle> { + self.grant.as_ref().filter(|_| !self.stopped + && fresh_timestamp(&self.device.session.expires_at)) + } + + /// No navigation, signature or result is constructed on any refusal path. + pub fn allows(&self, command: &Value, current_screen: &str) -> bool { + let Some(lifecycle) = self.active() else { return false; }; + let grant = &command["control_grant"]; + if grant.get("item_id").is_some_and(|id| !id.is_null() && !id.is_string()) { + return false; + } + let item_id = grant["item_id"].as_str(); + if grant["grant_id"] != lifecycle.grant_id || grant["run_id"] != lifecycle.run_id + || grant["device"] != self.device.session.device_id + || grant["session"] != self.device.session.session_actor + || !fresh(&grant["expires_at"]) + || item_id != lifecycle.item_id.as_deref() + { return false; } + let Some(id) = command["id"].as_str() else { return false; }; + let Some(verb) = command["command_kind"].as_str() else { return false; }; + let screen = if verb == "phone.inspect" { current_screen } + else if verb == "phone.navigate" { command["args"]["screen"].as_str().unwrap_or("") } + else { return false; }; + matches!(screen, "NowRoot" | "Attention") + && command["device_id"] == self.device.session.device_id + && command["session_actor"] == self.device.session.session_actor + && command["grant_id"] == grant["grant_id"] + && command["run_id"] == grant["run_id"] + && command["issued_by"] == grant["actor_agent"] + && matches!(command["state"].as_str(), Some("pending" | "delivered")) + && fresh(&command["expires_at"]) + && grant["verbs"].as_array().is_some_and(|verbs| verbs.iter().any(|v| v == verb)) + && grant["screens"].as_array().is_some_and(|screens| screens.iter().any(|s| s == screen)) + && (screen != "Attention" || item_id.is_some_and(|item| !item.is_empty() + && (verb == "phone.inspect" || command["args"]["params"]["id"].as_str() == Some(item)))) + && grant["max_commands"].as_u64().is_some_and(|max| self.used.len() < max as usize) + && !self.used.contains(id) + } + + pub fn result(&mut self, command: &Value, visible_state: ClientVisibleState) -> Result> { + if !self.allows(command, &visible_state.screen) { return Ok(None); } + if command["command_kind"] == "phone.navigate" + && (command["args"]["screen"] != visible_state.screen + || (visible_state.screen == "Attention" + && visible_state.route != format!("home:{}", command["args"]["params"]["id"].as_str().unwrap_or("")))) + { + return Ok(None); + } + let grant = &command["control_grant"]; + if visible_state.screen == "Attention" && visible_state.opened_item.as_ref() + .is_none_or(|item| grant["item_id"].as_str() != Some(item.id.as_str())) { + return Ok(None); + } + let binding = ClientResultBinding { + purpose: "client.device-result.v1".into(), + command_kind: command["command_kind"].as_str().unwrap().into(), + device: self.device.session.device_id.clone(), session: self.device.session.session_actor.clone(), + grant_id: grant["grant_id"].as_str().unwrap().into(), run_id: grant["run_id"].as_str().unwrap().into(), + command_id: command["id"].as_str().unwrap().into(), outcome: "completed".into(), + error_code: None, visible_state, + }; + let signature = self.device.sign_client_result(&binding)?; + let result = fractal_client::DeviceResult { + command_id: binding.command_id.clone(), + outcome: fractal_client::DeviceCommandOutcome::Completed, + result: Some(serde_json::to_value(&binding.visible_state)?), + error_code: None, + binding: Some(binding), + signature: Some(signature), + }; + self.used.insert(command["id"].as_str().unwrap().into()); + Ok(Some(result)) + } + + pub fn stop(&mut self) { self.stopped = true; self.grant = None; } +} + +/// One foreground subscription on the existing collections socket; it never opens a privileged socket. +pub(crate) async fn subscribe(client: Client, tx: mpsc::Sender) -> Result<()> { + let capabilities = client.capabilities().await?; + ensure!(capabilities.value.grants("phone-commands", 1), "Device commands unavailable"); + let mut stream = client.collection_stream().await?; + stream.subscribe("commands", "commands", 8, None, None).await?; + while let Some(frame) = stream.next().await? { + if tx.send(frame).is_err() { break; } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use super::*; + use fractal_client::device::{KeyAlgorithm, SigningKey}; + + #[test] + fn identity_is_read_only_redacted_and_detects_secret_changes() { + use fractal_client::device::Profile; + use sha2::{Digest, Sha256}; + use base64::Engine; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("fixture.json"); + let normal = dir.path().join("normal.json"); + let mut device = fixture().0.device; + device.signing_key = Some(SigningKey::generate(KeyAlgorithm::P256).unwrap()); + let mut profile = Profile { devices: vec![device] }; + profile.save(&path).unwrap(); + let before = std::fs::read(&path).unwrap(); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + let raw: Value = serde_json::from_slice(&before).unwrap(); + let output = identity(&path, &normal).unwrap(); + assert_eq!(output.as_object().unwrap().len(), 5); + let point = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(output["public_key"].as_str().unwrap().strip_prefix("p256:").unwrap()).unwrap(); + assert_eq!(point.len(), 65); + assert_eq!(output["fingerprint"], format!("{:x}", Sha256::digest(&point))); + assert_eq!(output["device_id"], raw["devices"][0]["session"]["device_id"]); + assert_eq!(output["session_actor"], raw["devices"][0]["session"]["session_actor"]); + let printed = serde_json::to_string(&output).unwrap(); + for (section, field) in [("signing_key", "pkcs8"), ("session", "credential")] { + let secret = raw["devices"][0][section][field].as_str().unwrap(); + assert!(!printed.contains(secret)); + assert_eq!(output["profile"]["devices"][0][section][field]["redacted"], "[REDACTED]"); + assert_eq!(output["profile"]["devices"][0][section][field]["sha256"], + format!("{:x}", Sha256::digest(secret.as_bytes()))); + } + let mut restored = output["profile"].clone(); + restored["devices"][0]["signing_key"]["pkcs8"] = raw["devices"][0]["signing_key"]["pkcs8"].clone(); + restored["devices"][0]["session"]["credential"] = raw["devices"][0]["session"]["credential"].clone(); + assert_eq!(restored, raw); + assert_eq!(Sha256::digest(std::fs::read(&path).unwrap()), Sha256::digest(&before)); + assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), modified); + assert!(identity(&path, &path).is_err()); + #[cfg(unix)] { + std::os::unix::fs::symlink(&path, &normal).unwrap(); + assert!(identity(&normal, &path).is_err()); + } + profile.devices[0].session.credential.push('x'); + profile.devices[0].signing_key = Some(SigningKey::generate(KeyAlgorithm::P256).unwrap()); + profile.save(&path).unwrap(); + let changed = identity(&path, &dir.path().join("other.json")).unwrap(); + for (section, field) in [("signing_key", "pkcs8"), ("session", "credential")] { + assert_ne!(changed["profile"]["devices"][0][section][field]["sha256"], + output["profile"]["devices"][0][section][field]["sha256"]); + } + profile.devices.push(profile.devices[0].clone()); + profile.save(&path).unwrap(); + assert!(identity(&path, &dir.path().join("other.json")).is_err()); + } + + fn fixture() -> (Control, Value) { + let device: Device = serde_json::from_value(json!({ + "endpoint":"unix:/fixture/fractal-client.sock", "allow_public_http":false, + "session":{"kind":"paired-session","device_id":"device/fixture", + "person_id":"person/client-control-fixture","session_actor":"person/client-control-fixture/session/tui", + "credential":"fixture-only-credential-00000000000000000000000000", + "scopes":["read.projections","control.commands"],"expires_at":"2099-01-01T00:00:00Z"} + })).unwrap(); + let mut control = Control::new(device); + control.update_lifecycle(json!({"kind":"client-control-grant-lifecycle","id":"grant/one", + "revision":"1","updated_at":"2026-11-10T12:00:00Z","reason":"granted", + "grant_id":"grant/one","run_id":"run/one","sequence":1,"state":"started", + "device":control.device.session.device_id,"session":control.device.session.session_actor})); + let command = json!({"id":"device-command/one","kind":"device-command", + "device_id":control.device.session.device_id,"session_actor":control.device.session.session_actor, + "issued_by":"agent/fixture","grant_id":"grant/one","run_id":"run/one", + "command_kind":"phone.navigate","args":{"screen":"NowRoot"}, + "control_grant":{"grant_id":"grant/one","run_id":"run/one", + "device":control.device.session.device_id,"session":control.device.session.session_actor, + "actor_agent":"agent/fixture","verbs":["phone.inspect","phone.navigate"], + "screens":["NowRoot","Attention"],"max_commands":50,"expires_at":"2099-01-01T00:00:00Z"}, + "state":"delivered","expires_at":"2099-01-01T00:00:00Z"}); + (control, command) + } + + fn visible() -> ClientVisibleState { + ClientVisibleState { route:"list:home".into(),screen:"NowRoot".into(), + visible_item_ids:Vec::new(),opened_item:None } + } + + #[test] + fn refusals_never_navigate_construct_results_or_sign() { + for case in ["no grant", "wrong run", "wrong device", "wrong session", "wrong actor", + "verb", "screen", "expired grant", "expired command", "budget", "read mutation", + "conversation", "answer", "close", "wrong item"] { + let (mut control, mut command) = fixture(); + match case { + "no grant" => control.grant = None, + "wrong run" => command["run_id"] = json!("run/other"), + "wrong device" => command["device_id"] = json!("device/other"), + "wrong session" => command["session_actor"] = json!("session/other"), + "wrong actor" => command["issued_by"] = json!("agent/other"), + "verb" => command["control_grant"]["verbs"] = json!(["phone.inspect"]), + "screen" => command["control_grant"]["screens"] = json!(["Attention"]), + "expired grant" => command["control_grant"]["expires_at"] = json!("2000-01-01T00:00:00Z"), + "expired command" => command["expires_at"] = json!("2000-01-01T00:00:00Z"), + "budget" => command["control_grant"]["max_commands"] = json!(0), + "read mutation" => command["command_kind"] = json!("phone.message.read"), + "conversation" => command["args"] = json!({"screen":"Conversation","params":{"target":"agent/fixture"}}), + "answer" => command["command_kind"] = json!("work.answer"), + "close" => command["command_kind"] = json!("work.close"), + "wrong item" => { + command["control_grant"]["item_id"] = json!("attention/seed"); + control.grant.as_mut().unwrap().lifecycle.item_id = Some("attention/seed".into()); + command["args"] = json!({"screen":"Attention","params":{"id":"attention/other"}}); + } + _ => unreachable!(), + } + let mut ui = crate::ui::Ui::new(crate::ui::demo::test_world()); + let before = ui.control_visible(); + assert!(!ui.control_apply(&control, &command), "{case}"); + assert_eq!(ui.control_visible(), before, "{case}: navigation changed"); + // No key is installed: reaching the signer would return an error, not None. + assert!(control.result(&command, visible()).unwrap().is_none(), "{case}"); + } + } + + #[test] + fn active_result_is_signed_once_and_revoke_latches_closed() { + let (mut control, command) = fixture(); + control.device.signing_key = Some(SigningKey::generate(KeyAlgorithm::P256).unwrap()); + let result = control.result(&command, visible()).unwrap().unwrap(); + assert!(result.signature.is_some()); + assert_eq!(result.binding.unwrap().run_id, "run/one"); + assert!(control.result(&command, visible()).unwrap().is_none()); + let grant = control.grant.clone().unwrap(); + control.stop(); + control.update_lifecycle(serde_json::to_value(grant).unwrap()); + assert!(control.active().is_none()); + assert!(control.result(&command, visible()).unwrap().is_none()); + } + + #[test] + fn shared_lifecycle_traces_drive_the_shipped_reducer() { + let vectors: Value = serde_json::from_str(include_str!( + "../../../fixtures/clients/client-control-lifecycle-v1.json")).unwrap(); + for trace in vectors["traces"].as_array().unwrap() { + let (mut control, mut command) = fixture(); + control.grant = None; + control.sequences.clear(); + control.device.session.device_id = "device/test-tui".into(); + control.device.session.session_actor = "client-session/test-tui".into(); + command["device_id"] = json!(control.device.session.device_id); + command["session_actor"] = json!(control.device.session.session_actor); + command["grant_id"] = trace["grant_id"].clone(); + command["run_id"] = json!("mission-run/golden"); + command["control_grant"]["device"] = command["device_id"].clone(); + command["control_grant"]["session"] = command["session_actor"].clone(); + command["control_grant"]["grant_id"] = command["grant_id"].clone(); + command["control_grant"]["run_id"] = command["run_id"].clone(); + command["control_grant"]["item_id"] = trace["item_id"].clone(); + for step in trace["steps"].as_array().unwrap() { + let before = control.grant.clone(); + if let Some(resource) = step.get("resource") { + control.ingest(&json!({"kind":"changes","id":"commands","upserts":[resource],"removes":[]})); + } + match step["expected"].as_str().unwrap() { + "activate" | "allow-lifecycle-gate-only" => assert!(control.allows(&command, "NowRoot")), + "terminate" | "remain-terminal" | "refuse-terminal-lifecycle" | "refuse-no-active-lifecycle" => { + assert!(control.active().is_none()); + assert!(control.result(&command, visible()).unwrap().is_none()); + } + "refuse-invalid-frame" | "ignore-stale-started" | "ignore-item-mismatch" | "ignore-final-grant" => { + assert_eq!(control.grant, before); + } + expected => panic!("Unhandled shared trace expectation: {expected}"), + } + } + } + let (mut control, command) = fixture(); + let mut revoked = serde_json::to_value(control.grant.as_ref().unwrap()).unwrap(); + revoked["state"] = json!("revoked"); + revoked["sequence"] = json!(2); + control.update_lifecycle(revoked.clone()); + revoked["state"] = json!("started"); + revoked["sequence"] = json!(99); + control.update_lifecycle(revoked); + assert!(control.active().is_none()); + assert!(control.result(&command, visible()).unwrap().is_none()); + } + + #[test] + fn bound_item_cannot_be_removed_or_changed_before_shipped_navigation_or_signing() { + use fractal_schema::client_control::ClientVisibleItem; + fn native_asks() -> crate::ui::Ui { + use crate::ui::view::{AttentionKind, Load}; + let mut world = crate::ui::demo::test_world(); + let Load::Ready(items) = &mut world.attention else { panic!("attention must be loaded"); }; + let item = items.first_mut().unwrap(); + item.id = "attention/A".into(); + item.title = "Ask A".into(); + item.kind = AttentionKind::Request { from:"Fixture producer".into(),from_id:"agent/fixture".into(), + question:"Question A".into(),structured:None,images:Vec::new() }; + let mut other = item.clone(); + other.id = "attention/B".into(); + other.title = "Ask B".into(); + items.push(other); + crate::ui::Ui::new(world) + } + fn bind_a(control: &mut Control) { + let mut frame = serde_json::to_value(control.grant.as_ref().unwrap()).unwrap(); + frame["item_id"] = json!("attention/A"); + control.grant = None; + control.sequences.clear(); + control.ingest(&json!({"kind":"snapshot","id":"commands","items":[frame]})); + } + for supplied in [None, Some(Value::Null), Some(json!("attention/B"))] { + let (mut control, mut command) = fixture(); + bind_a(&mut control); + if let Some(item) = supplied { command["control_grant"]["item_id"] = item; } + command["args"] = json!({"screen":"Attention","params":{"id":"attention/B"}}); + let mut ui = native_asks(); + let before = ui.control_visible(); + assert!(!ui.control_apply(&control, &command)); + assert_eq!(ui.control_visible(), before); + let rendered = ClientVisibleState { route:"home:attention/B".into(),screen:"Attention".into(), + visible_item_ids:vec!["attention/B".into()],opened_item:Some(ClientVisibleItem { + id:"attention/B".into(),title:"Ask B".into(),body:"Question B".into() }) }; + // Deliberately no key: a signer call would error, not return None. + assert!(control.result(&command, rendered).unwrap().is_none()); + } + let (mut control, mut command) = fixture(); + bind_a(&mut control); + command["control_grant"]["item_id"] = json!("attention/A"); + command["args"] = json!({"screen":"Attention","params":{"id":"attention/A"}}); + let mut ui = native_asks(); + assert!(ui.control_apply(&control, &command)); + let rendered = ClientVisibleState { route:"home:attention/A".into(),screen:"Attention".into(), + visible_item_ids:vec!["attention/A".into()],opened_item:Some(ClientVisibleItem { + id:"attention/A".into(),title:"Ask A".into(),body:"Question A".into() }) }; + control.device.signing_key = Some(SigningKey::generate(KeyAlgorithm::P256).unwrap()); + assert!(control.result(&command, rendered).unwrap().unwrap().signature.is_some()); + let (mut control, mut command) = fixture(); + assert!(control.allows(&command, "NowRoot")); // both unbound + command["control_grant"]["item_id"] = json!(5); + let mut ui = native_asks(); + let before = ui.control_visible(); + assert!(!ui.control_apply(&control, &command)); + assert_eq!(ui.control_visible(), before); + assert!(control.result(&command, visible()).unwrap().is_none()); + command["control_grant"].as_object_mut().unwrap().remove("item_id"); + command["args"] = json!({"screen":"Attention","params":{"id":"attention/B"}}); + let mut ui = native_asks(); + let before = ui.control_visible(); + assert!(!ui.control_apply(&control, &command)); // Attention always bound + assert_eq!(ui.control_visible(), before); + assert!(control.result(&command, visible()).unwrap().is_none()); + } + + #[test] + fn fixture_profile_refuses_other_principals_endpoints_and_message_authority() { + let (mut control, _) = fixture(); + control.device.signing_key = Some(SigningKey::generate(KeyAlgorithm::P256).unwrap()); + let socket = std::path::Path::new("/fixture/fractal-client.sock"); + assert!(validate_device(&control.device, socket).is_ok()); + assert!(validate_device(&control.device, std::path::Path::new("/fixture/privileged.sock")).is_err()); + let mut other = control.device.clone(); + other.session.person_id = "person/ordinary".into(); + assert!(validate_device(&other, socket).is_err()); + other = control.device.clone(); + other.session.scopes.push("control.messages".into()); + assert!(validate_device(&other, socket).is_err()); + other = control.device; + other.signing_key = None; + assert!(validate_device(&other, socket).is_err()); + } +} diff --git a/crates/fractal-tui/src/lib.rs b/crates/fractal-tui/src/lib.rs index ebe398314c5..3e87a09e8aa 100644 --- a/crates/fractal-tui/src/lib.rs +++ b/crates/fractal-tui/src/lib.rs @@ -4,6 +4,7 @@ mod ci_test_paths; mod connection; +mod client_control; mod environment; mod ui; mod version; @@ -751,6 +752,11 @@ pub struct Args { #[arg(skip)] endpoint: Option, #[arg(skip)] + client_control_socket: Option, + /// Isolated paired profile for fixture-only device commands (never a normal profile). + #[arg(long, conflicts_with = "local")] + pub client_control_profile: Option, + #[arg(skip)] initial_subject: Option, #[arg(skip)] playback: Option, @@ -804,6 +810,22 @@ pub enum Action { /// unless they are typing a message or `--keep-focus` says to leave it. The request waits /// for fractal to know the subject, so an agent made a moment ago can be opened. Open(OpenArgs), + /// Read-only fixture client observation; never pairs or opens the interface. + ClientControl { + #[command(subcommand)] + action: ClientControlAction, + }, +} + +#[derive(clap::Subcommand, Debug)] +pub enum ClientControlAction { + /// Print public identity and a secret-redacted profile snapshot without writing. + Identity { + #[arg(long)] + profile: std::path::PathBuf, + #[arg(long, required = true)] + json: bool, + }, } #[derive(clap::Args, Debug)] @@ -825,6 +847,12 @@ pub struct OpenArgs { } impl Args { + /// The CLI supplies this only after selecting the named fixture instance. + pub fn with_client_control_socket(mut self, socket: PathBuf) -> Self { + self.client_control_socket = Some(socket); + self + } + /// Override local socket discovery with the CLI's global endpoint option. pub fn with_endpoint(mut self, endpoint: PathBuf) -> Self { self.endpoint = Some(endpoint); @@ -967,8 +995,14 @@ fn open_in_terminal_ui(open: &OpenArgs) -> Result<()> { /// Run the TUI before starting a CLI runtime. This owns its runtime and terminal guards, /// and sets the client name before any connection can initialize the shared name lock. pub fn run(mut options: Args) -> Result<()> { - if let Some(Action::Open(open)) = options.action.take() { - return open_in_terminal_ui(&open); + match options.action.take() { + Some(Action::Open(open)) => return open_in_terminal_ui(&open), + Some(Action::ClientControl { action: ClientControlAction::Identity { profile, json: _ } }) => { + let identity = client_control::identity(&profile, &connection::profile_path()?)?; + println!("{}", serde_json::to_string(&identity)?); + return Ok(()); + } + None => {} } anyhow::ensure!(!options.classic, CLASSIC_GONE); let process_started = std::time::Instant::now(); @@ -1001,7 +1035,19 @@ pub fn run(mut options: Args) -> Result<()> { let local = args.iter().any(|arg| arg == "--local"); let client_only = args.iter().any(|arg| arg == "--client"); anyhow::ensure!(!(local && client_only), "Choose --client or --local"); - let profile = if local { + let control_device = if let Some(path) = options.client_control_profile.as_deref() { + let socket = options.client_control_socket.as_ref() + .context("Client control requires --instance client-control-fixture")?; + anyhow::ensure!(options.endpoint.as_ref() == Some(socket), + "Client control requires --endpoint pointing to the fixture client socket"); + let normal = connection::profile_path()?; + let device = client_control::load_fixture_device(path, &normal)?; + client_control::validate_device(&device, socket)?; + Some(device) + } else { None }; + let profile = if let Some(device) = &control_device { + Some(connection::Profile { devices: vec![device.clone()] }) + } else if local { None } else if std::env::var_os("XDG_CONFIG_HOME").is_none() && std::env::var_os("HOME").is_none() { None @@ -1088,6 +1134,7 @@ pub fn run(mut options: Args) -> Result<()> { initial_subject: options.initial_subject.filter(|_| profile.is_none()), requests, playback: options.playback, + client_control: control_device, }) .inspect_err(|error| ui::lastrun_note_exit(format!("error: {error:#}"))) } diff --git a/crates/fractal-tui/src/ui/client_control.rs b/crates/fractal-tui/src/ui/client_control.rs new file mode 100644 index 00000000000..e4ceced67b3 --- /dev/null +++ b/crates/fractal-tui/src/ui/client_control.rs @@ -0,0 +1,201 @@ +//! Status and local stop latch for the existing device-command protocol. +use chrono::{DateTime, Utc}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +#[derive(Default)] +pub(super) struct Status { + pub run_id: Option, + pub expires_at: Option>, + pub revoke_requested: bool, + pub focus_lost: bool, +} + +impl Status { + pub fn active(&self) -> bool { + !self.revoke_requested && self.run_id.is_some() + && self.expires_at.is_some_and(|expiry| expiry > Utc::now()) + } + + pub fn indicator(&self) -> Option { + if self.revoke_requested { + Some("Client control stopped · enrollment revocation pending".into()) + } else if self.active() { + Some(format!("Test run {} · ctrl+alt+x revoke enrollment", self.run_id.as_deref()?)) + } else { + None + } + } + + /// Stop locally before any network operation. This binding also wins over embedded PTYs. + pub fn key(&mut self, key: KeyEvent) -> bool { + if self.active() && key.kind == KeyEventKind::Press + && key.code == KeyCode::Char('x') + && key.modifiers == KeyModifiers::CONTROL.union(KeyModifiers::ALT) + { + self.revoke_requested = true; + true + } else { + false + } + } +} + +impl super::Ui { + pub(crate) fn control_apply(&mut self, control: &crate::client_control::Control, command: &serde_json::Value) -> bool { + control.allows(command, self.control_screen()) && self.control_navigate(command) + } + + pub(super) fn control_foreground(&self) -> bool { + // Both calls only query the controlling terminal and current process group. + !self.client_control.focus_lost + && unsafe { libc::tcgetpgrp(libc::STDIN_FILENO) == libc::getpgrp() } + } + + pub(super) fn control_screen(&self) -> &'static str { + if self.help || self.popover.is_some() || self.glasses.palette_open() { return ""; } + if self.home_open() { return "NowRoot"; } + match self.focused_pane() { + Some(super::pane::Pane::List(0)) => "NowRoot", + Some(super::pane::Pane::Home(Some(_))) => "Attention", + _ => "", + } + } + + pub(super) fn control_navigate(&mut self, command: &serde_json::Value) -> bool { + if self.help || self.popover.is_some() || self.glasses.palette_open() { return false; } + if command["command_kind"] == "phone.inspect" { return true; } + match command["args"]["screen"].as_str() { + Some("NowRoot") => { self.open_home(); true } + Some("Attention") => { + let Some(id) = command["args"]["params"]["id"].as_str() else { return false; }; + if !self.world.attention.items().iter().any(|item| item.id == id + && matches!(item.kind, super::view::AttentionKind::Request { .. })) { return false; } + self.open_in_glass(super::pane::Pane::Home(Some(id.into())), super::glass::Open::Tab); + true + } + _ => false, + } + } + + /// Called only after the terminal draw; ask content comes from the shipped card model. + pub(crate) fn control_visible(&self) -> fractal_schema::client_control::ClientVisibleState { + use fractal_schema::client_control::{ClientVisibleItem, ClientVisibleState}; + let screen = self.control_screen(); + let pane = self.focused_pane(); + let route = pane.as_ref().map_or_else(String::new, |pane| pane.key()); + let drawn = self.frame.borrow().panes.iter().any(|pane| + pane.key == route && pane.rect.width > 0 && pane.rect.height > 0); + let opened_item = if drawn && screen == "Attention" { + let id = match &pane { Some(super::pane::Pane::Home(Some(id))) => Some(id), _ => None }; + id.and_then(|id| self.world.attention.items().iter().find(|item| item.id == *id)) + .and_then(|item| match &item.kind { + super::view::AttentionKind::Request { question, structured, .. } => + Some(ClientVisibleItem { id: item.id.clone(), title: item.title.clone(), + body: structured.as_ref().map_or(question, |request| &request.question).clone() }), + _ => None, + }) + } else { None }; + let mut visible_item_ids = if screen == "NowRoot" { + self.frame.borrow().context_rows.iter() + .filter(|(_, id, _)| self.world.attention.items().iter().any(|item| item.id == *id)) + .map(|(_, id, _)| id.clone()).collect::>() + } else { opened_item.iter().map(|item| item.id.clone()).collect() }; + visible_item_ids.sort(); + visible_item_ids.dedup(); + ClientVisibleState { route, screen: screen.into(), visible_item_ids, opened_item } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn active() -> Status { + Status { run_id: Some("run/fixture-1".into()), + expires_at: Some(Utc::now() + chrono::Duration::minutes(1)), + revoke_requested: false, focus_lost: false } + } + + #[test] + fn indicator_names_run_and_revoke_binding() { + let status = active(); + let indicator = status.indicator().unwrap(); + assert!(indicator.contains("run/fixture-1")); + assert!(indicator.contains("ctrl+alt+x")); + assert!(Status::default().indicator().is_none()); + let mut expired = active(); + expired.expires_at = Some(Utc::now() - chrono::Duration::seconds(1)); + assert!(expired.indicator().is_none()); + } + + #[test] + fn revoke_key_stops_before_network_and_never_repeats() { + let mut status = active(); + assert!(!status.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))); + let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL | KeyModifiers::ALT); + assert!(status.key(key)); + assert!(!status.active()); + assert!(status.revoke_requested); + assert!(!status.key(key)); + assert!(status.indicator().unwrap().contains("revocation pending")); + } + + #[test] + fn status_line_and_global_revoke_work_even_offline_and_editing() { + use ratatui::{buffer::Buffer, layout::Rect}; + let mut ui = super::super::Ui::new(super::super::demo::test_world()); + ui.world.link = super::super::view::Link::Offline("offline".into()); + ui.client_control = active(); + ui.editing = true; + let area = Rect::new(0, 0, 100, 1); + let mut buffer = Buffer::empty(area); + ui.footer(&mut buffer, area); + let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect(); + assert!(text.contains("Test run run/fixture-1")); + assert!(text.contains("ctrl+alt+x")); + ui.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL | KeyModifiers::ALT)); + assert!(ui.client_control.revoke_requested); + assert!(ui.effects.is_empty(), "the revoke chord must not type into or submit a draft"); + } + + #[test] + fn ascii_seed_renders_exactly_one_line_and_navigation_sends_no_answer_key() { + use super::super::{Effect, Ui, demo, screens, view::{AttentionKind, Load}}; + use ratatui::{Terminal, backend::TestBackend}; + for (title, question) in [ + ("client control fixture request".to_owned(), "Seeded request body for terminal verification".to_owned()), + ("T".repeat(60), "Q".repeat(60)), + ] { + let mut world = demo::test_world(); + let Load::Ready(items) = &mut world.attention else { panic!("demo attention must be loaded"); }; + let item = items.first_mut().unwrap(); + item.title = title.clone(); + item.kind = AttentionKind::Request { from: "Fixture producer".into(), + from_id: "agent/fixture".into(), question: question.clone(), structured: None, images: Vec::new() }; + let id = item.id.clone(); + let mut ui = Ui::new(world); + assert!(ui.control_navigate(&serde_json::json!({ + "command_kind":"phone.navigate","args":{"screen":"Attention","params":{"id":id}} + }))); + assert!(ui.effects.iter().all(|effect| !matches!(effect, + Effect::TerminalKey(_) | Effect::Attention { .. } | Effect::Send { .. } + | Effect::Reply { .. } | Effect::Discuss { .. })), + "navigation must never synthesize an answer action or terminal key"); + let drafts = ui.drafts_for(&id, 100); + let doc = screens::home_detail(&ui.world, Some(&id), 100, &drafts); + let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); + terminal.draw(|frame| { + let area = frame.area(); + ui.pane(frame.buffer_mut(), &format!("home:{id}"), area, doc.clone(), false); + }).unwrap(); + let lines: Vec = terminal.backend().buffer().content.chunks(100) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .map(|row| row.trim().trim_matches(['│', '┃']).trim().to_owned()).collect(); + for expected in [&title, &question] { + assert!(expected.is_ascii() && expected.len() <= 60); + assert_eq!(lines.iter().filter(|line| *line == expected).count(), 1, + "seed text must occupy one exact terminal line: {expected:?}"); + } + } + } +} diff --git a/crates/fractal-tui/src/ui/live.rs b/crates/fractal-tui/src/ui/live.rs index fc88a98fa3c..6241152f910 100644 --- a/crates/fractal-tui/src/ui/live.rs +++ b/crates/fractal-tui/src/ui/live.rs @@ -177,6 +177,7 @@ impl ReadReceipts { pub struct Context { pub client: Client, + pub client_control: Option, pub runtime: tokio::runtime::Runtime, pub incoming: mpsc::Receiver, pub commands: tokio::sync::mpsc::UnboundedSender, @@ -509,6 +510,7 @@ fn save_glass( pub fn run(context: Context) -> Result<()> { let Context { mut client, + client_control, runtime, incoming, commands, @@ -551,6 +553,21 @@ pub fn run(context: Context) -> Result<()> { // Messages sent from here, shown at once until st reports them back. let mut pending: Vec = Vec::new(); let mut ui = Ui::new(adapt::world(&model, &person, &extras)); + let fixture_client = client_control.is_some(); + let mut control = client_control.map(crate::client_control::Control::new); + let (control_tx, control_rx) = mpsc::channel(); + let mut control_queue = std::collections::VecDeque::new(); + let mut control_pending: Option = None; + let mut revoke_sent = false; + if control.is_some() { + let tx = control_tx.clone(); + let client = client.clone(); + runtime.spawn(async move { + let result = crate::client_control::subscribe(client, tx.clone()).await; + let _ = tx.send(serde_json::json!({"kind":"control-offline", + "error":result.err().map(|error| error.to_string())})); + }); + } ui.load_prefs(); // A demo's agents are scripted stand-ins whose terminals stay empty: they open on their // conversation, without changing this device's choice. @@ -704,6 +721,53 @@ pub fn run(context: Context) -> Result<()> { sync_read_at = Some(Instant::now()); read_sync(&runtime, &client, &fetched_tx); } + if let Some(control) = &mut control { + if ui.client_control.revoke_requested && !revoke_sent { + control.stop(); + control_queue.clear(); + control_pending = None; + revoke_sent = true; + let device = control.device.session.device_id.clone(); + let client = client.clone(); + let tx = control_tx.clone(); + runtime.spawn(async move { + let result = client.client_test_revoke(&device).await; + let _ = tx.send(serde_json::json!({"kind":"control-revoked", + "error":result.err().map(|error| error.to_string())})); + }); + } + while let Ok(frame) = control_rx.try_recv() { + match frame["kind"].as_str() { + Some("control-offline") => { + control.stop(); + control_queue.clear(); + control_pending = None; + ui.flash("Client control disconnected; no commands will be applied"); + } + Some("control-revoked") if frame["error"].is_null() => { + ui.client_control.revoke_requested = false; + ui.flash("Test-client enrollment revoked"); + } + Some("control-revoked") => { + ui.flash(format!("Client control stopped; enrollment revoke failed: {}", frame["error"])); + } + Some("control-result-error") => { + ui.flash(format!("Client result was not accepted: {}", frame["error"])); + } + _ => { + for command in control.ingest(&frame) { + if control_queue.len() < 8 { control_queue.push_back(command); } + } + } + } + } + let grant = control.active(); + ui.client_control.run_id = grant.map(|grant| grant.run_id.clone()); + // Lifecycle frames intentionally contain no grant policy or expiry. + // Session expiry bounds the indicator; each command checks its own grant expiry. + ui.client_control.expires_at = chrono::DateTime::parse_from_rfc3339(&control.device.session.expires_at) + .ok().map(|stamp| stamp.with_timezone(&chrono::Utc)); + } while let Ok(update) = incoming.try_recv() { match update { feed::Update::GlassesVersion(version) => { @@ -1966,6 +2030,19 @@ pub fn run(context: Context) -> Result<()> { ui.step_drafts(); execute!(io::stdout(), BeginSynchronizedUpdate)?; let mut links = Vec::new(); + if let Some(control) = &control { + if control_pending.is_none() { + while let Some(command) = control_queue.pop_front() { + if ui.control_foreground() && ui.control_apply(control, &command) { + control_pending = Some(command); + break; + } + } + } + if control_pending.as_ref().is_some_and(|command| !control.allows(command, ui.control_screen())) { + control_pending = None; + } + } let draw_started = Instant::now(); terminal.draw(|frame| { ui.render(frame); @@ -1974,6 +2051,25 @@ pub fn run(context: Context) -> Result<()> { } })?; // A message just sent is on screen from this draw on: the time from pressing send to it. + if let (Some(control), Some(command)) = (&mut control, &control_pending) { + let state = ui.control_visible(); + if ui.control_foreground() && (state.screen != "Attention" || state.opened_item.is_some()) { + match control.result(command, state) { + Ok(Some(result)) => { + let client = client.clone(); + let tx = control_tx.clone(); + runtime.spawn(async move { + if let Err(error) = client.device_result(&result).await { + let _ = tx.send(serde_json::json!({"kind":"control-result-error","error":error.to_string()})); + } + }); + } + Ok(None) => {} + Err(error) => ui.flash(format!("Client result not signed: {error}")), + } + control_pending = None; + } + } for entry in pending.iter_mut().filter(|entry| !entry.echoed) { entry.echoed = true; super::slo::METRICS.observe("send_echo_ms", entry.since.elapsed()); @@ -2080,7 +2176,7 @@ pub fn run(context: Context) -> Result<()> { { read_receipts.displayed(id, Instant::now()); } - if extras.live + if extras.live && !fixture_client && let Some(id) = read_receipts.next(Instant::now()) { let client = client.clone(); diff --git a/crates/fractal-tui/src/ui/mod.rs b/crates/fractal-tui/src/ui/mod.rs index 7b468cde02e..60d803fd8ff 100644 --- a/crates/fractal-tui/src/ui/mod.rs +++ b/crates/fractal-tui/src/ui/mod.rs @@ -10,6 +10,7 @@ mod alerts; mod answers; mod attach; mod clickable; +mod client_control; mod content; mod context; #[cfg(test)] @@ -397,6 +398,7 @@ pub struct Ui { world: World, /// Negotiated by the terminal guard, never inferred from an ambiguous Escape byte. pub(super) keyboard_enhanced: bool, + pub(super) client_control: client_control::Status, triage: triage::Cursor, recent: recent::RecentState, /// Each list's selection, by the section it lists (Now, Agents, Missions, Fleet, Usage, @@ -784,6 +786,7 @@ impl Ui { world, selected: [0; 6], keyboard_enhanced: false, + client_control: client_control::Status::default(), triage: triage::Cursor::default(), recent: recent::RecentState::default(), list_top: RefCell::new([0; 6]), @@ -1967,6 +1970,11 @@ impl Ui { fn footer(&self, buf: &mut Buffer, area: Rect) { buf.set_style(area, Style::default().bg(theme::CRUST)); + if let Some(indicator) = self.client_control.indicator() { + buf.set_stringn(area.x + 1, area.y, indicator, area.width.saturating_sub(2) as usize, + Style::default().fg(theme::YELLOW).bg(theme::CRUST)); + return; + } if let Link::Offline(message) = &self.world.link { buf.set_stringn( area.x + 1, @@ -4188,6 +4196,11 @@ impl Ui { /// Dispatch the outer terminal's decoded input, shared by the live and demo loops. fn input_event(&mut self, event: Event) -> bool { + match event { + Event::FocusLost => self.client_control.focus_lost = true, + Event::FocusGained => self.client_control.focus_lost = false, + _ => {} + } if self.live && matches!(event, Event::Key(key) if key.kind != KeyEventKind::Release) && (self.editing @@ -4350,6 +4363,9 @@ impl Ui { } pub fn key(&mut self, key: KeyEvent) { + if self.client_control.key(key) { + return; + } if self.observe_recent_key(key) { return; } diff --git a/crates/fractal-tui/tests/client_control_capture.py b/crates/fractal-tui/tests/client_control_capture.py new file mode 100755 index 00000000000..58b90bb8c21 --- /dev/null +++ b/crates/fractal-tui/tests/client_control_capture.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Verify an independently captured terminal pane, never a device.result payload. + +Capture outside the TUI process with `tmux capture-pane -p -J -t PANE` and save +stdout. Supply the visible body rectangle (one-based rows/columns, inclusive). +Use a terminal wide/tall enough to show the entire seeded title and body. +""" +import argparse +import hashlib +import json +from pathlib import Path + + +def verify(capture, title, body_sha256, first_row, last_row, first_column, last_column): + if "\x1b" in capture or "\x00" in capture: + raise ValueError("capture must contain rendered plain terminal cells") + rows = capture.splitlines() + if not (1 <= first_row <= last_row <= len(rows) and 1 <= first_column <= last_column): + raise ValueError("body rectangle is outside the captured terminal") + if not any(title in row for row in rows): + raise ValueError("seeded title is not rendered") + body = "\n".join(row[first_column - 1:last_column].rstrip() + for row in rows[first_row - 1:last_row]) + digest = hashlib.sha256(body.encode("utf-8")).hexdigest() + if digest != body_sha256: + raise ValueError(f"rendered body digest mismatch: {digest}") + return {"independent_check": "passed", "title": title, "body_sha256": digest, + "capture_sha256": hashlib.sha256(capture.encode("utf-8")).hexdigest()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("capture", type=Path) + parser.add_argument("--title", required=True) + parser.add_argument("--body-sha256", required=True) + parser.add_argument("--body-rectangle", required=True, nargs=4, type=int, + metavar=("FIRST_ROW", "LAST_ROW", "FIRST_COLUMN", "LAST_COLUMN")) + args = parser.parse_args() + print(json.dumps(verify(args.capture.read_text(), args.title, args.body_sha256, + *args.body_rectangle), sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/crates/fractal-tui/tests/client_control_capture_test.py b/crates/fractal-tui/tests/client_control_capture_test.py new file mode 100755 index 00000000000..62728dab6cd --- /dev/null +++ b/crates/fractal-tui/tests/client_control_capture_test.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Unit tests use terminal-cell text, not client result reports.""" +import hashlib +import unittest +from client_control_capture import verify + + +class TerminalCaptureTest(unittest.TestCase): + def test_seeded_title_and_body_digest(self): + digest = hashlib.sha256(b"First line\nSecond line").hexdigest() + result = verify("title: client-control fixture 0001\n| First line |\n| Second line |\n", + "client-control fixture 0001", digest, 2, 3, 3, 15) + self.assertEqual(result["independent_check"], "passed") + self.assertEqual(result["body_sha256"], digest) + + def test_missing_title_wrong_body_and_raw_escape_fail(self): + for capture in ["wrong title\nbody\n", "seeded title\nwrong body\n", + "seeded title\nbody\x1b[2J\n"]: + with self.subTest(capture=capture), self.assertRaises(ValueError): + verify(capture, "seeded title", hashlib.sha256(b"body").hexdigest(), 2, 2, 1, 20) + + def test_truncated_body_and_outside_rectangle_fail(self): + with self.assertRaises(ValueError): + verify("seeded title\nfirst line\n", "seeded title", + hashlib.sha256(b"first line\nmissing line").hexdigest(), 2, 2, 1, 20) + with self.assertRaises(ValueError): + verify("seeded title\nbody\n", "seeded title", "0" * 64, 2, 3, 1, 20) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 24e3e1dc5e1..27617af37b3 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -5419,6 +5419,15 @@ fn plain_ui_requested(arguments: usize, stdin: bool, stdout: bool, seat: bool) - /// The TUI owns its runtime and terminal restoration; never enter it from run_cli. fn run_terminal_ui(mut options: fractal_tui::Args, endpoint: Option<&str>) -> ExitCode { let result = (|| -> Result<()> { + if options.client_control_profile.is_some() { + anyhow::ensure!(fractal_daemon::instance::current() + .is_some_and(|instance| instance.name() == "client-control-fixture"), + "Client control is restricted to client-control-fixture"); + let config = Config::load_client_unvalidated(None)?.0; + let socket = config.client_gateway_socket; + anyhow::ensure!(socket != config.socket, "Client control cannot use the privileged socket"); + options = options.with_client_control_socket(socket); + } if let Some(endpoint) = endpoint { match Endpoint::parse(endpoint)? { Endpoint::Unix(path) => options = options.with_endpoint(path), diff --git a/release-notes/tui-client-control-identity.json b/release-notes/tui-client-control-identity.json new file mode 100644 index 00000000000..bcb82f89b86 --- /dev/null +++ b/release-notes/tui-client-control-identity.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "The terminal fixture client exposes a read-only public identity and secret-redacted profile snapshot for independent observation.", + "replay": {"status": "none", "detail": "No replay changes."}, + "schema": {"status": "none", "detail": "No persisted schema changes."}, + "rules": {"status": "none", "detail": "No checkpoint rule changes."}, + "compatibility": {"status": "none", "detail": "Normal profiles are refused; existing fixture profiles are read without modification."}, + "downtime": {"status": "none", "detail": "No restart or activation."}, + "manual": {"status": "changed", "detail": "Use tui client-control identity --profile PATH --json for public identity and a redacted snapshot. Secret values are replaced by a fixed sentinel and their SHA-256."}, + "recovery": {"status": "none", "detail": "No profile writes or runtime effects to undo."} +} diff --git a/release-notes/tui-client-control-item-binding.json b/release-notes/tui-client-control-item-binding.json new file mode 100644 index 00000000000..75daf763de7 --- /dev/null +++ b/release-notes/tui-client-control-item-binding.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Terminal test clients refuse missing, null or mismatched command item bindings under an item-bound lifecycle; Attention always requires an item.", + "replay": {"status": "none", "detail": "No replay changes."}, + "schema": {"status": "none", "detail": "No persisted schema changes."}, + "rules": {"status": "none", "detail": "Daemon admission remains unchanged and independently enforced."}, + "compatibility": {"status": "changed", "detail": "Unbound NowRoot commands remain supported; unbound Attention commands are refused before navigation and signing."}, + "downtime": {"status": "none", "detail": "No restart or activation."}, + "manual": {"status": "none", "detail": "No runtime action required."}, + "recovery": {"status": "none", "detail": "No persisted changes to undo."} +} diff --git a/release-notes/tui-client-control-v1.json b/release-notes/tui-client-control-v1.json new file mode 100644 index 00000000000..455bcb7324f --- /dev/null +++ b/release-notes/tui-client-control-v1.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "The isolated terminal test client consumes bounded device commands, signs purpose-bound visible-state results, displays the active run, and offers a global enrollment revoke key.", + "replay": {"status": "none", "detail": "No replay changes; commands remain ephemeral."}, + "schema": {"status": "none", "detail": "Consumes the shared client-control result and grant schema."}, + "rules": {"status": "none", "detail": "No checkpoint rule changes or enrollment effects."}, + "compatibility": {"status": "none", "detail": "Normal terminal clients do not subscribe to commands; fixture control requires an explicit separate paired profile."}, + "downtime": {"status": "none", "detail": "No daemon restart or activation."}, + "manual": {"status": "changed", "detail": "Fixture setup and initial test-client enrollment require separate approvals. Use the isolated client socket, dedicated profile and narrow read-plus-command scopes."}, + "recovery": {"status": "none", "detail": "Revoke test-client enrollment and revert the source change; preserve normal pairings."} +}