diff --git a/Cargo.lock b/Cargo.lock index f054ff0..f4c3fde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5774,6 +5774,7 @@ dependencies = [ "serde_json", "smithay", "smithay-drm-extras", + "tempfile", "tracing", "tracing-subscriber", ] diff --git a/README.md b/README.md index 78ee56b..61c4590 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,21 @@ model: --evidence-dir artifacts/framework12-first-gate ``` +The same offline install can be baked into a checksum-pinned Fedora Workstation +`development-live` environment so the Framework 12 loop does not touch its +internal disk. Its password-protected SSH service, optional development-only +Wi-Fi autoconnect profile, and mutable overlay allow changed SOS binaries to be +deployed with `tools/linux-live-deploy` without rebuilding the ISO. Embedded +Wi-Fi credentials make that private development ISO unsuitable for sharing or +release. The bake accepts only Fedora's flat +EROFS rootfs format and performs a privileged metadata-preserving copy, +policy-based SELinux relabel, and repack so Linux ownership, ACLs, +capabilities, and security metadata are preserved. +Development-live diagnostics always record `promotion_eligible=false`; only a +future immutable release image can own release promotion. +It is not an installed product. See +[`docs/linux-live-image.md`](docs/linux-live-image.md). + `./tools/install-linux-login-session uninstall` removes the installed SOS session while preserving user state and the existing display-manager/default boot configuration. @@ -210,7 +225,8 @@ The direct-DRM acceptance command targets the disposable reference Debian VM: See [`docs/linux-stable-host.md`](docs/linux-stable-host.md), [`docs/linux-compositor.md`](docs/linux-compositor.md), [`docs/linux-vm.md`](docs/linux-vm.md), -[`docs/linux-hardware-gate.md`](docs/linux-hardware-gate.md), and +[`docs/linux-hardware-gate.md`](docs/linux-hardware-gate.md), +[`docs/linux-live-image.md`](docs/linux-live-image.md), and [`docs/sos-agent.md`](docs/sos-agent.md) for prerequisites and evidence limits. ### AOSP Cuttlefish @@ -319,8 +335,9 @@ documented external evidence directories and are intentionally not tracked. the physical stable-host and stateful-swap evidence. - [`docs/linux-stable-host.md`](docs/linux-stable-host.md), [`docs/linux-compositor.md`](docs/linux-compositor.md), - [`docs/linux-vm.md`](docs/linux-vm.md), and - [`docs/linux-hardware-gate.md`](docs/linux-hardware-gate.md) cover the Linux + [`docs/linux-vm.md`](docs/linux-vm.md), + [`docs/linux-hardware-gate.md`](docs/linux-hardware-gate.md), and + [`docs/linux-live-image.md`](docs/linux-live-image.md) cover the Linux path from virtual acceptance through the first physical campaign. - [`docs/aosp-cuttlefish.md`](docs/aosp-cuttlefish.md) covers the reproducible Android 17 system spike. diff --git a/apps/experience/src/android/agent.rs b/apps/experience/src/android/agent.rs index 7035f48..d6fc629 100644 --- a/apps/experience/src/android/agent.rs +++ b/apps/experience/src/android/agent.rs @@ -14,7 +14,7 @@ use std::thread; use std::time::{Duration, Instant}; use async_channel::Sender; -use experience_ir::{AgentConversation, ExperienceModel}; +use experience_ir::{AgentConfigurationAction, AgentConversation, ExperienceModel}; #[cfg(not(feature = "core-native"))] use gpui_mobile::android::jni::{activity, find_app_class, get_string, with_env}; #[cfg(not(feature = "core-native"))] @@ -246,12 +246,34 @@ pub fn status() -> Result { pub fn apply_status(conversation: &mut AgentConversation, status: &AgentStatus) { conversation.available = status.provider == "fake" || status.configured; + conversation.configuration_actions = supported_configuration_actions(); if !conversation.busy { conversation.activity = status.activity.clone(); } conversation.error = reconciled_request_error(conversation.error.take(), false); } +fn supported_configuration_actions() -> Vec { + #[cfg(feature = "core-native")] + { + vec![ + AgentConfigurationAction::ConfigureOpenRouter, + AgentConfigurationAction::UseFake, + AgentConfigurationAction::ClearCredential, + ] + } + #[cfg(not(feature = "core-native"))] + { + vec![ + AgentConfigurationAction::ConfigureOpenAi, + AgentConfigurationAction::ConfigureOpenRouter, + AgentConfigurationAction::ConfigureCodex, + AgentConfigurationAction::UseFake, + AgentConfigurationAction::ClearCredential, + ] + } +} + pub fn configure_openai() -> Result<(), String> { call_bool("configureOpenAi") } diff --git a/apps/experience/src/android/pointer_input.rs b/apps/experience/src/android/pointer_input.rs index a2cf0be..9091bb0 100644 --- a/apps/experience/src/android/pointer_input.rs +++ b/apps/experience/src/android/pointer_input.rs @@ -37,6 +37,14 @@ struct Surface { order: u64, } +#[derive(Clone)] +struct NativeInput { + id: String, + bounds: [f32; 4], + epoch: u64, + order: u64, +} + #[derive(Clone)] struct Capture { surface: Surface, @@ -53,6 +61,7 @@ struct Router { epoch: u64, order: u64, surfaces: HashMap, + native_inputs: HashMap, captures: HashMap, } @@ -92,8 +101,9 @@ pub fn install() { } #[cfg(target_os = "linux")] -pub fn install() { +pub fn install() -> async_channel::Receiver<()> { let start = *CLOCK.get_or_init(Instant::now); + let (wake_tx, wake_rx) = async_channel::bounded(1); gpui_linux::set_raw_touch_callback(Some(Box::new(move |event| { let phase = match event.phase { gpui_linux::RawTouchPhase::Down => Phase::Down, @@ -113,16 +123,20 @@ pub fn install() { .unwrap_or_else(|| "unavailable".into()), ); } - push_sample(Sample { - id: event.id.max(0) as u64, - phase, - x: event.x, - y: event.y, - pressure: event.pressure.unwrap_or(1.0).clamp(0.0, 1.0), - pointer_count: event.pointer_count.min(32), - event_time_nanos: start.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64, - }); + push_sample_and_wake( + Sample { + id: event.id.max(0) as u64, + phase, + x: event.x, + y: event.y, + pressure: event.pressure.unwrap_or(1.0).clamp(0.0, 1.0), + pointer_count: event.pointer_count.min(32), + event_time_nanos: start.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64, + }, + &wake_tx, + ); }))); + wake_rx } pub fn begin_frame() { @@ -135,6 +149,9 @@ pub fn begin_frame() { router .surfaces .retain(|_, surface| epoch.saturating_sub(surface.epoch) <= 2); + router + .native_inputs + .retain(|_, input| epoch.saturating_sub(input.epoch) <= 2); } pub fn record_surface(id: &str, bounds: Bounds, interaction: &Interaction) { @@ -161,6 +178,34 @@ pub fn record_surface(id: &str, bounds: Bounds, interaction: &Interactio router.surfaces.insert(id.to_owned(), surface); } +pub fn record_native_input(id: &str, bounds: Bounds) { + let mut router = ROUTER + .get_or_init(|| Mutex::new(Router::default())) + .lock() + .expect("pointer router lock"); + router.order = router.order.wrapping_add(1).max(1); + let input = NativeInput { + id: id.to_owned(), + bounds: [ + f32::from(bounds.origin.x), + f32::from(bounds.origin.y), + f32::from(bounds.size.width), + f32::from(bounds.size.height), + ], + epoch: router.epoch, + order: router.order, + }; + router.native_inputs.insert(id.to_owned(), input); +} + +pub fn native_input_at(x: f32, y: f32) -> Option { + let router = ROUTER + .get_or_init(|| Mutex::new(Router::default())) + .lock() + .expect("pointer router lock"); + hit_native_input(&router, x, y).map(|input| input.id.clone()) +} + pub fn take_samples() -> Vec { SAMPLES .get_or_init(|| Mutex::new(VecDeque::new())) @@ -318,6 +363,14 @@ fn hit_surface(router: &Router, x: f32, y: f32, event_time_nanos: u64) -> Option }) } +fn hit_native_input(router: &Router, x: f32, y: f32) -> Option<&NativeInput> { + router + .native_inputs + .values() + .filter(|input| contains(input.bounds, x, y)) + .max_by_key(|input| input.order) +} + fn contains(bounds: [f32; 4], x: f32, y: f32) -> bool { x >= bounds[0] && y >= bounds[1] && x <= bounds[0] + bounds[2] && y <= bounds[1] + bounds[3] } @@ -342,6 +395,15 @@ fn push_sample(sample: Sample) { samples.push_back(sample); } +#[cfg(target_os = "linux")] +fn push_sample_and_wake(sample: Sample, wakes: &async_channel::Sender<()>) { + push_sample(sample); + // Raw Wayland touch bypasses GPUI's element input path. Mark the host + // dirty through its foreground task instead of waiting for an unrelated + // mouse/key event to trigger the next entity render. + let _ = wakes.try_send(()); +} + #[cfg(test)] mod tests { use super::*; @@ -353,6 +415,61 @@ mod tests { assert_eq!(phase_name(Phase::Cancel), "cancel"); } + #[test] + fn native_input_hit_uses_the_topmost_recorded_bounds() { + let mut router = Router::default(); + router.native_inputs.insert( + "note-draft".into(), + NativeInput { + id: "note-draft".into(), + bounds: [10.0, 10.0, 80.0, 40.0], + epoch: 1, + order: 1, + }, + ); + router.native_inputs.insert( + "agent-prompt".into(), + NativeInput { + id: "agent-prompt".into(), + bounds: [20.0, 20.0, 80.0, 40.0], + epoch: 1, + order: 2, + }, + ); + + assert_eq!( + hit_native_input(&router, 25.0, 25.0).map(|input| input.id.as_str()), + Some("agent-prompt") + ); + assert_eq!( + hit_native_input(&router, 15.0, 15.0).map(|input| input.id.as_str()), + Some("note-draft") + ); + assert!(hit_native_input(&router, 200.0, 200.0).is_none()); + } + + #[cfg(target_os = "linux")] + #[test] + fn raw_touch_queues_one_coalesced_host_wake() { + take_samples(); + let (wake_tx, wake_rx) = async_channel::bounded(1); + let sample = Sample { + id: 1, + phase: Phase::Down, + x: 4.0, + y: 8.0, + pressure: 1.0, + pointer_count: 1, + event_time_nanos: 10, + }; + push_sample_and_wake(sample, &wake_tx); + push_sample_and_wake(sample, &wake_tx); + + assert!(wake_rx.try_recv().is_ok()); + assert!(wake_rx.try_recv().is_err()); + assert_eq!(take_samples().len(), 2); + } + #[test] fn surface_capture_routes_two_pointer_transform_until_release() { begin_frame(); diff --git a/apps/experience/src/lib.rs b/apps/experience/src/lib.rs index 0726488..a8ecae8 100644 --- a/apps/experience/src/lib.rs +++ b/apps/experience/src/lib.rs @@ -97,6 +97,37 @@ mod tests { .unwrap(); assert!(contains_action(&stock_scene.root, "audio_volume_up")); assert!(contains_agent_composer(&stock_scene.root)); + for source in [ + super::DEFAULT_EXPERIENCE, + super::TIMEFLOW_EXPERIENCE, + super::DAILY_FLOW_EXPERIENCE, + ] { + let runtime = runtime_luau::LuauRuntime::compile(source).unwrap(); + let model = providers_fake::snapshot(); + let scene = runtime.render(&model, &runtime.initial_state()).unwrap(); + for action in [ + "agent_configure_openai", + "agent_configure_openrouter", + "agent_configure_codex", + "agent_use_fake", + "agent_clear_credential", + ] { + assert!(!contains_action(&scene.root, action)); + } + + let mut configurable = model; + configurable.agent.configuration_actions = vec![ + experience_ir::AgentConfigurationAction::ConfigureCodex, + experience_ir::AgentConfigurationAction::UseFake, + ]; + let scene = runtime + .render(&configurable, &runtime.initial_state()) + .unwrap(); + assert!(contains_action(&scene.root, "agent_configure_codex")); + assert!(contains_action(&scene.root, "agent_use_fake")); + assert!(!contains_action(&scene.root, "agent_configure_openai")); + assert!(!contains_action(&scene.root, "agent_configure_openrouter")); + } let outcome = runtime .update_with_effects( &stock_model, diff --git a/apps/experience/src/linux.rs b/apps/experience/src/linux.rs index a87da10..821d81b 100644 --- a/apps/experience/src/linux.rs +++ b/apps/experience/src/linux.rs @@ -16,9 +16,9 @@ use experience_ir::{ MAX_AGENT_MESSAGES, MAX_AGENT_MESSAGE_BYTES, }; use gpui::{ - div, img, prelude::*, px, relative, rgb, size, Animation as GpuiAnimation, AnimationExt as _, - AnyElement, App, Bounds, Context, Entity, MouseButton, Render, SharedString, Window, - WindowBounds, WindowOptions, + div, img, point, prelude::*, px, relative, rgb, size, Animation as GpuiAnimation, + AnimationExt as _, AnyElement, App, Bounds, Context, Entity, MouseButton, Render, SharedString, + Window, WindowBounds, WindowOptions, }; use provider_state_service::ServiceClient; use providers_linux::{load_grants, ProviderContext, ProviderFrame, ProviderHub, ProviderSnapshot}; @@ -205,7 +205,7 @@ struct DurableState { } pub fn run() -> Result<()> { - pointer_input::install(); + let touch_wakes = pointer_input::install(); let options = parse_options(std::env::args().skip(1))?; let accessibility = linux_accessibility::start_from_environment().map_err(|error| anyhow::anyhow!(error))?; @@ -297,6 +297,7 @@ pub fn run() -> Result<()> { accessibility, options.service_socket, compositor_fence, + touch_wakes, cx, ) }) @@ -332,7 +333,9 @@ pub(super) struct LinuxExperienceHost { action_in_flight: bool, pending_input_events: VecDeque, inputs: HashMap>, + input_state_shadow: HashMap, active_input_id: Option, + pending_focus_restore: Option, action_commits: async_channel::Sender, next_action_request_id: u64, surface_gestures: HashMap, @@ -365,6 +368,7 @@ impl LinuxExperienceHost { accessibility: Option, service_socket: Option, compositor_fence: Option, + touch_wakes: async_channel::Receiver<()>, cx: &mut Context, ) -> Self { let (action_commits, action_results) = async_channel::unbounded(); @@ -374,6 +378,7 @@ impl LinuxExperienceHost { Self::attach_provider_updates(provider_updates, cx); Self::attach_action_results(action_results, cx); Self::attach_agent_updates(agent_results, cx); + Self::attach_touch_wakes(touch_wakes, cx); if let Some(accessibility) = &accessibility { Self::attach_accessibility_actions(accessibility.actions(), cx); } @@ -406,7 +411,9 @@ impl LinuxExperienceHost { action_in_flight: false, pending_input_events: VecDeque::new(), inputs: HashMap::new(), + input_state_shadow: HashMap::new(), active_input_id: None, + pending_focus_restore: None, action_commits, next_action_request_id: 1, surface_gestures: HashMap::new(), @@ -581,6 +588,22 @@ impl LinuxExperienceHost { .detach(); } + fn attach_touch_wakes(wakes: async_channel::Receiver<()>, cx: &mut Context) { + cx.spawn(async move |this, cx| { + while wakes.recv().await.is_ok() { + if this + .update(cx, |_, cx| { + cx.notify(); + }) + .is_err() + { + break; + } + } + }) + .detach(); + } + fn handle_agent_update(&mut self, update: AgentUpdate, cx: &mut Context) { match update { AgentUpdate::Started { prompt } => { @@ -989,6 +1012,7 @@ impl LinuxExperienceHost { return; } let revision_id = commit.revision.revision.revision_id.clone(); + self.pending_focus_restore = self.active_input_id.clone(); if let Some(fence) = &self.compositor_fence { let after_commit_sequence = match fence .arm(commit.present_request_id, &revision_id) @@ -1096,7 +1120,9 @@ impl LinuxExperienceHost { let Some(service_socket) = self.service_socket.clone() else { self.action_in_flight = false; if effects.is_empty() { + reconcile_input_state_shadow(&mut self.input_state_shadow, &state); self.state = state; + merge_input_state_shadow(&mut self.state, &self.input_state_shadow); self.scene = scene; self.status = None; } else { @@ -1166,7 +1192,12 @@ impl LinuxExperienceHost { result.request_id ); } else { + reconcile_input_state_shadow( + &mut self.input_state_shadow, + &authoritative.state, + ); self.state = authoritative.state; + merge_input_state_shadow(&mut self.state, &self.input_state_shadow); self.scene = result.scene; self.status = None; eprintln!( @@ -1232,8 +1263,9 @@ impl LinuxExperienceHost { self.state = serde_json::json!({}); } if let Some(object) = self.state.as_object_mut() { - object.insert(state_key, JsonValue::String(value.clone())); + object.insert(state_key.clone(), JsonValue::String(value.clone())); } + self.input_state_shadow.insert(state_key, value.clone()); eprintln!( "sos_linux_text_changed node_id={} bytes={}", node_id, @@ -1310,6 +1342,7 @@ impl LinuxExperienceHost { let request_id = self.next_action_request_id; self.next_action_request_id = self.next_action_request_id.wrapping_add(1).max(1); self.action_in_flight = true; + merge_input_state_shadow(&mut self.state, &self.input_state_shadow); if let Err(error) = self.worker .action(request_id, self.model.clone(), self.state.clone(), event) @@ -1329,6 +1362,15 @@ impl LinuxExperienceHost { && self.pending_presentation.is_none() { self.dispatch_event(event, cx); + } else { + eprintln!( + "sos_input_event_blocked action={} preparing={} prepared={} pending_commit={} pending_presentation={}", + event.action, + self.preparing.is_some(), + self.prepared.is_some(), + self.pending_commit.is_some(), + self.pending_presentation.is_some() + ); } } @@ -1808,6 +1850,11 @@ impl LinuxExperienceHost { .child(SharedString::from(text.value.clone())); } if let Some(Content::TextSession(input)) = &node.content { + let displayed_value = self + .input_state_shadow + .get(&input.state_key) + .cloned() + .unwrap_or_else(|| input.value.clone()); let created = !self.inputs.contains_key(&element_id); let native = if let Some(native) = self.inputs.get(&element_id) { native.clone() @@ -1829,11 +1876,11 @@ impl LinuxExperienceHost { native }; let should_activate = (created && input.autofocus) - || self.active_input_id.as_deref() == Some(element_id.as_str()); + || self.pending_focus_restore.as_deref() == Some(element_id.as_str()); native.update(cx, |native, native_cx| { native.sync( &input.state_key, - &input.value, + &displayed_value, &input.placeholder, input.submit_action.as_deref(), window, @@ -1843,6 +1890,9 @@ impl LinuxExperienceHost { native.activate(window, native_cx); } }); + if self.pending_focus_restore.as_deref() == Some(element_id.as_str()) { + self.pending_focus_restore = None; + } element = element .border_1() .border_color(rgb(0x98A29B)) @@ -2023,6 +2073,22 @@ impl Render for LinuxExperienceHost { ); } for sample in pointer_input::take_samples() { + if sample.phase == pointer_input::Phase::Down { + if let Some(node_id) = pointer_input::native_input_at(sample.x, sample.y) { + if let Some(input) = self.inputs.get(&node_id).cloned() { + let position = point(px(sample.x), px(sample.y)); + eprintln!( + "sos_linux_touch_focus node_id={node_id} x={:.1} y={:.1}", + sample.x, sample.y + ); + window.defer(cx, move |window, cx| { + input.update(cx, |input, input_cx| { + input.focus_at(position, window, input_cx) + }); + }); + } + } + } for event in pointer_input::route(sample) { self.queue_input_event(event, cx); } @@ -2081,6 +2147,23 @@ fn node_by_id<'a>(node: &'a SceneNode, id: &str) -> Option<&'a SceneNode> { node.children.iter().find_map(|child| node_by_id(child, id)) } +fn merge_input_state_shadow(state: &mut JsonValue, shadow: &HashMap) { + if !state.is_object() { + *state = serde_json::json!({}); + } + if let Some(object) = state.as_object_mut() { + for (key, value) in shadow { + object.insert(key.clone(), JsonValue::String(value.clone())); + } + } +} + +fn reconcile_input_state_shadow(shadow: &mut HashMap, authoritative: &JsonValue) { + shadow.retain(|key, value| { + authoritative.get(key).and_then(JsonValue::as_str) != Some(value.as_str()) + }); +} + fn semantic_ids(scene: &Scene) -> Vec { fn visit(node: &SceneNode, output: &mut Vec) { if let Some(id) = node @@ -2637,6 +2720,20 @@ mod tests { assert!(parse_options(["--unknown".into()].into_iter()).is_err()); } + #[test] + fn newer_native_text_survives_an_older_authority_result_until_caught_up() { + let mut shadow = HashMap::from([("draft".into(), "newest".into())]); + let mut local = serde_json::json!({"draft": "older"}); + + reconcile_input_state_shadow(&mut shadow, &local); + assert_eq!(shadow.get("draft").map(String::as_str), Some("newest")); + merge_input_state_shadow(&mut local, &shadow); + assert_eq!(local["draft"], "newest"); + + reconcile_input_state_shadow(&mut shadow, &serde_json::json!({"draft": "newest"})); + assert!(shadow.is_empty()); + } + #[test] fn provider_effects_remain_typed_at_the_linux_boundary() { let effect = experience_ir::ProviderEffect { diff --git a/apps/experience/src/linux_input.rs b/apps/experience/src/linux_input.rs index 0b4a490..56a59bc 100644 --- a/apps/experience/src/linux_input.rs +++ b/apps/experience/src/linux_input.rs @@ -10,7 +10,7 @@ use gpui::{ }; use unicode_segmentation::UnicodeSegmentation as _; -use crate::linux::LinuxExperienceHost; +use crate::{linux::LinuxExperienceHost, pointer_input}; actions!( sos_linux_text_input, @@ -136,6 +136,18 @@ impl NativeTextInput { } } + pub fn focus_at( + &mut self, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + if !self.focus_handle.is_focused(window) { + window.focus(&self.focus_handle, cx); + } + self.move_to(self.index_for_mouse_position(position), cx); + } + pub fn accessibility_set_value( &mut self, value: &str, @@ -751,7 +763,11 @@ impl Element for TextElement { window: &mut Window, cx: &mut App, ) { - let focus = self.input.read(cx).focus_handle.clone(); + let (focus, node_id) = { + let input = self.input.read(cx); + (input.focus_handle.clone(), input.node_id.clone()) + }; + pointer_input::record_native_input(&node_id, bounds); window.handle_input( &focus, ElementInputHandler::new(bounds, self.input.clone()), diff --git a/crates/experience-ir/src/lib.rs b/crates/experience-ir/src/lib.rs index 5f7cdd6..6c0a6fe 100644 --- a/crates/experience-ir/src/lib.rs +++ b/crates/experience-ir/src/lib.rs @@ -252,6 +252,22 @@ pub struct AgentConversation { pub messages: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, + #[serde(default)] + pub configuration_actions: Vec, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum AgentConfigurationAction { + #[serde(rename = "configure_openai")] + ConfigureOpenAi, + #[serde(rename = "configure_openrouter")] + ConfigureOpenRouter, + #[serde(rename = "configure_codex")] + ConfigureCodex, + #[serde(rename = "use_fake")] + UseFake, + #[serde(rename = "clear_credential")] + ClearCredential, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1086,6 +1102,27 @@ fn valid_scene_number(value: f32) -> bool { mod tests { use super::*; + #[test] + fn agent_configuration_actions_have_stable_wire_names() { + assert_eq!( + serde_json::to_value([ + AgentConfigurationAction::ConfigureOpenAi, + AgentConfigurationAction::ConfigureOpenRouter, + AgentConfigurationAction::ConfigureCodex, + AgentConfigurationAction::UseFake, + AgentConfigurationAction::ClearCredential, + ]) + .unwrap(), + serde_json::json!([ + "configure_openai", + "configure_openrouter", + "configure_codex", + "use_fake", + "clear_credential" + ]) + ); + } + #[test] fn rejects_duplicate_ids() { let scene = Scene { diff --git a/crates/linux-session/src/system_session.rs b/crates/linux-session/src/system_session.rs index 71406f3..319ad30 100644 --- a/crates/linux-session/src/system_session.rs +++ b/crates/linux-session/src/system_session.rs @@ -30,6 +30,8 @@ use crate::{bootstrap_authority, shutdown_authority, stage_revision}; const POLL_INTERVAL: Duration = Duration::from_millis(20); const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); +const HOST_PROXY_DISCONNECT_GRACE: Duration = Duration::from_millis(250); +const SESSION_EXIT_REQUEST: &[u8] = b"logout\n"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ServiceIdentity { @@ -94,6 +96,7 @@ struct SessionProcesses { compositor: Option, provider: Option, supervisor: Option, + host_launcher: Option, } pub fn run_system_session(options: SystemSessionOptions) -> Result<()> { @@ -267,6 +270,16 @@ fn start_and_monitor( .with_context(|| format!("bind {}", recovery_command_socket.display()))?; fs::set_permissions(&recovery_command_socket, fs::Permissions::from_mode(0o660))?; recovery_socket.set_nonblocking(true)?; + let session_exit_socket_path = options.runtime_directory.join("session-exit.sock"); + let session_exit_socket = if options.identity_mode == SessionIdentityMode::SharedLoginUser { + let socket = UnixDatagram::bind(&session_exit_socket_path) + .with_context(|| format!("bind {}", session_exit_socket_path.display()))?; + fs::set_permissions(&session_exit_socket_path, fs::Permissions::from_mode(0o600))?; + socket.set_nonblocking(true)?; + Some(socket) + } else { + None + }; write_recovery_status( &recovery_state_file, &store, @@ -300,6 +313,9 @@ fn start_and_monitor( let mut compositor_command = role_command(&options.compositor_executable, &options.compositor_identity); + if session_exit_socket.is_some() { + compositor_command.env("SOS_SESSION_EXIT_SOCKET", &session_exit_socket_path); + } let compositor = compositor_command .arg("--backend") .arg("drm") @@ -394,7 +410,7 @@ fn start_and_monitor( )?; println!("linux_system_session_authority outcome={bootstrap:?}"); - let _host_launcher = HostLauncher::start( + processes.host_launcher = Some(HostLauncher::start( &host_launcher_socket, HostLaunchSpec { executable: options.host_executable.clone(), @@ -415,7 +431,7 @@ fn start_and_monitor( supervisor_uid: options.supervisor_identity.uid, registry: registry.clone(), }, - )?; + )?); let mut supervisor_command = role_command(&options.supervisor_executable, &options.supervisor_identity); let supervisor = supervisor_command @@ -479,6 +495,12 @@ fn start_and_monitor( if stopping.load(Ordering::Relaxed) { return Ok(()); } + if let Some(socket) = &session_exit_socket { + if take_session_exit_request(socket)? { + println!("linux_login_session_stopped reason=user_logout"); + return Ok(()); + } + } for (name, child) in [ ("compositor", processes.compositor.as_mut().unwrap()), ("provider", processes.provider.as_mut().unwrap()), @@ -530,6 +552,19 @@ fn start_and_monitor( } } +fn take_session_exit_request(socket: &UnixDatagram) -> Result { + let mut buffer = [0_u8; 32]; + match socket.recv(&mut buffer) { + Ok(size) if &buffer[..size] == SESSION_EXIT_REQUEST => Ok(true), + Ok(size) => bail!( + "invalid selectable login-session exit request: {:?}", + String::from_utf8_lossy(&buffer[..size]) + ), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(false), + Err(error) => Err(error).context("receive selectable login-session exit request"), + } +} + fn wait_for_socket( path: &Path, description: &str, @@ -932,6 +967,7 @@ fn launch_host(mut stream: UnixStream, spec: &HostLaunchSpec) -> Result<()> { .spawn() .with_context(|| format!("launch isolated experience host as {}", spec.identity.name))?; let pid = child.id(); + let host_pid = Pid::from_raw(i32::try_from(pid).context("host PID exceeds i32")?); spec.registry.record("host", &spec.executable, pid)?; println!( "linux_system_session_component component=host pid={pid} uid={}", @@ -940,7 +976,21 @@ fn launch_host(mut stream: UnixStream, spec: &HostLaunchSpec) -> Result<()> { let mut child_input = child.stdin.take().context("host stdin was not piped")?; let mut child_output = child.stdout.take().context("host stdout was not piped")?; let input_stream = stream.try_clone()?; - let input = thread::spawn(move || pump_lines(BufReader::new(input_stream), &mut child_input)); + let input = thread::spawn(move || { + let result = pump_lines(BufReader::new(input_stream), &mut child_input); + // EOF means the supervisor-side proxy disappeared. The proxy is only + // transport; allow an already-delivered Shutdown request a short grace + // period, then reap a host that would otherwise overlap the replacement + // the supervisor is about to launch. + let deadline = Instant::now() + HOST_PROXY_DISCONNECT_GRACE; + while process_is_running(pid) && Instant::now() < deadline { + thread::sleep(POLL_INTERVAL); + } + if process_is_running(pid) { + let _ = kill(host_pid, Signal::SIGKILL); + } + result + }); let output_result = pump_lines(BufReader::new(&mut child_output), &mut stream); let _ = stream.shutdown(std::net::Shutdown::Write); let _ = input.join(); @@ -1243,6 +1293,9 @@ fn shutdown_processes(options: &SystemSessionOptions, processes: &mut SessionPro let _ = shutdown_authority(&provider_socket, SHUTDOWN_TIMEOUT); } terminate_child("supervisor", &mut processes.supervisor); + // The supervisor may reconnect its host proxy while shutting down. Keep + // the launcher socket available until the supervisor has actually exited. + drop(processes.host_launcher.take()); terminate_child("provider", &mut processes.provider); terminate_child("compositor", &mut processes.compositor); } @@ -1281,6 +1334,7 @@ fn terminate_child(name: &str, slot: &mut Option) { #[cfg(test)] mod tests { use super::*; + use std::sync::mpsc; fn identity(name: &str, uid: u32) -> ServiceIdentity { ServiceIdentity { @@ -1324,4 +1378,103 @@ mod tests { .unwrap_err(); assert!(error.to_string().contains("must all use the current UID")); } + + #[test] + fn lifecycle_owner_consumes_one_logout_request() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("session-exit.sock"); + let receiver = UnixDatagram::bind(&path).unwrap(); + receiver.set_nonblocking(true).unwrap(); + let sender = UnixDatagram::unbound().unwrap(); + sender.send_to(SESSION_EXIT_REQUEST, &path).unwrap(); + + assert!(take_session_exit_request(&receiver).unwrap()); + assert!(!take_session_exit_request(&receiver).unwrap()); + } + + #[test] + fn graceful_host_shutdown_wins_the_proxy_disconnect_grace() { + let temporary = tempfile::tempdir().unwrap(); + let spec = HostLaunchSpec { + executable: PathBuf::from("/usr/bin/bash"), + args: vec![ + "-c".into(), + "read -r _; exit 0".into(), + "graceful-host-fixture".into(), + ], + identity: ServiceIdentity::current().unwrap(), + runtime_directory: temporary.path().to_path_buf(), + cache_directory: temporary.path().to_path_buf(), + wayland_display: "wayland-test".into(), + control_socket: temporary.path().join("control.sock"), + token_file: temporary.path().join("token"), + safe_mode_file: temporary.path().join("safe-mode"), + provider_disable_file: temporary.path().join("providers-disabled"), + supervisor_uid: Uid::effective(), + registry: ProcessRegistry::new(temporary.path().join("registry.json")), + }; + let (launcher_stream, mut proxy_stream) = UnixStream::pair().unwrap(); + let (done_tx, done_rx) = mpsc::channel(); + thread::spawn(move || { + let _ = done_tx.send(launch_host(launcher_stream, &spec)); + }); + + proxy_stream.write_all(b"shutdown\n").unwrap(); + drop(proxy_stream); + + done_rx + .recv_timeout(Duration::from_secs(2)) + .expect("graceful host launcher remained blocked") + .expect("graceful host exit was classified as a failure"); + } + + #[test] + fn proxy_disconnect_reaps_the_actual_isolated_host() { + let temporary = tempfile::tempdir().unwrap(); + let pid_file = temporary.path().join("host.pid"); + let executable = PathBuf::from("/usr/bin/bash"); + let current = ServiceIdentity::current().unwrap(); + let spec = HostLaunchSpec { + executable, + args: vec![ + "-c".into(), + "echo $$ > \"$1\"; trap '' TERM; while :; do :; done".into(), + "isolated-host-fixture".into(), + pid_file.clone().into_os_string(), + ], + identity: current, + runtime_directory: temporary.path().to_path_buf(), + cache_directory: temporary.path().to_path_buf(), + wayland_display: "wayland-test".into(), + control_socket: temporary.path().join("control.sock"), + token_file: temporary.path().join("token"), + safe_mode_file: temporary.path().join("safe-mode"), + provider_disable_file: temporary.path().join("providers-disabled"), + supervisor_uid: Uid::effective(), + registry: ProcessRegistry::new(temporary.path().join("registry.json")), + }; + let (launcher_stream, proxy_stream) = UnixStream::pair().unwrap(); + let (done_tx, done_rx) = mpsc::channel(); + thread::spawn(move || { + let _ = done_tx.send(launch_host(launcher_stream, &spec)); + }); + + let deadline = Instant::now() + Duration::from_secs(2); + while !pid_file.exists() && Instant::now() < deadline { + thread::sleep(POLL_INTERVAL); + } + let pid: u32 = fs::read_to_string(&pid_file) + .unwrap() + .trim() + .parse() + .unwrap(); + assert!(process_is_running(pid)); + + drop(proxy_stream); + let result = done_rx + .recv_timeout(Duration::from_secs(2)) + .expect("host launcher remained blocked after proxy disconnect"); + assert!(result.is_err()); + assert!(!process_is_running(pid)); + } } diff --git a/crates/runtime-luau/src/lib.rs b/crates/runtime-luau/src/lib.rs index 8c5f6ba..e3042fc 100644 --- a/crates/runtime-luau/src/lib.rs +++ b/crates/runtime-luau/src/lib.rs @@ -1144,9 +1144,16 @@ fn decode_effects(table: Option, lua: &Lua) -> Result | ("agent", "configure_codex") | ("agent", "use_fake") | ("agent", "clear_credential") + | ("audio", "set_volume") + | ("audio", "set_muted") + | ("media", "play_pause") + | ("media", "next") + | ("media", "previous") | ("network", "refresh") | ("network", "connect") | ("network", "disconnect") + | ("apps", "launch") + | ("attention", "acknowledge") ) { return Err(RuntimeError::Invalid(format!( "provider action is not allowed: {provider}.{action}" diff --git a/crates/sos-compositor/Cargo.toml b/crates/sos-compositor/Cargo.toml index 7f74734..ddd31b2 100644 --- a/crates/sos-compositor/Cargo.toml +++ b/crates/sos-compositor/Cargo.toml @@ -21,6 +21,9 @@ smithay-drm-extras = { version = "=0.1.0", default-features = false, optional = tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +[dev-dependencies] +tempfile = "3" + [features] default = ["nested-backend"] direct-backend = [ diff --git a/crates/sos-compositor/src/direct.rs b/crates/sos-compositor/src/direct.rs index ef3f6f9..816bb7d 100644 --- a/crates/sos-compositor/src/direct.rs +++ b/crates/sos-compositor/src/direct.rs @@ -3,7 +3,7 @@ // SOS policy independent from KMS and releases an activation fence only from the // VBlank event corresponding to the queued shell buffer. -use std::{collections::HashMap, fs, path::Path, time::Duration}; +use std::{cell::RefCell, collections::HashMap, fs, path::Path, rc::Rc, time::Duration}; use anyhow::{bail, Context as _, Result}; use compositor_control_protocol::{PresentationClock, PresentationEvidence}; @@ -19,7 +19,7 @@ use smithay::{ compositor::FrameFlags, exporter::gbm::GbmFramebufferExporter, output::{DrmOutput, DrmOutputManager, DrmOutputRenderElements}, - DrmDevice, DrmDeviceFd, DrmEvent, DrmEventMetadata, DrmEventTime, DrmNode, + DrmDevice, DrmDeviceFd, DrmEvent, DrmEventMetadata, DrmEventTime, DrmNode, NodeType, }, egl::{EGLContext, EGLDevice, EGLDisplay}, input::{Device as _, InputEvent}, @@ -31,7 +31,7 @@ use smithay::{ Kind, RenderElementStates, }, gles::GlesRenderer, - ImportEgl, ImportMemWl, + ImportDma, ImportEgl, ImportMemWl, }, session::{libseat::LibSeatSession, Event as SessionEvent, Session}, udev::{UdevBackend, UdevEvent}, @@ -53,8 +53,11 @@ use smithay::{ wayland_server::{protocol::wl_surface::WlSurface, Resource as _}, }, utils::{DeviceFd, Monotonic, Time, Transform}, - wayland::compositor, - wayland::presentation::Refresh, + wayland::{ + compositor, + dmabuf::{DmabufFeedbackBuilder, DmabufState}, + presentation::Refresh, + }, }; use smithay_drm_extras::drm_scanner::{DrmScanEvent, DrmScanner}; @@ -94,7 +97,7 @@ struct OutputData { struct DeviceData { event_token: RegistrationToken, - renderer: GlesRenderer, + renderer: Rc>, manager: DrmOutputManager< GbmAllocator, GbmFramebufferExporter, @@ -167,7 +170,7 @@ pub fn init_direct( _ => {} } data.state.process_input_event(event); - if data.state.take_session_exit_request() { + if data.state.handoff_session_exit_request() { data.loop_signal.stop(); } }) @@ -220,6 +223,7 @@ pub fn init_direct( let node = DrmNode::from_dev_id(device_id).context("identify DRM node")?; add_device(&loop_handle, data, node, &path)?; } + refresh_dmabuf_global(data)?; if data.state.space.outputs().next().is_none() { bail!("no connected desktop DRM output was found"); } @@ -241,6 +245,8 @@ pub fn init_direct( } if let Err(error) = add_device(&udev_loop_handle, data, node, &path) { tracing::error!(%error, ?node, path = %path.display(), "could not hot-add DRM device"); + } else if let Err(error) = refresh_dmabuf_global(data) { + tracing::error!(%error, ?node, "could not update dmabuf feedback after hot-add"); } else { tracing::info!(?node, path = %path.display(), "hot-added DRM device"); } @@ -251,6 +257,7 @@ pub fn init_direct( }; let result = refresh_output_config(data) .and_then(|changed| (!changed).then(|| scan_connectors(data, node)).transpose()) + .and_then(|_| refresh_dmabuf_global(data)) .map(|_| ()); if let Err(error) = result { tracing::error!(%error, ?node, "could not apply DRM connector hotplug"); @@ -310,11 +317,13 @@ fn add_device( unsafe { EGLDisplay::new(gbm.clone()) }.context("initialize GBM EGL display")?; let render_node = EGLDevice::device_for_display(&egl_display) .ok() - .and_then(|device| device.try_get_render_node().ok().flatten()); + .and_then(|device| device.try_get_render_node().ok().flatten()) + .or_else(|| node.node_with_type(NodeType::Render).and_then(Result::ok)); + let feedback_node = render_node.unwrap_or(node); let context = EGLContext::new(&egl_display).context("create EGL context")?; let mut renderer = unsafe { GlesRenderer::new(context) }.context("create GLES renderer")?; if let Err(error) = renderer.bind_wl_display(&data.display_handle) { - tracing::warn!(%error, "EGL Wayland display binding is unavailable; wl_shm remains enabled"); + tracing::warn!(%error, "EGL Wayland display binding is unavailable; continuing with explicit Linux dmabuf and wl_shm paths"); } data.state.shm_state.update_formats(renderer.shm_formats()); let render_formats = renderer @@ -323,6 +332,7 @@ fn add_device( .iter() .copied() .collect::(); + let renderer = Rc::new(RefCell::new(renderer)); let allocator = GbmAllocator::new( gbm.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT, @@ -340,16 +350,91 @@ fn add_device( node, DeviceData { event_token, - renderer, + renderer: Rc::clone(&renderer), manager, scanner: DrmScanner::new(), outputs: HashMap::new(), }, ); + data.state.dmabuf_renderers.insert(node, renderer); + data.state.dmabuf_render_nodes.insert(node, feedback_node); scan_connectors(data, node)?; Ok(()) } +fn common_dmabuf_formats(mut sets: impl Iterator) -> FormatSet { + let Some(mut common) = sets.next() else { + return FormatSet::default(); + }; + for formats in sets { + common = common.intersection(&formats).copied().collect(); + } + common +} + +fn refresh_dmabuf_global(data: &mut CompositorData) -> Result<()> { + let active_devices = data + .direct + .as_ref() + .context("direct backend is missing")? + .devices + .iter() + .filter_map(|(node, device)| (!device.outputs.is_empty()).then_some(*node)) + .collect(); + data.state.dmabuf_active_devices = active_devices; + let state = &mut data.state; + let mut devices = state + .dmabuf_active_devices + .iter() + .copied() + .collect::>(); + devices.sort_by_key(|node| (node.major(), node.minor())); + let Some(primary_device) = devices.first().copied() else { + state.dmabuf_primary = None; + tracing::warn!("no connected direct renderer is available for Linux dmabuf"); + return Ok(()); + }; + let primary_render_node = *state + .dmabuf_render_nodes + .get(&primary_device) + .context("direct renderer has no dmabuf render node")?; + let format_sets = devices + .iter() + .map(|node| { + state + .dmabuf_renderers + .get(node) + .expect("listed dmabuf renderer exists") + .borrow() + .dmabuf_formats() + }) + .collect::>(); + let formats = common_dmabuf_formats(format_sets.into_iter()); + if formats.iter().next().is_none() { + bail!("direct renderers share no importable dmabuf format"); + } + let format_count = formats.iter().count(); + let feedback = DmabufFeedbackBuilder::new(primary_render_node.dev_id(), formats) + .build() + .context("build direct renderer dmabuf feedback")?; + if let Some((dmabuf_state, global)) = &state.dmabuf_state { + dmabuf_state.set_default_feedback(global, &feedback); + } else { + let mut dmabuf_state = DmabufState::new(); + let global = dmabuf_state + .create_global_with_default_feedback::(&state.display_handle, &feedback); + state.dmabuf_state = Some((dmabuf_state, global)); + } + state.dmabuf_primary = Some(primary_render_node); + tracing::info!( + ?primary_render_node, + format_count, + renderers = devices.len(), + "advertised Linux dmabuf feedback" + ); + Ok(()) +} + fn remove_device( loop_handle: &LoopHandle<'_, CompositorData>, data: &mut CompositorData, @@ -365,6 +450,12 @@ fn remove_device( for output in device.outputs.drain().map(|(_, output)| output.output) { data.state.space.unmap_output(&output); } + data.state.dmabuf_renderers.remove(&node); + data.state.dmabuf_render_nodes.remove(&node); + data.state.dmabuf_active_devices.remove(&node); + if let Err(error) = refresh_dmabuf_global(data) { + tracing::error!(%error, ?node, "could not update dmabuf feedback after device removal"); + } loop_handle.remove(device.event_token); update_output_layout(&mut data.state); } @@ -517,6 +608,7 @@ fn connect_output( .get_mut(&node) .context("DRM device is missing")?; let planes = device.manager.device().planes(&crtc)?; + let mut renderer = device.renderer.borrow_mut(); let drm_output = device .manager .initialize_output::<_, DirectRenderElement>( @@ -525,7 +617,7 @@ fn connect_output( &[connector.handle()], &output, Some(planes), - &mut device.renderer, + &mut *renderer, &DrmOutputRenderElements::default(), ) .context("initialize direct KMS output")?; @@ -673,9 +765,11 @@ fn render_output(data: &mut CompositorData, node: DrmNode, crtc: crtc::Handle) - if output_data.frame_pending { return Ok(()); } + let renderer = Rc::clone(&device.renderer); + let mut renderer = renderer.borrow_mut(); let mut elements = if output_data.needs_initial_damage { let marker = MemoryRenderBufferRenderElement::from_buffer( - &mut device.renderer, + &mut *renderer, (0.0, 0.0), &direct.initial_damage_buffer, None, @@ -689,40 +783,29 @@ fn render_output(data: &mut CompositorData, node: DrmNode, crtc: crtc::Handle) - Vec::new() }; elements.extend(cursor_render_elements( - &mut device.renderer, + &mut renderer, state, &output_data.output, &direct.cursor_buffer, )); if state.policy.shell_mapped() { elements.extend(input_method_render_elements( - &mut device.renderer, + &mut renderer, state, &output_data.output, )); elements.extend( - space_render_elements( - &mut device.renderer, - [&state.space], - &output_data.output, - 1.0, - )? - .into_iter() - .map(DirectRenderElement::Space), + space_render_elements(&mut *renderer, [&state.space], &output_data.output, 1.0)? + .into_iter() + .map(DirectRenderElement::Space), ); - } else if let Some(element) = - recovery_render_element(&mut device.renderer, state, &output_data.output) + } else if let Some(element) = recovery_render_element(&mut renderer, state, &output_data.output) { elements.push(DirectRenderElement::Cursor(element)); } let result = output_data .drm_output - .render_frame( - &mut device.renderer, - &elements, - CLEAR_COLOR, - FrameFlags::DEFAULT, - ) + .render_frame(&mut *renderer, &elements, CLEAR_COLOR, FrameFlags::DEFAULT) .map_err(|error| anyhow::anyhow!("prepare direct frame: {error}"))?; if output_data.needs_initial_damage { tracing::info!( @@ -1041,7 +1124,9 @@ fn take_presentation_feedback( #[cfg(test)] mod tests { - use super::{default_cursor_pixels, CURSOR_HEIGHT, CURSOR_WIDTH}; + use smithay::backend::allocator::{format::FormatSet, Format, Fourcc, Modifier}; + + use super::{common_dmabuf_formats, default_cursor_pixels, CURSOR_HEIGHT, CURSOR_WIDTH}; #[test] fn fallback_cursor_has_a_stable_nonempty_extent() { @@ -1051,4 +1136,31 @@ mod tests { assert!(opaque > 40); assert!(opaque < (CURSOR_WIDTH * CURSOR_HEIGHT) as usize); } + + #[test] + fn dmabuf_feedback_advertises_only_formats_shared_by_every_renderer() { + let linear_argb = Format { + code: Fourcc::Argb8888, + modifier: Modifier::Linear, + }; + let implicit_argb = Format { + code: Fourcc::Argb8888, + modifier: Modifier::Invalid, + }; + let linear_abgr = Format { + code: Fourcc::Abgr8888, + modifier: Modifier::Linear, + }; + let first = [linear_argb, implicit_argb, linear_abgr] + .into_iter() + .collect::(); + let second = [linear_argb, linear_abgr] + .into_iter() + .collect::(); + let third = [linear_argb].into_iter().collect::(); + + let common = common_dmabuf_formats([first, second, third].into_iter()); + + assert_eq!(common.iter().copied().collect::>(), [linear_argb]); + } } diff --git a/crates/sos-compositor/src/handlers/compositor.rs b/crates/sos-compositor/src/handlers/compositor.rs index bd5362c..6a7ae7c 100644 --- a/crates/sos-compositor/src/handlers/compositor.rs +++ b/crates/sos-compositor/src/handlers/compositor.rs @@ -18,6 +18,13 @@ use smithay::{ use crate::{handlers::xdg_shell, state::ClientState, state::SosCompositor}; +#[cfg(feature = "direct-backend")] +use smithay::{ + backend::{allocator::dmabuf::Dmabuf, renderer::ImportDma}, + delegate_dmabuf, + wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier}, +}; + impl CompositorHandler for SosCompositor { fn compositor_state(&mut self) -> &mut CompositorState { &mut self.compositor_state @@ -69,3 +76,51 @@ impl ShmHandler for SosCompositor { delegate_compositor!(SosCompositor); delegate_shm!(SosCompositor); + +#[cfg(feature = "direct-backend")] +impl DmabufHandler for SosCompositor { + fn dmabuf_state(&mut self) -> &mut DmabufState { + &mut self + .dmabuf_state + .as_mut() + .expect("direct backend initializes dmabuf state before accepting clients") + .0 + } + + fn dmabuf_imported( + &mut self, + _global: &DmabufGlobal, + dmabuf: Dmabuf, + notifier: ImportNotifier, + ) { + let Some(primary) = self.dmabuf_primary else { + tracing::warn!("rejected dmabuf because the direct renderer is unavailable"); + notifier.failed(); + return; + }; + + for (node, renderer) in &self.dmabuf_renderers { + if !self.dmabuf_active_devices.contains(node) { + continue; + } + let Ok(mut renderer) = renderer.try_borrow_mut() else { + tracing::warn!(?node, "rejected dmabuf while its direct renderer was busy"); + notifier.failed(); + return; + }; + if let Err(error) = renderer.import_dmabuf(&dmabuf, None) { + tracing::warn!(?node, %error, "direct renderer rejected client dmabuf"); + notifier.failed(); + return; + } + } + + dmabuf.set_node(primary); + if notifier.successful::().is_err() { + tracing::warn!("dmabuf client disappeared before buffer creation completed"); + } + } +} + +#[cfg(feature = "direct-backend")] +delegate_dmabuf!(SosCompositor); diff --git a/crates/sos-compositor/src/state.rs b/crates/sos-compositor/src/state.rs index 98c403c..435f764 100644 --- a/crates/sos-compositor/src/state.rs +++ b/crates/sos-compositor/src/state.rs @@ -4,9 +4,15 @@ use std::{ collections::{HashMap, HashSet}, ffi::OsString, + io, + os::unix::net::UnixDatagram, + path::{Path, PathBuf}, sync::Arc, }; +#[cfg(feature = "direct-backend")] +use std::{cell::RefCell, rc::Rc}; + use compositor_control_protocol::{CompositorEvent, PresentationEvidence}; use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; use smithay::{ @@ -37,6 +43,12 @@ use smithay::{ }, }; +#[cfg(feature = "direct-backend")] +use smithay::{ + backend::{drm::DrmNode, renderer::gles::GlesRenderer}, + wayland::dmabuf::{DmabufGlobal, DmabufState}, +}; + use crate::{ control::ControlCommand, input::TouchLifecycle, @@ -67,6 +79,7 @@ pub struct SosCompositor { pub recovery_button_pressed: bool, pub session_exit_enabled: bool, pub session_exit_requested: bool, + pub session_exit_socket: Option, pub compositor_state: CompositorState, pub xdg_shell_state: XdgShellState, @@ -89,6 +102,17 @@ pub struct SosCompositor { pub text_input_manager_state: TextInputManagerState, pub popups: PopupManager, pub seat: Seat, + + #[cfg(feature = "direct-backend")] + pub dmabuf_state: Option<(DmabufState, DmabufGlobal)>, + #[cfg(feature = "direct-backend")] + pub dmabuf_renderers: HashMap>>, + #[cfg(feature = "direct-backend")] + pub dmabuf_render_nodes: HashMap, + #[cfg(feature = "direct-backend")] + pub dmabuf_active_devices: HashSet, + #[cfg(feature = "direct-backend")] + pub dmabuf_primary: Option, } impl SosCompositor { @@ -149,6 +173,7 @@ impl SosCompositor { recovery_button_pressed: false, session_exit_enabled: std::env::var("SOS_ALLOW_SESSION_EXIT").as_deref() == Ok("1"), session_exit_requested: false, + session_exit_socket: std::env::var_os("SOS_SESSION_EXIT_SOCKET").map(PathBuf::from), compositor_state, xdg_shell_state, xwayland_shell_state, @@ -164,6 +189,16 @@ impl SosCompositor { text_input_manager_state, popups, seat, + #[cfg(feature = "direct-backend")] + dmabuf_state: None, + #[cfg(feature = "direct-backend")] + dmabuf_renderers: HashMap::new(), + #[cfg(feature = "direct-backend")] + dmabuf_render_nodes: HashMap::new(), + #[cfg(feature = "direct-backend")] + dmabuf_active_devices: HashSet::new(), + #[cfg(feature = "direct-backend")] + dmabuf_primary: None, }) } @@ -171,6 +206,25 @@ impl SosCompositor { std::mem::take(&mut self.session_exit_requested) } + pub fn handoff_session_exit_request(&mut self) -> bool { + if !self.take_session_exit_request() { + return false; + } + let Some(socket) = self.session_exit_socket.as_deref() else { + return true; + }; + match notify_session_owner(socket) { + Ok(()) => { + tracing::info!(socket = %socket.display(), "handed logout request to session owner"); + false + } + Err(error) => { + tracing::warn!(%error, socket = %socket.display(), "could not hand logout request to session owner"); + true + } + } + } + fn init_wayland_listener( display: Display, event_loop: &mut EventLoop, @@ -389,6 +443,12 @@ impl SosCompositor { } } +fn notify_session_owner(path: &Path) -> io::Result<()> { + let socket = UnixDatagram::unbound()?; + socket.send_to(b"logout\n", path)?; + Ok(()) +} + pub struct ClientState { pub compositor_state: CompositorClientState, pub pid: u32, @@ -402,3 +462,22 @@ impl ClientData for ClientState { tracing::info!(pid = self.pid, role = ?self.role, ?reason, "Wayland client disconnected"); } } + +#[cfg(test)] +mod tests { + use super::notify_session_owner; + use std::os::unix::net::UnixDatagram; + + #[test] + fn session_exit_notification_reaches_the_lifecycle_owner() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("logout.sock"); + let receiver = UnixDatagram::bind(&path).unwrap(); + + notify_session_owner(&path).unwrap(); + + let mut buffer = [0_u8; 16]; + let size = receiver.recv(&mut buffer).unwrap(); + assert_eq!(&buffer[..size], b"logout\n"); + } +} diff --git a/crates/sos-compositor/src/winit.rs b/crates/sos-compositor/src/winit.rs index 089c6fe..efb77ed 100644 --- a/crates/sos-compositor/src/winit.rs +++ b/crates/sos-compositor/src/winit.rs @@ -78,7 +78,7 @@ pub fn init_winit( } WinitEvent::Input(event) => { data.state.process_input_event(event); - if data.state.take_session_exit_request() { + if data.state.handoff_session_exit_request() { data.loop_signal.stop(); } } diff --git a/docs/experience-api.md b/docs/experience-api.md index 9d45c53..ab1420c 100644 --- a/docs/experience-api.md +++ b/docs/experience-api.md @@ -100,6 +100,8 @@ model.agent = { busy = false, activity = "Ready", error = nil, + -- Empty on Linux, whose provider is selected before the SOS session. + configuration_actions = {}, messages = { { role = "user", text = "Make this calmer" }, { role = "assistant", text = "I changed the daily flow." }, @@ -111,6 +113,12 @@ An experience decides how and where to render this state. It normally pairs it with a `text_session` whose submit action returns an `agent.prompt` effect. The conversation is not a GPUI widget and is preserved across provider refresh and revision activation by the host. +`configuration_actions` is the trusted platform's typed allowlist for provider +configuration controls: `configure_openai`, `configure_openrouter`, +`configure_codex`, `use_fake`, and `clear_credential`. Experiences must not +render a control that is absent. Linux deliberately supplies an empty list; +run `sos-agent-login` from GNOME or a text login and start a new SOS session to +change its resident provider. The resident-agent validation path requires each submitted revision to retain at least one Luau `text_session` with `submit_action = "agent_submit"`. diff --git a/docs/linux-compositor.md b/docs/linux-compositor.md index 96c628a..f8a8c1b 100644 --- a/docs/linux-compositor.md +++ b/docs/linux-compositor.md @@ -231,10 +231,16 @@ dispatching callbacks/frames after releasing that borrow fixed the crash. Core `wl_touch` has no pressure field, so finger samples explicitly report pressure unavailable. A separate pressure stylus is carried through tablet-v2; the direct VM gate observes normalized nonzero Scene pressure. -The VM's software GBM/EGL stack lacks `EGL_WL_bind_wayland_display`, so the -compositor uses its advertised `wl_shm` path for clients. None of this run is a -physical display, latency, touch, GPU-performance, suspend/resume, or thermal -claim. +The VM's software GBM/EGL stack lacks `EGL_WL_bind_wayland_display`; the cited +run therefore used the compositor's advertised `wl_shm` path. The direct +backend now also advertises explicit Linux dmabuf feedback from the formats +importable by every renderer with a connected output. It validates each client +dmabuf against those active renderers before completing buffer creation and +updates the feedback across connector/device changes. This is independent of +the optional EGL Wayland binding and leaves `wl_shm` as the safe fallback. The +cited VM result predates that protocol path and remains a software-rendering +result; none of it is a physical display, latency, touch, GPU-performance, +suspend/resume, or thermal claim. ## Source pin and remaining boundary diff --git a/docs/linux-hardware-gate.md b/docs/linux-hardware-gate.md index 58a8b99..fe9a4c2 100644 --- a/docs/linux-hardware-gate.md +++ b/docs/linux-hardware-gate.md @@ -1,6 +1,7 @@ # First physical Linux hardware gate Date: 2026-08-21 +Updated: 2026-08-25 The first physical Linux gate uses the selectable GDM session. It does not install the boot-owned appliance target, stop or reconfigure GDM, or change the @@ -14,8 +15,25 @@ bounded interactions below, and the controller collects finalized evidence after SOS returns cleanly to GDM. It never injects input or infers physical behavior from VM results. +Two environments can exercise the criteria below, but they do not produce the +same verdict: + +- **installed-workstation:** Fedora Workstation installed to disk, SOS + installed from this checkout with `install --offline`. +- **development-live:** a mutable SOS-baked Fedora Workstation live remix, + prepared and collected on the same live overlay boot. It produces diagnostic + evidence only, is not an installed product, and is never promotion eligible. + See [`linux-live-image.md`](linux-live-image.md). + +Hardware (DRM, input, DMI) is the same silicon on both. Persistence, disk, +and bootloader differ. Stock Fedora live media without SOS baked in is not a +development image. A future immutable `release` image owns release promotion; +there is no acceptance-live artifact class. + ## Prepare the target +### Installed Fedora Workstation + Use a current Fedora Workstation installation with GDM on the Framework Laptop 12. Framework lists Fedora as an officially supported Linux distribution and recommends a current kernel for this hardware. Keep the conventional GNOME @@ -66,6 +84,41 @@ the preferred panel mode, scale 1.0, and rotation 0. A bounded override may set Automatic tablet rotation is not part of the first gate. Finalize this file before preparing evidence; the harness records its exact contents. +### Development-live remix, same-boot diagnostics + +The Framework development loop may boot the mutable Fedora Workstation remix. +It keeps GNOME, password-protected SSH, and the selectable SOS session. Rebuild +the base only when its environment changes; for ordinary SOS patches use +`tools/linux-live-deploy` from GNOME after logging out of SOS. Copy evidence +off the overlay before reboot. Verify Fedora's signed CHECKSUM before the base +bake; the bake requires that expected SHA-256. + +```sh +./tools/linux-live-image bake \ + --source-iso /path/to/Fedora-Workstation-Live-x86_64-*.iso \ + --source-sha256 "$FEDORA_ISO_SHA256" \ + --output-dir artifacts/linux-live-image \ + --liveuser-password-file /path/to/private-password-file \ + --networkmanager-profile-file /path/to/private-development-wifi.nmconnection +# Boot the remixed ISO, then from the GNOME live session: +/usr/local/libexec/sos/linux-hardware-gate prepare \ + --expect-product 'Laptop 12' \ + --evidence-dir /home/liveuser/framework12-first-gate +``` + +The bake verifies that Fedora's `LiveOS/squashfs.img` is a flat EROFS rootfs, +extracts it as root while preserving owners, permissions, and xattrs, applies +the image's SELinux file-context policy after staging SOS, and repacks as root. +Prepare records +`boot_kind=development-live`, `not_installed_product=true`, +`promotion_eligible=false`, the exact kernel `boot_id`, +both matching `image-identity.env` records, and the mandatory live-media payload +byte size and SHA-256. It also verifies any incremental deployment manifest and +snapshots the current SOS bytes. Persistence is optional only because prepare +and collect prove the same boot ID. A live overlay without both SOS image +identities is refused. An install-to-disk of this remix is also refused: it is +neither development-live nor the installed-workstation campaign. + ## Run the clamshell smoke gate Commit the feature branch, reinstall from that clean revision, and prepare a @@ -78,9 +131,13 @@ new ignored or external evidence directory: ``` The command proves that it is running on bare metal, checks the exact installed -manifest and source revision, records the OS, kernel, BIOS, CPU, GPU/driver, -DRM connectors and EDID hashes, libinput inventory, package/tool versions, and -the current journal cursor. It then prints the operator steps: +manifest and revision pin, records the OS, kernel, BIOS, CPU, GPU/driver, +DRM connectors and EDID hashes, libinput inventory, package/tool versions, +development-versus-installed image identity, and the current journal cursor. +The installed-workstation pin is a clean matching source worktree. The +development-live pin preserves the baked image identity but permits an exact, +hashed overlay deployment whose source dirty state is recorded. It then prints +the operator steps: 1. Log out and choose **SOS** from GDM. 2. Confirm the compositor recovery view and generated experience appear. @@ -104,10 +161,17 @@ measures campaign wall time from same-boot monotonic timestamps, generates `evidence-manifest.tsv`, and independently verifies every path, byte size, and SHA-256. -## PASS contract +On development-live, use the baked harness for collection: + +```sh +/usr/local/libexec/sos/linux-hardware-gate collect \ + --evidence-dir /home/liveuser/framework12-first-gate +``` + +## Runtime criteria and verdicts -Every criterion is required; a missing observation is a FAIL rather than a -SKIP: +Every criterion is required; a missing observation fails the run rather than +becoming a SKIP: - the compositor's recovery view reaches a physical DRM page flip before the generated shell starts; @@ -126,13 +190,17 @@ SKIP: - the campaign contains no SOS process failure, Rust panic, or matching kernel DRM/GPU hang/reset marker. -This first PASS establishes the physical panel, Intel DRM/KMS/GBM path, +An installed-workstation PASS establishes the physical panel, Intel DRM/KMS/GBM path, keyboard, touchpad, touchscreen, deterministic resident authoring, durable activation, and reversible GDM lifecycle for the exact evidence revision. It does not establish stylus pressure/calibration, tablet rotation, suspend/resume, external-display hotplug, host crash recovery, latency, memory pressure, thermals, or soak. Run those as later focused gates without weakening this -baseline. +baseline. Development-live uses the same observations to diagnose the mutable +runtime, but emits `DIAGNOSTIC_PASS promotion_eligible=false` or +`DIAGNOSTIC_FAIL`; it can never emit the normal PASS line. Only the future +immutable `release` artifact and its artifact-matched gate may support release +promotion. ## Audit, recovery, and uninstall diff --git a/docs/linux-live-image.md b/docs/linux-live-image.md new file mode 100644 index 0000000..04f5434 --- /dev/null +++ b/docs/linux-live-image.md @@ -0,0 +1,195 @@ +# Fedora development-live image + +Date: 2026-08-25 + +SOS has two image classes: + +- **`development-live`** is the current mutable Fedora Workstation test + environment. It keeps GNOME, GDM, SSH, and the SOS selectable session so + changed SOS binaries can be deployed into the disposable live overlay. + It always records `promotion_eligible=false`. +- **`release`** is the future immutable, SOS-only deliverable. Its composer + and promotion gate do not exist yet; release evidence must eventually bind + the tested artifact to the exact shipped bytes. + +There is no intermediate acceptance-live image. Rebuilding and reflashing a +3-GB Fedora remix for each patch costs too much during hardware development, +while calling a mutable diagnostic environment release evidence would be +misleading. + +The current builder remixes a checksum-pinned official ISO; it does not run a +full lorax compose for this development loop. + +`development-live` is not an installed product and never installs Fedora or +SOS to the target's internal disk. The removable-media write happens on the +build/operator machine. + +## What development-live contains + +- Fedora Workstation live userspace with GDM and GNOME retained +- the offline `install-linux-login-session` output and an SOS GDM session +- runtime packages only, including `openssh-server` +- SSH password login restricted to `liveuser`; root SSH login is disabled +- fresh SSH host keys generated by Fedora on each boot, never reusable keys + baked into the ISO +- an optional root-owned NetworkManager Wi-Fi profile that autoconnects before + the operator logs in, without recording its SSID or PSK in image metadata +- a root-only Fedora `livesys` hook that assigns the baked SHA-512 password hash + after Fedora creates `liveuser`, relocks Fedora's temporary passwordless root + account, and disables GDM autologin after the GNOME live hook enables it +- no offline SSH enablement; the `livesys` hook enables and starts SSH as its + final fail-closed action, after password assignment, root relock, and GDM + configuration, so remote access cannot start with Fedora's temporary + passwordless live accounts +- `/usr/local/libexec/sos/linux-hardware-gate` for same-boot diagnostics +- matching rootfs and ISO-level `image-identity.env` records with the embedded + EROFS payload size and SHA-256 + +The image does not enable `sos-session.target`, replace GDM, change the Fedora +volume label, or claim release acceptance. + +## Bake the development environment + +Download an official Fedora Workstation live x86-64 ISO and verify its signed +CHECKSUM using Fedora's documented process. The Fedora x86-64 build host must +be at the same release as the ISO. Install the dependencies listed in +[`linux-hardware-gate.md`](linux-hardware-gate.md), plus `erofs-utils`, +`isomd5sum`, `policycoreutils`, `rsync`, and `xorriso`. + +Write the development password to a private, non-symlink file. The bake reads +exactly one non-empty line and never places the plaintext password in metadata +or command output. It derives the password hash on the build host and stores +only that hash in the root-only boot hook. + +For automatic Wi-Fi, provide a NetworkManager keyfile generated for the target +network. This option is development-only. A Wi-Fi keyfile necessarily embeds +the PSK, and anyone with the ISO can extract an equivalent network credential +offline regardless of its runtime `0600` permissions. Never publish or share +such an ISO, rotate the network credential if custody is lost, and do not carry +the profile into the future release image. + +```sh +install -m 0600 /dev/null /tmp/sos-liveuser-password +read -r -s -p 'development-live password: ' SOS_DEV_PASSWORD +printf '%s\n' "$SOS_DEV_PASSWORD" > /tmp/sos-liveuser-password +unset SOS_DEV_PASSWORD + +install -m 0600 /dev/null /tmp/sos-development-wifi.nmconnection +read -r -p 'development Wi-Fi SSID: ' SOS_WIFI_SSID +read -r -s -p 'development Wi-Fi PSK: ' SOS_WIFI_PSK +printf '\n' +nmcli --offline connection add \ + type wifi \ + con-name 'SOS development Wi-Fi' \ + ssid "$SOS_WIFI_SSID" \ + wifi-sec.key-mgmt wpa-psk \ + wifi-sec.psk "$SOS_WIFI_PSK" \ + connection.autoconnect yes \ + connection.autoconnect-priority 100 \ + ipv4.method auto \ + ipv6.method auto \ + > /tmp/sos-development-wifi.nmconnection +unset SOS_WIFI_SSID SOS_WIFI_PSK +chmod 0600 /tmp/sos-development-wifi.nmconnection + +./tools/linux-live-image doctor +./tools/linux-live-image check-networkmanager-profile \ + --profile-file /tmp/sos-development-wifi.nmconnection +./tools/linux-live-image bake \ + --source-iso /path/to/Fedora-Workstation-Live-x86_64-*.iso \ + --source-sha256 "$FEDORA_ISO_SHA256" \ + --output-dir artifacts/linux-live-image \ + --liveuser-password-file /tmp/sos-liveuser-password \ + --networkmanager-profile-file /tmp/sos-development-wifi.nmconnection +``` + +The base bake still requires a clean source revision. Fedora 44 calls its +payload `LiveOS/squashfs.img`, but it is a flat EROFS root filesystem. The +builder verifies that layout, mounts it read-only, copies it as root while +preserving numeric ownership, permissions, hardlinks, ACLs, capabilities, and +portable xattrs, validates the image's own SELinux policy, and applies file +contexts during root-owned EROFS repacking. It preserves the source volume ID +and BIOS/UEFI boot records, re-implants Fedora's media checksum, and verifies +that embedded media checksum. + +The output sidecar records the clean base revision, Fedora/build-host release, +base ISO identity, EROFS payload hash, output ISO hash, and these immutable +classification fields: + +```text +image_kind=development-live +campaign_class=development-live +not_installed_product=true +promotion_eligible=false +mutable_runtime=true +ssh_enabled=true +wifi_autoconnect=true +network_credentials_embedded=true +``` + +Omit `--networkmanager-profile-file` to bake a development image without +embedded network credentials; its two network identity fields are `false`. + +Write the resulting hybrid ISO to removable media and boot it. Log into +`liveuser` with the development password, choosing GNOME for administration or +SOS for interaction testing. + +## Deploy changed SOS binaries without rebuilding the ISO + +Log out of SOS and remain in GNOME or a text login. From the Fedora development +checkout, deploy only the changed native components: + +```sh +./tools/linux-live-deploy components +./tools/linux-live-deploy deploy \ + --target liveuser@192.168.1.127 \ + --component experience-host \ + --component compositor +``` + +With no `--component`, the tool builds and deploys all six native session +components. It opens one multiplexed SSH connection, verifies the target's +`development-live`/non-promotable identity, refuses deployment while SOS is +running, builds locally, stages files under a private target directory, and +installs them root-owned into the live overlay. It then verifies every remote +SHA-256 and writes: + +- `/usr/share/doc/sos/development-deployment.env` +- `/usr/share/doc/sos/development-deployment-manifest.tsv` +- a matching ignored host record under `artifacts/linux-live-deploy/` + +Dirty source deployments are allowed and explicitly recorded. Rebooting drops +all deployed changes and returns to the baked base. + +## Run and collect a diagnostic campaign + +Prepare from GNOME, test SOS, return to GNOME, and collect on the same boot: + +```sh +/usr/local/libexec/sos/linux-hardware-gate prepare \ + --expect-product 'Laptop 12' \ + --evidence-dir /home/liveuser/framework12-diagnostic + +/usr/local/libexec/sos/linux-hardware-gate collect \ + --evidence-dir /home/liveuser/framework12-diagnostic +``` + +Preparation verifies the baked media payload identity, verifies any deployment +manifest, and snapshots the current byte size and SHA-256 of every installed +SOS artifact. Collection binds the journal interval to the same kernel +`boot_id`. A fully successful run emits +`linux_hardware_gate_result=DIAGNOSTIC_PASS promotion_eligible=false`; a failed +criterion emits `DIAGNOSTIC_FAIL`. Neither is release acceptance. Copy evidence +off the disposable overlay before reboot. + +Stock Fedora live media without the SOS identities is refused. Installing this +remix to disk is also refused because a `development-live` identity without a +live overlay is neither a development boot nor an installed-product campaign. + +## Future release boundary + +The future `release` process must produce an immutable SOS-only artifact, +disable development access, exclude embedded network profiles and overlay +deployment metadata, and run its own artifact-matched physical and release +gates. Development diagnostic evidence can justify fixes and test mechanisms, +but it cannot promote a release even when all runtime criteria pass. diff --git a/docs/linux-stable-host.md b/docs/linux-stable-host.md index 6e121aa..1c87ea0 100644 --- a/docs/linux-stable-host.md +++ b/docs/linux-stable-host.md @@ -212,6 +212,9 @@ and a text console available, then use `tools/linux-hardware-gate` and the exact PASS contract in [`linux-hardware-gate.md`](linux-hardware-gate.md). The gate refuses VMs, dirty or revision-mismatched installs, missing observations, and tampered evidence. +A SOS-baked Fedora Workstation `development-live` remix is a mutable diagnostic +path; it is not an installed product or release-acceptance artifact. See +[`linux-live-image.md`](linux-live-image.md). After returning to the conventional desktop, `./tools/install-linux-login-session uninstall` removes the exact installed SOS diff --git a/docs/progress.md b/docs/progress.md index ee8922e..4082fb8 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -9110,3 +9110,1310 @@ as merge-ready evidence tooling only. Physical Intel KMS/panel/input, GDM lifecycle, and exact-target evidence remain open until the clean merged revision is installed and the documented same-boot campaign runs on the Framework Laptop 12. +## 2026-08-23 — Add a Fedora live remix path for first Framework 12 evidence + +**Goal / environment:** Make the first Framework 12 Linux loop rebuild ISO, +boot live, prepare, select SOS in GDM, collect on that boot, and copy evidence +off, without loosening the hardware-gate PASS contract or calling a live boot +an installed product. The implementation host was Ubuntu 24.04.4 x86_64, not +Fedora, and no Framework Laptop 12, GDM, seat, DRM, or live ISO bake ran. + +**Changed:** `tools/linux-live-image` remixes an official Fedora Workstation +live ISO by staging the existing `install --offline --destdir` output, runtime +packages (not `-devel`), GDM, the SOS session, the offline agent, and the +hardware-gate harness. It does not compose Fedora from lorax/kiwi/kickstart +and does not enable the boot-owned appliance target. Image identity records +live-boot labels, source revision, base ISO identity, and squashfs/erofs +payload hashes. `tools/linux-hardware-gate` now classifies live-boot versus +installed-workstation, pins live-boot from baked identity, refuses stock live +media and install-to-disk of a live remix, and records live-versus-installed +fields without changing audit criteria. Operator docs and README state that +live-boot evidence is not an installed product. + +**Evidence / measurements:** One clean host campaign ran `bash -n` on the five +changed/new shell programs, both hardware-gate and live-image host suites, +ShellCheck 0.9.0, `git diff --check`, `tools/linux-live-image doctor`, and +`classify-boot --sysroot` of an empty tree. It passed in 1.346 seconds wall +time. Doctor reported `host=ubuntu`, named the missing ISO/EROFS tools, and +still exited 0 for layout checks. Classify-boot labeled the empty sysroot +`boot_kind=installed`. Synthetic audit still emits the exact PASS line for +both boot kinds and still FAILs missing touchscreen input. No ISO was built +and no hardware claim is made. + +**Failures / fixes / decision / next gate:** Payload hashes stay on the ISO +filesystem (`/sos-image-identity.env`) rather than inside the squashfs they +describe, so the pin is not self-referential. `readlink -f` against a destroot +resolved host paths and was replaced with raw symlink basenames. Accept this +as live-image tooling and harness labeling only. Bake the remixed ISO on a +Fedora x86_64 host from a clean revision, boot it on the Framework Laptop 12, +and run the documented same-boot `prepare -> physical interactions -> collect` +loop. A live-boot PASS remains live-boot, not installed product. Physical +Intel KMS/panel/input behavior, persistence, disk install, stylus, rotation, +suspend, latency, thermals, and soak remain open. + +## 2026-08-24 — Pin the diskless Framework gate to real Fedora live media + +**Goal / environment:** Keep the first Framework Laptop 12 Linux campaign off +its internal disk while correcting the unverified multi-format live-remix path. +The review host was Fedora 44 Server x86-64, kernel +`6.19.10-300.fc44.x86_64`, at PR revision `e5da24f1f60c` plus this rework. No +Framework Laptop 12, physical GDM session, DRM/input transition, or internal +target disk was used. The host had no non-interactive sudo authorization and +lacked the native SOS development modules, so no privileged rootfs mutation or +complete SOS ISO bake is claimed. + +**Changed:** `tools/linux-live-image` now requires the SHA-256 obtained from +Fedora's signed CHECKSUM, a Fedora x86-64 build host at the ISO's exact Fedora +release, every image/build command, and every native compile module. Inspection +is pinned to a flat EROFS rootfs at `LiveOS/squashfs.img`, matching the official +Fedora 44 Workstation media despite that historical filename. The mutation path +uses privileged `fsck.erofs --xattrs --preserve`, runs the existing offline +destroot install, reapplies the image's SELinux file-context policy, repacks as +root, verifies the rebuilt EROFS, and re-implants and verifies the ISO media +checksum. It refuses another container/rootfs layout instead of silently losing +UID/GID, permissions, capabilities, xattrs, or labels. Build work must be new +and is removed only from the exact bounded output path after success. + +The hardware gate now requires the rootfs and ISO-level identities to agree on +release, revision, source ISO, agent mode, payload, and bake time. Live prepare +requires and hashes the mounted payload rather than treating that identity as +optional. Prepare records `/proc/sys/kernel/random/boot_id`; collect rejects a +different kernel boot for both live and installed campaigns. Live instructions +now invoke the baked `/usr/local/libexec/sos/linux-hardware-gate` path. Docs keep +the result explicitly `live-boot` / `not_installed_product=true` and describe +the removable-media-only operator path. + +**Evidence / measurements:** Fedora's signed +`Fedora-Workstation-44-1.7-x86_64-CHECKSUM` verified with key +`36F612DCF27F7D1A48A835E4DBFCF71C6D9F90A6`. The transient source artifact +`/tmp/sos-pr11-fedora44.T3r9Wc/Fedora-Workstation-Live-44-1.7.x86_64.iso` +was 2,851,612,672 bytes with SHA-256 +`1620295f6a00c27c3208f0c00b8ece4eab1ec69b9002152d97488bf26a426ddf`; +its embedded media check passed in 3.22 seconds. Its +`LiveOS/squashfs.img` was 2,487,484,416 bytes and the new exact payload check +reported `container_format=erofs-rootfs`. Targeted official-image extraction +preserved `root:root`, mode `0755`, and SELinux type `netutils_exec_t`; a +user-namespace EROFS round trip separately preserved a `1000:1000` file, mode +`0750`, and a user xattr. This does not substitute for the pending fully +privileged whole-rootfs metadata audit. `fsck.erofs` also accepted the complete +official payload used to validate the rebuilt-payload integrity boundary. The +same ISO's `dmsquash-live-root` mounts its backing device at +`/run/initramfs/live` and resolves `LiveOS/squashfs.img` there, confirming that +the ISO-level identity and payload paths used by the boot classifier are on the +live medium retained by Fedora's initramfs. + +An ISO replay probe preserved volume ID `Fedora-WS-Live-44`, BIOS and UEFI El +Torito images, protective MBR, and GPT in 1.31 seconds. After a new embedded +checksum was implanted, `checkisomd5` passed in 3.04 seconds. The transient +`/tmp/sos-pr11-fedora44.T3r9Wc/replay.iso` was 2,851,930,112 bytes with SHA-256 +`045745cb6547e216cdfee1747ab62afe635aa0bc33cbb5a8a2bd873304db9221`. +Both focused host suites passed; Bash parsing, ShellCheck 0.11.0 from container +digest `b9389b73c8f26f710a7171cb7d8848a34a9c1e07a7865e727c9ec4ce99f9a83f`, +the official-payload `fsck.erofs`, and `git diff --check` also passed. The final +ordered host campaign took 2.04 seconds wall time. No model provider ran, so +live-model and model-weighted gate cost were zero. + +**Failures / fixes / decision / next gate:** The first review found that normal +user extraction of flat SquashFS/EROFS maps root-owned files to the builder UID +and cannot restore SELinux xattrs. An initial correction narrowed support to a +nested SquashFS/ext4 layout; inspection of the real signed Fedora 44 ISO rejected +that assumption because its nominal `squashfs.img` is a flat EROFS rootfs. The +final path therefore uses privileged EROFS extraction/repack plus explicit +relabeling. Focused tests then caught a command-substitution failure that had +masked a rejected payload and were extended to require the outer identity, +identity agreement, boot ID, installed collect path, media checksum, and EROFS +rootfs classification. Keep the PR draft until a Fedora 44 host with sudo and +the documented build modules completes one whole ISO bake, verifies the final +rootfs metadata and ISO sidecar, and boots it on the Framework Laptop 12 for the +same-boot physical `prepare -> SOS -> collect -> copy off` gate. That run may +write removable media but must not install to or mutate the laptop's internal +disk. + +## 2026-08-24 — Validate staged private live-user state through sudo + +**Goal / failure:** Close the remaining real-bake blocker in live-rootfs +validation without relaxing the mode of offline agent configuration. Although +the first hardening pass added a sudo fallback for reading a private config, an +ordinary unprivileged `[[ -f ... ]]` still ran before that fallback. The staged +`/etc/skel/.local` tree is root-owned and mode `0700`, so the builder cannot +traverse it and a real bake would report the config as missing. + +**Changed / evidence / decision:** `live_image_require_exact_line` now performs +both existence and content validation directly when readable, or performs both +through sudo when the builder cannot traverse the path. The skel and optional +liveuser callers no longer preflight private files unprivileged. The focused +test locks the staged `.local` tree against ordinary traversal, retains a mode +`0600` config, proves that validation invokes privileged `test -f` and `grep`, +and requires `check-rootfs` to pass. Where the host supplies subordinate IDs +and a setuid-capable workspace, the suite repeats that check as namespace UID +1000 against an actual namespace-root-owned mode-`0600` fixture. Keep private +state private; do not weaken directory or file modes to make the image builder +able to read them. The next gate remains the complete privileged Fedora 44 bake +and removable-media-only Framework Laptop 12 campaign described above. + +**Verification / measurement:** On the Fedora 44 review host, the subordinate-ID +namespace fixture ran rather than skipping. `tests/linux-live-image-test.sh` and +`tests/linux-hardware-gate-test.sh` passed, as did Bash parsing of the five +relevant scripts, ShellCheck 0.11.0 from container digest +`b9389b73c8f26f710a7171cb7d8848a34a9c1e07a7865e727c9ec4ce99f9a83f`, and +`git diff --check`. The ordered campaign took 2.15 seconds wall time. No model +provider ran, so live-model and model-weighted gate cost were zero. + +## 2026-08-24 — Fix fragment-packed Fedora EROFS extraction after the first bake + +**Goal / environment / failure:** Run the first complete privileged live-image +bake before writing removable media for the Framework Laptop 12. The Fedora 44 +Server x86-64 build host was at clean revision +`f25b44935d91cc203f6565acb4f5cec28df0de34`, with `erofs-utils-1.9.2-2.fc44` +and a strict `tools/linux-live-image doctor` PASS. The signed Fedora source at +`/home/carlid/dev/sos/artifacts/linux-live-source/Fedora-Workstation-Live-44-1.7.x86_64.iso` +was 2,851,612,672 bytes with SHA-256 +`1620295f6a00c27c3208f0c00b8ece4eab1ec69b9002152d97488bf26a426ddf`. +`xorriso` restored all 355 ISO-tree files in one second, then privileged +`fsck.erofs --extract` stopped before rootfs mutation because it tried to open +the pre-created extraction directory as the image's hidden packed-fragment +inode. The finalized failure log is +`/home/carlid/dev/sos/artifacts/linux-live-bake-attempt1.log`, 1,729 bytes, +SHA-256 `9471415cca20e0273beb4e058baddf34c82d7cee0344028dee83de6bbf31431f`. +No remixed ISO was produced, no removable media was written, and no Framework +or internal laptop disk was involved. + +**Changed / evidence:** EROFS extraction now selects the filesystem root +explicitly with `fsck.erofs --path=/` while retaining privileged xattr, owner, +and permission preservation. It also rejects a nonempty extraction destination +instead of adding `--overwrite` and concealing stale files. A full probe against +the official Fedora payload is recorded below; the probe deliberately ran as +the ordinary builder with owner, permission, and xattr restoration disabled, so +it proves traversal and decompression compatibility only. The next privileged +bake remains responsible for the metadata-preservation gate. + +With `--path=/`, the complete official root tree extracted successfully in +998.82 seconds with 33,412 KiB maximum RSS. It contained 155,630 paths and +`du --bytes --summarize` reported 6,709,518,634 bytes. The finalized probe log +at `/home/carlid/dev/sos/artifacts/linux-live-erofs-root-path-probe.log` is 51 +bytes with SHA-256 +`d1e1ee4fbb95c6145a00ac75bdb4216b1761ff7c60d1f11600fbaa9ca4d1015a`. +The rejected absent-destination attempt log at +`/home/carlid/dev/sos/artifacts/linux-live-erofs-absent-destination-attempt.log` +is 222 bytes with SHA-256 +`30d9fadb6c6d6e6733db264d240df5e8c785afb3cf688b5cc3e509990ac1e50b`. +The live-image suite also builds a small `all-fragments` EROFS and requires the +explicit-root extraction to reproduce its file exactly. The live-image and +hardware-gate host suites, Bash parsing of all five relevant scripts, ShellCheck +0.11.0 from container digest +`b9389b73c8f26f710a7171cb7d8848a34a9c1e07a7865e727c9ec4ce99f9a83f`, +and `git diff --check` passed in one ordered 1.92-second campaign with 46,556 +KiB maximum RSS. No model provider ran, so live-model and model-weighted gate +cost were zero. + +**Rejected approach / decision / next gate:** Merely leaving the destination +absent was insufficient: `fsck.erofs` wrote the packed inode as a +3,662,513,055-byte regular file and then rejected it as the root directory after +18.06 seconds. Keep the explicit root selector and fail-closed empty-directory +check. After retaining the three finalized logs above, the bounded partial +output at `/home/carlid/dev/sos/artifacts/linux-live-image` was removed; it was +generated failed work and is not recoverable. Commit and push the fix, then run +one clean privileged bake. A successful host bake still does not close the +physical gate; the next gate is removable-media boot and the documented +same-boot `prepare -> physical interactions -> collect` campaign on the +Framework Laptop 12, without installing to or modifying its internal disk. + +## 2026-08-24 — Relabel Fedora EROFS from policy instead of compose xattrs + +**Goal / environment / failure:** Retry the complete privileged Fedora 44 live +bake at clean revision `478a8ed97a1fe1b0e6e142498752267f1be0e159` after +fixing fragment-packed traversal. The strict doctor and ISO-tree extraction +again passed, then EROFS extraction failed while setting `security.selinux` on +inode 12114131 with `EINVAL`. The finalized second-attempt log at +`/home/carlid/dev/sos/artifacts/linux-live-bake-attempt2.log` is 1,734 bytes +with SHA-256 +`4c761c3479111a0ab742aa8eaa012e909162da3d5c2261bf4b8f2254a465a899`. +No remixed ISO or removable media was produced, and no Framework or internal +laptop disk was involved. + +**Causal chain / changed:** `dump.erofs` resolves the failing inode to +`/usr/bin/nbdkit`. A read-only FUSE view of the signed official payload reports +its source label as `system_u:object_r:fusefs_t:s0`, while the Fedora 44 policy +for `/usr/bin/nbdkit` requires `system_u:object_r:bin_t:s0`. Restoring the +compose-filesystem label is therefore neither portable to the staging +filesystem nor the desired final state. The bake now mounts the EROFS payload +read-only and uses privileged `rsync -aHAXS --numeric-ids` to retain content, +numeric ownership, modes, timestamps, hardlinks, sparse layout, ACLs, +capabilities, and all applicable non-SELinux xattrs. It excludes +`security.selinux` and, consistently with rsync's superuser default, the +`system.*` namespace; `rsync -A` separately retains POSIX ACLs. After all +package and SOS mutations, the existing `setfiles` phase applies the rootfs's +own Fedora policy to the complete tree. The mount is bounded under the bake +work directory and has an EXIT cleanup before the work directory can be +removed. Before unmounting, a second metadata-only rsync dry run must report no +size, timestamp, owner, mode, hardlink, or ACL difference; normalized manifests +must report no capability or other included-xattr difference. + +**Focused evidence / measurements:** Copying the official failing file through +the filtered rsync path preserved its bytes and omitted the stale `fusefs_t` +label in 0.08 seconds with 5,840 KiB maximum RSS. A subordinate-user-namespace +round trip then preserved mode `0750`, a hardlink, a user xattr, and +`cap_net_bind_service=ep` in 0.05 seconds with 5,708 KiB maximum RSS. The +finalized combined probe log at +`/home/carlid/dev/sos/artifacts/linux-live-xattr-rsync-probe.log` is 571 bytes +with SHA-256 +`09db4e06f22bad30144ee67cd115129aea43f89ed49aaa638618bb6950f96ce7`. +The focused live-image test constructs a hardlinked mode-`0750` fixture with a +user xattr, conditionally gives it the same stale SELinux type, and requires +the copy to preserve every requested attribute except that source label; its +post-copy metadata audit must also be empty. The strict doctor, live-image and +hardware-gate host suites, Bash parsing of all five relevant scripts, +ShellCheck 0.11.0 from container digest +`b9389b73c8f26f710a7171cb7d8848a34a9c1e07a7865e727c9ec4ce99f9a83f`, +and `git diff --check` passed in one ordered 2.18-second campaign with 46,420 +KiB maximum RSS. No model provider ran, so live-model and model-weighted gate +cost were zero. + +**Rejected approaches / decision / next gate:** Continuing +`fsck.erofs --xattrs` would repeatedly fail on a compose label. Disabling all +xattrs would silently destroy capabilities and was rejected. Copying source +SELinux contexts and relabeling only after that is also unnecessary and blocks +the build before the authoritative policy phase. Stop full bake retries until +the new mount/copy layer and nearby host regressions are green. Then delete +only the bounded partial attempt-two output, commit and push the correction, +and run one fresh privileged bake. That downstream run must still prove the +whole-rootfs mount/copy, package mutation, policy relabel, EROFS repack, ISO +checksum, and identities before removable-media hardware testing begins. + +## 2026-08-24 — Separate logical metadata and raw-xattr audits + +**Goal / environment / failure:** Run the fresh downstream bake at clean +revision `f6ad4e9f69d967ddeca517f2760cac5f0969934d` after replacing direct +EROFS xattr extraction. The strict doctor, ISO-tree extraction, read-only EROFS +mount, and complete privileged rsync copy passed. The new dry-run audit then +failed on exactly `.d........x var/log/journal/`. The finalized bake log at +`/home/carlid/dev/sos/artifacts/linux-live-bake-attempt3.log` is 1,720 bytes +with SHA-256 +`1e02477374a3ca0d264b3f475d91bce695829fa7153549cd0fbdc575b15ab46c`; +the 29-byte raw audit at +`/home/carlid/dev/sos/artifacts/linux-live-rsync-audit-attempt3.log` has +SHA-256 `f8ab10a71d8144e2f0004a0c587e48fc2e4b46950f6b54bce660af97609951e3`. +No remixed ISO or removable media was produced, and no Framework or internal +laptop disk was involved. + +**Diagnosis / evidence:** Rsync's itemized `x` flag combined its raw-xattr view +with an ACL-bearing directory even though the copy intentionally filtered the +source SELinux label and handled ACLs separately. The source and destination +`/var/log/journal` both had numeric owner `0:190`, mode `2755`, identical access +ACLs, and identical default ACLs; only the expected staging SELinux context +differed. More importantly, the completed privileged copy retained the real +`security.capability` on +`/usr/libexec/gstreamer-1.0/gst-ptp-helper` as +`cap_net_bind_service,cap_net_admin,cap_sys_nice=ep`. Its source and destination +bytes both had SHA-256 +`f3849ca6c51675c7365eb7b0bb048bc11f256aa09d069818ae83ef44744066a5`. +Thus the copy boundary passed and the combined audit, not metadata +preservation, was the earliest broken layer. + +**Changed / decision / next gate:** Keep the fail-closed audit but split its +semantics. A metadata-only rsync dry run without `-X` now checks content size +and time, numeric ownership, modes, hardlinks, and logical ACLs. +Separate sorted `getfattr` manifests compare exact `user.*`, `trusted.*`, and +non-SELinux `security.*` names and values, including capabilities, while +deliberately excluding `security.selinux` and ACLs already checked logically. +The focused fixture requires both the metadata audit and normalized xattr +manifest comparison to be empty. Do not whitelist `/var/log/journal`, discard +the audit, or accept arbitrary rsync `x` differences. After nearby checks pass, +commit and push the correction. The next operator command must remove only the +bounded partial attempt-three output, then run one fresh privileged bake +through relabel and repack. + +The strict doctor, live-image and hardware-gate host suites, Bash parsing of +all five relevant scripts, ShellCheck 0.11.0 from container digest +`b9389b73c8f26f710a7171cb7d8848a34a9c1e07a7865e727c9ec4ce99f9a83f`, +and `git diff --check` passed in one ordered 2.16-second campaign with 46,900 +KiB maximum RSS. No model provider ran, so live-model and model-weighted gate +cost were zero. The pending privileged bake remains the next gate. + +## 2026-08-24 — Ignore XFS's internal ACL xattrs in the portable manifest + +**Goal / environment / failure:** Run the next privileged bake at clean +revision `dd97da735effa4392891049fbcc4e9df0b85601a` with separate logical +metadata and raw-xattr audits. The strict doctor, ISO-tree extraction, +read-only EROFS mount, complete privileged copy, and metadata-only rsync audit +all passed; the latter produced a zero-byte audit. The exact xattr-manifest +comparison then failed with 10 source entries and 12 destination entries. The +finalized bake log at +`/home/carlid/dev/sos/artifacts/linux-live-bake-attempt4.log` is 1,798 bytes +with SHA-256 +`b839aa84bf97e6cedd0f88c88d7101368f6846abe683d3139860dd8f4b587717`. +No remixed ISO or removable media was produced, and no Framework or internal +laptop disk was involved. + +**Diagnosis / evidence:** All ten source `security.capability` entries were +present byte-for-byte in the destination manifest. The only destination-only +entries were `trusted.SGI_ACL_FILE` and `trusted.SGI_ACL_DEFAULT` on +`var/log/journal`; XFS synthesizes these trusted xattrs from the POSIX ACLs that +the independent logical audit had already proved identical. The preserved +source manifest at +`/home/carlid/dev/sos/artifacts/linux-live-xattr-source-attempt4.txt` is 736 +bytes with SHA-256 +`6d8e758528fc1f6ef7947bff6c1fc9fa92ad0e3a8280abf15e93135b9faccf79`. +The destination manifest at +`/home/carlid/dev/sos/artifacts/linux-live-xattr-dest-attempt4.txt` is 1,027 +bytes with SHA-256 +`d33751772586b3eaa47bb7582e107744f3fa14ea57022062a660fb08cc7e032c`. +The zero-byte metadata audit is retained at +`/home/carlid/dev/sos/artifacts/linux-live-metadata-audit-attempt4.txt` with +the empty-file SHA-256 +`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`. + +**Changed / decision / next gate:** Exclude only the target-filesystem-internal +`trusted.SGI_ACL_*` encodings from the portable raw-xattr manifest. Continue to +compare every source `user.*`, other `trusted.*`, and non-SELinux `security.*` +value exactly, and continue to require the separate owner/mode/hardlink/ACL +audit to be empty. Filtering the two SGI entries from the retained destination +manifest made it identical to the retained source manifest: ten capabilities +matched and no portable xattr was missing or changed. Do not exclude all +`trusted.*`, weaken capability comparison, or whitelist a filesystem path. +After nearby host checks pass, commit and push the correction; the next +operator command must remove only the bounded partial attempt-four output and +run one fresh privileged bake through package mutation, relabel, and repack. + +The strict doctor, live-image and hardware-gate host suites, Bash parsing of +all five relevant scripts, ShellCheck 0.11.0 from container digest +`b9389b73c8f26f710a7171cb7d8848a34a9c1e07a7865e727c9ec4ce99f9a83f`, +and `git diff --check` passed in one ordered 2.20-second campaign with 46,152 +KiB maximum RSS. No model provider ran, so live-model and model-weighted gate +cost were zero. The pending privileged bake remains the next gate. + +## 2026-08-24 — Preserve the active rootfs across offline-home staging + +**Goal / environment / failure:** Run the next privileged Fedora 44 bake at +clean revision `1a658a866d0f8b9300175d113f13ee82a7c6c91e`. Rootfs extraction, +metadata verification, runtime-package installation, all Rust release builds, +and the agent TypeScript bundle completed. Destroot installation then tried to +measure +`/usr/local/libexec/sos-agent/dist/agent-runner.cjs` as the normal builder but +could not traverse its root-owned mode-`0700` `dist` directory. Staging the +offline skeleton subsequently failed while removing +`/tmp/sos-live-skel.9LdFDl/etc`, which had unexpectedly become root-owned. The +finalized log at +`/home/carlid/dev/sos/artifacts/linux-live-bake-attempt5.log` is 27,375 bytes +with SHA-256 +`49e3de11b0e3b271ff73be60a9baf61077988a17402a3b9c06f41972a1d74ce7`. +The bounded output contains only `work/`; there is no ISO or image identity, +and no removable media or Framework disk was involved. + +**Diagnosis / changed code:** `write-offline-user-state` reused the global +`live_image_rootfs` variable. Bash's function scoping therefore replaced the +surrounding bake root with its temporary home path; every subsequent +`live_image_target` call addressed that temporary directory, and privileged +copy setup created its root-owned `etc`. Root-option parsing now returns a path +instead of mutating shared state, offline-home writing uses a local +`home_root`, and rootfs validation uses a function-local root. The regression +sources the real tool, sets an active-root sentinel, calls the nested helper, +and requires the sentinel to remain unchanged. Separately, destroot publishing +now normalizes the agent code tree to `u=rwX,go=rX` after root ownership and +fails explicitly unless every manifest artifact is a readable regular file +with a valid nonzero size and SHA-256. A focused copy of the actual build tree +proved all directories traversable and files readable; the 1,878,811-byte +runner became mode `0755` with SHA-256 +`3eee6e7922fb82e344277793a435bb8edd36a2c183050b638a3c6ca13d3bc99a`. + +**Rejected approaches / decision / remaining risk / next gate:** Running the +whole bake as root would violate the builder boundary. Hashing the private +bundle only through `sudo` would make the manifest succeed while leaving the +desktop user unable to load the agent, and cleaning the corrupted temporary +tree through `sudo` would conceal the wrong destination. Keep the bake +unprivileged, publish runtime code readably, and reserve privilege for rootfs +mutation. The strict doctor, live-image and hardware-gate host suites, Bash +parsing of all five relevant scripts, ShellCheck 0.11.0 from container digest +`b9389b73c8f26f710a7171cb7d8848a34a9c1e07a7865e727c9ec4ce99f9a83f`, +and `git diff --check` passed in one ordered 2.13-second campaign with 47,380 +KiB maximum RSS. No model provider ran, so live-model and model-weighted gate +cost were zero. The remaining risk is the downstream privileged integration: +remove only the bounded partial attempt-five output, then run one clean bake +through staging, relabel, EROFS repack, ISO replay, media checksum, and final +identity generation before writing removable media. + +## 2026-08-24 — Assign image-policy SELinux labels during EROFS creation + +**Goal / environment / failure:** Run the next privileged Fedora 44 bake at +clean revision `b618608a93e707efd2764911aeaa6f9a81dd99fe`. Extraction, +metadata verification, package mutation, cached release builds, readable +destroot installation, and both offline-home staging paths passed. The first +whole-root relabel then stopped while loading the image's file-context rules: +the build host's currently loaded targeted policy rejected +`nbdkit_exec_t` and `nbdkit_unit_file_t`. The finalized log at +`/home/carlid/dev/sos/artifacts/linux-live-bake-attempt6.log` is 8,563 bytes +with SHA-256 +`4e1a7e830a7e4a448e228b4ec0c7899f4865627acd5c6f7bdb4965cd4262a496`. +The bounded output again contains only `work/`; no ISO or image identity was +produced, and no removable media or Framework disk was involved. + +**Diagnosis / evidence:** This was not an invalid Fedora image policy. Running +`setfiles -n -m -c ROOT/etc/selinux/targeted/policy/policy.35 -r ROOT +ROOT/etc/selinux/targeted/contexts/files/file_contexts ROOT/usr/bin/nbdkit` +passed, proving the rootfs's own binary policy accepts the expected +`nbdkit_exec_t` rule. The installed `setfiles(8)` documents `-c` specifically +for checking contexts against another binary policy. A focused EROFS probe +used the rootfs file contexts with `mkfs.erofs --file-contexts`, produced a +180,224-byte image whose `/usr/bin/nbdkit` inode carried a 60-byte xattr area, +and embedded `system_u:object_r:nbdkit_exec_t:s0`. Attempting to restore that +label onto the host filesystem reproduced `EINVAL`, confirming why staging +tree relabeling cannot be the cross-policy boundary. A separate valid-context +probe extracted `system_u:object_r:bin_t:s0` exactly from the rebuilt EROFS. + +**Changed / rejected approaches / decision / next gate:** Validate all file +contexts read-only against the rootfs's highest compiled `policy.*`, then pass +the same context file directly to `mkfs.erofs`; verify the rebuilt filesystem +with explicit xattr inspection. This keeps the staging tree's incidental host +labels out of the artifact while preserving ownership, modes, ACLs, +capabilities, and other portable xattrs. Loading the image policy into the +enforcing build host would be a global and unsafe mutation. Continuing to use +the host policy would reject valid image-only types, while retaining compose +or staging labels would make the live image incorrect. The focused host suite +now constructs an EROFS with a supplied file-context rule, requires the label +string in the image, and, when SELinux is active, extracts and checks the exact +`security.selinux` value; it passed in 0.55 seconds with 30,444 KiB maximum +RSS. The next gate is one fresh privileged bake through policy validation, +EROFS creation, ISO replay, embedded checksum verification, and final identity +generation before removable-media testing. + +The strict doctor, live-image and hardware-gate host suites, Bash parsing of +all five relevant scripts, ShellCheck 0.11.0 from container digest +`b9389b73c8f26f710a7171cb7d8848a34a9c1e07a7865e727c9ec4ce99f9a83f`, +and `git diff --check` passed in one ordered 2.11-second campaign with 47,492 +KiB maximum RSS. No model provider ran, so live-model and model-weighted gate +cost were zero. The pending privileged bake remains the next gate. + +## 2026-08-24 — Add the missing direct-session Linux dmabuf path + +**Goal / environment / failed gate:** Boot the Fedora 44 live remix at clean +revision `24dc85c2e29891d4072cd9674e656fcc05c97686` on the Framework Laptop 12 +(13th-generation Intel Core, Raptor Lake-P UHD `8086:a721`, `i915`) and run the +first physical selectable-session gate. The exact ISO at +`/home/carlid/dev/sos/artifacts/linux-live-image/sos-fedora-workstation-live-24dc85c2e298.iso` +is 3,056,205,824 bytes with SHA-256 +`cbbf9cca1bb70858713a9ae2ab5c3a9203f295ceeb46e82ce0710a983c2d9570`. +The same-boot campaign used boot ID +`77187e2c-d323-4a52-b88d-24d22a87bc33` and ran for +1,303,847,671,263 ns. Recovery and direct DRM page flips passed, but the shell +never became ready; the remaining agent, input, activation, lifecycle, and +logout criteria consequently failed. The finalized 937-byte verdict at +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260824/framework12-first-gate/verdict.txt` +has SHA-256 +`882faf47c47cb1c3518cf2418c9e6d7c182a4cd935f57d80a0e9526a2a67024a`. +The associated 614,603-byte user journal has SHA-256 +`4e08e4e406022a6db00226960026a4049ec202f33bd68a176355e4642ac092c2`. + +**Earliest failure / rejected approaches:** The compositor logged that +`EGL_WL_bind_wayland_display` was unavailable and exposed only `wl_shm` to the +client. GPUI therefore reached Mesa's software path despite an available Intel +render node; Mesa 26.0.3 and LLVM 22.1.1 aborted in `fs_variant_partial` while +`libvulkan_lvp.so`/llvmpipe compiled the shell fragment shader. The revision +supervisor timeout and return to GDM were downstream. Temporarily forcing +`VK_DRIVER_FILES` to Intel changed Vulkan enumeration but retained the +software-OpenGL presentation boundary and reproduced the LLVM failure. Do not +ship that environment override or treat a Mesa/LLVM upgrade as the fix: either +would mask the missing Wayland buffer-sharing protocol rather than provide the +hardware client/compositor path. + +**Changed compositor / focused physical evidence:** The direct backend now +publishes Linux dmabuf feedback independently of the optional EGL Wayland +binding. It advertises only the intersection of formats importable by all +renderers with connected outputs, selects the corresponding render node, +validates every client buffer against each active renderer, and refreshes the +feedback across connector/device changes; `wl_shm` remains available as the +fallback. A unit fixture requires three renderer format sets to collapse to +their sole common format. A 5,699,056-byte focused release build with SHA-256 +`4de0c2131339b3f8b9ec04a12aacdfede2ed73227e99132eccf31cf481974f70` +was copied only into the live overlay. With both the login script and running +shell free of `VK_DRIVER_FILES`, the compositor advertised 240 formats on +`renderD128`, the experience host held Intel DRM file descriptors, revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +produced its first compositor-owned page flip 415,636 microseconds after host +start, the system session became ready, and the offline agent started. The +25,750-byte diagnostic directory is retained at +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260824/framework12-dmabuf-diagnostic`; +its verified 242-byte manifest has SHA-256 +`245c161354c9ddaf03f0b47de19c0d6307dfb76f590552f6ff06d6c86535f0c7`. + +**Logout diagnosis / changed lifecycle:** The focused clean logout recorded +`linux_login_session_stopped reason=user_logout`, then six milliseconds later +a supervisor host proxy observed an already-removed `host-launcher.sock` and +emitted `linux_session_failed`. The launcher was a local in +`start_and_monitor`, so Rust dropped its socket before `run_system_session` +could stop the still-running supervisor. Session ownership now retains the +launcher alongside the child processes and drops it only after the supervisor +has stopped. This removes the shutdown race instead of weakening the hardware +gate's process-failure criterion. + +**Host checks / decision / remaining risk / next gate:** One ordered campaign +ran formatting and diff checks, the compositor's 12 direct-backend tests, the +Linux session's seven unit/integration tests, warning-denying Clippy for both +packages, and the hardware-gate host suite. It passed in 5.84 seconds with +609,852 KiB maximum RSS. The finalized 5,949-byte log at +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260824/host-checks.log` +has SHA-256 +`75a605c8e4091487b76607d7f982edb09c5a99050d6f5e9e6ce73525fcd0a282`. +No model provider ran, so live-model and model-weighted gate cost were zero. +The in-place binary experiment is diagnostic evidence, not a PASS for the +baked revision; the shutdown-order change, multi-GPU selection, physical input, +transactional activation, and final clean logout still lack artifact-matched +physical proof. Commit the fix, rebuild a clean revision-pinned ISO, boot it +fresh, and repeat prepare, all observed interactions, collect, manifest audit, +and same-boot verdict before promoting the Framework gate. + +## 2026-08-24 — Gate agent configuration controls by platform capability + +**Goal / physical observation / evidence:** Continue the focused Framework 12 +live-overlay diagnostic after dmabuf startup succeeded and exercise ordinary +interaction. The compositor independently recorded native +`relative_pointer`, `pointer_button`, and `keyboard` input; the Linux host then +recorded 33 bounded text edits in the agent prompt. Selecting `CODEX SUB`, +`FAKE`, and `CODEX SUB` again produced action requests 34–36, each of which +reached the worker but failed commit as unsupported `agent.configure_codex` or +`agent.use_fake` effects. The bounded 21,440-byte journal at +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260824/framework12-dmabuf-diagnostic/journal-interaction.txt` +has SHA-256 +`3f414fb9e87251bf60e9d917c7c05095486dbb9d058d1397ed763ca2109c29ca`. +The expanded 47,280-byte diagnostic directory verifies through its 332-byte +manifest, whose SHA-256 is +`ca4f67a1d4a952be8883e36d88f45564b06a6e188f5fcc392b45ef1be5bd87e7`. +These are focused physical observations on a modified overlay, not a complete +artifact-matched input gate; touch, activation, and the final verdict remain +open. + +**Diagnosis / changed contract:** Linux provider selection is intentionally a +pre-session operation: `sos-agent-login` writes the private provider/model +configuration, and a new GDM SOS session starts and monitors exactly that +resident agent. Restarting or replacing it from a generated experience would +currently terminate the graphical session. The shared reference experiences +nevertheless rendered Android/Core credential buttons unconditionally, while +the Linux host correctly rejected their effects. `model.agent` now carries a +typed `configuration_actions` allowlist. Linux leaves it empty; Android Compat +publishes its five trusted credential actions; Core publishes only its pinned +OpenRouter, fake, and clear actions. Default, Daily Flow, and Timeflow render +only listed controls and otherwise explain that provider changes are managed +before login. Generated Luau still cannot bypass the trusted host's effect +validation. + +The same check exposed an older decoder mismatch: the stock experience could +render capability-granted System Providers v1 controls, but Luau's bounded +effect decoder omitted audio volume/mute, media transport, app launch, and +attention acknowledgement. The decoder now accepts exactly those documented +typed actions; platform capability and adapter checks still decide whether an +individual request is authorized and executable. + +**Rejected approaches / checks / next gate:** Do not turn `use_fake` into a +silent Linux no-op, launch a credential helper behind the direct session, or +weaken the host's unknown-effect rejection. Each would make rendered state or +process ownership disagree with the actual resident provider. A measured host +campaign passed five experience-IR tests, 22 Luau runtime tests, 26 Linux-host +experience tests, warning-denying Clippy, and NDK 29/API-31 Compat and Core +cross-checks in 10.24 seconds with 2,482,232 KiB maximum RSS. The finalized +10,087-byte log at +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260824/provider-controls-host-checks.log` +has SHA-256 +`6c4d43c4eace55f09f4b99f97709fbe2a818f3caaad7fca30b3e7234755790c3`. +No model provider ran, so live-model and model-weighted gate cost were zero. +Build the next ISO only from the resulting clean committed revision, then repeat +the complete Framework prepare, SOS input and deterministic prompt activation, +clean logout, collect, audit, and copy-off sequence. + +## 2026-08-24 — Complete a focused Framework prompt activation + +**Goal / environment / physical result:** Continue the same focused Framework +12 live-overlay session on boot ID +`77187e2c-d323-4a52-b88d-24d22a87bc33` and test the transactional path after +the user independently confirmed that pointer and keyboard interaction behaved +normally. The 16-byte deterministic prompt `make this calmer` was submitted to +the offline fake agent. The agent fetched context, validated its generated +experience, and submitted revision +`c6d87d5809bbdc3a859ea4fc634f49d0588c3a9248fcc821eca67dcf26293e7f`. +Preparation measured 83 microseconds queued, 3,609 microseconds compiling, +1,629 microseconds rendering, and 5,246 microseconds total worker time. + +**Transactional evidence / lifecycle:** The compositor quiesced input with +zero held keys, buttons, or touches and dropped no events, armed after commit +sequence 159,723, then presented the revision at commit sequence 159,724 and +submit sequence 431 with direct DRM page-flip evidence. The host reported that +frame 383,880 microseconds after text submission; the complete offline-agent +turn finished 400,536 microseconds after submission. A later same-boot sample +showed the experience host still at PID 45,075, with its original 16:19:06 +start time, and the revision supervisor still reported the presented revision +as current. No experience-host restart occurred during activation. + +**Evidence / decision / remaining risk / next gate:** The finalized +15,995-byte activation journal at +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260824/framework12-dmabuf-diagnostic/journal-activation.txt` +has SHA-256 +`d32c7d03d247089c0fbedfa0a58fb12769009bb808e5f6fdfb7abf938153cb93`. +The 495-byte lifecycle sample has SHA-256 +`726817c87482f38fd5f81feb5a02f2ba0f28750ade773c506d841bca262ab8d9`. +All six bounded files in the 63,948-byte diagnostic directory verify through +its 510-byte manifest, whose SHA-256 is +`caf98c4374738e48a82a092ba8152174318ce07ea482fb031ab8bfa55bba1a72`. +This closes focused physical pointer, keyboard, agent, revision-commit, direct +page-flip, and stable-host-lifecycle diagnosis. It does not promote the baked +ISO or complete the hardware gate: the compositor was replaced only in the +live overlay, the provider-control and logout fixes are not present in that +artifact, and touchscreen, corrected clean logout, image identity, collection, +and final manifest audit remain unproved. No live model provider ran, so model +cost was zero. Build one clean revision-pinned ISO containing all fixes, boot +it fresh, and run the full same-boot prepare/interact/collect/audit gate. + +## 2026-08-24 — Run the revision-pinned Framework gate and isolate touch focus + +**Goal / artifact / complete gate:** Boot the clean revision +`fb704784d5b35860c49d44c028ceb3a7fe7daf63` from the baked Fedora 44 live ISO +`/home/carlid/dev/sos/artifacts/linux-live-image-fb70478/sos-fedora-workstation-live-fb704784d5b3.iso` +(3,056,205,824 bytes, SHA-256 +`c043f5657c68ea39e91006cb83fcf4cdb1013fcdf19cc1ef464f502632d48a91`) +on the Framework Laptop 12 and execute one same-boot prepare, physical input, +offline-agent activation, clean logout, collect, copy-off, and manifest audit +campaign. The 350,565,261,856-nanosecond campaign passed live-boot identity, +recovery and direct-compositor page flips, session/agent readiness, keyboard, +touchpad motion/button, touchscreen observation, clean logout, transactional +activation, fallback display manager, and kernel GPU checks. It correctly +failed overall: the host launched twice, the durable pointer remained at +`32f4b2a9c26f632bc20a3139d06a1b59aa9073e6513fabb7698566b669847a5c` +while authority reached +`0560f50dc390dc20c97db99d4a16ee45b11f2956315f927408a7a7b3b5dafcf6`, +and process-failure evidence was present. + +The copied 107,405-byte directory at +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260824/framework12-fb70478-gate` +independently verifies 36 files when checked with the campaign's +`en_US.utf8` collation. Its 3,370-byte manifest has SHA-256 +`a0605f305798af3cd11ad59a8d6c56454e450f3bcaeddba5edad6498c7cfa35f`; +the 1,008-byte verdict and 53,574-byte user journal have SHA-256 +`01533d6a6a2263a01904d305710e3f3e533768efd273921b879fa0eff17d565b` +and `bc93e8583ec9879b37f6784326f5c88d463db18eae0edb3e08099a55703b973b`. +Manifest ordering is locale-sensitive today: verification passes under the +collection locale but not the development host's `C.UTF-8`, which remains a +portability defect rather than evidence corruption. + +**Failure and recovery diagnosis:** The compositor presented candidate +`0560f50d…` after a 4,867-microsecond prepare, but the first experience host +did not consume the compositor-fence notification on its GPUI thread. The +supervisor timed out and launched a replacement while the original host was +still resident; the replacement failed with `Resource temporarily unavailable`. +The original host later panicked during logout after the compositor surface +had disappeared. A focused second SOS login on the same boot used one host PID, +recognized the incomplete transaction, prepared the identical candidate in +5,674 microseconds, directly page-flipped it, aligned current and authority, +and removed the activation journal. This proves durable recovery and candidate +validity, but does not clear the intermittent first-session host stall or +overlapping-restart risk. The user also observed scroll lag during the degraded +first host lifecycle. No live model ran, so model and model-weighted cost were +zero. + +**Physical focus reproduction / changed code:** In the recovered session, a +touchpad click focused `note-draft` and hardware keys edited it. Four subsequent +physical touchscreen contacts over the other editor produced no +`agent-prompt` focus event; later keys still edited `note-draft`. The bounded +13,258-byte journal and 1,738-byte runtime snapshot are stored with a verified +162-byte manifest at +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260824/framework12-fb70478-focus-diagnostic`; +the manifest SHA-256 is +`1344f8d5752a1f4f1f25d9d7fc98652440b57c3367bae27d8f39944aa6ae824d`. +The Linux GPUI backend exposed raw Wayland touch only to the scene-pointer +router, while native text inputs registered mouse handlers but no touch hit +bounds. Native fields now register their painted bounds; a raw touch-down picks +the topmost field, focuses its persistent entity, and positions the cursor. +The existing active-field restoration remains in place because the first gate +shows it restoring the prompt after compositor input quiescing. The nested +compositor workflow now explicitly transfers focus from the autofocused note +editor to the agent prompt and back, guarding that behavior against becoming +sticky during ordinary interaction. + +**Checks / focused overlay result:** An initial release host build completed in +99.70 seconds with 2,010,256 KiB maximum RSS; the 17,071,928-byte diagnostic +binary has SHA-256 +`4f797a3ebd7721fd7af460b338a17be1a766763c8e784aeaf30deb1c2508d12c`. +The nested gate was not claimed because this development host lacks Weston. +The release host was then installed as the only changed binary in the +disposable live overlay, with its hash verified before login. On the next SOS +login, mouse input transferred focus from `note-draft` to `agent-prompt` and +edited the prompt. Physical touchscreen taps then transferred focus from the +agent prompt to the note and back to the prompt; the host emitted bounded +`sos_linux_touch_focus` records with the painted node IDs, followed by the +matching blur/focus pairs. The experience remained on its original PID 17,545, +current and authority both remained at `0560f50d…`, and no activation journal +or process failure appeared. The verified 8,775-byte focused-result bundle is +at +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260824/framework12-fb70478-focus-fix`; +its 166-byte manifest has SHA-256 +`0c7089c3d753ccf9dc1ff5a3a719f7599ebbbd96b73038e93d42b50791697fe5`. + +**Rejected inference / final source / next gate:** The first implementation +also made active-field restoration one-shot. Review against the original +activation journal showed this was unproved and unsafe: repeated restoration +had correctly returned keyboard focus after compositor quiescing. That change +was removed; the final diff contains only native touch hit routing and its +coverage. All 27 Linux-host tests passed in 0.12 seconds and warning-denying +Clippy passed. The final 17,071,928-byte host built in 79.24 seconds with +2,011,288 KiB maximum RSS and has SHA-256 +`ce3c8a486c03f47b1a08d7c2dcfea51dd1ff00c77bc938520327f2e76f394418`. +GDM powered the live system off before that narrowed binary could replace the +diagnostic build, so the physical result closes the touch-routing mechanism on +the overlay but is not exact final-binary or baked-artifact evidence. Commit, +bake a clean revision-pinned ISO, and repeat the complete Framework campaign; +do not promote the hardware gate until artifact-matched focus transfer and +single-host activation both pass. + +## 2026-08-25 — Bake and audit the touch-focus Fedora live ISO + +**Goal / clean source / host:** Build the first artifact containing the Linux +native touchscreen-to-text-field routing fix, without treating a host bake as +physical acceptance. The Fedora 44 x86-64 host passed strict +`tools/linux-live-image doctor`; the source worktree was clean at +`d9d783cd65b7e6faabacc5dc4c26e63d4bf0eca6`. All 27 Linux-host tests, +warning-denying Clippy, Bash parsing of the live-image and nested-compositor +workflows, and `git diff --check` passed before the privileged bake. The pinned +official Fedora Workstation 44 source remained 2,851,612,672 bytes with +SHA-256 +`1620295f6a00c27c3208f0c00b8ece4eab1ec69b9002152d97488bf26a426ddf`. + +**Bake result / measurements:** The complete privileged remix passed rootfs +extraction and metadata preservation, Fedora runtime-package mutation, the +release host and offline-agent builds, offline-user staging, rootfs identity +validation, SELinux policy assignment, EROFS repack, ISO replay, and the +embedded media check. It completed in 2,703.62 seconds wall time with +2,041,868 KiB maximum RSS. The finalized 8,924-byte bake log at +`/home/carlid/dev/sos/artifacts/linux-live-bake-d9d783c.log` has SHA-256 +`ed22f055cbe8008aa0019185dcd69ad01bb1d4d62400d5c2176742562c110bd4`; +the 985-byte GNU-time record has SHA-256 +`144c05d8f95dac71d0f29f78ecc6d7c040b1097e6dcf470626b4e9ab74517809`. +No model provider ran, so live-model and model-weighted cost were zero. + +The resulting live-boot artifact is: + +| Artifact | Revision | Bytes | SHA-256 | +| --- | --- | ---: | --- | +| `/home/carlid/dev/sos/artifacts/linux-live-image-d9d783c/sos-fedora-workstation-live-d9d783cd65b7.iso` | `d9d783cd65b7e6faabacc5dc4c26e63d4bf0eca6` | 3,056,074,752 | `21332392b6564e4f286c527f79645d564270f287d5313a1243c3b040f37738f9` | + +Its 821-byte sidecar has SHA-256 +`b8d30725b4c10e5e4ca4860b9aaf9cd0bc96a2780eb230153a1626786515ee46` +and records `source_dirty=false`, Fedora/build-host release 44, offline agent +mode, `live-boot`, and `not_installed_product=true`. The payload is the expected +flat EROFS rootfs, 2,691,596,288 bytes with SHA-256 +`dc3c28416007457a72548b1959653cd869585b378900cefde8bb2d1275810235`. + +**Independent audit / decision / next gate:** A fresh SHA-256 computation +matched the sidecar; `checkisomd5` independently passed in 3.31 seconds; direct +extraction of the ISO-level identity matched the revision, source, payload, +release, agent, and live-boot claims; direct extraction and hashing of +`LiveOS/squashfs.img` matched its declared size and digest; both +`check-payload` and `fsck.erofs` passed. Xorriso confirmed volume ID +`Fedora-WS-Live-44`, bootable BIOS and UEFI El Torito entries, protective MBR, +and GPT. The finalized 5,336-byte audit bundle at +`/home/carlid/dev/sos/artifacts/linux-live-image-d9d783c-audit` verifies all ten +evidence files through its 834-byte manifest, whose SHA-256 is +`5eb95ac37cac70a7a83f14b7fb506a4db52364d9e10149cda85ecb2e747c3a64`. +The temporary 2.69-GB extracted payload used for this audit was removed after +hashing and remains reproducible from the preserved ISO. + +Accept this exact ISO as flashable live-test media only. It is not an installed +product and the host bake does not prove physical DRM, focus transfer, stable +single-host activation, input, or logout. Write this exact hybrid ISO to the +removable USB, boot it on the Framework Laptop 12, and run one fresh same-boot +prepare, mouse/touch field-transfer, offline-agent activation, clean logout, +collect, copy-off, and manifest-audit campaign before promoting the gate. + +## 2026-08-25 — Diagnose and close Framework touch-triggered host starvation + +**Goal / media / complete-gate result:** Write the revision-pinned Fedora 44 +ISO from the preceding entry to the removable device, run its complete +Framework Laptop 12 gate, and use focused live-overlay experiments to resolve +the remaining host-lifecycle and input failures without repeatedly baking the +3-GB image. The exact 3,056,074,752-byte ISO with SHA-256 +`21332392b6564e4f286c527f79645d564270f287d5313a1243c3b040f37738f9` +was written to `/dev/sda` with +`sudo dd if=...iso of=/dev/sda bs=16M status=progress conv=fsync` and verified +byte-for-byte. The write took 5 minutes 15.20 seconds with 18,828 KiB maximum +RSS. The 276-byte write log, 852-byte time record, and 57-byte verification log +have SHA-256 +`a189ed58d99c4c149a93cb8861fe6d7825f8ccb8fbdeca5f139436432de247f1`, +`5448b31022d418190f53f2b3e2c1016e6c7e8d9786994582f9ff22909141e9a4`, +and `741e2a6c327be22bc80654a2917206f5906792292ec605a7713abe79624219c5`. + +The 357,403,147,177-nanosecond same-boot campaign on boot ID +`9ad78727-161a-45ca-b84c-57b95d020f59` passed live-image identity, recovery and +direct DRM page flips, session and agent readiness, keyboard, touchpad, +touchscreen, clean logout, transactional activation, fallback display manager, +and kernel GPU checks. It correctly failed overall: `stable_host_lifecycle` +observed two host launches, durable authority remained at candidate +`0560f50d…` while the current pointer stayed at `32f4b2a9…`, and SOS process +failures were present. All 36 files verify in the copied 149,313-byte evidence +directory +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260825/framework12-d9d783c-gate`. +Its 3,372-byte manifest, 1,008-byte verdict, 93,916-byte user journal, and +1,072-byte kernel journal have SHA-256 +`d8e39496f33f5c171f6dfc4d51fa18bc3fa95ff107ec251f08be5c57022c71e3`, +`128a21ef749ee330f6d1fe427976e49870bfaa7aeec0bef0f21b4989229f0853`, +`6f3c2b625238cc310a15168d304827dc48bc7baafb881fdadaff6050540b0172`, +and `6c4c168e92364c18615ee7f1981ac9f1d6f3a047fa33708c0b5ea7f8ebdf1fc4`. + +**Focused failures and rejected approaches:** Physical retests first proved +touch focus but exposed three independent symptoms: a native field reverted to +an older authority value on blur, foreground action results could stop draining +under continuous animation, and touch eventually made text, focus, and Submit +lag or stall. A local input-state shadow fixed the stale-value race and one-shot +focus restoration preserved activation focus without stealing ordinary field +transfers. Counting successful foreground sends and re-pinging calloop fixed +lost readiness; limiting each dispatch to 64 tasks prevented an endlessly +self-replenishing foreground queue from monopolizing the loop. Running tasks +directly inside the calloop callback and then bounding those direct runs were +both rejected after physical tests: they initially responded, then touch focus +could be delayed for seconds and Submit again stopped. Moving tasks to +calloop's idle list was also rejected because continuous frame traffic could +starve that idle queue. + +The decisive `eu-stack` sample of the stalled exact host showed its main thread +in `ppoll`, `wl_display_dispatch_queue`, Wayland WSI present, +`anv_QueuePresentKHR`, and the SOS host, while the Luau worker was idle. Wayland +frame callbacks and raw touch callbacks were calling `window.frame()` +synchronously; Vulkan presentation could wait for a swapchain-buffer release +before the Wayland source callback returned, preventing staged action results +from running. The 8,675-byte stack and its 154,585-byte bounded journal have +SHA-256 +`98d75d0f7916a584beb1917883d84b7c847dea61510dc89abeb4e66144f87a36` +and `380ff03462020db901b759b0ec9b589614afd33e1a1bc9c4a88baca34f04da06`. +The first queued-frame build correctly moved rendering out of protocol +dispatch but kept a `RefCell` borrow alive through `window.frame()` and +deterministically panicked during startup; separating the pop and render +statements fixed that rejected implementation. + +**Changed runtime and lifecycle:** Raw Linux touch now supplies a bounded, +coalesced wake receiver to the experience host so touch-only input marks its +entity dirty. Native text state is shadowed until the serialized authority +catches up, and activation restores focus once rather than on every render. +The GPUI calloop bridge now tracks unmatched sends, re-wakes while work remains, +bounds foreground batches, stages them until all ready protocol sources have +run, and services foreground work before a deduplicated post-dispatch frame +queue. Frame-callback, touch, and tablet paths enqueue frames rather than +rendering synchronously inside Wayland dispatch. + +The isolated host launcher also reaps the actual GPU host after an unexpected +proxy disconnect, closing the overlapping-restart failure from the complete +gate. Orderly logout needed a distinct path: the compositor now sends a private +`0600` Unix-datagram request and remains alive while the lifecycle owner shuts +down the supervisor, provider, and host first. Proxy EOF grants an +already-delivered Shutdown request 250 milliseconds to exit; `/proc` state +decides whether a still-live host needs SIGKILL. Tests cover both graceful exit +and forced orphan reaping. This replaced two rejected logout variants: exiting +the compositor first caused a supervisor recovery launch, and unconditional +SIGKILL on proxy EOF produced a false `linux_host_launcher_failed` during +intentional shutdown. + +**Exact focused physical result:** The final 17,080,760-byte experience host +has SHA-256 +`5214883604708ce504b8cdbdae7ec21399d655de6f25566e1f4c4027635bc9f6`; +its release build took 1 minute 27.98 seconds with 2,010,032 KiB maximum RSS, +and the 820-byte GNU-time record has SHA-256 +`9ceac69443469ff3582dd80c92e02b1f96a710ba48dfdca3fb1ccda52b9fd79a`. +`/proc//exe` was verified against that digest before interaction. Across +54 complete physical touchscreen contacts, the compositor recorded 54 downs +and 54 releases and the host routed 36 native field-focus changes. It drained +and durably committed all 115 action requests, including 95 text changes and +21 focus changes. The user reported that touch, typing, Submit, and scrolling +were much better and remained responsive. Submit request 115 completed in +2,840 microseconds, committed authority revision 498, prepared and committed +revision `90e852f54c9d07465f5986d19d1e68a18916edd9eaf7080d958d4d9018b5c699` +in 5,121 microseconds of worker time, presented it by direct DRM page flip, and +activated it under the same supervisor host PID. + +The final 5,706,016-byte compositor and 1,632,504-byte session owner have +SHA-256 +`7bd3b6a0f50969e80cd8369cf33d4b746eb286d5335fd550e95db6a4481516d8` +and `352543f9cbfafb8f7e3ffa2701f5d132e73be8d5f42c7bccc65c103b53e48fe3`. +The exact-source logout emitted the compositor request and handoff followed by +`linux_login_session_stopped reason=user_logout`; it emitted no SOS failure, +panic, Vulkan surface error, or recovery launch, and the post-logout process +table contained no SOS compositor, supervisor, proxy, or experience host. The +final session-owner-only release build took 8.05 seconds with 349,400 KiB +maximum RSS; its 801-byte time record has SHA-256 +`b91284dc5e2733d9e8154146a04928312a645e90349f2746a9a4dba42e021778`. + +**Evidence / checks / decision / next gate:** The copied focused bundle at +`/home/carlid/dev/sos/artifacts/linux-live-image/evidence/framework12-20260825/framework12-staged-frame-focused-20260825` +is 341,138 bytes. All eight evidence files verify through its 1,179-byte +manifest, SHA-256 +`013b91b17c88582a42d241e62b16f34cb239d09a17655d5dca24e5eba3045ff7`. +The 107,152-byte interaction journal, 42,142-byte exact logout journal, and +1,112-byte kernel journal have SHA-256 +`c0c99d2ddef86af1aa33fb45a2ff3eeb2fc40531272dd2a4c4b5feaa0e65b066`, +`020b595447d1efeeb90787117598334b53e63bbbde4420ea848c6ac7c09ea4f4`, +and `a19dd8f9f0fab4f4af068661169ece01717dc2eb995031f34b8cc757fb73bf94`. +Three GPUI dispatcher tests, 29 Linux-host experience tests, nine Linux-session +unit tests plus its authority integration test, and 11 compositor tests passed; +warning-denying Clippy passed for all three affected products and both direct +Linux feature sets. No live model ran, so model and model-weighted cost were +zero. + +Accept the focused overlay as physical evidence for the diagnosed mechanisms, +not as promotion of the old ISO: the booted artifact still contains revision +`d9d783c`, and the final binaries were installed into its disposable overlay. +Commit these fixes, bake one clean revision-pinned ISO, then run a fresh full +prepare, touch/keyboard/scroll/Submit, clean logout, collect, copy-off, and +manifest-audit campaign. Only that artifact-matched campaign can promote the +Framework live gate. + +## 2026-08-25 — Replace acceptance-live with a mutable development environment + +**Goal / decision / rejected workflow:** Separate fast physical iteration from +future release promotion. Rebuilding the prior 3.06-GB Fedora remix took +2,703.62 seconds and writing it to USB took another 315.20 seconds, so requiring +that cycle after every SOS patch is disproportionate during diagnosis. The +intermediate acceptance-live artifact class was rejected: it adds almost the +same compose/flash cost as release without providing the immutable SOS-only +artifact that will eventually ship. SOS now has two image classes only: +`development-live`, which is mutable and always +`promotion_eligible=false`, and a future immutable `release`, whose composer +and artifact-matched promotion gate remain to be built. The existing focused +Framework overlay evidence remains diagnostic mechanism evidence; it is not +retroactively promoted. + +**Changed environment and controls:** `tools/linux-live-image` now labels the +Fedora Workstation remix `development-live`, installs and enables +`openssh-server`, opens the Fedora firewall's SSH service, and requires a +private non-symlink `--liveuser-password-file`. Password authentication is +restricted to `liveuser`, root SSH is disabled, reusable host keys are removed +so Fedora generates them at boot, and GDM liveuser autologin is disabled so the +operator can choose GNOME or SOS. Fedora creates `liveuser` during boot, so the +builder derives a SHA-512 password hash and installs a root-owned mode-`0700` +`livesys-session-extra` hook that assigns it after account creation, relocks +Fedora's temporary passwordless root account, and disables GDM autologin after +the GNOME live hook enables it. SSH requires and follows `livesys.service`. +Rootfs validation checks that boot-time provisioning, SSHD +enablement/configuration, absent host keys, and the non-promotable/mutable +identity fields without exposing the password. + +`tools/linux-live-deploy` builds any selected compositor, experience-host, +provider, supervisor, session, or authoring binary locally and deploys it over +one multiplexed SSH connection. It refuses targets whose baked identity is not +mutable/non-promotable development-live and refuses a running SOS session. It +records base/source revision and dirty state, installs root-owned files into the +RAM overlay, verifies their remote SHA-256 values, and preserves matching host +and target deployment manifests. `tools/linux-hardware-gate` verifies that +manifest, snapshots the current bytes against the baked install manifest, and +emits only `DIAGNOSTIC_PASS promotion_eligible=false` or `DIAGNOSTIC_FAIL` for +development-live. Installed-workstation criteria remain available, but no +current environment is labeled a release artifact. + +**Evidence / failures / measurement:** +`./tests/linux-live-image-test.sh` exercises identity fields, password-file +rejection, mocked password-hash/livesys/systemd/firewalld provisioning, +root-owned private rootfs validation, component selection, the complete mocked +SSH deployment, remote installation, metadata, and digest verification. It +passed with +`linux_live_image_host_tests=PASS`. `./tests/linux-hardware-gate-test.sh` +passed with `linux_hardware_gate_host_tests=PASS`, including the rule that a +complete development campaign is diagnostic rather than a normal PASS and a +missing touch observation is `DIAGNOSTIC_FAIL`. The combined suites, Bash +parsing, and `git diff --check` completed in 1.21 seconds with 30,316 KiB +maximum RSS. The test harness initially attempted root-owned fixture installs +without an effective-root mock and then exposed an EXIT-trap lifetime bug in +the deployer's SSH cleanup; the fixture now strips ownership flags while the +production path still uses sudo, and successful deployment explicitly cleans +up before function-local state goes out of scope. No model provider ran, so +model and model-weighted cost were zero. + +**Remaining risk / next gate:** No new ISO or physical acceptance is claimed. +The rootfs tests use controlled command doubles; a real Fedora bake must still +prove the boot-time `livesys` password/root-lock/GDM hook, offline +`sshd.service` enablement and ordering, firewall persistence, GDM session +selection, and per-boot host-key generation together. Bake and flash +development-live once, boot the Framework Laptop 12, verify password SSH and +GNOME/SOS selection, deploy one changed binary with +`tools/linux-live-deploy`, verify its recorded digest on the laptop, and run a +same-boot diagnostic collect. Ordinary SOS patches can then reuse that base; +design and gate the immutable SOS-only `release` process separately. + +## 2026-08-25 — Move development-live account setup to Fedora boot provisioning + +**Goal / environment:** Run the first real `development-live` bake at clean +revision `659003a35635da8423a7393ddf8d9b109ac355e1` from the checksum-pinned +Fedora Workstation Live 44 x86-64 source +`artifacts/linux-live-source/Fedora-Workstation-Live-44-1.7.x86_64.iso` +(2,851,612,672 bytes, +SHA-256 `1620295f6a00c27c3208f0c00b8ece4eab1ec69b9002152d97488bf26a426ddf`) +and validate the new development access against a real Fedora rootfs. + +**Failure / rejected approach:** The bake extracted and staged SOS, then failed +with `error: development rootfs has no liveuser account`. It exited 1 after +207.79 seconds with 2,001,264 KiB maximum RSS; no ISO was produced and no +physical-device result is claimed. The finalized failure log is +`artifacts/development-live-659003a-bake.log` (8,659 bytes, SHA-256 +`90094fc77daa4aead345817069023a78c9449613e478db5f70716b699a0b42d7`), +and its timing record is `artifacts/development-live-659003a-bake.time` (92 +bytes, SHA-256 +`d7fc757d673677f5465d5d2ace3801d8afaa515f346a18e933fa44f246051645`). +Inspection of the extracted Fedora rootfs showed that +`/usr/libexec/livesys/livesys-main` creates `liveuser` at boot, temporarily +clears the root password, runs the GNOME hook that enables autologin, and only +then sources `/var/lib/livesys/livesys-session-extra`. Offline `chpasswd` was +therefore impossible, while editing GDM offline would be overwritten at boot; +both approaches were rejected. + +**Decision / changed code:** `tools/linux-live-image` now verifies Fedora's +expected `livesys` contract, derives a salted SHA-512 password hash with +OpenSSL, and installs a root-owned mode-`0700` derived-spin hook. At boot the +hook assigns that hash to the newly created `liveuser`, relocks root, and +disables GDM autologin after Fedora's GNOME hook. An SSH unit drop-in requires +and follows `livesys.service`, failing remote access closed if provisioning +fails. Rootfs validation requires the hook, its ownership/mode and hash form, +the root relock, disabled autologin, and SSH ordering while confirming that no +pre-boot `liveuser` was fabricated. The Fedora-realistic test fixture now +models boot-time account creation rather than an offline shadow entry. + +**Evidence / remaining risk / next gate:** +`./tests/linux-live-image-test.sh` and +`./tests/linux-hardware-gate-test.sh` passed with +`linux_live_image_host_tests=PASS` and +`linux_hardware_gate_host_tests=PASS`; combined with Bash parsing and +`git diff --check`, the measured run took 1.25 seconds with 30,428 KiB maximum +RSS. No model provider ran, so model and model-weighted cost were zero. These +host tests prove the generated files and fail-closed relationships, not Fedora +boot behavior. Commit the correction, rerun a clean bake in a new output +directory, independently audit the ISO, then boot it and verify root remains +locked, liveuser password SSH starts only after livesys, GDM offers both GNOME +and SOS without autologin, and reboot removes incremental deployments. + +## 2026-08-25 — Activate development SSH from the completed livesys hook + +**Goal / environment:** Complete the first real `development-live` bake and +boot it on the Framework Laptop 12 without touching its installed Omarchy +disk, then verify password-protected remote access before using the image as +the reusable SOS development base. The clean source revision was +`f057d251de7781622bb60a70c960d4bc01f8e37d`; the target identified itself as +Framework `Laptop 12 (13th Gen Intel Core)` revision A5, running Fedora 44 +kernel `6.19.10-300.fc44.x86_64` in boot +`edb42181-55f8-4a36-a388-971f5db601e2`. + +**Bake and media evidence:** The first bake invocation completed extraction, +package/runtime staging, rootfs validation, and EROFS repacking, but its cached +sudo authorization expired after 2,910.74 seconds; it exited 1 while waiting +to remove the work tree. The failure/resume inputs remain +`artifacts/development-live-f057d25-bake.log` (8,867 bytes, SHA-256 +`a07f97f70385d288e05ced0a01613e1da81b3b4025fa2c7320d37b74bd54140a`) +and `artifacts/development-live-f057d25-bake.time` (92 bytes, SHA-256 +`39ddd08d869807a026eecb18815989831e54582e1a1948f499f8f4b206390da8`). +Resuming from the already finalized payload produced +`artifacts/development-live-f057d25/sos-development-live-f057d251de77.iso` +(3,056,205,824 bytes, SHA-256 +`c2232111ab8b4aa6d55907dfdf5830a688468bf4be7b8dd218f26c727925ffc0`). +Its embedded EROFS payload is 2,691,727,360 bytes with SHA-256 +`c222e53420d88b7ac541e18629573660d2bc71f6174379cea1659ee37edc7f7e`. +An independent `checkisomd5` completed in 3.45 seconds with PASS. Uploading the +ISO to PiKVM virtual media took 138.62 seconds; the PiKVM copy matched the host +byte count and SHA-256 and remained connected read-only. + +**Physical failure / rejected approach:** GDM required the configured +`liveuser` password instead of autologging in, and `livesys.service` completed +the password assignment, root relock, and GDM rewrite successfully. SSH did +not start: `sshd.service` was inactive/disabled with no port 22 listener, and +the boot journal contained no SSH start attempt. Direct EROFS inspection proved +the baked lower rootfs contained the offline +`multi-user.target.wants/sshd.service` link and the +`Requires=livesys.service` drop-in, while the initial running merged rootfs did +not expose the enablement link. Therefore offline enablement plus a dependency +on a successful but normally inactive oneshot service is rejected as the +development access boundary. + +Before any live mutation, `findmnt` showed `/` as the writable +`LiveOS_rootfs` overlay with `/run/rootfsbase` as its lower directory and a +RAM-backed `/run/overlayfs` upper directory. `lsblk` showed the internal 1 TB +WD_BLACK NVMe with VFAT and LUKS partitions and no mountpoints. No installer +target was selected and no internal-disk write was performed. + +**Focused proof / decision / changed code:** On that same disposable overlay, +disabling the dependency drop-in and running +`systemctl enable --now sshd.service` after completed provisioning made SSH +enabled and active with IPv4 and IPv6 port 22 listeners. A fresh independent +password SSH connection then passed; root reported locked, `liveuser` reported +a password, and the per-boot Ed25519 host private/public keys were root-owned +mode `0600`/`0644`. `tools/linux-live-image` now omits offline SSH enablement +and the `Requires=livesys.service` drop-in. The root-only Fedora hook makes +GDM configuration fail closed and performs `systemctl enable --now +sshd.service` as its final action, only after assigning the liveuser password +and relocking root. Rootfs validation requires that exact final action and +rejects any pre-provisioning SSH enablement. The fixture tests cover the new +metadata and reject a hook with any action after SSH activation; +`docs/linux-live-image.md` records the boundary. + +Raw console screenshots, OCR, SSH audits, upload timing, ISO integrity, and +host test records are indexed by +`artifacts/pikvm-development-live-f057d25/evidence-manifest.tsv` (2,493 bytes, +SHA-256 +`8c86900caa2b6d033610b760bf9e9271c064ccfd1701dc6f52788456d89694d5`). +`./tests/linux-live-image-test.sh` and +`./tests/linux-hardware-gate-test.sh` passed with +`linux_live_image_host_tests=PASS` and +`linux_hardware_gate_host_tests=PASS`; together with Bash parsing and +`git diff --check`, they completed in 1.30 seconds with 30,316 KiB maximum +RSS. No model provider ran, so model and model-weighted cost were zero. + +**Remaining risk / next gate:** This physical boot proved the failure and the +focused live-overlay correction, not the newly generated hook on a fresh boot. +No hardware, latency, release, or promotion gate is complete. Bake the corrected +clean revision once, attach it read-only, cold-boot the Framework, and require +automatic SSH enablement only after successful `livesys` provisioning. Then +verify GDM offers GNOME and SOS, deploy one changed SOS component with +`tools/linux-live-deploy`, verify its recorded digest, and run a same-boot +diagnostic campaign before treating this image as the reusable development +base. + +## 2026-08-25 — Add private Wi-Fi autoconnect to development-live + +**Goal / environment:** Make the reusable Framework development image join its +lab Wi-Fi without console input so PiKVM can cold-boot directly into an +SSH-manageable environment. Reuse the NetworkManager profile created by the +running Fedora 44 development boot instead of reconstructing or printing its +secret. The profile was copied over the already authenticated SSH channel to a +private host file, validated as mode `0600` and 288 bytes, and intentionally +excluded from Git, logs, hashes, and evidence manifests because it contains a +network credential. + +**Security decision / changed code:** This is an optional development-only +facility. `tools/linux-live-image` accepts +`--networkmanager-profile-file`, rejects symlinks and group/world-readable +inputs, and validates a Wi-Fi connection UUID, autoconnect, WPA-PSK/SAE with a +stored boot-time PSK, and automatic IPv4 without printing the SSID or PSK. It +installs the profile as root-owned mode `0600` under +`/etc/NetworkManager/system-connections`; rootfs validation rechecks the +profile and its root-owned mode `0700` parent. Matching rootfs and outer image +identities record only `wifi_autoconnect=true` and +`network_credentials_embedded=true`. Omitting the option records both fields +as false and embeds no SOS-owned profile. The standalone +`check-networkmanager-profile` command validates a prospective private input +before a long bake. + +Runtime file permissions do not make the ISO secret: anyone holding the image +can extract an equivalent Wi-Fi credential offline. Documentation now says the +credentialed ISO must remain private, its network credential must be rotated if +custody is lost, and the future immutable release must exclude the profile. +The network name and credential are not present in source, identity metadata, +progress records, or test output. + +**Evidence:** The fixture suite covers credentialed and uncredentialed identity +fields, private-file and non-symlink enforcement, disabled autoconnect, +missing PSK, installed ownership/mode, metadata agreement, and the no-profile +case. The actual private Framework profile emitted only +`linux_live_image_network_profile_checked=PASS wifi_autoconnect=true +network_credentials_embedded=true`. Bash parsing, +`./tests/linux-live-image-test.sh`, +`./tests/linux-hardware-gate-test.sh`, and `git diff --check` all passed in +1.69 seconds with 30,308 KiB maximum RSS. ShellCheck was not installed. The +finalized output is +`artifacts/development-live-network-profile/host-tests.log` (227 bytes, +SHA-256 `196ce54c4aa66c4c4b7a78d5842cee7c98b9f947edd3866b2c7275bda684c09f`) +and its timing is +`artifacts/development-live-network-profile/host-tests.time` (1,133 bytes, +SHA-256 `32a7d3f5a0339bf818ea300a36cac7849c60007fee89548071c2d8c66f919b05`). +No model provider ran, so model and model-weighted cost were zero. + +**Remaining risk / next gate:** No new ISO or unattended network boot is yet +claimed. Commit the builder change, bake one clean credentialed +`development-live` ISO, attach it read-only through PiKVM, and cold-boot the +Framework without network HID input. Require the identity classification, +automatic Wi-Fi activation, the post-`livesys` SSH listener, password SSH, and +the still-unmounted internal Omarchy NVMe to pass together before adopting the +image as the reusable base. + +## 2026-08-25 — Bake the credentialed development-live image + +**Goal / environment:** Produce the first clean reusable development image +that combines the post-`livesys` SSH activation with private Wi-Fi +autoconnect. The clean source revision was +`28cf8fffee8e2492fc4f2b69fcfe27db3baf7b36`; the source media was +`artifacts/linux-live-source/Fedora-Workstation-Live-44-1.7.x86_64.iso` +(SHA-256 +`1620295f6a00c27c3208f0c00b8ece4eab1ec69b9002152d97488bf26a426ddf`). +The private NetworkManager input remained outside Git and build evidence. + +**Bake evidence / decision:** `tools/linux-live-image bake` staged SOS, +configured the development account and post-provisioning SSH activation, +installed the private NetworkManager profile, validated the rootfs, repacked +EROFS, and passed the embedded media check. It completed in 2,640.49 seconds +with 704,264 KiB maximum RSS. The result is +`artifacts/development-live-28cf8ff/sos-development-live-28cf8fffee8e.iso` +(3,056,205,824 bytes, SHA-256 +`a346369a50cf5d1b32610fcf1c55c95ea7238172a46a2b7c6c1618428f4ed152`). +Its identity is +`artifacts/development-live-28cf8ff/image-identity.env` (954 bytes, SHA-256 +`7685dab93216d94d8e512d4108ea553e143bbe003fb9d01d5f7d46b68c596c6d`) +and records the exact source revision, `development-live`, +`promotion_eligible=false`, `wifi_autoconnect=true`, and +`network_credentials_embedded=true` without an SSID, password, PSK, or +passphrase field. + +The finalized bake output is `artifacts/development-live-28cf8ff-bake.log` +(9,189 bytes, SHA-256 +`a9faee06c622fad34dcaddd341f223ff5c5721afcbc54ddd775a17fd18d5f69a`) +and its timing record is `artifacts/development-live-28cf8ff-bake.time` (54 +bytes, SHA-256 +`faff5ef450ed1fa5cf4cba1221f6a68119d18409c7c6c9849acc8ced5ce6c1ca`). +An independent SHA-256, `checkisomd5`, identity-agreement, and secret-field +audit passed in 5.10 seconds with 3,456 KiB maximum RSS. Its output is +`artifacts/development-live-28cf8ff-audit.log` (309 bytes, SHA-256 +`b5a9672ed9ca23127a13a479f49827f5d30ef9e878175081179baed3cb31ceac`) +and timing is `artifacts/development-live-28cf8ff-audit.time` (49 bytes, +SHA-256 +`97667dd628bd7bd8574a391693c728971343807a3a576987419d2a5a65f3ecb5`). +No model provider ran, so model and model-weighted cost were zero. + +**Remaining risk / next gate:** This is build evidence, not unattended-boot or +hardware acceptance. Keep the credentialed ISO private. Attach this exact hash +read-only through PiKVM, cold-boot the Framework without network HID input, +and require automatic Wi-Fi activation, post-`livesys` SSH availability, +password SSH, correct image identity, and an unmounted internal Omarchy NVMe +before adopting it as the reusable development base. + +## 2026-08-25 — Prove development-live Wi-Fi and SSH on Framework 12 + +**Goal / environment:** Boot the credentialed revision +`28cf8fffee8e2492fc4f2b69fcfe27db3baf7b36` on the Framework Laptop 12 and +prove that it becomes remotely manageable without entering a network +credential while preserving the installed Omarchy NVMe. The exact ISO was +uploaded to PiKVM in 134.96 seconds with 5,978,736 KiB maximum host RSS. The +PiKVM-side SHA-256 matched +`a346369a50cf5d1b32610fcf1c55c95ea7238172a46a2b7c6c1618428f4ed152`, +and the selected 3,056,205,824-byte virtual CD-ROM reported connected, +complete, read-only, and non-writable. + +**Boot failure / boundary:** PiKVM's ATX state did not track or change the +laptop's power state, and remote HID did not reliably catch the Framework +firmware boot-menu window. The first reboot therefore entered the installed +Omarchy lock screen rather than the virtual CD-ROM. No installer or +block-device writer was invoked. The user then selected the already attached +read-only PiKVM CD-ROM and booted Fedora. This rejects fully unattended remote +cold boot with the present console wiring/configuration; it does not reject the +image's development-network path. + +**Physical evidence / decision:** PiKVM captured Fedora startup and the GDM +`Live System User` chooser without autologin. The live boot obtained +`192.168.1.129` from the embedded Wi-Fi profile with no network HID input, and +password SSH became reachable. A 4.54-second SSH audit with 9,584 KiB maximum +host RSS reported `image_kind=development-live`, the exact source revision, +`promotion_eligible=false`, `wifi_autoconnect=true`, +`network_credentials_embedded=true`, enabled and active `sshd`, connected +Wi-Fi, `LiveOS_rootfs` overlay root, and boot ID +`c1e6564e-b427-471d-946e-9d83f2d8efde`. It also proved no +`/dev/nvme0n1` source was mounted. The credentialed ISO is accepted as the +reusable development base when selected explicitly at boot; it remains +private and is not a release candidate. + +The finalized upload, stored-image state and digest, boot/GDM screenshots, +OCR capability state, SSH audit, and timing records are indexed by +`artifacts/pikvm-development-live-28cf8ff/evidence-manifest.tsv` (1,110 bytes, +SHA-256 +`dd6f8ec86ce40b8cfb36509ae8b54c98bbe9d25fd2355b4a9fdcd6e80c9fe9ea`). +No model provider ran, so model and model-weighted cost were zero. + +**Remaining risk / next gate:** Firmware boot selection still requires local +help until PiKVM power/boot-menu control is made reliable. The image has not +passed a release, promotion, latency, or full SOS interaction gate. Use this +boot for incremental `tools/linux-live-deploy` iterations, verify each deployed +digest and same-boot identity, and keep the internal NVMe unmounted. Later, +define a separate credential-free immutable release bake and physical release +gate. diff --git a/experiences/daily-flow.luau b/experiences/daily-flow.luau index 996d110..97667c3 100644 --- a/experiences/daily-flow.luau +++ b/experiences/daily-flow.luau @@ -33,6 +33,13 @@ local function group(flow, layout, children, options) } end +local function has_agent_configuration_action(agent, action) + for _, supported in ipairs(agent.configuration_actions or {}) do + if supported == action then return true end + end + return false +end + local function agent_panel(model, state) local agent = model.agent or {} local messages = {} @@ -64,7 +71,7 @@ local function agent_panel(model, state) hint = "Type a request and press enter", }, }) - return group("column", { gap = 9, padding = 16 }, { + local children = { group("row", { justify = "between" }, { text("MAKE IT YOURS", 12, coral), text(agent.activity or (agent.available and "Ready" or "Agent unavailable"), 11, muted, { @@ -72,32 +79,56 @@ local function agent_panel(model, state) value = agent.activity or (agent.available and "Ready" or "Agent unavailable"), }, "agent-status"), }), - group("row", { gap = 6 }, { + } + local primary_controls = {} + if has_agent_configuration_action(agent, "configure_openai") then + table.insert(primary_controls, group("row", { padding = 8 }, { text("OPENAI", 10, coral) }, { id = "agent-configure", background = 0xF0ECE4, radius = 10, tap_action = "agent_configure_openai", - }), + })) + end + if has_agent_configuration_action(agent, "configure_openrouter") then + table.insert(primary_controls, group("row", { padding = 8 }, { text("OPENROUTER", 10, coral) }, { id = "agent-configure-openrouter", background = 0xF0ECE4, radius = 10, tap_action = "agent_configure_openrouter", - }), + })) + end + if has_agent_configuration_action(agent, "configure_codex") then + table.insert(primary_controls, group("row", { padding = 8 }, { text("CODEX SUB", 10, coral) }, { id = "agent-configure-codex", background = 0xF0ECE4, radius = 10, tap_action = "agent_configure_codex", - }), - }), - group("row", { gap = 6 }, { + })) + end + local secondary_controls = {} + if has_agent_configuration_action(agent, "use_fake") then + table.insert(secondary_controls, group("row", { padding = 8 }, { text("FAKE", 10, coral) }, { id = "agent-use-fake", background = 0xF0ECE4, radius = 10, tap_action = "agent_use_fake", - }), + })) + end + if has_agent_configuration_action(agent, "clear_credential") then + table.insert(secondary_controls, group("row", { padding = 8 }, { text("REMOVE KEY", 10, 0x9A3D36) }, { id = "agent-clear-key", background = 0xF0ECE4, radius = 10, tap_action = "agent_clear_credential", - }), - }), - group("column", { gap = 7 }, messages), - }, { id = "agent-conversation", background = white, radius = 20 }) + })) + end + if #primary_controls > 0 then + table.insert(children, group("row", { gap = 6 }, primary_controls)) + end + if #secondary_controls > 0 then + table.insert(children, group("row", { gap = 6 }, secondary_controls)) + end + if #primary_controls == 0 and #secondary_controls == 0 then + table.insert(children, text("Provider changes are managed before login.", 10, muted)) + end + table.insert(children, group("column", { gap = 7 }, messages)) + return group("column", { gap = 9, padding = 16 }, children, + { id = "agent-conversation", background = white, radius = 20 }) end local function network_panel(model) diff --git a/experiences/default.luau b/experiences/default.luau index a16abdc..b6e2e6b 100644 --- a/experiences/default.luau +++ b/experiences/default.luau @@ -194,6 +194,13 @@ local function attention_center(providers) return group("column", { gap = 9 }, rows, nil, 0, "attention-center") end +local function has_agent_configuration_action(agent, action) + for _, supported in ipairs(agent.configuration_actions or {}) do + if supported == action then return true end + end + return false +end + local function authoring_surface(model, state) local agent = model.agent or {} local messages = {} @@ -224,20 +231,34 @@ local function authoring_surface(model, state) hint = "Type a request and press enter", }, }) - return group("column", { padding = 16, gap = 9 }, { + local children = { group("row", { justify = "between", align = "center" }, { text("MAKE IT YOURS", 12, moss), text(agent.activity or (agent.available and "Ready" or "Unavailable"), 11, muted), }), - group("row", { gap = 6 }, { - button("OPENAI", "agent_configure_openai", moss), - button("OPENROUTER", "agent_configure_openrouter", moss), - button("CODEX", "agent_configure_codex", moss), - button("OFFLINE", "agent_use_fake", moss), - }), - group("column", { gap = 7 }, messages), - agent.error and text(agent.error, 11, alert) or text("", 1, muted), - }, 0xECE9DC, 20, "authoring-surface") + } + local controls = {} + if has_agent_configuration_action(agent, "configure_openai") then + table.insert(controls, button("OPENAI", "agent_configure_openai", moss)) + end + if has_agent_configuration_action(agent, "configure_openrouter") then + table.insert(controls, button("OPENROUTER", "agent_configure_openrouter", moss)) + end + if has_agent_configuration_action(agent, "configure_codex") then + table.insert(controls, button("CODEX", "agent_configure_codex", moss)) + end + if has_agent_configuration_action(agent, "use_fake") then + table.insert(controls, button("OFFLINE", "agent_use_fake", moss)) + end + if #controls > 0 then + table.insert(children, group("row", { gap = 6 }, controls)) + else + table.insert(children, text("Provider changes are managed before login.", 10, muted)) + end + table.insert(children, group("column", { gap = 7 }, messages)) + table.insert(children, agent.error and text(agent.error, 11, alert) or text("", 1, muted)) + return group("column", { padding = 16, gap = 9 }, children, + 0xECE9DC, 20, "authoring-surface") end local function render(model, state) diff --git a/experiences/timeflow.luau b/experiences/timeflow.luau index 36af8b1..096bcc7 100644 --- a/experiences/timeflow.luau +++ b/experiences/timeflow.luau @@ -83,6 +83,13 @@ local function network_panel(model) return group("column", { gap = 8, padding = 14 }, rows, 0x18251F, 16, "network-panel") end +local function has_agent_configuration_action(agent, action) + for _, supported in ipairs(agent.configuration_actions or {}) do + if supported == action then return true end + end + return false +end + local function agent_panel(model, state) local agent = model.agent or {} local children = { @@ -93,21 +100,43 @@ local function agent_panel(model, state) value = agent.activity or (agent.available and "Ready" or "Agent unavailable"), }, "agent-status"), }), - group("row", { gap = 6 }, { + } + local primary_controls = {} + if has_agent_configuration_action(agent, "configure_openai") then + table.insert(primary_controls, group("row", { padding = 8 }, { text("OPENAI", 10, sage) }, - 0x213129, 10, "agent-configure", "agent_configure_openai"), + 0x213129, 10, "agent-configure", "agent_configure_openai")) + end + if has_agent_configuration_action(agent, "configure_openrouter") then + table.insert(primary_controls, group("row", { padding = 8 }, { text("OPENROUTER", 10, sage) }, - 0x213129, 10, "agent-configure-openrouter", "agent_configure_openrouter"), + 0x213129, 10, "agent-configure-openrouter", "agent_configure_openrouter")) + end + if has_agent_configuration_action(agent, "configure_codex") then + table.insert(primary_controls, group("row", { padding = 8 }, { text("CODEX SUB", 10, sage) }, - 0x213129, 10, "agent-configure-codex", "agent_configure_codex"), - }), - group("row", { gap = 6 }, { + 0x213129, 10, "agent-configure-codex", "agent_configure_codex")) + end + local secondary_controls = {} + if has_agent_configuration_action(agent, "use_fake") then + table.insert(secondary_controls, group("row", { padding = 8 }, { text("FAKE", 10, sage) }, - 0x213129, 10, "agent-use-fake", "agent_use_fake"), + 0x213129, 10, "agent-use-fake", "agent_use_fake")) + end + if has_agent_configuration_action(agent, "clear_credential") then + table.insert(secondary_controls, group("row", { padding = 8 }, { text("REMOVE KEY", 10, 0xE07A6A) }, - 0x213129, 10, "agent-clear-key", "agent_clear_credential"), - }), - } + 0x213129, 10, "agent-clear-key", "agent_clear_credential")) + end + if #primary_controls > 0 then + table.insert(children, group("row", { gap = 6 }, primary_controls)) + end + if #secondary_controls > 0 then + table.insert(children, group("row", { gap = 6 }, secondary_controls)) + end + if #primary_controls == 0 and #secondary_controls == 0 then + table.insert(children, text("Provider changes are managed before login.", 10, dim)) + end for index, message in ipairs(agent.messages or {}) do table.insert(children, group("column", { gap = 3, padding = 11 }, { text(message.role == "user" and "YOU" or "SOS", 10, amber), diff --git a/tests/linux-hardware-gate-test.sh b/tests/linux-hardware-gate-test.sh index f00e887..b7c1dac 100755 --- a/tests/linux-hardware-gate-test.sh +++ b/tests/linux-hardware-gate-test.sh @@ -38,6 +38,9 @@ test_boot_id=12345678-1234-1234-1234-123456789abc test_other_boot_id=87654321-4321-4321-4321-cba987654321 printf '%s\n' \ 'agent_mode=offline' \ + 'boot_kind=installed' \ + 'campaign_class=installed-workstation' \ + 'not_installed_product=false' \ "boot_id=$test_boot_id" >"$test_evidence/campaign.env" printf 'boot_id=%s\n' "$test_boot_id" >"$test_evidence/collection.env" printf '%s\n' \ @@ -64,6 +67,12 @@ grep -Fx "criterion=same_boot result=PASS boot_id=$test_boot_id" \ grep -Fx \ 'linux_hardware_gate_result=PASS evidence=drm_page_flip physical_input=keyboard,touchpad,touchscreen' \ "$test_root/pass-audit.txt" >/dev/null +grep -Fx 'boot_kind=installed campaign_class=installed-workstation' \ + "$test_root/pass-audit.txt" >/dev/null +if grep -F 'not_installed_product=true' "$test_root/pass-audit.txt" >/dev/null; then + printf 'error: installed-workstation audit labeled the campaign as a live non-product\n' >&2 + exit 1 +fi sed -i '/input_class="touch"/d' "$test_evidence/journal-user.txt" if "$test_gate" audit --evidence-dir "$test_evidence" >"$test_root/fail-audit.txt"; then @@ -96,4 +105,124 @@ if "$test_gate" verify-manifest --evidence-dir "$test_evidence" \ fi grep -F 'manifested evidence size changed' "$test_root/manifest-fail.txt" >/dev/null +printf '2222\n' >"$test_evidence/current-revision.txt" +printf '%s\n' \ + 'agent_mode=offline' \ + 'boot_kind=development-live' \ + 'campaign_class=development-live' \ + 'not_installed_product=true' \ + 'promotion_eligible=false' \ + "boot_id=$test_boot_id" >"$test_evidence/campaign.env" +printf '%s\n' \ + 'observed native compositor input input_class="touch"' >>"$test_evidence/journal-user.txt" +"$test_gate" audit --evidence-dir "$test_evidence" >"$test_root/live-audit.txt" +grep -Fx \ + 'linux_hardware_gate_result=DIAGNOSTIC_PASS promotion_eligible=false evidence=drm_page_flip physical_input=keyboard,touchpad,touchscreen' \ + "$test_root/live-audit.txt" >/dev/null +grep -Fx 'boot_kind=development-live campaign_class=development-live' \ + "$test_root/live-audit.txt" >/dev/null +grep -Fx 'not_installed_product=true' "$test_root/live-audit.txt" >/dev/null +grep -Fx 'promotion_eligible=false' "$test_root/live-audit.txt" >/dev/null + +sed -i '/input_class="touch"/d' "$test_evidence/journal-user.txt" +if "$test_gate" audit --evidence-dir "$test_evidence" >"$test_root/live-fail-audit.txt"; then + printf 'error: development-live audit accepted evidence without physical touchscreen input\n' >&2 + exit 1 +fi +grep -Fx 'criterion=touchscreen_input result=FAIL' "$test_root/live-fail-audit.txt" >/dev/null +grep -Fx 'linux_hardware_gate_result=DIAGNOSTIC_FAIL promotion_eligible=false' \ + "$test_root/live-fail-audit.txt" >/dev/null +grep -Fx 'boot_kind=development-live campaign_class=development-live' \ + "$test_root/live-fail-audit.txt" >/dev/null + +test_sysroot="$test_root/sysroot" +mkdir -p \ + "$test_sysroot/usr/share/doc/sos" \ + "$test_sysroot/run/initramfs/live" \ + "$test_sysroot/proc/sys/kernel/random" +printf '12345678-1234-1234-1234-123456789abc\n' \ + >"$test_sysroot/proc/sys/kernel/random/boot_id" +printf '%s\n' \ + 'image_kind=development-live' \ + 'campaign_class=development-live' \ + 'not_installed_product=true' \ + 'promotion_eligible=false' \ + 'mutable_runtime=true' \ + 'ssh_enabled=true' \ + 'container_format=erofs-rootfs' \ + 'fedora_release=44' \ + 'build_host_release=44' \ + 'source_revision=abc123' \ + 'source_dirty=false' \ + 'agent_mode=offline' \ + 'base_iso_filename=Fedora-Workstation-Live-x86_64-44-1.1.iso' \ + 'base_iso_bytes=2048' \ + 'base_iso_sha256=0000000000000000000000000000000000000000000000000000000000000001' \ + 'payload_relpath=LiveOS/squashfs.img' \ + 'baked_at_utc=2026-08-24T00:00:00Z' \ + >"$test_sysroot/usr/share/doc/sos/image-identity.env" +if "$test_gate" classify-boot --sysroot "$test_sysroot" \ + >"$test_root/classify-missing-media-identity.txt" 2>&1; then + printf 'error: classify-boot accepted live media without its ISO-level identity\n' >&2 + exit 1 +fi +grep -F 'missing the ISO-level image identity' \ + "$test_root/classify-missing-media-identity.txt" >/dev/null +cp -- "$test_sysroot/usr/share/doc/sos/image-identity.env" \ + "$test_sysroot/run/initramfs/live/sos-image-identity.env" +printf '%s\n' \ + 'payload_bytes=1024' \ + 'payload_sha256=0000000000000000000000000000000000000000000000000000000000000002' \ + >>"$test_sysroot/run/initramfs/live/sos-image-identity.env" +"$test_gate" classify-boot --sysroot "$test_sysroot" >"$test_root/classify-live.txt" +grep -Fx 'boot_kind=development-live' "$test_root/classify-live.txt" >/dev/null +grep -Fx 'not_installed_product=true' "$test_root/classify-live.txt" >/dev/null +grep -Fx 'promotion_eligible=false' "$test_root/classify-live.txt" >/dev/null +grep -Fx 'boot_id=12345678-1234-1234-1234-123456789abc' \ + "$test_root/classify-live.txt" >/dev/null +grep -Fx 'live_overlay=present' "$test_root/classify-live.txt" >/dev/null + +sed -i 's/source_revision=abc123/source_revision=wrong/' \ + "$test_sysroot/run/initramfs/live/sos-image-identity.env" +if "$test_gate" classify-boot --sysroot "$test_sysroot" \ + >"$test_root/classify-identity-mismatch.txt" 2>&1; then + printf 'error: classify-boot accepted mismatched rootfs and media identities\n' >&2 + exit 1 +fi +grep -F 'rootfs and ISO-level image identities disagree' \ + "$test_root/classify-identity-mismatch.txt" >/dev/null + +rm -r -- "$test_sysroot/run/initramfs/live" +if "$test_gate" classify-boot --sysroot "$test_sysroot" >"$test_root/classify-stale.txt" 2>&1; then + printf 'error: classify-boot accepted development-live identity without a live overlay\n' >&2 + exit 1 +fi +grep -F 'do not collect it as development-live or an installed product' \ + "$test_root/classify-stale.txt" >/dev/null + +rm -f -- "$test_sysroot/usr/share/doc/sos/image-identity.env" +mkdir -p "$test_sysroot/run/initramfs/live" +if "$test_gate" classify-boot --sysroot "$test_sysroot" >"$test_root/classify-stock.txt" 2>&1; then + printf 'error: classify-boot accepted stock live media without SOS identity\n' >&2 + exit 1 +fi +grep -F 'stock live media is not a development image' "$test_root/classify-stock.txt" >/dev/null + +rm -r -- "$test_sysroot/run/initramfs/live" +"$test_gate" classify-boot --sysroot "$test_sysroot" >"$test_root/classify-installed.txt" +grep -Fx 'boot_kind=installed' "$test_root/classify-installed.txt" >/dev/null +grep -Fx 'campaign_class=installed-workstation' "$test_root/classify-installed.txt" >/dev/null + +for test_label in development-live 'not an installed product' image-identity squashfs; do + grep -F "$test_label" "$test_repo_root/docs/linux-hardware-gate.md" >/dev/null + grep -F "$test_label" "$test_repo_root/docs/linux-live-image.md" >/dev/null +done +grep -F 'boot_kind=development-live' "$test_gate" >/dev/null +grep -F 'not_installed_product=true' "$test_gate" >/dev/null +grep -F 'DIAGNOSTIC_PASS promotion_eligible=false' "$test_gate" >/dev/null +grep -F 'image-identity.env' "$test_gate" >/dev/null +grep -F 'payload_sha256' "$test_gate" >/dev/null +grep -F 'boot_id=' "$test_gate" >/dev/null +grep -F '/usr/local/libexec/sos/linux-hardware-gate collect' "$test_gate" >/dev/null + printf 'linux_hardware_gate_host_tests=PASS\n' diff --git a/tests/linux-live-image-test.sh b/tests/linux-live-image-test.sh new file mode 100755 index 0000000..b0d0522 --- /dev/null +++ b/tests/linux-live-image-test.sh @@ -0,0 +1,824 @@ +#!/usr/bin/env bash + +set -euo pipefail + +test_repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_image="$test_repo_root/tools/linux-live-image" +test_deploy="$test_repo_root/tools/linux-live-deploy" +test_install="$test_repo_root/tools/install-linux-login-session" +test_root="$(mktemp -d -t sos-linux-live-image-test.XXXXXX)" +test_revision=abcdef1234567890abcdef1234567890abcdef12 +test_namespace_dir="" +test_locked_dir="" + +test_cleanup() { + if [[ -n "$test_locked_dir" && -d "$test_locked_dir" ]]; then + chmod 0700 "$test_locked_dir" 2>/dev/null || true + fi + if [[ -n "$test_namespace_dir" && -d "$test_namespace_dir" ]]; then + rm -r -- "$test_namespace_dir" + fi + rm -r -- "$test_root" +} +trap test_cleanup EXIT + +bash -n "$test_image" +bash -n "$test_deploy" +bash -n "$test_install" +"$test_deploy" components >"$test_root/deploy-components.txt" +for test_component in compositor experience-host provider supervisor session authoring; do + grep -E "^${test_component}[[:space:]]+/usr/local/libexec/sos/" \ + "$test_root/deploy-components.txt" >/dev/null +done +if "$test_deploy" deploy --target root@example.test --component compositor \ + >"$test_root/deploy-unsafe-target.txt" 2>&1; then + printf 'error: development deploy accepted a non-liveuser SSH target\n' >&2 + exit 1 +fi +grep -F 'target must be liveuser@HOST' "$test_root/deploy-unsafe-target.txt" >/dev/null +if "$test_deploy" deploy --target liveuser@example.test --component unknown \ + >"$test_root/deploy-unknown-component.txt" 2>&1; then + printf 'error: development deploy accepted an unknown component\n' >&2 + exit 1 +fi +grep -F 'unknown component: unknown' "$test_root/deploy-unknown-component.txt" >/dev/null + +test_deploy_bin="$test_root/deploy-bin" +test_deploy_remote="$test_root/deploy-remote" +test_deploy_target="$test_root/deploy-target" +test_deploy_state="$test_root/deploy-stage-path" +mkdir -p "$test_deploy_bin" "$test_deploy_remote" "$test_deploy_target/release" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'binary=""' \ + 'while [[ "$#" -gt 0 ]]; do' \ + ' if [[ "$1" == --bin ]]; then binary="$2"; shift 2; else shift; fi' \ + 'done' \ + '[[ -n "$binary" ]]' \ + 'mkdir -p "$CARGO_TARGET_DIR/release"' \ + 'printf "mock binary %s\\n" "$binary" >"$CARGO_TARGET_DIR/release/$binary"' \ + 'chmod 0755 "$CARGO_TARGET_DIR/release/$binary"' \ + >"$test_deploy_bin/cargo" +chmod 0755 "$test_deploy_bin/cargo" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'if [[ "${1:-}" == -tt ]]; then shift; fi' \ + 'while [[ "${1:-}" == -o ]]; do shift 2; done' \ + 'if [[ "${1:-}" == -O ]]; then exit 0; fi' \ + 'target="$1"; shift' \ + 'command="$*"' \ + '[[ "$target" == liveuser@mock-target ]]' \ + 'case "$command" in' \ + ' true) exit 0 ;;' \ + ' *"pgrep -f"*) exit 0 ;;' \ + ' "cat /usr/share/doc/sos/image-identity.env"|"cat '\''/usr/share/doc/sos/image-identity.env'\''")' \ + ' printf "%s\\n" image_kind=development-live promotion_eligible=false mutable_runtime=true source_revision=1111111111111111111111111111111111111111' \ + ' ;;' \ + ' "umask 077; mktemp -d -p /tmp sos-development-deploy.XXXXXX")' \ + ' mktemp -d -p /tmp sos-development-deploy.XXXXXX' \ + ' ;;' \ + ' "set -euo pipefail;"*)' \ + ' stage="$(cat "$TEST_DEPLOY_STATE")"' \ + ' mkdir -p "$TEST_DEPLOY_REMOTE/usr/local/libexec/sos" "$TEST_DEPLOY_REMOTE/usr/share/doc/sos"' \ + ' for source in "$stage"/sos-*; do cp -- "$source" "$TEST_DEPLOY_REMOTE/usr/local/libexec/sos/$(basename "$source")"; done' \ + ' cp -- "$stage/development-deployment.env" "$TEST_DEPLOY_REMOTE/usr/share/doc/sos/"' \ + ' cp -- "$stage/development-deployment-manifest.tsv" "$TEST_DEPLOY_REMOTE/usr/share/doc/sos/"' \ + ' rm -r -- "$stage"' \ + ' ;;' \ + ' "sha256sum "*)' \ + ' path="${command:11:-1}"' \ + ' sha256sum "$TEST_DEPLOY_REMOTE$path" | sed "s|$TEST_DEPLOY_REMOTE||"' \ + ' ;;' \ + ' "rm -r -- "*)' \ + ' path="${command:10:-1}"' \ + ' [[ ! -e "$path" ]] || rm -r -- "$path"' \ + ' ;;' \ + ' *) printf "unexpected mock SSH command: %s\\n" "$command" >&2; exit 1 ;;' \ + 'esac' \ + >"$test_deploy_bin/ssh" +chmod 0755 "$test_deploy_bin/ssh" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'while [[ "${1:-}" == -o ]]; do shift 2; done' \ + 'sources=()' \ + 'while [[ "$#" -gt 1 ]]; do sources+=("$1"); shift; done' \ + 'destination="${1#*:}"' \ + 'cp -- "${sources[@]}" "$destination/"' \ + 'printf "%s\\n" "$destination" >"$TEST_DEPLOY_STATE"' \ + >"$test_deploy_bin/scp" +chmod 0755 "$test_deploy_bin/scp" +PATH="$test_deploy_bin:$PATH" \ +CARGO_TARGET_DIR="$test_deploy_target" \ +TEST_DEPLOY_REMOTE="$test_deploy_remote" \ +TEST_DEPLOY_STATE="$test_deploy_state" \ +SOS_DEVELOPMENT_DEPLOY_ARTIFACTS_DIR="$test_root/deploy-artifacts" \ + "$test_deploy" deploy \ + --target liveuser@mock-target \ + --component experience-host \ + --component compositor \ + >"$test_root/deploy-pass.txt" +grep -F 'linux_development_live_deployed=PASS' "$test_root/deploy-pass.txt" >/dev/null +grep -F 'promotion_eligible=false' "$test_root/deploy-pass.txt" >/dev/null +for test_binary in sos-experience-host sos-compositor; do + [[ -x "$test_deploy_remote/usr/local/libexec/sos/$test_binary" ]] +done +test_deployment_metadata="$test_deploy_remote/usr/share/doc/sos/development-deployment.env" +test_deployment_manifest="$test_deploy_remote/usr/share/doc/sos/development-deployment-manifest.tsv" +grep -Fx 'image_kind=development-live' "$test_deployment_metadata" >/dev/null +grep -Fx 'promotion_eligible=false' "$test_deployment_metadata" >/dev/null +[[ "$(wc -l <"$test_deployment_manifest")" -eq 2 ]] +while IFS=$'\t' read -r test_path test_bytes test_sha; do + [[ "$(stat -c %s "$test_deploy_remote$test_path")" == "$test_bytes" ]] + [[ "$(sha256sum "$test_deploy_remote$test_path" | cut -d ' ' -f 1)" == "$test_sha" ]] +done <"$test_deployment_manifest" +"$test_image" doctor --layout-only >"$test_root/doctor.txt" +grep -Fx 'linux_live_image_doctor=PASS mode=layout-only bake_ready=false' \ + "$test_root/doctor.txt" >/dev/null +if "$test_image" doctor >"$test_root/bake-doctor.txt"; then + grep -Fx 'linux_live_image_doctor=PASS bake_ready=true' "$test_root/bake-doctor.txt" >/dev/null +else + grep -Fx 'linux_live_image_doctor=FAIL bake_ready=false' "$test_root/bake-doctor.txt" >/dev/null +fi +grep -F 'nodejs' "$test_root/doctor.txt" >/dev/null +grep -F 'openssh-server' "$test_root/doctor.txt" >/dev/null +if grep -E 'libinput-devel|mesa-libgbm-devel|libseat-devel' "$test_root/doctor.txt" >/dev/null; then + printf 'error: live image doctor advertised development packages as runtime deps\n' >&2 + exit 1 +fi + +"$test_image" format-identity \ + --source-revision "$test_revision" \ + --source-dirty false \ + --agent-mode offline \ + --fedora-release 44 \ + --build-host-release 44 \ + --base-iso-filename Fedora-Workstation-Live-x86_64-44-1.1.iso \ + --base-iso-bytes 2048 \ + --base-iso-sha256 0000000000000000000000000000000000000000000000000000000000000001 \ + --payload-relpath LiveOS/squashfs.img \ + --payload-bytes 1024 \ + --payload-sha256 0000000000000000000000000000000000000000000000000000000000000002 \ + --container-format erofs-rootfs \ + --wifi-autoconnect true \ + --baked-at-utc 2026-08-23T00:00:00Z \ + --output-iso-filename sos-development-live-abcdef123456.iso \ + --output-iso-bytes 4096 \ + --output-iso-sha256 0000000000000000000000000000000000000000000000000000000000000003 \ + >"$test_root/identity.env" +for test_key in \ + image_kind=development-live \ + campaign_class=development-live \ + not_installed_product=true \ + promotion_eligible=false \ + mutable_runtime=true \ + ssh_enabled=true \ + wifi_autoconnect=true \ + network_credentials_embedded=true \ + fedora_release=44 \ + build_host_release=44 \ + source_revision="$test_revision" \ + source_dirty=false \ + agent_mode=offline \ + payload_relpath=LiveOS/squashfs.img \ + payload_sha256=0000000000000000000000000000000000000000000000000000000000000002 \ + output_iso_sha256=0000000000000000000000000000000000000000000000000000000000000003; do + grep -Fx "$test_key" "$test_root/identity.env" >/dev/null +done +"$test_image" format-identity \ + --source-revision "$test_revision" \ + --source-dirty false \ + --agent-mode offline \ + --fedora-release 44 \ + --build-host-release 44 \ + --base-iso-filename Fedora-Workstation-Live-x86_64-44-1.1.iso \ + --base-iso-bytes 2048 \ + --base-iso-sha256 0000000000000000000000000000000000000000000000000000000000000001 \ + --payload-relpath LiveOS/squashfs.img \ + --container-format erofs-rootfs \ + --baked-at-utc 2026-08-23T00:00:00Z \ + >"$test_root/identity-no-wifi.env" +grep -Fx 'wifi_autoconnect=false' "$test_root/identity-no-wifi.env" >/dev/null +grep -Fx 'network_credentials_embedded=false' \ + "$test_root/identity-no-wifi.env" >/dev/null +if "$test_image" format-identity \ + --source-revision "$test_revision" \ + --source-dirty true \ + --agent-mode offline \ + --fedora-release 44 \ + --build-host-release 44 \ + --base-iso-filename Fedora-Workstation-Live-x86_64-44-1.1.iso \ + --base-iso-bytes 2048 \ + --base-iso-sha256 0000000000000000000000000000000000000000000000000000000000000001 \ + --payload-relpath LiveOS/squashfs.img \ + --container-format erofs-rootfs \ + >"$test_root/dirty-identity.txt" 2>&1; then + printf 'error: format-identity accepted a dirty source revision\n' >&2 + exit 1 +fi +if "$test_image" format-identity \ + --source-revision "$test_revision" \ + --source-dirty false \ + --agent-mode live \ + --fedora-release 44 \ + --build-host-release 44 \ + --base-iso-filename Fedora-Workstation-Live-x86_64-44-1.1.iso \ + --base-iso-bytes 2048 \ + --base-iso-sha256 0000000000000000000000000000000000000000000000000000000000000001 \ + --payload-relpath LiveOS/squashfs.img \ + --container-format erofs-rootfs \ + >"$test_root/live-agent-identity.txt" 2>&1; then + printf 'error: format-identity accepted a live agent bake\n' >&2 + exit 1 +fi + +if "$test_image" format-identity \ + --source-revision "$test_revision" \ + --source-dirty false \ + --agent-mode offline \ + --fedora-release 44 \ + --build-host-release 44 \ + --base-iso-filename Fedora-Workstation-Live-x86_64-44-1.1.iso \ + --base-iso-bytes 2048 \ + --base-iso-sha256 0000000000000000000000000000000000000000000000000000000000000001 \ + --payload-relpath LiveOS/squashfs.img \ + --container-format squashfs-rootfs-img \ + >"$test_root/flat-identity.txt" 2>&1; then + printf 'error: format-identity accepted a non-EROFS payload\n' >&2 + exit 1 +fi +grep -F 'only metadata-preserving erofs-rootfs is supported' \ + "$test_root/flat-identity.txt" >/dev/null + +if command -v fsck.erofs >/dev/null 2>&1 \ + && command -v mkfs.erofs >/dev/null 2>&1 \ + && mkfs.erofs --help 2>&1 | grep -F 'all-fragments' >/dev/null; then + test_packed_source="$test_root/packed-source" + test_packed_dest="$test_root/packed-rootfs" + test_packed_image="$test_root/packed.erofs" + mkdir "$test_packed_source" "$test_packed_dest" + printf 'packed extraction regression\n' >"$test_packed_source/file" + mkfs.erofs -zlzma -Eall-fragments \ + "$test_packed_image" "$test_packed_source" >/dev/null + fsck.erofs --path=/ --extract="$test_packed_dest" --xattrs --preserve \ + "$test_packed_image" >/dev/null + cmp "$test_packed_source/file" "$test_packed_dest/file" + + if command -v strings >/dev/null 2>&1 \ + && mkfs.erofs --help 2>&1 | grep -F -- '--file-contexts' >/dev/null; then + test_label_source="$test_root/label-source" + test_label_dest="$test_root/label-dest" + test_label_image="$test_root/label.erofs" + test_file_contexts="$test_root/file_contexts" + mkdir "$test_label_source" "$test_label_dest" + printf 'SELinux label regression\n' >"$test_label_source/probe" + printf '/probe -- system_u:object_r:bin_t:s0\n' >"$test_file_contexts" + mkfs.erofs --file-contexts="$test_file_contexts" \ + "$test_label_image" "$test_label_source" >/dev/null + fsck.erofs --xattrs "$test_label_image" >/dev/null + strings "$test_label_image" | \ + grep -Fx 'system_u:object_r:bin_t:s0' >/dev/null + if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then + fsck.erofs --extract="$test_label_dest" --xattrs \ + --no-preserve-owner --no-preserve-perms "$test_label_image" >/dev/null + [[ "$(getfattr -h -n security.selinux --only-values \ + "$test_label_dest/probe")" == system_u:object_r:bin_t:s0 ]] + fi + fi +fi + +if command -v getfattr >/dev/null 2>&1 \ + && command -v rsync >/dev/null 2>&1 \ + && command -v setfattr >/dev/null 2>&1; then + test_metadata_source="$test_root/metadata-source" + test_metadata_dest="$test_root/metadata-dest" + mkdir "$test_metadata_source" "$test_metadata_dest" + printf 'metadata extraction regression\n' >"$test_metadata_source/file" + chmod 0750 "$test_metadata_source/file" + ln "$test_metadata_source/file" "$test_metadata_source/hardlink" + setfattr -n user.sos_probe -v preserved "$test_metadata_source/file" + test_metadata_source_label="" + if command -v chcon >/dev/null 2>&1 \ + && command -v selinuxenabled >/dev/null 2>&1 \ + && selinuxenabled \ + && chcon system_u:object_r:fusefs_t:s0 "$test_metadata_source/file"; then + test_metadata_source_label="$(stat -c %C "$test_metadata_source/file")" + fi + rsync -aHAXS --numeric-ids \ + --filter='-x security.selinux' \ + --filter='-x system.*' \ + "$test_metadata_source/" "$test_metadata_dest/" + rsync -aHASni --numeric-ids \ + "$test_metadata_source/" "$test_metadata_dest/" \ + >"$test_root/metadata-audit.txt" + [[ ! -s "$test_root/metadata-audit.txt" ]] + for test_metadata_side in source dest; do + test_metadata_dir="$test_metadata_source" + [[ "$test_metadata_side" == source ]] || test_metadata_dir="$test_metadata_dest" + ( + cd "$test_metadata_dir" + getfattr -hRPd -m- . + ) | awk ' + /^# file: / { path = substr($0, 9); next } + /^(user|trusted|security)\./ && \ + !/^security\.selinux=/ && \ + !/^trusted\.SGI_ACL_/ { + print path "\t" $0 + } + ' | LC_ALL=C sort >"$test_root/metadata-$test_metadata_side-xattrs.txt" + done + cmp "$test_root/metadata-source-xattrs.txt" "$test_root/metadata-dest-xattrs.txt" + cmp "$test_metadata_source/file" "$test_metadata_dest/file" + [[ "$(stat -c %a "$test_metadata_dest/file")" == 750 ]] + [[ "$(stat -c %i "$test_metadata_dest/file")" \ + == "$(stat -c %i "$test_metadata_dest/hardlink")" ]] + [[ "$(getfattr -n user.sos_probe --only-values "$test_metadata_dest/file")" \ + == preserved ]] + if [[ -n "$test_metadata_source_label" ]]; then + [[ "$(stat -c %C "$test_metadata_dest/file")" \ + != "$test_metadata_source_label" ]] + fi +fi + +mkdir "$test_root/bin" +# The single-quoted expansions belong to the generated mock, not this test process. +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'if [[ "${1:-}" == --ls && "${TEST_PAYLOAD_LAYOUT:-}" != erofs-root ]]; then' \ + ' exit 1' \ + 'fi' \ + 'exit 0' >"$test_root/bin/dump.erofs" +chmod 0755 "$test_root/bin/dump.erofs" +# Simulate sudo crossing a root-owned, non-traversable directory boundary. The +# wrapper temporarily grants its owning test process access, runs the real +# command, and restores the boundary. An ordinary [[ -f path ]] cannot pass. +# shellcheck disable=SC2016 +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'if [[ "${1:-}" == stat && "$*" == *"/var/lib/livesys/livesys-session-extra" ]]; then' \ + ' printf "root:root:700\\n"' \ + ' exit 0' \ + 'fi' \ + 'if [[ "${1:-}" == stat && "$*" == *"/etc/NetworkManager/system-connections/60-sos-development-live.nmconnection" ]]; then' \ + ' if [[ "$*" == *"%U:%G:%a"* ]]; then printf "root:root:600\\n"; else printf "600\\n"; fi' \ + ' exit 0' \ + 'fi' \ + 'if [[ "${1:-}" == stat && "$*" == *"/etc/NetworkManager/system-connections" ]]; then' \ + ' printf "root:root:700\\n"' \ + ' exit 0' \ + 'fi' \ + 'if [[ -z "${TEST_SUDO_UNLOCK_DIR:-}" || -z "${TEST_SUDO_LOG:-}" ]]; then' \ + ' if [[ "${1:-}" == install ]]; then' \ + ' shift' \ + ' filtered=()' \ + ' while [[ "$#" -gt 0 ]]; do' \ + ' case "$1" in -o|-g) shift 2 ;; *) filtered+=("$1"); shift ;; esac' \ + ' done' \ + ' exec install "${filtered[@]}"' \ + ' fi' \ + ' exec "$@"' \ + 'fi' \ + 'printf "%s\\n" "$*" >>"$TEST_SUDO_LOG"' \ + 'chmod 0700 "$TEST_SUDO_UNLOCK_DIR"' \ + 'set +e' \ + '"$@"' \ + 'status=$?' \ + 'set -e' \ + 'chmod 000 "$TEST_SUDO_UNLOCK_DIR"' \ + 'exit "$status"' >"$test_root/bin/sudo" +chmod 0755 "$test_root/bin/sudo" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + '[[ "$*" == "passwd -6 -stdin" ]]' \ + 'IFS= read -r password' \ + '[[ -n "$password" ]]' \ + 'printf "\\x24%s\\x24%s\\x24%s\\n" 6 development-salt development-hash' \ + >"$test_root/bin/openssl" +chmod 0755 "$test_root/bin/openssl" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'printf "%s\\n" "$*" >>"$TEST_FIREWALL_LOG"' \ + '[[ " $* " == *" --service=ssh "* ]]' \ + >"$test_root/bin/firewall-offline-cmd" +chmod 0755 "$test_root/bin/firewall-offline-cmd" +: >"$test_root/payload.img" +PATH="$test_root/bin:$PATH" TEST_PAYLOAD_LAYOUT=erofs-root \ + "$test_image" check-payload --payload "$test_root/payload.img" \ + >"$test_root/erofs-payload.txt" +grep -F 'container_format=erofs-rootfs' "$test_root/erofs-payload.txt" >/dev/null +if PATH="$test_root/bin:$PATH" TEST_PAYLOAD_LAYOUT=not-rootfs \ + "$test_image" check-payload --payload "$test_root/payload.img" \ + >"$test_root/not-rootfs-payload.txt" 2>&1; then + printf 'error: check-payload accepted EROFS without a Fedora rootfs\n' >&2 + exit 1 +fi +grep -F 'EROFS payload is not a flat Fedora root filesystem' \ + "$test_root/not-rootfs-payload.txt" >/dev/null + +"$test_image" write-offline-user-state --home-root "$test_root/skel" \ + >"$test_root/skel.txt" +grep -Fx 'SOS_AGENT_MODEL=faux' "$test_root/skel/.local/state/sos/agent/config.env" >/dev/null +grep -Fx 'SOS_AGENT_FAKE_SOURCE=/usr/share/sos/experiences/daily-flow.luau' \ + "$test_root/skel/.local/state/sos/agent/config.env" >/dev/null +grep -Fx '{}' "$test_root/skel/.local/state/sos/output.json" >/dev/null +[[ "$(stat -c %a "$test_root/skel/.local/state/sos/agent/config.env")" == 600 ]] + +# Calling the user-state helper from a bake must not replace the active rootfs +# used by the surrounding staging function. +( + set -- write-offline-user-state --home-root "$test_root/source-skel" + # shellcheck source=/dev/null + source "$test_image" >/dev/null + live_image_rootfs="$test_root/rootfs-sentinel" + live_image_write_offline_user_state \ + --home-root "$test_root/nested-skel" >/dev/null + [[ "$live_image_rootfs" == "$test_root/rootfs-sentinel" ]] +) + +test_rootfs="$test_root/rootfs" +mkdir -p \ + "$test_rootfs/usr/local/libexec/sos" \ + "$test_rootfs/usr/local/libexec/sos-agent/dist" \ + "$test_rootfs/usr/bin" \ + "$test_rootfs/usr/libexec/livesys" \ + "$test_rootfs/usr/share/wayland-sessions" \ + "$test_rootfs/usr/share/sos/experiences" \ + "$test_rootfs/usr/share/doc/sos" \ + "$test_rootfs/usr/lib/systemd/system" \ + "$test_rootfs/usr/lib/firewalld" \ + "$test_rootfs/etc/skel" \ + "$test_rootfs/etc/gdm" \ + "$test_rootfs/etc/ssh" \ + "$test_rootfs/etc/firewalld" \ + "$test_rootfs/home/liveuser" +: >"$test_rootfs/usr/local/libexec/sos/sos-login-session" +: >"$test_rootfs/usr/local/libexec/sos/sos-agent-login" +: >"$test_rootfs/usr/local/libexec/sos/linux-hardware-gate" +: >"$test_rootfs/usr/local/libexec/sos-agent/dist/agent-runner.cjs" +: >"$test_rootfs/usr/share/wayland-sessions/sos.desktop" +: >"$test_rootfs/usr/share/sos/experiences/daily-flow.luau" +: >"$test_rootfs/usr/lib/systemd/system/gdm.service" +: >"$test_rootfs/usr/lib/systemd/system/sshd.service" +: >"$test_rootfs/usr/lib/systemd/system/NetworkManager.service" +: >"$test_rootfs/usr/bin/systemctl" +chmod 0755 "$test_rootfs/usr/bin/systemctl" +printf '%s\n' \ + 'root:x:0:0:root:/root:/bin/bash' >"$test_rootfs/etc/passwd" +printf '%s\n' \ + 'root:*:20000:0:99999:7:::' >"$test_rootfs/etc/shadow" +printf '%s\n' \ + '#!/usr/bin/sh' \ + 'useradd ${USERADDARGS:+"$USERADDARGS"} -c "Live System User" liveuser' \ + '. /var/lib/livesys/livesys-session-extra' \ + >"$test_rootfs/usr/libexec/livesys/livesys-main" +printf '%s\n' \ + '[daemon]' \ + 'AutomaticLoginEnable=True' \ + 'AutomaticLogin=liveuser' >"$test_rootfs/etc/gdm/custom.conf" +printf '%s\n' \ + 'ID=fedora' \ + 'VERSION_ID=44' \ + 'VARIANT_ID=workstation' >"$test_rootfs/etc/os-release" +printf '%s\n' \ + "source_revision=$test_revision" \ + 'source_dirty=false' \ + 'agent_mode=offline' >"$test_rootfs/usr/share/doc/sos/install-metadata.env" +: >"$test_rootfs/usr/share/doc/sos/install-manifest.tsv" +cp -- "$test_root/identity.env" "$test_rootfs/usr/share/doc/sos/image-identity.env" +"$test_image" write-offline-user-state --home-root "$test_rootfs/etc/skel" >/dev/null +"$test_image" write-offline-user-state --home-root "$test_rootfs/home/liveuser" >/dev/null +mkdir -p "$test_rootfs/etc/systemd/system" +ln -s graphical.target "$test_rootfs/usr/lib/systemd/system/default.target" +printf 'development-password\n' >"$test_root/liveuser-password" +test_network_profile="$test_root/development-wifi.nmconnection" +printf '%s\n' \ + '[connection]' \ + 'id=SOS development Wi-Fi' \ + 'uuid=11111111-2222-4333-8444-555555555555' \ + 'type=wifi' \ + 'autoconnect=true' \ + '' \ + '[wifi]' \ + 'mode=infrastructure' \ + 'ssid=Test Network' \ + '' \ + '[wifi-security]' \ + 'key-mgmt=wpa-psk' \ + 'psk=test-network-password' \ + '' \ + '[ipv4]' \ + 'method=auto' \ + '' \ + '[ipv6]' \ + 'method=auto' >"$test_network_profile" +chmod 0644 "$test_network_profile" +: >"$test_root/firewall.log" +if PATH="$test_root/bin:$PATH" TEST_FIREWALL_LOG="$test_root/firewall.log" \ + "$test_image" configure-development-access \ + --root "$test_rootfs" \ + --password-file "$test_root/liveuser-password" \ + --networkmanager-profile-file "$test_network_profile" \ + >"$test_root/configure-public-network-profile.txt" 2>&1; then + printf 'error: development access accepted a public network profile\n' >&2 + exit 1 +fi +grep -F 'must not be accessible by group or other users' \ + "$test_root/configure-public-network-profile.txt" >/dev/null +chmod 0600 "$test_network_profile" +"$test_image" check-networkmanager-profile \ + --profile-file "$test_network_profile" \ + >"$test_root/check-network-profile.txt" +grep -Fx \ + 'linux_live_image_network_profile_checked=PASS wifi_autoconnect=true network_credentials_embedded=true' \ + "$test_root/check-network-profile.txt" >/dev/null +ln -s development-wifi.nmconnection "$test_root/symlink-network-profile" +if PATH="$test_root/bin:$PATH" TEST_FIREWALL_LOG="$test_root/firewall.log" \ + "$test_image" configure-development-access \ + --root "$test_rootfs" \ + --password-file "$test_root/liveuser-password" \ + --networkmanager-profile-file "$test_root/symlink-network-profile" \ + >"$test_root/configure-symlink-network-profile.txt" 2>&1; then + printf 'error: development access accepted a symlink network profile\n' >&2 + exit 1 +fi +grep -F 'readable regular file, not a symlink' \ + "$test_root/configure-symlink-network-profile.txt" >/dev/null +sed 's/autoconnect=true/autoconnect=false/' "$test_network_profile" \ + >"$test_root/disabled-network-profile.nmconnection" +chmod 0600 "$test_root/disabled-network-profile.nmconnection" +if PATH="$test_root/bin:$PATH" TEST_FIREWALL_LOG="$test_root/firewall.log" \ + "$test_image" configure-development-access \ + --root "$test_rootfs" \ + --password-file "$test_root/liveuser-password" \ + --networkmanager-profile-file "$test_root/disabled-network-profile.nmconnection" \ + >"$test_root/configure-disabled-network-profile.txt" 2>&1; then + printf 'error: development access accepted disabled Wi-Fi autoconnect\n' >&2 + exit 1 +fi +grep -F 'NetworkManager profile must enable autoconnect' \ + "$test_root/configure-disabled-network-profile.txt" >/dev/null +mkdir -p "$test_rootfs/etc/systemd/system/multi-user.target.wants" +ln -s /usr/lib/systemd/system/sshd.service \ + "$test_rootfs/etc/systemd/system/multi-user.target.wants/sshd.service" +if PATH="$test_root/bin:$PATH" TEST_FIREWALL_LOG="$test_root/firewall.log" \ + "$test_image" configure-development-access \ + --root "$test_rootfs" \ + --password-file "$test_root/liveuser-password" \ + --networkmanager-profile-file "$test_network_profile" \ + >"$test_root/configure-premature-ssh.txt" 2>&1; then + printf 'error: development access accepted pre-provisioning SSH enablement\n' >&2 + exit 1 +fi +grep -F 'source image already enables sshd before liveuser provisioning' \ + "$test_root/configure-premature-ssh.txt" >/dev/null +rm -f -- "$test_rootfs/etc/systemd/system/multi-user.target.wants/sshd.service" +PATH="$test_root/bin:$PATH" TEST_FIREWALL_LOG="$test_root/firewall.log" \ + "$test_image" configure-development-access \ + --root "$test_rootfs" \ + --password-file "$test_root/liveuser-password" \ + --networkmanager-profile-file "$test_network_profile" \ + >"$test_root/configure-development-access.txt" +grep -F 'linux_live_image_development_access=PASS' \ + "$test_root/configure-development-access.txt" >/dev/null +grep -F 'wifi_autoconnect=true' \ + "$test_root/configure-development-access.txt" >/dev/null +grep -F -- '--service=ssh' "$test_root/firewall.log" >/dev/null +grep -Fx 'PermitRootLogin no' \ + "$test_rootfs/etc/ssh/sshd_config.d/60-sos-development-live.conf" >/dev/null +test_livesys_hook="$test_rootfs/var/lib/livesys/livesys-session-extra" +[[ "$(stat -c %a "$test_livesys_hook")" == 700 ]] +sh -n "$test_livesys_hook" +grep -Fx "usermod --password '\$6\$development-salt\$development-hash' liveuser || exit 1" \ + "$test_livesys_hook" >/dev/null +grep -Fx 'passwd --lock root >/dev/null || exit 1' "$test_livesys_hook" >/dev/null +grep -Fx 'AutomaticLoginEnable=False' "$test_livesys_hook" >/dev/null +grep -Fx "cat > /etc/gdm/custom.conf <<'SOS_DEVELOPMENT_GDM' || exit 1" \ + "$test_livesys_hook" >/dev/null +grep -Fx 'systemctl enable --now sshd.service >/dev/null || exit 1' \ + "$test_livesys_hook" >/dev/null +grep -Fx 'ssh_activation=livesys-session-extra-final-action' \ + "$test_rootfs/usr/share/doc/sos/development-access.env" >/dev/null +grep -Fx 'wifi_autoconnect=true' \ + "$test_rootfs/usr/share/doc/sos/development-access.env" >/dev/null +grep -Fx 'network_credentials_embedded=true' \ + "$test_rootfs/usr/share/doc/sos/development-access.env" >/dev/null +test_installed_network_profile="$test_rootfs/etc/NetworkManager/system-connections/60-sos-development-live.nmconnection" +[[ "$(stat -c %a "$test_installed_network_profile")" == 600 ]] +grep -Fx 'ssid=Test Network' "$test_installed_network_profile" >/dev/null +[[ ! -e "$test_rootfs/etc/systemd/system/multi-user.target.wants/sshd.service" ]] +[[ ! -L "$test_rootfs/etc/systemd/system/multi-user.target.wants/sshd.service" ]] +[[ ! -e "$test_rootfs/etc/systemd/system/sshd.service.d/60-sos-development-live.conf" ]] +printf 'one\ntwo\n' >"$test_root/two-line-password" +if PATH="$test_root/bin:$PATH" TEST_FIREWALL_LOG="$test_root/firewall.log" \ + "$test_image" configure-development-access \ + --root "$test_rootfs" \ + --password-file "$test_root/two-line-password" \ + --networkmanager-profile-file "$test_network_profile" \ + >"$test_root/two-line-password.txt" 2>&1; then + printf 'error: development access accepted a multi-line password file\n' >&2 + exit 1 +fi +grep -F 'must contain exactly one line' "$test_root/two-line-password.txt" >/dev/null +ln -s liveuser-password "$test_root/symlink-password" +if PATH="$test_root/bin:$PATH" TEST_FIREWALL_LOG="$test_root/firewall.log" \ + "$test_image" configure-development-access \ + --root "$test_rootfs" \ + --password-file "$test_root/symlink-password" \ + --networkmanager-profile-file "$test_network_profile" \ + >"$test_root/symlink-password.txt" 2>&1; then + printf 'error: development access accepted a symlink password file\n' >&2 + exit 1 +fi +grep -F 'regular file, not a symlink' "$test_root/symlink-password.txt" >/dev/null +PATH="$test_root/bin:$PATH" \ + "$test_image" check-rootfs --root "$test_rootfs" >"$test_root/check-pass.txt" +grep -F 'linux_live_image_rootfs_checked=PASS' "$test_root/check-pass.txt" >/dev/null +grep -F 'boot_kind=development-live' "$test_root/check-pass.txt" >/dev/null +grep -F 'promotion_eligible=false' "$test_root/check-pass.txt" >/dev/null +grep -F 'not_installed_product=true' "$test_root/check-pass.txt" >/dev/null + +cp -- "$test_installed_network_profile" "$test_root/installed-network-profile.saved" +sed -i 's/^psk=.*/psk=/' "$test_installed_network_profile" +if PATH="$test_root/bin:$PATH" \ + "$test_image" check-rootfs --root "$test_rootfs" \ + >"$test_root/check-network-secret.txt" 2>&1; then + printf 'error: check-rootfs accepted a network profile without a PSK\n' >&2 + exit 1 +fi +grep -F 'must contain a boot-time Wi-Fi PSK' \ + "$test_root/check-network-secret.txt" >/dev/null +cp -- "$test_root/installed-network-profile.saved" "$test_installed_network_profile" + +printf '%s\n' '# activation must remain the final hook action' >>"$test_livesys_hook" +if PATH="$test_root/bin:$PATH" \ + "$test_image" check-rootfs --root "$test_rootfs" \ + >"$test_root/check-premature-ssh.txt" 2>&1; then + printf 'error: check-rootfs accepted SSH activation before the final hook action\n' >&2 + exit 1 +fi +grep -F 'does not activate SSH as its final action' \ + "$test_root/check-premature-ssh.txt" >/dev/null +sed -i '$d' "$test_livesys_hook" + +test_locked_dir="$test_rootfs/etc/skel/.local" +test_locked_config="$test_locked_dir/state/sos/agent/config.env" +test_sudo_log="$test_root/sudo.log" +chmod 0600 "$test_locked_config" +chmod 000 "$test_locked_dir" +if [[ -f "$test_locked_config" ]]; then + printf 'error: locked private config remained visible to an ordinary file test\n' >&2 + exit 1 +fi +PATH="$test_root/bin:$PATH" \ + TEST_SUDO_UNLOCK_DIR="$test_locked_dir" \ + TEST_SUDO_LOG="$test_sudo_log" \ + "$test_image" check-rootfs --root "$test_rootfs" \ + >"$test_root/check-privileged-config.txt" +chmod 0700 "$test_locked_dir" +grep -F 'linux_live_image_rootfs_checked=PASS' \ + "$test_root/check-privileged-config.txt" >/dev/null +grep -Fx "test -f $test_locked_config" "$test_sudo_log" >/dev/null +grep -Fx \ + "grep -Fx SOS_AGENT_FAKE_SOURCE=/usr/share/sos/experiences/daily-flow.luau $test_locked_config" \ + "$test_sudo_log" >/dev/null + +# When subordinate-ID user namespaces and a setuid-capable workspace are +# available, repeat the check with an actual namespace-root-owned 0600 fixture +# and a namespace-root setuid command trampoline standing in for sudo. +test_user="$(id -un)" +test_uid="$(id -u)" +test_gid="$(id -g)" +test_subuid_start="$(awk -F: -v user="$test_user" '$1 == user && $3 >= 1001 { print $2; exit }' /etc/subuid 2>/dev/null || true)" +test_subgid_start="$(awk -F: -v user="$test_user" '$1 == user && $3 >= 1001 { print $2; exit }' /etc/subgid 2>/dev/null || true)" +if [[ "$test_uid" -ne 0 && -n "$test_subuid_start" && -n "$test_subgid_start" ]] \ + && command -v unshare >/dev/null 2>&1 \ + && command -v setpriv >/dev/null 2>&1 \ + && command -v findmnt >/dev/null 2>&1 \ + && ! findmnt -T "$test_repo_root" -no OPTIONS | tr ',' '\n' | grep -Fx nosuid >/dev/null; then + mkdir -p "$test_repo_root/.cache" + test_namespace_dir="$(mktemp -d -p "$test_repo_root/.cache" sos-root-owned-config.XXXXXX)" + chmod 0755 "$test_namespace_dir" + # The variables in this script belong to the namespace process. + # shellcheck disable=SC2016 + unshare \ + --map-users="0:$test_uid:1" \ + --map-users="1:$test_subuid_start:65536" \ + --map-groups="0:$test_gid:1" \ + --map-groups="1:$test_subgid_start:65536" \ + bash -c ' + set -euo pipefail + rootfs="$1" + image_root="$2" + helper_dir="$3" + result="$4" + env_binary="$5" + cleanup_namespace_fixture() { + chown -R 0:0 "$rootfs/home/liveuser/.local" 2>/dev/null || true + rm -f -- "$helper_dir/sudo" + } + trap cleanup_namespace_fixture EXIT + chmod 0755 "$(dirname "$rootfs")" + find "$rootfs" -type d -exec chmod 0755 {} + + chmod 0700 "$rootfs/etc/NetworkManager/system-connections" + chmod 0600 "$rootfs/etc/NetworkManager/system-connections/60-sos-development-live.nmconnection" + find "$rootfs/etc/skel/.local" -type d -exec chmod 0700 {} + + chmod 0600 "$rootfs/etc/skel/.local/state/sos/agent/config.env" + chown -R 1000:1000 "$rootfs/home/liveuser/.local" + cp -- "$env_binary" "$helper_dir/sudo" + chown 0:1000 "$helper_dir" "$helper_dir/sudo" + chmod 0710 "$helper_dir" + chmod 4750 "$helper_dir/sudo" + exec 8<"$helper_dir" + exec 9<"$image_root" + setpriv --reuid=1000 --regid=1000 --clear-groups \ + env PATH="/proc/self/fd/8:/usr/bin:/bin" \ + /proc/self/fd/9/tools/linux-live-image check-rootfs --root "$rootfs" \ + >"$result" + grep -F "linux_live_image_rootfs_checked=PASS" "$result" >/dev/null + ' bash \ + "$test_rootfs" \ + "$test_repo_root" \ + "$test_namespace_dir" \ + "$test_root/check-root-owned-config.txt" \ + "$(command -v env)" + rm -r -- "$test_namespace_dir" + test_namespace_dir="" +fi + +ln -sf sos-session.target "$test_rootfs/etc/systemd/system/default.target" +if "$test_image" check-rootfs --root "$test_rootfs" >"$test_root/check-appliance.txt" 2>&1; then + printf 'error: check-rootfs accepted a boot-owned appliance default target\n' >&2 + exit 1 +fi +grep -F 'boot-owned appliance target' "$test_root/check-appliance.txt" >/dev/null +rm -f -- "$test_rootfs/etc/systemd/system/default.target" + +rm -f -- "$test_rootfs/usr/share/doc/sos/image-identity.env" +if "$test_image" check-rootfs --root "$test_rootfs" >"$test_root/check-stock.txt" 2>&1; then + printf 'error: check-rootfs accepted a rootfs without SOS live identity\n' >&2 + exit 1 +fi + +"$test_install" 2>"$test_root/install-usage.txt" || true +grep -F -- '--destdir ROOT' "$test_root/install-usage.txt" >/dev/null +grep -F -- '--offline' "$test_install" >/dev/null +grep -F 'image destroot staging accepts only the offline agent' "$test_install" >/dev/null +grep -F 'sudo chmod -R u=rwX,go=rX' "$test_install" >/dev/null +grep -F 'installed artifact is not readable:' "$test_install" >/dev/null +"$test_image" 2>"$test_root/image-usage.txt" || true +grep -F -- '--source-sha256 SHA256' "$test_root/image-usage.txt" >/dev/null +grep -F -- '--liveuser-password-file FILE' "$test_root/image-usage.txt" >/dev/null +grep -F -- '--networkmanager-profile-file FILE' "$test_root/image-usage.txt" >/dev/null +grep -F -- 'check-networkmanager-profile --profile-file FILE' \ + "$test_root/image-usage.txt" >/dev/null +grep -F 'rootfs extraction destination is not empty' "$test_image" >/dev/null +grep -F "sudo mount -t erofs -o loop,ro \"\$payload\" \"\$mountpoint\"" \ + "$test_image" >/dev/null +grep -F 'sudo rsync -aHAXS --numeric-ids' "$test_image" >/dev/null +grep -F 'sudo rsync -aHASni --numeric-ids' "$test_image" >/dev/null +grep -F 'rootfs metadata audit differs after copy' "$test_image" >/dev/null +grep -F 'rootfs xattr audit differs after copy' "$test_image" >/dev/null +grep -F 'sudo getfattr -hRPd -m- .' "$test_image" >/dev/null +grep -F '!/^trusted\.SGI_ACL_/' "$test_image" >/dev/null +grep -F -- "--filter='-x security.selinux'" "$test_image" >/dev/null +grep -F -- "--filter='-x system.*'" "$test_image" >/dev/null +grep -F "sudo umount -- \"\$mountpoint\"" "$test_image" >/dev/null +grep -F 'sudo setfiles -n -m -c "$policy"' "$test_image" >/dev/null +grep -F -- '--file-contexts="$file_contexts"' "$test_image" >/dev/null +grep -F 'sudo fsck.erofs --xattrs "$output"' "$test_image" >/dev/null +grep -F 'implantisomd5 --force' "$test_image" >/dev/null +grep -F "checkisomd5 \"\$output_iso\"" "$test_image" >/dev/null + +for test_doc in \ + "$test_repo_root/docs/linux-live-image.md" \ + "$test_repo_root/docs/linux-hardware-gate.md" \ + "$test_repo_root/README.md"; do + grep -F 'development-live' "$test_doc" >/dev/null + grep -E 'not an installed product|not_installed_product' "$test_doc" >/dev/null +done +grep -F 'promotion_eligible=false' "$test_repo_root/docs/linux-live-image.md" >/dev/null +grep -F 'future `release`' "$test_repo_root/docs/linux-live-image.md" >/dev/null +grep -F 'tools/linux-live-deploy' "$test_repo_root/docs/linux-live-image.md" >/dev/null +grep -F 'network_credentials_embedded=true' \ + "$test_repo_root/docs/linux-live-image.md" >/dev/null +grep -F "target is not a mutable, non-promotable development-live image" \ + "$test_deploy" >/dev/null +grep -F "log out of SOS before deploying" "$test_deploy" >/dev/null +grep -F 'development-deployment-manifest.tsv' "$test_deploy" >/dev/null +grep -F 'lorax' "$test_repo_root/docs/linux-live-image.md" >/dev/null +grep -F 'install-linux-login-session' "$test_repo_root/docs/linux-live-image.md" >/dev/null +grep -F 'same-boot' "$test_repo_root/docs/linux-hardware-gate.md" >/dev/null +grep -F -- '--source-sha256' "$test_repo_root/docs/linux-live-image.md" >/dev/null +grep -F 'embedded media' "$test_repo_root/docs/linux-live-image.md" >/dev/null +grep -F 'recovery_page_flip' "$test_repo_root/tools/linux-hardware-gate" >/dev/null +grep -F 'completed significant DRM page flip.*recovery_view=true' \ + "$test_repo_root/tools/linux-hardware-gate" >/dev/null + +printf 'linux_live_image_host_tests=PASS\n' diff --git a/tools/install-linux-login-session b/tools/install-linux-login-session index 6e6c9eb..904d3ab 100755 --- a/tools/install-linux-login-session +++ b/tools/install-linux-login-session @@ -14,7 +14,7 @@ sos_install_usage() { printf '%s\n' \ 'usage:' \ ' tools/install-linux-login-session doctor' \ - ' tools/install-linux-login-session install [--offline]' \ + ' tools/install-linux-login-session install [--offline] [--destdir ROOT]' \ ' tools/install-linux-login-session uninstall' } @@ -36,6 +36,10 @@ sos_install_dependency_hint() { fi } +sos_install_target() { + printf '%s%s\n' "${sos_install_destdir:-}" "$1" +} + sos_install_preflight() { local sos_install_command node_version for sos_install_command in cargo git install node npm pkg-config rustc sha256sum stat sudo; do @@ -45,8 +49,10 @@ sos_install_preflight() { node_version="$(node --version | sed 's/^v//')" [[ "$(printf '%s\n' 22.19.0 "$node_version" | sort -V | head -n 1)" == 22.19.0 ]] || \ sos_install_fail "sos-agent requires Node 22.19 or newer" - [[ -d /usr/share/wayland-sessions ]] || \ - sos_install_fail "/usr/share/wayland-sessions is missing; install GDM or another Wayland-capable display manager first" + if [[ -z "${sos_install_destdir:-}" ]]; then + [[ -d /usr/share/wayland-sessions ]] || \ + sos_install_fail "/usr/share/wayland-sessions is missing; install GDM or another Wayland-capable display manager first" + fi sos_install_missing_modules=() for sos_install_module in gbm libinput libseat libudev wayland-client xkbcommon xkbcommon-x11; do @@ -106,11 +112,28 @@ fi exit 1 } sos_install_agent_mode=live -if [[ "${1:-}" == --offline ]]; then - sos_install_agent_mode=offline - shift +sos_install_destdir="" +while [[ "$#" -gt 0 ]]; do + case "$1" in + --offline) + sos_install_agent_mode=offline + shift + ;; + --destdir) + [[ "$#" -ge 2 ]] || sos_install_fail "--destdir requires a directory" + sos_install_destdir="$(realpath -m -- "$2")" + [[ "$sos_install_destdir" == /* ]] || sos_install_fail "--destdir must be an absolute path" + [[ "$sos_install_destdir" != / ]] || sos_install_fail "--destdir / is the host install path; omit --destdir" + shift 2 + ;; + *) + sos_install_fail "install accepts only --offline and --destdir ROOT" + ;; + esac +done +if [[ -n "$sos_install_destdir" && "$sos_install_agent_mode" != offline ]]; then + sos_install_fail "image destroot staging accepts only the offline agent; pass --offline" fi -[[ "$#" -eq 0 ]] || sos_install_fail "install accepts only the optional --offline flag" sos_install_preflight sos_install_source_revision="$(git rev-parse HEAD)" @@ -135,10 +158,10 @@ cargo build --locked --release \ sos_install_bin_dir=/usr/local/libexec/sos sudo install -d -o root -g root -m 0755 \ - "$sos_install_bin_dir" \ - /usr/share/sos/experiences \ - /usr/share/doc/sos \ - /usr/share/wayland-sessions + "$(sos_install_target "$sos_install_bin_dir")" \ + "$(sos_install_target /usr/share/sos/experiences)" \ + "$(sos_install_target /usr/share/doc/sos)" \ + "$(sos_install_target /usr/share/wayland-sessions)" for sos_install_binary in \ sos-compositor \ sos-experience-host \ @@ -147,31 +170,39 @@ for sos_install_binary in \ sos-linux-session \ sos-agent-authoring; do sudo install -o root -g root -m 0755 \ - "target/release/$sos_install_binary" "$sos_install_bin_dir/$sos_install_binary" + "target/release/$sos_install_binary" \ + "$(sos_install_target "$sos_install_bin_dir/$sos_install_binary")" done sudo install -o root -g root -m 0755 \ - packaging/libexec/sos-login-session "$sos_install_bin_dir/sos-login-session" + packaging/libexec/sos-login-session \ + "$(sos_install_target "$sos_install_bin_dir/sos-login-session")" sudo install -o root -g root -m 0755 \ - packaging/libexec/sos-agent-login "$sos_install_bin_dir/sos-agent-login" -sudo install -d -o root -g root -m 0755 /usr/local/libexec/sos-agent + packaging/libexec/sos-agent-login \ + "$(sos_install_target "$sos_install_bin_dir/sos-agent-login")" +sudo install -d -o root -g root -m 0755 "$(sos_install_target /usr/local/libexec/sos-agent)" sudo cp -a \ services/sos-agent/dist \ services/sos-agent/node_modules \ services/sos-agent/package.json \ - /usr/local/libexec/sos-agent/ -sudo chown -R root:root /usr/local/libexec/sos-agent + "$(sos_install_target /usr/local/libexec/sos-agent)/" +sudo chown -R root:root "$(sos_install_target /usr/local/libexec/sos-agent)" +# npm's clean build can leave dist private to the builder. The live session +# executes the bundle as an unprivileged desktop user, so publish code and +# dependencies readably while retaining executable bits on tools that need it. +sudo chmod -R u=rwX,go=rX "$(sos_install_target /usr/local/libexec/sos-agent)" sudo install -o root -g root -m 0644 \ - packaging/wayland-sessions/sos.desktop /usr/share/wayland-sessions/sos.desktop + packaging/wayland-sessions/sos.desktop \ + "$(sos_install_target /usr/share/wayland-sessions/sos.desktop)" sudo install -o root -g root -m 0644 \ - experiences/default.luau /usr/share/sos/experiences/default.luau + experiences/default.luau "$(sos_install_target /usr/share/sos/experiences/default.luau)" sudo install -o root -g root -m 0644 \ - experiences/daily-flow.luau /usr/share/sos/experiences/daily-flow.luau + experiences/daily-flow.luau "$(sos_install_target /usr/share/sos/experiences/daily-flow.luau)" sudo install -o root -g root -m 0644 \ - docs/experience-api.md /usr/share/doc/sos/experience-api.md + docs/experience-api.md "$(sos_install_target /usr/share/doc/sos/experience-api.md)" sudo install -o root -g root -m 0644 \ - docs/linux-stable-host.md /usr/share/doc/sos/linux-stable-host.md + docs/linux-stable-host.md "$(sos_install_target /usr/share/doc/sos/linux-stable-host.md)" sudo install -o root -g root -m 0644 \ - docs/sos-agent.md /usr/share/doc/sos/sos-agent.md + docs/sos-agent.md "$(sos_install_target /usr/share/doc/sos/sos-agent.md)" sos_install_metadata="$(mktemp -t sos-install-metadata.XXXXXX)" sos_install_manifest="$(mktemp -t sos-install-manifest.XXXXXX)" @@ -202,40 +233,56 @@ sos_install_artifacts=( /usr/share/sos/experiences/daily-flow.luau ) for sos_install_artifact in "${sos_install_artifacts[@]}"; do + sos_install_artifact_target="$(sos_install_target "$sos_install_artifact")" + [[ -f "$sos_install_artifact_target" && -r "$sos_install_artifact_target" ]] || \ + sos_install_fail "installed artifact is not readable: $sos_install_artifact" + sos_install_artifact_bytes="$(stat -c %s "$sos_install_artifact_target")" + sos_install_artifact_sha="$(sha256sum "$sos_install_artifact_target" | cut -d ' ' -f 1)" + [[ "$sos_install_artifact_bytes" =~ ^[1-9][0-9]*$ \ + && "$sos_install_artifact_sha" =~ ^[0-9a-f]{64}$ ]] || \ + sos_install_fail "installed artifact identity is invalid: $sos_install_artifact" printf '%s\t%s\t%s\n' \ "$sos_install_artifact" \ - "$(stat -c %s "$sos_install_artifact")" \ - "$(sha256sum "$sos_install_artifact" | cut -d ' ' -f 1)" >>"$sos_install_manifest" + "$sos_install_artifact_bytes" \ + "$sos_install_artifact_sha" \ + >>"$sos_install_manifest" done sudo install -o root -g root -m 0644 \ - "$sos_install_metadata" /usr/share/doc/sos/install-metadata.env + "$sos_install_metadata" "$(sos_install_target /usr/share/doc/sos/install-metadata.env)" sudo install -o root -g root -m 0644 \ - "$sos_install_manifest" /usr/share/doc/sos/install-manifest.tsv + "$sos_install_manifest" "$(sos_install_target /usr/share/doc/sos/install-manifest.tsv)" -if [[ "$sos_install_agent_mode" == offline ]]; then - SOS_AGENT_FAKE_SOURCE=/usr/share/sos/experiences/daily-flow.luau \ - "$sos_install_bin_dir/sos-agent-login" --offline -else - sos_install_existing_agent_config="${XDG_STATE_HOME:-$HOME/.local/state}/sos/agent/config.env" - if [[ -s "$sos_install_existing_agent_config" ]] \ - && grep -q '^SOS_AGENT_FAKE_SOURCE=' "$sos_install_existing_agent_config"; then - "$sos_install_bin_dir/sos-agent-login" +if [[ -z "$sos_install_destdir" ]]; then + if [[ "$sos_install_agent_mode" == offline ]]; then + SOS_AGENT_FAKE_SOURCE=/usr/share/sos/experiences/daily-flow.luau \ + "$sos_install_bin_dir/sos-agent-login" --offline else - "$sos_install_bin_dir/sos-agent-login" --if-needed + sos_install_existing_agent_config="${XDG_STATE_HOME:-$HOME/.local/state}/sos/agent/config.env" + if [[ -s "$sos_install_existing_agent_config" ]] \ + && grep -q '^SOS_AGENT_FAKE_SOURCE=' "$sos_install_existing_agent_config"; then + "$sos_install_bin_dir/sos-agent-login" + else + "$sos_install_bin_dir/sos-agent-login" --if-needed + fi fi + sos_install_user_state="${XDG_STATE_HOME:-$HOME/.local/state}/sos" + install -d -m 0700 "${XDG_STATE_HOME:-$HOME/.local/state}" "$sos_install_user_state" + if [[ ! -e "$sos_install_user_state/output.json" ]]; then + printf '{}\n' >"$sos_install_user_state/output.json" + fi + [[ ! -L "$sos_install_user_state/output.json" && -f "$sos_install_user_state/output.json" \ + && -O "$sos_install_user_state/output.json" ]] || \ + sos_install_fail "output configuration must be a regular file" + chmod 0600 "$sos_install_user_state/output.json" + printf '%s\n' \ + "SOS login session and resident agent installed in $sos_install_agent_mode mode." \ + "Source revision: $sos_install_source_revision (dirty=$sos_install_source_dirty)." \ + 'Log out, select SOS from the login screen session menu, and log in.' \ + 'Selecting the previous desktop session returns to GNOME on the next login.' +else + printf '%s\n' \ + "SOS offline login session staged under destroot $sos_install_destdir." \ + "Source revision: $sos_install_source_revision (dirty=$sos_install_source_dirty)." \ + 'GDM and the default boot target were not changed. No host user state was written.' \ + 'Do not treat destroot staging alone as an installed product or development-live image.' fi -sos_install_user_state="${XDG_STATE_HOME:-$HOME/.local/state}/sos" -install -d -m 0700 "${XDG_STATE_HOME:-$HOME/.local/state}" "$sos_install_user_state" -if [[ ! -e "$sos_install_user_state/output.json" ]]; then - printf '{}\n' >"$sos_install_user_state/output.json" -fi -[[ ! -L "$sos_install_user_state/output.json" && -f "$sos_install_user_state/output.json" \ - && -O "$sos_install_user_state/output.json" ]] || \ - sos_install_fail "output configuration must be a regular file" -chmod 0600 "$sos_install_user_state/output.json" - -printf '%s\n' \ - "SOS login session and resident agent installed in $sos_install_agent_mode mode." \ - "Source revision: $sos_install_source_revision (dirty=$sos_install_source_dirty)." \ - 'Log out, select SOS from the login screen session menu, and log in.' \ - 'Selecting the previous desktop session returns to GNOME on the next login.' diff --git a/tools/linux-compositor/verify-nested b/tools/linux-compositor/verify-nested index 662d6a4..ced1ad7 100755 --- a/tools/linux-compositor/verify-nested +++ b/tools/linux-compositor/verify-nested @@ -350,6 +350,29 @@ gate_accessibility_generation="$(accessibility_call '{"method":"snapshot"}' | jq gate_accessibility_wait="$(accessibility_call "{\"method\":\"wait\",\"after_generation\":$gate_accessibility_generation,\"timeout_ms\":2000}")" [[ "$(jq -r '.ok' <<<"$gate_accessibility_wait")" == true ]] [[ "$(jq -r '.snapshot.focused' <<<"$gate_accessibility_wait")" == note-draft ]] +# A looping scene animation rerenders both native inputs continuously. Prove +# that ordinary focus transfer still works while the host also preserves the +# active field across a compositor-quiesced revision switch. +gate_agent_focus_before="$(grep -c 'sos_linux_text_focus node_id=agent-prompt focused=true' "$gate_run_dir/session.log" || true)" +[[ "$(accessibility_call '{"method":"action","kind":"focus","target":"agent-prompt"}' | jq -r '.ok')" == true ]] +for _ in {1..500}; do + gate_agent_focus_after="$(grep -c 'sos_linux_text_focus node_id=agent-prompt focused=true' "$gate_run_dir/session.log" || true)" + if (( gate_agent_focus_after > gate_agent_focus_before )); then + break + fi + sleep 0.01 +done +(( gate_agent_focus_after > gate_agent_focus_before )) +gate_note_focus_before="$(grep -c 'sos_linux_text_focus node_id=note-draft focused=true' "$gate_run_dir/session.log" || true)" +[[ "$(accessibility_call '{"method":"action","kind":"focus","target":"note-draft"}' | jq -r '.ok')" == true ]] +for _ in {1..500}; do + gate_note_focus_after="$(grep -c 'sos_linux_text_focus node_id=note-draft focused=true' "$gate_run_dir/session.log" || true)" + if (( gate_note_focus_after > gate_note_focus_before )); then + break + fi + sleep 0.01 +done +(( gate_note_focus_after > gate_note_focus_before )) [[ "$(accessibility_call '{"method":"action","kind":"set_selection","target":"note-draft","value":"0:7"}' | jq -r '.ok')" == true ]] [[ "$(accessibility_call '{"method":"action","kind":"copy","target":"note-draft"}' | jq -r '.ok')" == true ]] [[ "$(accessibility_call '{"method":"action","kind":"cut","target":"note-draft"}' | jq -r '.ok')" == true ]] diff --git a/tools/linux-hardware-gate b/tools/linux-hardware-gate index 9b0359f..8707867 100755 --- a/tools/linux-hardware-gate +++ b/tools/linux-hardware-gate @@ -6,6 +6,10 @@ umask 077 hardware_gate_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" hardware_gate_install_metadata=/usr/share/doc/sos/install-metadata.env hardware_gate_install_manifest=/usr/share/doc/sos/install-manifest.tsv +hardware_gate_image_identity=/usr/share/doc/sos/image-identity.env +hardware_gate_live_media_identity=/sos-image-identity.env +hardware_gate_development_metadata=/usr/share/doc/sos/development-deployment.env +hardware_gate_development_manifest=/usr/share/doc/sos/development-deployment-manifest.tsv hardware_gate_session_entry=/usr/share/wayland-sessions/sos.desktop hardware_gate_state_home="${XDG_STATE_HOME:-$HOME/.local/state}" @@ -21,7 +25,8 @@ hardware_gate_usage() { ' tools/linux-hardware-gate collect --evidence-dir DIR' \ ' tools/linux-hardware-gate audit --evidence-dir DIR' \ ' tools/linux-hardware-gate finalize-manifest --evidence-dir DIR' \ - ' tools/linux-hardware-gate verify-manifest --evidence-dir DIR' + ' tools/linux-hardware-gate verify-manifest --evidence-dir DIR' \ + ' tools/linux-hardware-gate classify-boot [--sysroot DIR]' } hardware_gate_require_command() { @@ -37,15 +42,6 @@ hardware_gate_monotonic_ns() { python3 -c 'import time; print(time.monotonic_ns())' } -hardware_gate_read_boot_id() { - local path=/proc/sys/kernel/random/boot_id boot_id - [[ -r "$path" ]] || hardware_gate_fail "kernel boot ID is unavailable: $path" - boot_id="$(tr -d '\n' <"$path")" - [[ "$boot_id" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] || \ - hardware_gate_fail "kernel boot ID is malformed: $boot_id" - printf '%s\n' "${boot_id,,}" -} - hardware_gate_resolve_new_dir() { local requested="$1" resolved resolved="$(realpath -m -- "$requested")" @@ -108,6 +104,121 @@ hardware_gate_capture_optional() { printf '%s\n' "$status" >"$output.status" } +hardware_gate_sysroot_path() { + local sysroot="$1" path="$2" + printf '%s%s\n' "$sysroot" "$path" +} + +hardware_gate_read_boot_id() { + local sysroot="${1:-}" path boot_id + path="$(hardware_gate_sysroot_path "$sysroot" /proc/sys/kernel/random/boot_id)" + [[ -r "$path" ]] || hardware_gate_fail "kernel boot ID is unavailable: $path" + boot_id="$(tr -d '\n' <"$path")" + [[ "$boot_id" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] || \ + hardware_gate_fail "kernel boot ID is malformed: $boot_id" + printf '%s\n' "${boot_id,,}" +} + +hardware_gate_has_live_overlay() { + local sysroot="${1:-}" cmdline + if [[ -n "$sysroot" ]]; then + [[ -d "$(hardware_gate_sysroot_path "$sysroot" /run/initramfs/live)" ]] && return 0 + cmdline="$(hardware_gate_sysroot_path "$sysroot" /proc/cmdline)" + [[ -r "$cmdline" ]] && grep -Eq 'rd\.live(\.|=|[[:space:]]|$)' "$cmdline" && return 0 + return 1 + fi + [[ -d /run/initramfs/live ]] && return 0 + [[ -r /proc/cmdline ]] && grep -Eq 'rd\.live(\.|=|[[:space:]]|$)' /proc/cmdline && return 0 + return 1 +} + +hardware_gate_classify_boot() { + local sysroot="${1:-}" + local identity_file media_identity overlay=absent image_kind="" boot_id + identity_file="$(hardware_gate_sysroot_path "$sysroot" "$hardware_gate_image_identity")" + boot_id="$(hardware_gate_read_boot_id "$sysroot")" + if [[ -z "$sysroot" && -d /run/initramfs/live ]]; then + media_identity="/run/initramfs/live$hardware_gate_live_media_identity" + else + media_identity="$(hardware_gate_sysroot_path "$sysroot" "/run/initramfs/live$hardware_gate_live_media_identity")" + fi + if hardware_gate_has_live_overlay "$sysroot"; then + overlay=present + fi + if [[ -r "$identity_file" ]]; then + image_kind="$(hardware_gate_metadata_value "$identity_file" image_kind)" + fi + if [[ "$overlay" == present && "$image_kind" == development-live ]]; then + [[ -r "$media_identity" ]] || \ + hardware_gate_fail "SOS live overlay is missing the ISO-level image identity: $media_identity" + local inner_revision media_revision media_kind media_product_flag + local media_promotion_eligible media_mutable_runtime media_ssh_enabled + local payload_relpath payload_bytes payload_sha container_format + inner_revision="$(hardware_gate_metadata_value "$identity_file" source_revision)" + media_revision="$(hardware_gate_metadata_value "$media_identity" source_revision)" + media_kind="$(hardware_gate_metadata_value "$media_identity" image_kind)" + media_product_flag="$(hardware_gate_metadata_value "$media_identity" not_installed_product)" + media_promotion_eligible="$(hardware_gate_metadata_value "$media_identity" promotion_eligible)" + media_mutable_runtime="$(hardware_gate_metadata_value "$media_identity" mutable_runtime)" + media_ssh_enabled="$(hardware_gate_metadata_value "$media_identity" ssh_enabled)" + payload_relpath="$(hardware_gate_metadata_value "$media_identity" payload_relpath)" + payload_bytes="$(hardware_gate_metadata_value "$media_identity" payload_bytes)" + payload_sha="$(hardware_gate_metadata_value "$media_identity" payload_sha256)" + container_format="$(hardware_gate_metadata_value "$media_identity" container_format)" + [[ -n "$inner_revision" && "$inner_revision" == "$media_revision" ]] || \ + hardware_gate_fail "rootfs and ISO-level image identities disagree" + local identity_key inner_value media_value + for identity_key in \ + image_kind campaign_class not_installed_product promotion_eligible \ + mutable_runtime ssh_enabled container_format \ + fedora_release build_host_release source_revision source_dirty agent_mode \ + base_iso_filename base_iso_bytes base_iso_sha256 payload_relpath baked_at_utc; do + inner_value="$(hardware_gate_metadata_value "$identity_file" "$identity_key")" + media_value="$(hardware_gate_metadata_value "$media_identity" "$identity_key")" + [[ -n "$inner_value" && "$inner_value" == "$media_value" ]] || \ + hardware_gate_fail "rootfs and ISO-level image identities disagree on $identity_key" + done + [[ "$media_kind" == development-live && "$media_product_flag" == true \ + && "$media_promotion_eligible" == false && "$media_mutable_runtime" == true \ + && "$media_ssh_enabled" == true ]] || \ + hardware_gate_fail "ISO-level identity is not a mutable, non-promotable development-live image" + [[ "$payload_relpath" == LiveOS/squashfs.img ]] || \ + hardware_gate_fail "unsupported or unsafe live payload path: $payload_relpath" + [[ "$container_format" == erofs-rootfs ]] || \ + hardware_gate_fail "unsupported live payload format: $container_format" + [[ "$payload_bytes" =~ ^[1-9][0-9]*$ ]] || \ + hardware_gate_fail "ISO-level identity has an invalid payload size" + [[ "$payload_sha" =~ ^[0-9a-f]{64}$ ]] || \ + hardware_gate_fail "ISO-level identity has an invalid payload SHA-256" + printf '%s\n' \ + 'boot_kind=development-live' \ + 'campaign_class=development-live' \ + 'not_installed_product=true' \ + 'promotion_eligible=false' \ + 'mutable_runtime=true' \ + "boot_id=$boot_id" \ + "live_overlay=$overlay" \ + "image_kind=$image_kind" \ + "image_identity=$identity_file" \ + "live_media_identity=$media_identity" + return 0 + fi + if [[ "$overlay" == present ]]; then + hardware_gate_fail "live overlay without SOS image-identity (stock live media is not a development image)" + fi + if [[ "$image_kind" == development-live ]]; then + hardware_gate_fail "image-identity is development-live but this boot is not a live overlay; do not collect it as development-live or an installed product" + fi + printf '%s\n' \ + 'boot_kind=installed' \ + 'campaign_class=installed-workstation' \ + 'not_installed_product=false' \ + 'promotion_eligible=false' \ + "boot_id=$boot_id" \ + "live_overlay=$overlay" \ + 'image_kind=' +} + hardware_gate_verify_installed_files() { local output="$1" path expected_bytes expected_sha actual_bytes actual_sha count=0 : >"$output" @@ -130,6 +241,69 @@ hardware_gate_verify_installed_files() { printf 'installed_artifacts_verified=PASS files=%s\n' "$count" >>"$output" } +hardware_gate_snapshot_development_files() { + local output="$1" path expected_bytes expected_sha actual_bytes actual_sha count=0 status + : >"$output" + while IFS=$'\t' read -r path expected_bytes expected_sha; do + [[ -n "$path" && -n "$expected_bytes" && -n "$expected_sha" ]] || \ + hardware_gate_fail "malformed baked artifact manifest" + case "$path" in + /usr/local/libexec/sos/*|/usr/local/libexec/sos-agent/*|/usr/share/wayland-sessions/sos.desktop|/usr/share/sos/*) ;; + *) hardware_gate_fail "unsafe baked artifact path: $path" ;; + esac + [[ -f "$path" ]] || hardware_gate_fail "development artifact is missing: $path" + actual_bytes="$(stat -c %s "$path")" + actual_sha="$(sha256sum "$path" | cut -d ' ' -f 1)" + status=modified + if [[ "$actual_bytes" == "$expected_bytes" && "$actual_sha" == "$expected_sha" ]]; then + status=baked + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$path" "$actual_bytes" "$actual_sha" "$status" "$expected_bytes" "$expected_sha" \ + >>"$output" + count=$((count + 1)) + done <"$hardware_gate_install_manifest" + [[ "$count" -gt 0 ]] || hardware_gate_fail "baked artifact manifest is empty" + printf 'development_artifacts_snapshotted=PASS files=%s promotion_eligible=false\n' \ + "$count" >>"$output" +} + +hardware_gate_verify_development_deployment() { + local evidence_dir="$1" + [[ -e "$hardware_gate_development_metadata" \ + || -e "$hardware_gate_development_manifest" ]] || return 0 + [[ -r "$hardware_gate_development_metadata" \ + && -r "$hardware_gate_development_manifest" ]] || \ + hardware_gate_fail "development deployment metadata and manifest must be present together" + local source_revision source_dirty path expected_bytes expected_sha count=0 + source_revision="$(hardware_gate_metadata_value "$hardware_gate_development_metadata" source_revision)" + source_dirty="$(hardware_gate_metadata_value "$hardware_gate_development_metadata" source_dirty)" + [[ "$source_revision" =~ ^[0-9a-f]{40}$ && "$source_dirty" =~ ^(true|false)$ ]] || \ + hardware_gate_fail "development deployment source identity is invalid" + while IFS=$'\t' read -r path expected_bytes expected_sha; do + case "$path" in + /usr/local/libexec/sos/sos-compositor|\ + /usr/local/libexec/sos/sos-experience-host|\ + /usr/local/libexec/sos/sos-provider-state-service|\ + /usr/local/libexec/sos/sos-revision-supervisor|\ + /usr/local/libexec/sos/sos-linux-session|\ + /usr/local/libexec/sos/sos-agent-authoring) ;; + *) hardware_gate_fail "unsafe development deployment path: $path" ;; + esac + [[ "$expected_bytes" =~ ^[1-9][0-9]*$ && "$expected_sha" =~ ^[0-9a-f]{64}$ ]] || \ + hardware_gate_fail "invalid development deployment identity: $path" + [[ -f "$path" && "$(stat -c %s "$path")" == "$expected_bytes" \ + && "$(sha256sum "$path" | cut -d ' ' -f 1)" == "$expected_sha" ]] || \ + hardware_gate_fail "development deployment no longer matches: $path" + count=$((count + 1)) + done <"$hardware_gate_development_manifest" + [[ "$count" -gt 0 ]] || hardware_gate_fail "development deployment manifest is empty" + cp -- "$hardware_gate_development_metadata" \ + "$evidence_dir/installed/development-deployment.env" + cp -- "$hardware_gate_development_manifest" \ + "$evidence_dir/installed/development-deployment-manifest.tsv" +} + hardware_gate_capture_drm() { local output="$1" status connector edid : >"$output" @@ -152,14 +326,26 @@ hardware_gate_capture_drm() { hardware_gate_prepare() { hardware_gate_parse_dir_options "$@" - for command in cargo cut git journalctl jq libinput lscpu lspci loginctl node npm pkg-config python3 readlink realpath \ - rustc sed sha256sum stat sudo systemctl systemd-detect-virt tr uname; do + for command in cut journalctl jq libinput lscpu lspci loginctl python3 readlink realpath \ + sed sha256sum stat sudo systemctl systemd-detect-virt tr uname; do hardware_gate_require_command "$command" done [[ "$(id -u)" -ne 0 ]] || hardware_gate_fail "run the physical gate as the desktop user, not root" if systemd-detect-virt --quiet; then hardware_gate_fail "the physical Linux gate refuses virtual machines" fi + local boot_class + boot_class="$(hardware_gate_classify_boot)" + local boot_kind campaign_class not_installed_product boot_id + boot_kind="$(printf '%s\n' "$boot_class" | sed -n 's/^boot_kind=//p' | tail -n 1)" + campaign_class="$(printf '%s\n' "$boot_class" | sed -n 's/^campaign_class=//p' | tail -n 1)" + not_installed_product="$(printf '%s\n' "$boot_class" | sed -n 's/^not_installed_product=//p' | tail -n 1)" + boot_id="$(printf '%s\n' "$boot_class" | sed -n 's/^boot_id=//p' | tail -n 1)" + if [[ "$boot_kind" == installed ]]; then + for command in cargo git node npm pkg-config rustc; do + hardware_gate_require_command "$command" + done + fi [[ "${XDG_CURRENT_DESKTOP:-}" != SOS ]] || \ hardware_gate_fail "prepare the gate from the fallback desktop or a text login" systemctl is-active --quiet display-manager || \ @@ -174,20 +360,35 @@ hardware_gate_prepare() { [[ -r "$hardware_gate_install_metadata" && -r "$hardware_gate_install_manifest" ]] || \ hardware_gate_fail "SOS install metadata is missing; reinstall with the current installer" - cd "$hardware_gate_root" - [[ -z "$(git status --porcelain --untracked-files=normal)" ]] || \ - hardware_gate_fail "physical acceptance requires a clean source worktree" - local source_revision installed_revision installed_dirty installed_mode product evidence_dir - source_revision="$(git rev-parse HEAD)" + local source_revision source_dirty installed_revision installed_dirty installed_mode product evidence_dir installed_revision="$(hardware_gate_metadata_value "$hardware_gate_install_metadata" source_revision)" installed_dirty="$(hardware_gate_metadata_value "$hardware_gate_install_metadata" source_dirty)" installed_mode="$(hardware_gate_metadata_value "$hardware_gate_install_metadata" agent_mode)" - [[ "$installed_revision" == "$source_revision" ]] || \ - hardware_gate_fail "installed revision $installed_revision does not match source $source_revision" [[ "$installed_dirty" == false ]] || \ hardware_gate_fail "installed binaries came from a dirty source tree" [[ "$installed_mode" == offline || "$installed_mode" == live ]] || \ hardware_gate_fail "installed agent mode is invalid: $installed_mode" + source_dirty=false + if [[ "$boot_kind" == development-live ]]; then + [[ "$installed_mode" == offline ]] || \ + hardware_gate_fail "development-live requires the baked offline agent" + local image_revision + image_revision="$(hardware_gate_metadata_value "$hardware_gate_image_identity" source_revision)" + [[ -n "$image_revision" && "$image_revision" == "$installed_revision" ]] || \ + hardware_gate_fail "development-live image revision $image_revision does not match its baked install $installed_revision" + source_revision="$image_revision" + if [[ -r "$hardware_gate_development_metadata" ]]; then + source_revision="$(hardware_gate_metadata_value "$hardware_gate_development_metadata" source_revision)" + source_dirty="$(hardware_gate_metadata_value "$hardware_gate_development_metadata" source_dirty)" + fi + else + cd "$hardware_gate_root" + [[ -z "$(git status --porcelain --untracked-files=normal)" ]] || \ + hardware_gate_fail "physical acceptance requires a clean source worktree" + source_revision="$(git rev-parse HEAD)" + [[ "$installed_revision" == "$source_revision" ]] || \ + hardware_gate_fail "installed revision $installed_revision does not match source $source_revision" + fi local agent_config="$hardware_gate_state_home/sos/agent/config.env" [[ -s "$agent_config" ]] || hardware_gate_fail "selectable-session agent config is missing" if [[ "$installed_mode" == offline ]]; then @@ -210,7 +411,42 @@ hardware_gate_prepare() { mkdir -m 0700 "$evidence_dir/environment" "$evidence_dir/installed" cp -- "$hardware_gate_install_metadata" "$evidence_dir/installed/install-metadata.env" cp -- "$hardware_gate_install_manifest" "$evidence_dir/installed/install-manifest.tsv" - hardware_gate_verify_installed_files "$evidence_dir/installed/verified-artifacts.tsv" + printf '%s\n' "$boot_class" >"$evidence_dir/environment/boot-kind.txt" + if [[ -r "$hardware_gate_image_identity" ]]; then + cp -- "$hardware_gate_image_identity" "$evidence_dir/installed/image-identity.env" + fi + if [[ "$boot_kind" == development-live ]]; then + local live_media=/run/initramfs/live payload_relpath payload_path + local actual_payload_bytes actual_payload_sha expected_payload_bytes expected_payload_sha + [[ -d "$live_media" && -r "$live_media$hardware_gate_live_media_identity" ]] || \ + hardware_gate_fail "mounted live media identity is unavailable" + cp -- "$live_media$hardware_gate_live_media_identity" \ + "$evidence_dir/installed/live-media-identity.env" + payload_relpath="$(hardware_gate_metadata_value "$live_media$hardware_gate_live_media_identity" payload_relpath)" + [[ "$payload_relpath" == LiveOS/squashfs.img ]] || \ + hardware_gate_fail "unsupported or unsafe live payload path: $payload_relpath" + payload_path="$live_media/$payload_relpath" + [[ -f "$payload_path" ]] || hardware_gate_fail "booted live payload is unavailable: $payload_path" + actual_payload_bytes="$(stat -c %s "$payload_path")" + actual_payload_sha="$(sha256sum "$payload_path" | cut -d ' ' -f 1)" + expected_payload_bytes="$(hardware_gate_metadata_value "$live_media$hardware_gate_live_media_identity" payload_bytes)" + expected_payload_sha="$(hardware_gate_metadata_value "$live_media$hardware_gate_live_media_identity" payload_sha256)" + [[ "$actual_payload_bytes" == "$expected_payload_bytes" \ + && "$actual_payload_sha" == "$expected_payload_sha" ]] || \ + hardware_gate_fail "booted live payload identity does not match the baked image" + printf '%s\n' \ + "payload_relpath=$payload_relpath" \ + "payload_bytes=$actual_payload_bytes" \ + "payload_sha256=$actual_payload_sha" \ + "payload_identity_match=PASS" >"$evidence_dir/installed/live-payload.txt" + fi + if [[ "$boot_kind" == development-live ]]; then + hardware_gate_verify_development_deployment "$evidence_dir" + hardware_gate_snapshot_development_files \ + "$evidence_dir/installed/current-artifacts.tsv" + else + hardware_gate_verify_installed_files "$evidence_dir/installed/verified-artifacts.tsv" + fi [[ -f "$hardware_gate_state_home/sos/output.json" \ && ! -L "$hardware_gate_state_home/sos/output.json" \ && -O "$hardware_gate_state_home/sos/output.json" \ @@ -245,15 +481,28 @@ hardware_gate_prepare() { hardware_gate_capture_optional "$evidence_dir/environment/display-manager-before.txt" \ systemctl status display-manager --no-pager printf '%s\n' "$display_manager_unit" >"$evidence_dir/environment/display-manager-unit.txt" - { - printf 'rustc=%s\n' "$(rustc --version)" - printf 'cargo=%s\n' "$(cargo --version)" - printf 'node=%s\n' "$(node --version)" - printf 'npm=%s\n' "$(npm --version)" - for module in gbm libinput libseat libudev wayland-client xkbcommon xkbcommon-x11; do - printf '%s=%s\n' "$module" "$(pkg-config --modversion "$module")" - done - } >"$evidence_dir/environment/tool-versions.txt" + if [[ "$boot_kind" == development-live ]]; then + { + printf 'boot_kind=development-live\n' + printf 'not_installed_product=true\n' + printf 'promotion_eligible=false\n' + printf 'mutable_runtime=true\n' + printf 'rustc_version=%s\n' \ + "$(hardware_gate_metadata_value "$hardware_gate_install_metadata" rustc_version)" + printf 'node_version=%s\n' \ + "$(hardware_gate_metadata_value "$hardware_gate_install_metadata" node_version)" + } >"$evidence_dir/environment/tool-versions.txt" + else + { + printf 'rustc=%s\n' "$(rustc --version)" + printf 'cargo=%s\n' "$(cargo --version)" + printf 'node=%s\n' "$(node --version)" + printf 'npm=%s\n' "$(npm --version)" + for module in gbm libinput libseat libudev wayland-client xkbcommon xkbcommon-x11; do + printf '%s=%s\n' "$module" "$(pkg-config --modversion "$module")" + done + } >"$evidence_dir/environment/tool-versions.txt" + fi local boot_id cursor started_ns cursor="$(sudo journalctl --show-cursor -n 0 --no-pager | sed -n 's/^-- cursor: //p' | tail -n 1)" @@ -262,9 +511,13 @@ hardware_gate_prepare() { started_ns="$(hardware_gate_monotonic_ns)" printf '%s\n' \ "source_revision=$source_revision" \ - "source_dirty=false" \ + "source_dirty=$source_dirty" \ "agent_mode=$installed_mode" \ "boot_id=$boot_id" \ + "boot_kind=$boot_kind" \ + "campaign_class=$campaign_class" \ + "not_installed_product=$not_installed_product" \ + 'promotion_eligible=false' \ "user_uid=$(id -u)" \ "state_home=$hardware_gate_state_home" \ "product_name=$product" \ @@ -272,17 +525,28 @@ hardware_gate_prepare() { "started_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ "started_monotonic_ns=$started_ns" >"$evidence_dir/campaign.env" printf '%s\n' "$cursor" >"$evidence_dir/journal.cursor" + local collect_command='./tools/linux-hardware-gate collect' + if [[ "$boot_kind" == development-live ]]; then + collect_command='/usr/local/libexec/sos/linux-hardware-gate collect' + fi printf '%s\n' \ '1. Log out and select SOS from the GDM session menu.' \ '2. Confirm the recovery view and generated experience appear.' \ '3. Exercise keyboard, touchpad motion/button, and the touchscreen.' \ '4. Enter one composer prompt and wait for the offline/live revision to activate.' \ '5. Press Ctrl+Alt+Backspace, return to GDM, and log into the fallback desktop.' \ - "6. Run: ./tools/linux-hardware-gate collect --evidence-dir $evidence_dir" \ + "6. Run: $collect_command --evidence-dir $evidence_dir" \ >"$evidence_dir/instructions.txt" + if [[ "$boot_kind" == development-live ]]; then + printf '%s\n' \ + 'This is mutable development-live diagnostic evidence, not release acceptance.' \ + 'Its verdict is never promotion eligible, even when every criterion passes.' \ + 'Collect on this same boot and copy the evidence directory off before reboot.' \ + >>"$evidence_dir/instructions.txt" + fi touch "$evidence_dir/prepared" - printf 'linux_hardware_gate_prepared evidence_dir=%s revision=%s product=%s agent_mode=%s\n' \ - "$evidence_dir" "$source_revision" "$product" "$installed_mode" + printf 'linux_hardware_gate_prepared evidence_dir=%s revision=%s product=%s agent_mode=%s boot_kind=%s\n' \ + "$evidence_dir" "$source_revision" "$product" "$installed_mode" "$boot_kind" sed -n '1,6p' "$evidence_dir/instructions.txt" } @@ -314,8 +578,10 @@ hardware_gate_audit_dir() { hardware_gate_fail "campaign, collection, or collected session journal is missing" hardware_gate_audit_failed=false local agent_mode current_revision authority_revision presentation_count presentation_revisions - local prepared_boot_id collected_boot_id + local boot_kind campaign_class prepared_boot_id collected_boot_id agent_mode="$(hardware_gate_metadata_value "$evidence_dir/campaign.env" agent_mode)" + boot_kind="$(hardware_gate_metadata_value "$evidence_dir/campaign.env" boot_kind)" + campaign_class="$(hardware_gate_metadata_value "$evidence_dir/campaign.env" campaign_class)" prepared_boot_id="$(hardware_gate_metadata_value "$evidence_dir/campaign.env" boot_id)" collected_boot_id="$(hardware_gate_metadata_value "$evidence_dir/collection.env" boot_id)" if [[ "$prepared_boot_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ \ @@ -326,6 +592,13 @@ hardware_gate_audit_dir() { "$prepared_boot_id" "$collected_boot_id" hardware_gate_audit_failed=true fi + if [[ -n "$boot_kind" ]]; then + printf 'boot_kind=%s campaign_class=%s\n' "$boot_kind" "${campaign_class:-$boot_kind}" + if [[ "$boot_kind" == development-live ]]; then + printf 'not_installed_product=true\n' + printf 'promotion_eligible=false\n' + fi + fi hardware_gate_criterion_pattern recovery_page_flip \ 'completed significant DRM page flip.*recovery_view=true' "$journal" hardware_gate_criterion_pattern direct_compositor \ @@ -391,9 +664,17 @@ hardware_gate_audit_dir() { 'GPU HANG|drm.*(ERROR|FAULT)|i915.*(reset|hang)|xe.*(reset|hang)' \ "$evidence_dir/journal-kernel.txt" if [[ "$hardware_gate_audit_failed" == true ]]; then - printf 'linux_hardware_gate_result=FAIL\n' + if [[ "$boot_kind" == development-live ]]; then + printf 'linux_hardware_gate_result=DIAGNOSTIC_FAIL promotion_eligible=false\n' + else + printf 'linux_hardware_gate_result=FAIL\n' + fi return 1 fi + if [[ "$boot_kind" == development-live ]]; then + printf 'linux_hardware_gate_result=DIAGNOSTIC_PASS promotion_eligible=false evidence=drm_page_flip physical_input=keyboard,touchpad,touchscreen\n' + return 0 + fi printf 'linux_hardware_gate_result=PASS evidence=drm_page_flip physical_input=keyboard,touchpad,touchscreen\n' } @@ -455,7 +736,7 @@ hardware_gate_collect() { [[ "${XDG_CURRENT_DESKTOP:-}" != SOS ]] || \ hardware_gate_fail "return to the fallback desktop before collecting evidence" local evidence_dir cursor user_uid state_home started_ns finished_ns duration_ns - local prepared_boot_id current_boot_id + local prepared_boot_kind current_boot_kind prepared_boot_id current_boot_id evidence_dir="$(hardware_gate_resolve_existing_dir "$hardware_gate_requested_dir")" [[ -f "$evidence_dir/prepared" ]] || hardware_gate_fail "campaign was not prepared" [[ ! -f "$evidence_dir/collected" ]] || hardware_gate_fail "campaign was already collected" @@ -464,10 +745,16 @@ hardware_gate_collect() { state_home="$(hardware_gate_metadata_value "$evidence_dir/campaign.env" state_home)" [[ "$user_uid" == "$(id -u)" ]] || hardware_gate_fail "collect must run as the user who prepared the gate" [[ "$state_home" == /* ]] || hardware_gate_fail "recorded state home is invalid" + prepared_boot_kind="$(hardware_gate_metadata_value "$evidence_dir/campaign.env" boot_kind)" prepared_boot_id="$(hardware_gate_metadata_value "$evidence_dir/campaign.env" boot_id)" current_boot_id="$(hardware_gate_read_boot_id)" [[ -n "$prepared_boot_id" && "$current_boot_id" == "$prepared_boot_id" ]] || \ hardware_gate_fail "evidence must be collected on the exact kernel boot that prepared it" + if [[ "$prepared_boot_kind" == development-live ]]; then + current_boot_kind="$(hardware_gate_classify_boot | sed -n 's/^boot_kind=//p' | tail -n 1)" + [[ "$current_boot_kind" == development-live ]] || \ + hardware_gate_fail "development-live evidence must be collected on the same live overlay boot" + fi # The redirects intentionally run as the evidence owner; only journal access needs sudo. # shellcheck disable=SC2024 @@ -537,6 +824,20 @@ case "$hardware_gate_command" in [[ -z "$hardware_gate_expected_product" ]] || hardware_gate_fail "--expect-product is accepted only by prepare" hardware_gate_verify_manifest_dir "$(hardware_gate_resolve_existing_dir "$hardware_gate_requested_dir")" ;; + classify-boot) + hardware_gate_sysroot="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --sysroot) + [[ "$#" -ge 2 ]] || hardware_gate_fail "--sysroot requires a directory" + hardware_gate_sysroot="$(realpath -e -- "$2")" + shift 2 + ;; + *) hardware_gate_fail "classify-boot accepts only --sysroot DIR" ;; + esac + done + hardware_gate_classify_boot "$hardware_gate_sysroot" + ;; *) hardware_gate_usage >&2 exit 1 diff --git a/tools/linux-live-deploy b/tools/linux-live-deploy new file mode 100755 index 0000000..882efea --- /dev/null +++ b/tools/linux-live-deploy @@ -0,0 +1,253 @@ +#!/usr/bin/env bash + +set -euo pipefail +umask 077 + +development_deploy_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +development_deploy_target_dir="${CARGO_TARGET_DIR:-$development_deploy_root/target}" +development_deploy_artifacts_dir="${SOS_DEVELOPMENT_DEPLOY_ARTIFACTS_DIR:-$development_deploy_root/artifacts/linux-live-deploy}" +development_deploy_identity=/usr/share/doc/sos/image-identity.env +development_deploy_metadata=/usr/share/doc/sos/development-deployment.env +development_deploy_manifest=/usr/share/doc/sos/development-deployment-manifest.tsv + +development_deploy_fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +development_deploy_usage() { + printf '%s\n' \ + 'usage:' \ + ' tools/linux-live-deploy components' \ + ' tools/linux-live-deploy deploy --target liveuser@HOST [--component NAME]...' +} + +development_deploy_components() { + printf '%s\n' \ + 'compositor /usr/local/libexec/sos/sos-compositor' \ + 'experience-host /usr/local/libexec/sos/sos-experience-host' \ + 'provider /usr/local/libexec/sos/sos-provider-state-service' \ + 'supervisor /usr/local/libexec/sos/sos-revision-supervisor' \ + 'session /usr/local/libexec/sos/sos-linux-session' \ + 'authoring /usr/local/libexec/sos/sos-agent-authoring' +} + +development_deploy_metadata_value() { + local file="$1" key="$2" + sed -n "s/^${key}=//p" "$file" | tail -n 1 +} + +development_deploy_binary_name() { + case "$1" in + compositor) printf 'sos-compositor\n' ;; + experience-host) printf 'sos-experience-host\n' ;; + provider) printf 'sos-provider-state-service\n' ;; + supervisor) printf 'sos-revision-supervisor\n' ;; + session) printf 'sos-linux-session\n' ;; + authoring) printf 'sos-agent-authoring\n' ;; + *) development_deploy_fail "unknown component: $1" ;; + esac +} + +development_deploy_build_component() { + local component="$1" + case "$component" in + compositor) + cargo build --locked --release \ + -p sos-compositor --features direct-backend --bin sos-compositor + ;; + experience-host) + cargo build --locked --release \ + -p sos-experience --features linux-host --bin sos-experience-host + ;; + provider) + cargo build --locked --release \ + -p provider-state-service --bin sos-provider-state-service + ;; + supervisor) + cargo build --locked --release \ + -p revision-supervisor --bin sos-revision-supervisor + ;; + session) + cargo build --locked --release \ + -p sos-linux-session --bin sos-linux-session + ;; + authoring) + cargo build --locked --release \ + -p sos-linux-session --bin sos-agent-authoring + ;; + *) development_deploy_fail "unknown component: $component" ;; + esac +} + +development_deploy_run() { + local target="" + local -a requested_components=() + while [[ "$#" -gt 0 ]]; do + case "$1" in + --target) + [[ "$#" -ge 2 ]] || development_deploy_fail "--target requires liveuser@HOST" + target="$2" + shift 2 + ;; + --component) + [[ "$#" -ge 2 ]] || development_deploy_fail "--component requires a name" + requested_components+=("$2") + shift 2 + ;; + *) development_deploy_fail "unexpected deploy option: $1" ;; + esac + done + [[ "$target" =~ ^liveuser@([A-Za-z0-9._-]+|\[[0-9A-Fa-f:]+\])$ ]] || \ + development_deploy_fail "target must be liveuser@HOST without SSH options" + if [[ "${#requested_components[@]}" -eq 0 ]]; then + requested_components=(compositor experience-host provider supervisor session authoring) + fi + + local -A selected=() + local -a components=() + local component + for component in "${requested_components[@]}"; do + development_deploy_binary_name "$component" >/dev/null + if [[ -z "${selected[$component]:-}" ]]; then + selected[$component]=true + components+=("$component") + fi + done + local command + for command in cargo date git install mktemp scp sed sha256sum ssh stat; do + command -v "$command" >/dev/null 2>&1 || \ + development_deploy_fail "required command not found: $command" + done + [[ "$(id -u)" -ne 0 ]] || development_deploy_fail "run deployment as the development user, not root" + + cd "$development_deploy_root" + local source_revision source_dirty=false started_ns + source_revision="$(git rev-parse HEAD)" + [[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || \ + development_deploy_fail "source revision is not a full Git object ID" + if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then + source_dirty=true + fi + started_ns="$(date +%s%N)" + + local temporary control_socket connection_started=false remote_dir="" + temporary="$(mktemp -d -t sos-development-deploy.XXXXXX)" + control_socket="$temporary/ssh-control" + local -a ssh_options=( + -o ControlMaster=auto + -o ControlPersist=120 + -o "ControlPath=$control_socket" + ) + development_deploy_cleanup() { + if [[ "$connection_started" == true ]]; then + if [[ -n "$remote_dir" ]]; then + ssh "${ssh_options[@]}" "$target" "rm -r -- '$remote_dir'" >/dev/null 2>&1 || true + fi + ssh "${ssh_options[@]}" -O exit "$target" >/dev/null 2>&1 || true + fi + rm -r -- "$temporary" + } + trap development_deploy_cleanup EXIT + + ssh "${ssh_options[@]}" "$target" true + connection_started=true + ssh "${ssh_options[@]}" "$target" \ + "command -v pgrep >/dev/null && ! pgrep -f '^/usr/local/libexec/sos/sos-(compositor|experience-host|provider-state-service|revision-supervisor|linux-session|agent-authoring)' >/dev/null" || \ + development_deploy_fail "log out of SOS before deploying, and ensure pgrep is installed" + + local identity="$temporary/image-identity.env" + ssh "${ssh_options[@]}" "$target" "cat '$development_deploy_identity'" >"$identity" + [[ "$(development_deploy_metadata_value "$identity" image_kind)" == development-live \ + && "$(development_deploy_metadata_value "$identity" promotion_eligible)" == false \ + && "$(development_deploy_metadata_value "$identity" mutable_runtime)" == true ]] || \ + development_deploy_fail "target is not a mutable, non-promotable development-live image" + local base_revision + base_revision="$(development_deploy_metadata_value "$identity" source_revision)" + + local staging="$temporary/staging" + mkdir "$staging" + local binary source_path destination bytes digest + local manifest="$staging/development-deployment-manifest.tsv" + : >"$manifest" + for component in "${components[@]}"; do + development_deploy_build_component "$component" + binary="$(development_deploy_binary_name "$component")" + source_path="$development_deploy_target_dir/release/$binary" + destination="/usr/local/libexec/sos/$binary" + [[ -x "$source_path" ]] || development_deploy_fail "built artifact is missing: $source_path" + install -m 0755 "$source_path" "$staging/$binary" + bytes="$(stat -c %s "$staging/$binary")" + digest="$(sha256sum "$staging/$binary" | cut -d ' ' -f 1)" + printf '%s\t%s\t%s\n' "$destination" "$bytes" "$digest" >>"$manifest" + done + + local deployed_at deployment_id metadata evidence_dir + deployed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + deployment_id="$(date -u +%Y%m%dT%H%M%SZ)-${source_revision:0:12}-$$" + metadata="$staging/development-deployment.env" + printf '%s\n' \ + "deployment_id=$deployment_id" \ + "source_revision=$source_revision" \ + "source_dirty=$source_dirty" \ + "base_image_revision=$base_revision" \ + "deployed_at_utc=$deployed_at" \ + 'image_kind=development-live' \ + 'promotion_eligible=false' >"$metadata" + + remote_dir="$(ssh "${ssh_options[@]}" "$target" \ + 'umask 077; mktemp -d -p /tmp sos-development-deploy.XXXXXX')" + [[ "$remote_dir" =~ ^/tmp/sos-development-deploy\.[A-Za-z0-9]+$ ]] || \ + development_deploy_fail "target returned an unsafe staging path" + scp "${ssh_options[@]}" "$staging"/* "$target:$remote_dir/" + + local remote_command="set -euo pipefail; trap 'rm -r -- \"$remote_dir\"' EXIT;" + for component in "${components[@]}"; do + binary="$(development_deploy_binary_name "$component")" + remote_command+=" sudo install -o root -g root -m 0755 '$remote_dir/$binary' '/usr/local/libexec/sos/$binary';" + done + remote_command+=" sudo install -o root -g root -m 0644 '$remote_dir/development-deployment.env' '$development_deploy_metadata';" + remote_command+=" sudo install -o root -g root -m 0644 '$remote_dir/development-deployment-manifest.tsv' '$development_deploy_manifest';" + ssh -tt "${ssh_options[@]}" "$target" "$remote_command" + remote_dir="" + + while IFS=$'\t' read -r destination bytes digest; do + [[ "$(ssh "${ssh_options[@]}" "$target" "sha256sum '$destination'" | cut -d ' ' -f 1)" \ + == "$digest" ]] || development_deploy_fail "remote digest mismatch: $destination" + done <"$manifest" + + evidence_dir="$development_deploy_artifacts_dir/$deployment_id" + mkdir -p -- "$evidence_dir" + install -m 0644 "$metadata" "$evidence_dir/development-deployment.env" + install -m 0644 "$manifest" "$evidence_dir/development-deployment-manifest.tsv" + local finished_ns duration_ns + finished_ns="$(date +%s%N)" + duration_ns=$((finished_ns - started_ns)) + printf '%s\n' \ + "target=$target" \ + "measured_wall_time_ns=$duration_ns" \ + "components=${components[*]}" >"$evidence_dir/deployment-result.env" + development_deploy_cleanup + trap - EXIT + printf 'linux_development_live_deployed=PASS target=%s deployment_id=%s revision=%s dirty=%s components=%s measured_wall_time_ns=%s evidence_dir=%s promotion_eligible=false\n' \ + "$target" "$deployment_id" "$source_revision" "$source_dirty" \ + "${components[*]}" "$duration_ns" "$evidence_dir" +} + +[[ "$#" -ge 1 ]] || { + development_deploy_usage >&2 + exit 1 +} +development_deploy_command="$1" +shift +case "$development_deploy_command" in + components) + [[ "$#" -eq 0 ]] || development_deploy_fail "components accepts no options" + development_deploy_components + ;; + deploy) development_deploy_run "$@" ;; + *) + development_deploy_usage >&2 + exit 1 + ;; +esac diff --git a/tools/linux-live-image b/tools/linux-live-image new file mode 100755 index 0000000..884cec2 --- /dev/null +++ b/tools/linux-live-image @@ -0,0 +1,1158 @@ +#!/usr/bin/env bash + +set -euo pipefail +umask 077 + +live_image_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +live_image_identity_path=/usr/share/doc/sos/image-identity.env +live_image_offline_source=/usr/share/sos/experiences/daily-flow.luau +live_image_harness=/usr/local/libexec/sos/linux-hardware-gate +live_image_development_access=/usr/share/doc/sos/development-access.env +live_image_ssh_config=/etc/ssh/sshd_config.d/60-sos-development-live.conf +live_image_livesys_hook=/var/lib/livesys/livesys-session-extra +live_image_networkmanager_profile=/etc/NetworkManager/system-connections/60-sos-development-live.nmconnection + +live_image_fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +live_image_usage() { + printf '%s\n' \ + 'usage:' \ + ' tools/linux-live-image doctor [--layout-only]' \ + ' tools/linux-live-image format-identity [identity fields...]' \ + ' tools/linux-live-image write-offline-user-state --home-root DIR' \ + ' tools/linux-live-image check-networkmanager-profile --profile-file FILE' \ + ' tools/linux-live-image configure-development-access --root DIR --password-file FILE [--networkmanager-profile-file FILE]' \ + ' tools/linux-live-image check-payload --payload FILE' \ + ' tools/linux-live-image check-rootfs --root DIR' \ + ' tools/linux-live-image bake --source-iso ISO --source-sha256 SHA256 --output-dir DIR --liveuser-password-file FILE [--networkmanager-profile-file FILE]' +} + +live_image_require_command() { + command -v "$1" >/dev/null 2>&1 || live_image_fail "required command not found: $1" +} + +live_image_validate_sha256() { + local name="$1" value="$2" + [[ "$value" =~ ^[0-9a-f]{64}$ ]] || live_image_fail "$name must be a lowercase SHA-256" +} + +live_image_validate_bytes() { + local name="$1" value="$2" + [[ "$value" =~ ^[1-9][0-9]*$ ]] || live_image_fail "$name must be a positive byte count" +} + +live_image_os_release_value() { + local file="$1" key="$2" value + value="$(sed -n "s/^${key}=//p" "$file" | tail -n 1)" + value="${value#\"}" + value="${value%\"}" + printf '%s\n' "$value" +} + +live_image_metadata_value() { + local file="$1" key="$2" + sed -n "s/^${key}=//p" "$file" | tail -n 1 +} + +live_image_target() { + printf '%s%s\n' "$live_image_rootfs" "$1" +} + +live_image_absolute_leaf_path() { + local path="$1" parent leaf + parent="$(dirname -- "$path")" + leaf="$(basename -- "$path")" + printf '%s/%s\n' "$(realpath -m -- "$parent")" "$leaf" +} + +live_image_runtime_packages() { + printf '%s\n' \ + nodejs \ + libseat \ + libinput \ + libinput-utils \ + mesa-libgbm \ + libxkbcommon \ + libxkbcommon-x11 \ + vulkan-loader \ + mesa-dri-drivers \ + mesa-vulkan-drivers \ + pipewire-libs \ + alsa-lib \ + fontconfig \ + libgit2 \ + glib2 \ + sqlite \ + openssl \ + libzstd \ + jq \ + python3 \ + pciutils \ + openssh-server +} + +live_image_parse_root_option() { + local expected_option="$1" + shift + local parsed_root="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + "$expected_option") + [[ "$#" -ge 2 ]] || live_image_fail "$1 requires a directory" + parsed_root="$(realpath -m -- "$2")" + shift 2 + ;; + *) live_image_fail "unexpected option: $1" ;; + esac + done + [[ -n "$parsed_root" ]] || live_image_fail "$expected_option is required" + printf '%s\n' "$parsed_root" +} + +live_image_doctor() { + local layout_only=false + if [[ "${1:-}" == --layout-only ]]; then + layout_only=true + shift + fi + [[ "$#" -eq 0 ]] || live_image_fail "doctor accepts only --layout-only" + local command + for command in awk basename cargo cmp dirname find firewall-offline-cmd getfattr \ + git install mount mountpoint node npm pkg-config python3 realpath rsync rustc sha256sum \ + sort stat sudo umount openssl; do + live_image_require_command "$command" + done + local missing=() + for command in checkisomd5 dnf dump.erofs fsck.erofs implantisomd5 mkfs.erofs setfiles xorriso; do + command -v "$command" >/dev/null 2>&1 || missing+=("$command") + done + local missing_modules=() + for command in gbm libinput libseat libudev wayland-client xkbcommon xkbcommon-x11; do + pkg-config --exists "$command" || missing_modules+=("$command") + done + local host_id="unknown" host_release="unknown" host_arch + if [[ -r /etc/os-release ]]; then + host_id="$(live_image_os_release_value /etc/os-release ID)" + host_release="$(live_image_os_release_value /etc/os-release VERSION_ID)" + fi + host_arch="$(uname -m)" + printf 'linux_live_image_doctor host=%s release=%s arch=%s rust=%s node=%s\n' \ + "$host_id" "$host_release" "$host_arch" "$(rustc --version | tr ' ' '_')" "$(node --version)" + if [[ "$host_id" != fedora ]]; then + printf 'linux_live_image_doctor_problem bake requires a Fedora x86_64 host\n' + fi + if [[ "$host_arch" != x86_64 ]]; then + printf 'linux_live_image_doctor_problem bake requires x86_64; found %s\n' "$host_arch" + fi + if [[ "${#missing[@]}" -ne 0 ]]; then + printf 'linux_live_image_doctor_missing %s\n' "${missing[*]}" + fi + if [[ "${#missing_modules[@]}" -ne 0 ]]; then + printf 'linux_live_image_doctor_missing_modules %s\n' "${missing_modules[*]}" + fi + printf 'linux_live_image_runtime_packages %s\n' "$(live_image_runtime_packages | tr '\n' ' ')" + if [[ "$layout_only" == true ]]; then + printf 'linux_live_image_doctor=PASS mode=layout-only bake_ready=false\n' + return 0 + fi + if [[ "$host_id" != fedora || "$host_arch" != x86_64 \ + || "${#missing[@]}" -ne 0 || "${#missing_modules[@]}" -ne 0 ]]; then + printf 'linux_live_image_doctor=FAIL bake_ready=false\n' + return 1 + fi + printf 'linux_live_image_doctor=PASS bake_ready=true\n' +} + +live_image_format_identity() { + local source_revision="" source_dirty=false agent_mode=offline + local image_format=fedora-workstation-live-remix + local fedora_release="" build_host_release="" + local base_iso_filename="" base_iso_bytes="" base_iso_sha256="" + local payload_relpath="" payload_bytes="" payload_sha256="" + local container_format="" baked_at_utc="" + local output_iso_filename="" output_iso_bytes="" output_iso_sha256="" + local wifi_autoconnect=false + while [[ "$#" -gt 0 ]]; do + case "$1" in + --source-revision) source_revision="$2"; shift 2 ;; + --source-dirty) source_dirty="$2"; shift 2 ;; + --agent-mode) agent_mode="$2"; shift 2 ;; + --image-format) image_format="$2"; shift 2 ;; + --fedora-release) fedora_release="$2"; shift 2 ;; + --build-host-release) build_host_release="$2"; shift 2 ;; + --base-iso-filename) base_iso_filename="$2"; shift 2 ;; + --base-iso-bytes) base_iso_bytes="$2"; shift 2 ;; + --base-iso-sha256) base_iso_sha256="$2"; shift 2 ;; + --payload-relpath) payload_relpath="$2"; shift 2 ;; + --payload-bytes) payload_bytes="$2"; shift 2 ;; + --payload-sha256) payload_sha256="$2"; shift 2 ;; + --container-format) container_format="$2"; shift 2 ;; + --baked-at-utc) baked_at_utc="$2"; shift 2 ;; + --output-iso-filename) output_iso_filename="$2"; shift 2 ;; + --output-iso-bytes) output_iso_bytes="$2"; shift 2 ;; + --output-iso-sha256) output_iso_sha256="$2"; shift 2 ;; + --wifi-autoconnect) wifi_autoconnect="$2"; shift 2 ;; + *) live_image_fail "unexpected identity field: $1" ;; + esac + done + [[ -n "$source_revision" && -n "$base_iso_filename" && -n "$base_iso_bytes" && -n "$base_iso_sha256" ]] || \ + live_image_fail "format-identity requires source revision and base ISO identity" + [[ "$source_revision" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || \ + live_image_fail "source revision must be a full lowercase Git object ID" + [[ "$base_iso_filename" != */* && "$base_iso_filename" != *$'\n'* \ + && "$base_iso_filename" != *$'\t'* && "$base_iso_filename" != *=* ]] || \ + live_image_fail "base ISO filename is unsafe for identity metadata" + live_image_validate_bytes "base ISO bytes" "$base_iso_bytes" + live_image_validate_sha256 "base ISO SHA-256" "$base_iso_sha256" + [[ "$fedora_release" =~ ^[1-9][0-9]*$ && "$build_host_release" == "$fedora_release" ]] || \ + live_image_fail "Fedora image and build host must use the same numeric release" + [[ "$payload_relpath" == LiveOS/squashfs.img ]] || \ + live_image_fail "only LiveOS/squashfs.img is supported" + [[ "$container_format" == erofs-rootfs ]] || \ + live_image_fail "only metadata-preserving erofs-rootfs is supported" + [[ "$agent_mode" == offline ]] || live_image_fail "live images bake the offline agent only" + [[ "$source_dirty" == false ]] || live_image_fail "live images require a clean source revision" + [[ "$wifi_autoconnect" == true || "$wifi_autoconnect" == false ]] || \ + live_image_fail "Wi-Fi autoconnect identity must be true or false" + [[ -n "$baked_at_utc" ]] || baked_at_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + [[ "$baked_at_utc" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || \ + live_image_fail "baked timestamp must be UTC in YYYY-MM-DDTHH:MM:SSZ form" + printf '%s\n' \ + 'image_kind=development-live' \ + 'image_label=development-live' \ + 'campaign_class=development-live' \ + 'not_installed_product=true' \ + 'promotion_eligible=false' \ + 'mutable_runtime=true' \ + 'ssh_enabled=true' \ + "wifi_autoconnect=$wifi_autoconnect" \ + "network_credentials_embedded=$wifi_autoconnect" \ + "image_format=$image_format" \ + "container_format=$container_format" \ + "fedora_release=$fedora_release" \ + "build_host_release=$build_host_release" \ + "source_revision=$source_revision" \ + "source_dirty=$source_dirty" \ + "agent_mode=$agent_mode" \ + "base_iso_filename=$base_iso_filename" \ + "base_iso_bytes=$base_iso_bytes" \ + "base_iso_sha256=$base_iso_sha256" \ + "payload_relpath=$payload_relpath" + if [[ -n "$payload_bytes" || -n "$payload_sha256" ]]; then + [[ -n "$payload_bytes" && -n "$payload_sha256" ]] || \ + live_image_fail "payload identity requires both bytes and sha256" + live_image_validate_bytes "payload bytes" "$payload_bytes" + live_image_validate_sha256 "payload SHA-256" "$payload_sha256" + printf '%s\n' \ + "payload_bytes=$payload_bytes" \ + "payload_sha256=$payload_sha256" + fi + if [[ -n "$output_iso_filename" ]]; then + [[ -n "$output_iso_bytes" && -n "$output_iso_sha256" ]] || \ + live_image_fail "output ISO identity requires filename, bytes, and sha256" + [[ "$output_iso_filename" != */* && "$output_iso_filename" != *$'\n'* \ + && "$output_iso_filename" != *$'\t'* && "$output_iso_filename" != *=* ]] || \ + live_image_fail "output ISO filename is unsafe for identity metadata" + live_image_validate_bytes "output ISO bytes" "$output_iso_bytes" + live_image_validate_sha256 "output ISO SHA-256" "$output_iso_sha256" + printf '%s\n' \ + "output_iso_filename=$output_iso_filename" \ + "output_iso_bytes=$output_iso_bytes" \ + "output_iso_sha256=$output_iso_sha256" + fi + printf 'baked_at_utc=%s\n' "$baked_at_utc" +} + +live_image_write_offline_user_state() { + local home_root + home_root="$(live_image_parse_root_option --home-root "$@")" + local state="$home_root/.local/state/sos" + install -d -m 0700 \ + "$home_root/.local" \ + "$home_root/.local/state" \ + "$state" \ + "$state/agent" + printf '{}\n' >"$state/output.json" + printf '%s\n' \ + 'SOS_AGENT_PROVIDER=openai-codex' \ + 'SOS_AGENT_MODEL=faux' \ + "SOS_AGENT_FAKE_SOURCE=$live_image_offline_source" >"$state/agent/config.env" + chmod 0600 "$state/output.json" "$state/agent/config.env" + printf 'linux_live_image_offline_user_state home_root=%s\n' "$home_root" +} + +live_image_read_development_password() { + local password_file="$1" password="" extra="" + [[ -f "$password_file" && ! -L "$password_file" && -r "$password_file" ]] || \ + live_image_fail "liveuser password file must be a readable regular file, not a symlink" + exec 9<"$password_file" + IFS= read -r password <&9 || [[ -n "$password" ]] || \ + live_image_fail "liveuser password file is empty" + if IFS= read -r extra <&9; then + live_image_fail "liveuser password file must contain exactly one line" + fi + exec 9<&- + [[ -n "$password" && "$password" != *:* && "$password" != *$'\r'* ]] || \ + live_image_fail "liveuser password must be non-empty and contain no colon or carriage return" + [[ "${#password}" -le 128 ]] || live_image_fail "liveuser password exceeds 128 characters" + printf '%s' "$password" +} + +live_image_networkmanager_value() { + local profile="$1" section="$2" key="$3" privileged="${4:-false}" + local -a reader=(awk) + [[ "$privileged" == false ]] || reader=(sudo awk) + "${reader[@]}" -v wanted_section="$section" -v wanted_key="$key" ' + $0 == "[" wanted_section "]" { in_section=1; next } + /^\[/ { in_section=0 } + in_section && index($0, wanted_key "=") == 1 { + value=substr($0, length(wanted_key) + 2) + } + END { print value } + ' "$profile" +} + +live_image_networkmanager_has_nonempty_value() { + local profile="$1" section="$2" key="$3" privileged="${4:-false}" + local -a reader=(awk) + [[ "$privileged" == false ]] || reader=(sudo awk) + "${reader[@]}" -v wanted_section="$section" -v wanted_key="$key" ' + $0 == "[" wanted_section "]" { in_section=1; next } + /^\[/ { in_section=0 } + in_section && index($0, wanted_key "=") == 1 \ + && length($0) > length(wanted_key) + 1 { found=1 } + END { exit !found } + ' "$profile" +} + +live_image_validate_networkmanager_profile() { + local profile="$1" privileged="${2:-false}" + if [[ "$privileged" == true ]]; then + sudo test -f "$profile" && sudo test ! -L "$profile" && sudo test -r "$profile" || \ + live_image_fail "NetworkManager profile must be a readable regular file, not a symlink" + else + [[ -f "$profile" && ! -L "$profile" && -r "$profile" ]] || \ + live_image_fail "NetworkManager profile must be a readable regular file, not a symlink" + fi + local mode + if [[ "$privileged" == true ]]; then + mode="$(sudo stat -c %a "$profile")" + else + mode="$(stat -c %a "$profile")" + fi + [[ "$mode" =~ ^[0-7]{3,4}$ ]] || \ + live_image_fail "NetworkManager profile permissions could not be validated" + (( (8#$mode & 8#077) == 0 )) || \ + live_image_fail "NetworkManager profile must not be accessible by group or other users" + + local type uuid autoconnect ssid key_mgmt psk_flags ipv4_method + type="$(live_image_networkmanager_value "$profile" connection type "$privileged")" + uuid="$(live_image_networkmanager_value "$profile" connection uuid "$privileged")" + autoconnect="$(live_image_networkmanager_value \ + "$profile" connection autoconnect "$privileged")" + ssid="$(live_image_networkmanager_value "$profile" wifi ssid "$privileged")" + key_mgmt="$(live_image_networkmanager_value \ + "$profile" wifi-security key-mgmt "$privileged")" + psk_flags="$(live_image_networkmanager_value \ + "$profile" wifi-security psk-flags "$privileged")" + ipv4_method="$(live_image_networkmanager_value \ + "$profile" ipv4 method "$privileged")" + [[ "$type" == wifi ]] || \ + live_image_fail "NetworkManager profile must have connection type wifi" + [[ "$uuid" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] || \ + live_image_fail "NetworkManager profile must contain a UUID" + [[ -z "$autoconnect" || "$autoconnect" == true || "$autoconnect" == yes \ + || "$autoconnect" == 1 ]] || \ + live_image_fail "NetworkManager profile must enable autoconnect" + [[ -n "$ssid" && "$ssid" != *$'\r'* ]] || \ + live_image_fail "NetworkManager profile must contain a Wi-Fi SSID" + [[ "$key_mgmt" == wpa-psk || "$key_mgmt" == sae ]] || \ + live_image_fail "NetworkManager profile must use WPA-PSK or SAE" + [[ -z "$psk_flags" || "$psk_flags" == 0 ]] || \ + live_image_fail "NetworkManager profile must store its PSK for boot-time autoconnect" + live_image_networkmanager_has_nonempty_value \ + "$profile" wifi-security psk "$privileged" || \ + live_image_fail "NetworkManager profile must contain a boot-time Wi-Fi PSK" + [[ "$ipv4_method" == auto ]] || \ + live_image_fail "NetworkManager profile must use automatic IPv4 configuration" +} + +live_image_check_networkmanager_profile() { + local profile_file="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --profile-file) + [[ "$#" -ge 2 ]] || live_image_fail "--profile-file requires a file" + profile_file="$(live_image_absolute_leaf_path "$2")" + shift 2 + ;; + *) live_image_fail "unexpected check-networkmanager-profile option: $1" ;; + esac + done + [[ -n "$profile_file" ]] || \ + live_image_fail "check-networkmanager-profile requires --profile-file FILE" + live_image_validate_networkmanager_profile "$profile_file" + printf 'linux_live_image_network_profile_checked=PASS wifi_autoconnect=true network_credentials_embedded=true\n' +} + +live_image_configure_development_access() { + local root="" password_file="" networkmanager_profile_file="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --root) + [[ "$#" -ge 2 ]] || live_image_fail "--root requires a directory" + root="$(realpath -m -- "$2")" + shift 2 + ;; + --password-file) + [[ "$#" -ge 2 ]] || live_image_fail "--password-file requires a file" + password_file="$(live_image_absolute_leaf_path "$2")" + shift 2 + ;; + --networkmanager-profile-file) + [[ "$#" -ge 2 ]] || live_image_fail "--networkmanager-profile-file requires a file" + networkmanager_profile_file="$(live_image_absolute_leaf_path "$2")" + shift 2 + ;; + *) live_image_fail "unexpected configure-development-access option: $1" ;; + esac + done + [[ -n "$root" && -d "$root" && -n "$password_file" ]] || \ + live_image_fail "configure-development-access requires --root DIR and --password-file FILE" + local password + password="$(live_image_read_development_password "$password_file")" + local wifi_autoconnect=false + if [[ -n "$networkmanager_profile_file" ]]; then + live_image_validate_networkmanager_profile "$networkmanager_profile_file" + wifi_autoconnect=true + fi + [[ -f "$root/etc/passwd" && -f "$root/etc/shadow" ]] || \ + live_image_fail "development rootfs is missing passwd or shadow" + [[ -f "$root/usr/libexec/livesys/livesys-main" ]] || \ + live_image_fail "development rootfs is missing Fedora livesys provisioning" + grep -F 'useradd ${USERADDARGS:+"$USERADDARGS"} -c "Live System User" liveuser' \ + "$root/usr/libexec/livesys/livesys-main" >/dev/null || \ + live_image_fail "Fedora livesys no longer provisions liveuser as expected" + grep -F '. /var/lib/livesys/livesys-session-extra' \ + "$root/usr/libexec/livesys/livesys-main" >/dev/null || \ + live_image_fail "Fedora livesys no longer exposes the derived-spin session hook" + [[ ! -e "$root$live_image_livesys_hook" ]] || \ + live_image_fail "source image already owns the livesys session-extra hook" + [[ -f "$root/usr/lib/systemd/system/sshd.service" ]] || \ + live_image_fail "development rootfs is missing openssh-server" + [[ -x "$root/usr/bin/systemctl" ]] || \ + live_image_fail "development rootfs is missing systemctl" + [[ -d "$root/usr/lib/firewalld" ]] || \ + live_image_fail "development rootfs is missing firewalld defaults" + [[ -f "$root/usr/lib/systemd/system/NetworkManager.service" ]] || \ + live_image_fail "development rootfs is missing NetworkManager" + [[ ! -e "$root$live_image_networkmanager_profile" \ + && ! -L "$root$live_image_networkmanager_profile" ]] || \ + live_image_fail "source image already owns the SOS development network profile" + [[ ! -e "$root/etc/systemd/system/multi-user.target.wants/sshd.service" \ + && ! -L "$root/etc/systemd/system/multi-user.target.wants/sshd.service" ]] || \ + live_image_fail "source image already enables sshd before liveuser provisioning" + + local password_hash ssh_config livesys_hook access_metadata + password_hash="$(printf '%s\n' "$password" | openssl passwd -6 -stdin)" + unset password + [[ "$password_hash" =~ ^\$6\$[^:[:space:]]+$ ]] || \ + live_image_fail "could not derive a SHA-512 liveuser password hash" + ssh_config="$(mktemp -t sos-development-sshd.XXXXXX)" + livesys_hook="$(mktemp -t sos-development-livesys.XXXXXX)" + access_metadata="$(mktemp -t sos-development-access.XXXXXX)" + printf '%s\n' \ + 'PasswordAuthentication yes' \ + 'PermitRootLogin no' \ + 'AllowUsers liveuser' >"$ssh_config" + { + printf '%s\n' \ + '#!/usr/bin/sh' \ + '# SOS development-live customization, sourced by Fedora livesys-main.' + printf "usermod --password '%s' liveuser || exit 1\n" "$password_hash" + printf '%s\n' \ + 'passwd --lock root >/dev/null || exit 1' \ + "cat > /etc/gdm/custom.conf <<'SOS_DEVELOPMENT_GDM' || exit 1" \ + '[daemon]' \ + 'AutomaticLoginEnable=False' \ + 'SOS_DEVELOPMENT_GDM' \ + 'systemctl enable --now sshd.service >/dev/null || exit 1' + } >"$livesys_hook" + unset password_hash + printf '%s\n' \ + 'image_kind=development-live' \ + 'ssh_enabled=true' \ + 'ssh_user=liveuser' \ + 'password_authentication=true' \ + 'root_login=false' \ + 'root_account_locked=true' \ + 'host_keys=generated_at_boot' \ + 'liveuser_provisioning=livesys-session-extra' \ + 'ssh_activation=livesys-session-extra-final-action' \ + 'gdm_autologin=false_after_livesys' \ + 'firewall_service=ssh' >"$access_metadata" + printf '%s\n' \ + "wifi_autoconnect=$wifi_autoconnect" \ + "network_credentials_embedded=$wifi_autoconnect" >>"$access_metadata" + + sudo install -d -o root -g root -m 0755 \ + "$root/etc/ssh/sshd_config.d" \ + "$root/var/lib/livesys" \ + "$root/usr/share/doc/sos" + if [[ "$wifi_autoconnect" == true ]]; then + sudo install -d -o root -g root -m 0700 \ + "$root/etc/NetworkManager/system-connections" + sudo install -o root -g root -m 0600 \ + "$networkmanager_profile_file" "$root$live_image_networkmanager_profile" + fi + sudo install -o root -g root -m 0644 "$ssh_config" "$root$live_image_ssh_config" + sudo install -o root -g root -m 0700 "$livesys_hook" "$root$live_image_livesys_hook" + sudo install -o root -g root -m 0644 \ + "$access_metadata" "$root$live_image_development_access" + sudo find "$root/etc/ssh" -maxdepth 1 -type f -name 'ssh_host_*_key*' -delete + sudo firewall-offline-cmd \ + --system-config "$root/etc/firewalld" \ + --default-config "$root/usr/lib/firewalld" \ + --service=ssh >/dev/null + rm -f -- "$ssh_config" "$livesys_hook" "$access_metadata" + printf 'linux_live_image_development_access=PASS root=%s ssh_user=liveuser autologin=false wifi_autoconnect=%s\n' \ + "$root" "$wifi_autoconnect" +} + +live_image_symlink_basename() { + local link="$1" target="" + if [[ -L "$link" ]]; then + target="$(readlink -- "$link")" + fi + printf '%s\n' "$(basename "${target:-}")" +} + +live_image_require_exact_line() { + local file="$1" expected="$2" missing_description="$3" mismatch_description="$4" + if [[ -r "$file" ]]; then + [[ -f "$file" ]] || live_image_fail "$missing_description" + grep -Fx "$expected" "$file" >/dev/null || live_image_fail "$mismatch_description" + else + sudo test -f "$file" || live_image_fail "$missing_description" + sudo grep -Fx "$expected" "$file" >/dev/null || live_image_fail "$mismatch_description" + fi +} + +live_image_check_rootfs() { + local live_image_rootfs + live_image_rootfs="$(live_image_parse_root_option --root "$@")" + [[ -d "$live_image_rootfs" ]] || live_image_fail "rootfs directory is missing: $live_image_rootfs" + local path + for path in \ + /usr/local/libexec/sos/sos-login-session \ + /usr/local/libexec/sos/sos-agent-login \ + "$live_image_harness" \ + /usr/local/libexec/sos-agent/dist/agent-runner.cjs \ + /usr/share/wayland-sessions/sos.desktop \ + /usr/share/sos/experiences/daily-flow.luau \ + /usr/share/doc/sos/install-metadata.env \ + /usr/share/doc/sos/install-manifest.tsv \ + "$live_image_development_access" \ + "$live_image_ssh_config" \ + "$live_image_livesys_hook" \ + /usr/lib/systemd/system/sshd.service \ + /usr/bin/systemctl \ + "$live_image_identity_path"; do + [[ -f "$(live_image_target "$path")" ]] || \ + live_image_fail "baked live rootfs is missing $path" + done + [[ -e "$(live_image_target /usr/lib/systemd/system/gdm.service)" \ + || -e "$(live_image_target /usr/sbin/gdm)" \ + || -e "$(live_image_target /usr/bin/gdm)" ]] || \ + live_image_fail "baked live rootfs does not contain GDM" + local default_target display_manager + default_target="$(live_image_symlink_basename "$(live_image_target /etc/systemd/system/default.target)")" + if [[ -z "$default_target" ]]; then + default_target="$(live_image_symlink_basename "$(live_image_target /usr/lib/systemd/system/default.target)")" + fi + [[ "$default_target" != sos-session.target ]] || \ + live_image_fail "baked live rootfs enables the boot-owned appliance target" + display_manager="$(live_image_symlink_basename "$(live_image_target /etc/systemd/system/display-manager.service)")" + [[ -z "$display_manager" || "$display_manager" == gdm.service || "$display_manager" == gdm3.service ]] || \ + live_image_fail "baked live rootfs replaces GDM as the display manager" + local identity metadata + identity="$(live_image_target "$live_image_identity_path")" + metadata="$(live_image_target /usr/share/doc/sos/install-metadata.env)" + [[ "$(live_image_metadata_value "$identity" image_kind)" == development-live ]] || \ + live_image_fail "image identity is not labeled development-live" + [[ "$(live_image_metadata_value "$identity" not_installed_product)" == true ]] || \ + live_image_fail "image identity must say this is not an installed product" + [[ "$(live_image_metadata_value "$identity" agent_mode)" == offline ]] || \ + live_image_fail "image identity must pin the offline agent" + [[ "$(live_image_metadata_value "$identity" promotion_eligible)" == false ]] || \ + live_image_fail "development image must not be promotion eligible" + [[ "$(live_image_metadata_value "$identity" mutable_runtime)" == true ]] || \ + live_image_fail "development image must permit measured runtime mutation" + [[ "$(live_image_metadata_value "$identity" ssh_enabled)" == true ]] || \ + live_image_fail "development image identity must declare SSH access" + local wifi_autoconnect + wifi_autoconnect="$(live_image_metadata_value "$identity" wifi_autoconnect)" + [[ "$wifi_autoconnect" == true || "$wifi_autoconnect" == false ]] || \ + live_image_fail "development image identity must classify Wi-Fi autoconnect" + [[ "$(live_image_metadata_value "$identity" network_credentials_embedded)" \ + == "$wifi_autoconnect" ]] || \ + live_image_fail "development image identity must classify embedded network credentials" + [[ "$(live_image_metadata_value "$identity" source_dirty)" == false ]] || \ + live_image_fail "image identity came from a dirty source tree" + [[ "$(live_image_metadata_value "$identity" container_format)" == erofs-rootfs ]] || \ + live_image_fail "image identity is not the metadata-preserving EROFS rootfs format" + [[ "$(live_image_metadata_value "$identity" payload_relpath)" == LiveOS/squashfs.img ]] || \ + live_image_fail "image identity has an unsupported payload path" + local identity_release rootfs_release + identity_release="$(live_image_metadata_value "$identity" fedora_release)" + rootfs_release="$(live_image_os_release_value "$(live_image_target /etc/os-release)" VERSION_ID)" + [[ -n "$identity_release" && "$identity_release" == "$rootfs_release" \ + && "$(live_image_metadata_value "$identity" build_host_release)" == "$identity_release" ]] || \ + live_image_fail "Fedora rootfs, identity, and build-host releases do not match" + local image_revision installed_revision + image_revision="$(live_image_metadata_value "$identity" source_revision)" + installed_revision="$(live_image_metadata_value "$metadata" source_revision)" + [[ -n "$image_revision" && "$image_revision" == "$installed_revision" ]] || \ + live_image_fail "image identity revision does not match staged install metadata" + [[ "$(live_image_metadata_value "$metadata" agent_mode)" == offline ]] || \ + live_image_fail "staged install metadata must pin the offline agent" + local skel_config liveuser_config="" + skel_config="$(live_image_target /etc/skel/.local/state/sos/agent/config.env)" + live_image_require_exact_line "$skel_config" \ + "SOS_AGENT_FAKE_SOURCE=$live_image_offline_source" \ + "offline live skel agent config is missing" \ + "offline live skel does not pin the baked daily-flow source" + if [[ -d "$(live_image_target /home/liveuser)" ]]; then + liveuser_config="$(live_image_target /home/liveuser/.local/state/sos/agent/config.env)" + live_image_require_exact_line "$liveuser_config" \ + "SOS_AGENT_FAKE_SOURCE=$live_image_offline_source" \ + "existing liveuser is missing offline agent config" \ + "existing liveuser does not pin the baked daily-flow source" + fi + live_image_require_exact_line "$(live_image_target "$live_image_ssh_config")" \ + 'PasswordAuthentication yes' \ + 'development SSH configuration is missing' \ + 'development SSH configuration does not enable password authentication' + live_image_require_exact_line "$(live_image_target "$live_image_ssh_config")" \ + 'PermitRootLogin no' \ + 'development SSH configuration is missing' \ + 'development SSH configuration does not disable root login' + live_image_require_exact_line "$(live_image_target "$live_image_ssh_config")" \ + 'AllowUsers liveuser' \ + 'development SSH configuration is missing' \ + 'development SSH configuration does not restrict access to liveuser' + live_image_require_exact_line "$(live_image_target "$live_image_development_access")" \ + 'host_keys=generated_at_boot' \ + 'development access metadata is missing' \ + 'development access metadata does not require per-boot SSH host keys' + live_image_require_exact_line "$(live_image_target "$live_image_development_access")" \ + 'liveuser_provisioning=livesys-session-extra' \ + 'development access metadata is missing' \ + 'development access metadata does not identify boot-time liveuser provisioning' + live_image_require_exact_line "$(live_image_target "$live_image_development_access")" \ + 'ssh_activation=livesys-session-extra-final-action' \ + 'development access metadata is missing' \ + 'development access metadata does not identify fail-closed SSH activation' + live_image_require_exact_line "$(live_image_target "$live_image_development_access")" \ + "wifi_autoconnect=$wifi_autoconnect" \ + 'development access metadata is missing' \ + 'development access metadata does not match Wi-Fi autoconnect identity' + live_image_require_exact_line "$(live_image_target "$live_image_development_access")" \ + "network_credentials_embedded=$wifi_autoconnect" \ + 'development access metadata is missing' \ + 'development access metadata does not match network credential identity' + local installed_network_profile + installed_network_profile="$(live_image_target "$live_image_networkmanager_profile")" + if [[ "$wifi_autoconnect" == true ]]; then + live_image_validate_networkmanager_profile "$installed_network_profile" true + [[ "$(sudo stat -c '%U:%G:%a' "$installed_network_profile")" \ + == root:root:600 ]] || \ + live_image_fail "development NetworkManager profile must be root-owned mode 0600" + [[ "$(sudo stat -c '%U:%G:%a' "$(dirname "$installed_network_profile")")" \ + == root:root:700 ]] || \ + live_image_fail "development NetworkManager profile directory must be root-owned mode 0700" + else + sudo test ! -e "$installed_network_profile" \ + && sudo test ! -L "$installed_network_profile" || \ + live_image_fail "development rootfs embeds a network profile without declaring it" + fi + live_image_require_exact_line "$(live_image_target "$live_image_livesys_hook")" \ + 'passwd --lock root >/dev/null || exit 1' \ + 'development livesys hook is missing' \ + 'development livesys hook does not relock the root account' + live_image_require_exact_line "$(live_image_target "$live_image_livesys_hook")" \ + 'AutomaticLoginEnable=False' \ + 'development livesys hook is missing' \ + 'development livesys hook does not disable GDM autologin' + live_image_require_exact_line "$(live_image_target "$live_image_livesys_hook")" \ + "cat > /etc/gdm/custom.conf <<'SOS_DEVELOPMENT_GDM' || exit 1" \ + 'development livesys hook is missing' \ + 'development livesys hook does not fail closed when GDM configuration fails' + live_image_require_exact_line "$(live_image_target "$live_image_livesys_hook")" \ + 'systemctl enable --now sshd.service >/dev/null || exit 1' \ + 'development livesys hook is missing' \ + 'development livesys hook does not activate SSH after provisioning' + [[ "$(sudo tail -n 1 "$(live_image_target "$live_image_livesys_hook")")" \ + == 'systemctl enable --now sshd.service >/dev/null || exit 1' ]] || \ + live_image_fail "development livesys hook does not activate SSH as its final action" + sudo grep -Eq "^usermod --password '\\\$6\\\$[^']+' liveuser \\|\\| exit 1$" \ + "$(live_image_target "$live_image_livesys_hook")" || \ + live_image_fail "development livesys hook does not install a SHA-512 liveuser password hash" + [[ "$(sudo stat -c '%U:%G:%a' "$(live_image_target "$live_image_livesys_hook")")" \ + == root:root:700 ]] || \ + live_image_fail "development livesys hook must be root-owned mode 0700" + [[ ! -e "$(live_image_target /etc/systemd/system/multi-user.target.wants/sshd.service)" \ + && ! -L "$(live_image_target /etc/systemd/system/multi-user.target.wants/sshd.service)" ]] || \ + live_image_fail "development rootfs enables sshd before liveuser provisioning" + ! awk -F: '$1 == "liveuser" { found=1 } END { exit !found }' \ + "$(live_image_target /etc/passwd)" || \ + live_image_fail "development rootfs unexpectedly contains a pre-boot liveuser account" + [[ -z "$(sudo find "$(live_image_target /etc/ssh)" -maxdepth 1 -type f \ + -name 'ssh_host_*_key*' -print -quit)" ]] || \ + live_image_fail "development image contains reusable SSH host keys" + printf 'linux_live_image_rootfs_checked=PASS root=%s revision=%s boot_kind=development-live promotion_eligible=false not_installed_product=true\n' \ + "$live_image_rootfs" "$image_revision" +} + +live_image_probe_payload() { + local payload="$1" + dump.erofs "$payload" >/dev/null 2>&1 || \ + live_image_fail "LiveOS/squashfs.img is not an EROFS root filesystem" + dump.erofs --ls --path=/etc/os-release "$payload" >/dev/null 2>&1 || \ + live_image_fail "EROFS payload is not a flat Fedora root filesystem" + printf 'erofs-rootfs\n' +} + +live_image_find_payload() { + local iso_tree="$1" + [[ -f "$iso_tree/LiveOS/squashfs.img" ]] || \ + live_image_fail "source ISO has no LiveOS/squashfs.img; the Fedora Workstation live payload is missing" + printf 'LiveOS/squashfs.img\n' +} + +live_image_check_payload() { + local payload="" container_format + while [[ "$#" -gt 0 ]]; do + case "$1" in + --payload) + [[ "$#" -ge 2 ]] || live_image_fail "--payload requires a file" + payload="$(realpath -e -- "$2")" + shift 2 + ;; + *) live_image_fail "check-payload accepts only --payload FILE" ;; + esac + done + [[ -n "$payload" ]] || live_image_fail "check-payload requires --payload FILE" + container_format="$(live_image_probe_payload "$payload")" + printf 'linux_live_image_payload_checked=PASS path=%s container_format=%s\n' \ + "$payload" "$container_format" +} + +live_image_write_xattr_manifest() { + local root="$1" output="$2" + ( + cd "$root" + sudo getfattr -hRPd -m- . + ) | awk ' + /^# file: / { + path = substr($0, 9) + next + } + /^(user|trusted|security)\./ && \ + !/^security\.selinux=/ && \ + !/^trusted\.SGI_ACL_/ { + print path "\t" $0 + } + ' | LC_ALL=C sort >"$output" +} + +live_image_extract_payload() { + local payload="$1" format="$2" dest="$3" work="$4" + local mountpoint="$work/erofs-source" + local audit="$work/erofs-rsync-audit.txt" + local source_xattrs="$work/erofs-source-xattrs.txt" + local dest_xattrs="$work/erofs-dest-xattrs.txt" + mkdir -p -- "$dest" "$mountpoint" + [[ -z "$(find "$dest" -mindepth 1 -print -quit)" ]] || \ + live_image_fail "rootfs extraction destination is not empty: $dest" + case "$format" in + erofs-rootfs) + # The official image can carry build-filesystem SELinux labels such as + # fusefs_t that the destination filesystem rejects. Mount the source + # read-only and copy every other metadata class; mkfs.erofs later applies + # the image's own policy directly to the rebuilt filesystem. + ( + # Invoked indirectly by the EXIT trap below. + # shellcheck disable=SC2329 + live_image_cleanup_erofs_mount() { + if mountpoint -q "$mountpoint"; then + sudo umount -- "$mountpoint" + fi + } + trap live_image_cleanup_erofs_mount EXIT + sudo mount -t erofs -o loop,ro "$payload" "$mountpoint" + sudo rsync -aHAXS --numeric-ids \ + --filter='-x security.selinux' \ + --filter='-x system.*' \ + "$mountpoint/" "$dest/" + # The builder intentionally owns this audit file for inspection. + # shellcheck disable=SC2024 + sudo rsync -aHASni --numeric-ids \ + "$mountpoint/" "$dest/" >"$audit" + [[ ! -s "$audit" ]] || \ + live_image_fail "rootfs metadata audit differs after copy; inspect $audit" + live_image_write_xattr_manifest "$mountpoint" "$source_xattrs" + live_image_write_xattr_manifest "$dest" "$dest_xattrs" + cmp -s -- "$source_xattrs" "$dest_xattrs" || \ + live_image_fail "rootfs xattr audit differs after copy; inspect $source_xattrs and $dest_xattrs" + rm -- "$audit" "$source_xattrs" "$dest_xattrs" + sudo umount -- "$mountpoint" + trap - EXIT + ) + rmdir -- "$mountpoint" + ;; + *) live_image_fail "cannot extract container format: $format" ;; + esac +} + +live_image_selinux_policy_file() { + local root="$1" + local policy_dir="$root/etc/selinux/targeted/policy" policy="" candidate + while IFS= read -r -d '' candidate; do + policy="$candidate" + done < <(find "$policy_dir" -maxdepth 1 -type f -name 'policy.*' -print0 2>/dev/null \ + | sort -zV) + [[ -n "$policy" && -r "$policy" ]] || \ + live_image_fail "Fedora SELinux binary policy is missing from the extracted rootfs" + printf '%s\n' "$policy" +} + +live_image_validate_rootfs_policy() { + local root="$1" + local file_contexts="$root/etc/selinux/targeted/contexts/files/file_contexts" + [[ -r "$file_contexts" ]] || \ + live_image_fail "Fedora SELinux file-context policy is missing from the extracted rootfs" + local policy + policy="$(live_image_selinux_policy_file "$root")" + # Validate every context against the image policy, not the host's loaded + # policy. -n deliberately leaves host-filesystem labels untouched. + sudo setfiles -n -m -c "$policy" -r "$root" "$file_contexts" "$root" >/dev/null +} + +live_image_repack_payload() { + local format="$1" rootfs="$2" output="$3" + case "$format" in + erofs-rootfs) + # Root execution preserves distinct system/liveuser ownership, ACLs, + # capabilities, and portable xattrs. Apply SELinux labels while creating + # EROFS so the host kernel never has to accept image-policy-only types. + local file_contexts="$rootfs/etc/selinux/targeted/contexts/files/file_contexts" + [[ -r "$file_contexts" ]] || \ + live_image_fail "Fedora SELinux file-context policy is missing from the staged rootfs" + sudo mkfs.erofs -zlzma -E dedupe,all-fragments -C65536 \ + --file-contexts="$file_contexts" \ + "$output" "$rootfs" >/dev/null + sudo fsck.erofs --xattrs "$output" >/dev/null + sudo chown "$(id -u):$(id -g)" "$output" + ;; + *) live_image_fail "cannot repack container format: $format" ;; + esac +} + +live_image_verify_fedora_workstation() { + local root="$1" os_release="$1/etc/os-release" + [[ -r "$os_release" ]] || live_image_fail "extracted live rootfs has no /etc/os-release" + local image_id variant_id variant + image_id="$(live_image_os_release_value "$os_release" ID)" + variant_id="$(live_image_os_release_value "$os_release" VARIANT_ID)" + variant="$(live_image_os_release_value "$os_release" VARIANT)" + live_image_base_release="$(live_image_os_release_value "$os_release" VERSION_ID)" + [[ "$image_id" == fedora ]] || \ + live_image_fail "source live image is not Fedora (ID=${image_id:-unknown})" + [[ "$variant_id" == workstation || "$variant" == *Workstation* ]] || \ + live_image_fail "source live image is not Fedora Workstation (VARIANT=${variant:-unknown})" + [[ "$live_image_base_release" =~ ^[1-9][0-9]*$ ]] || \ + live_image_fail "source live image has an invalid Fedora release" +} + +live_image_install_runtime_packages() { + local root="$1" releasever="$2" + [[ -r /etc/os-release ]] || live_image_fail "bake host has no /etc/os-release" + local host_id host_release + host_id="$(live_image_os_release_value /etc/os-release ID)" + host_release="$(live_image_os_release_value /etc/os-release VERSION_ID)" + [[ "$host_id" == fedora ]] || \ + live_image_fail "baking a Fedora live remix requires a Fedora host; found ${host_id:-unknown}" + [[ "$(uname -m)" == x86_64 ]] || \ + live_image_fail "Framework 12 live images must be baked on x86_64" + [[ "$host_release" == "$releasever" ]] || \ + live_image_fail "build host Fedora $host_release does not match source image Fedora $releasever" + live_image_require_command dnf + # Runtime packages only; do not install -devel toolchains or appliance units. + # shellcheck disable=SC2046 + sudo dnf -y --installroot="$root" --releasever="$releasever" \ + --setopt=install_weak_deps=False \ + install $(live_image_runtime_packages) + sudo dnf --installroot="$root" --releasever="$releasever" clean all +} + +live_image_stage_sos() { + local root="$1" + ( + cd "$live_image_root" + ./tools/install-linux-login-session install --offline --destdir "$root" + ) + sudo install -o root -g root -m 0755 \ + "$live_image_root/tools/linux-hardware-gate" \ + "$(live_image_target "$live_image_harness")" + sudo install -o root -g root -m 0644 \ + "$live_image_root/docs/linux-hardware-gate.md" \ + "$(live_image_target /usr/share/doc/sos/linux-hardware-gate.md)" + sudo install -o root -g root -m 0644 \ + "$live_image_root/docs/linux-live-image.md" \ + "$(live_image_target /usr/share/doc/sos/linux-live-image.md)" + sudo mkdir -p -- "$(live_image_target /etc/skel)" + local skel_tmp + skel_tmp="$(mktemp -d -t sos-live-skel.XXXXXX)" + live_image_write_offline_user_state --home-root "$skel_tmp" + sudo mkdir -p -- "$(live_image_target /etc/skel/.local/state)" + sudo cp -a "$skel_tmp/.local" "$(live_image_target /etc/skel)/" + sudo chown -R root:root "$(live_image_target /etc/skel/.local)" + rm -r -- "$skel_tmp" + if [[ -d "$(live_image_target /home/liveuser)" ]]; then + local liveuser_tmp live_uid=1000 live_gid=1000 + liveuser_tmp="$(mktemp -d -t sos-liveuser.XXXXXX)" + live_image_write_offline_user_state --home-root "$liveuser_tmp" + if [[ -r "$(live_image_target /etc/passwd)" ]]; then + live_uid="$(awk -F: '$1=="liveuser"{print $3}' "$(live_image_target /etc/passwd)")" + live_gid="$(awk -F: '$1=="liveuser"{print $4}' "$(live_image_target /etc/passwd)")" + [[ -n "$live_uid" && -n "$live_gid" ]] || { + live_uid=1000 + live_gid=1000 + } + fi + sudo mkdir -p -- "$(live_image_target /home/liveuser/.local/state)" + sudo cp -a "$liveuser_tmp/.local" "$(live_image_target /home/liveuser)/" + sudo chown -R "$live_uid:$live_gid" "$(live_image_target /home/liveuser/.local)" + rm -r -- "$liveuser_tmp" + fi +} + +live_image_write_identity_file() { + local dest="$1" + shift + local temporary + temporary="$(mktemp -t sos-image-identity.XXXXXX)" + live_image_format_identity "$@" >"$temporary" + if [[ -n "${live_image_rootfs:-}" && "$dest" == "$(live_image_target "$live_image_identity_path")" ]]; then + sudo install -o root -g root -m 0644 "$temporary" "$dest" + else + install -m 0644 "$temporary" "$dest" + fi + rm -f -- "$temporary" +} + +live_image_bake() { + local source_iso="" source_sha256="" output_dir="" liveuser_password_file="" + local networkmanager_profile_file="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --source-iso) + [[ "$#" -ge 2 ]] || live_image_fail "--source-iso requires a path" + source_iso="$(realpath -e -- "$2")" + shift 2 + ;; + --source-sha256) + [[ "$#" -ge 2 ]] || live_image_fail "--source-sha256 requires a digest" + source_sha256="${2,,}" + shift 2 + ;; + --output-dir) + [[ "$#" -ge 2 ]] || live_image_fail "--output-dir requires a directory" + output_dir="$(realpath -m -- "$2")" + shift 2 + ;; + --liveuser-password-file) + [[ "$#" -ge 2 ]] || live_image_fail "--liveuser-password-file requires a file" + liveuser_password_file="$(live_image_absolute_leaf_path "$2")" + shift 2 + ;; + --networkmanager-profile-file) + [[ "$#" -ge 2 ]] || live_image_fail "--networkmanager-profile-file requires a file" + networkmanager_profile_file="$(live_image_absolute_leaf_path "$2")" + shift 2 + ;; + *) live_image_fail "unexpected option: $1" ;; + esac + done + [[ -n "$source_iso" && -n "$source_sha256" && -n "$output_dir" \ + && -n "$liveuser_password_file" ]] || \ + live_image_fail "bake requires --source-iso ISO, --source-sha256 SHA256, --output-dir DIR, and --liveuser-password-file FILE" + live_image_read_development_password "$liveuser_password_file" >/dev/null + local wifi_autoconnect=false + if [[ -n "$networkmanager_profile_file" ]]; then + live_image_validate_networkmanager_profile "$networkmanager_profile_file" + wifi_autoconnect=true + fi + live_image_validate_sha256 "source ISO SHA-256" "$source_sha256" + [[ "$(id -u)" -ne 0 ]] || live_image_fail "run bake as a normal user; it invokes sudo only for rootfs mutation" + live_image_doctor || live_image_fail "live-image bake prerequisites are incomplete" + local actual_source_sha host_release + actual_source_sha="$(sha256sum "$source_iso" | cut -d ' ' -f 1)" + [[ "$actual_source_sha" == "$source_sha256" ]] || \ + live_image_fail "source ISO SHA-256 does not match the expected Fedora checksum" + host_release="$(live_image_os_release_value /etc/os-release VERSION_ID)" + + cd "$live_image_root" + [[ -z "$(git status --porcelain --untracked-files=normal)" ]] || \ + live_image_fail "live image bake requires a clean source worktree" + local source_revision + source_revision="$(git rev-parse HEAD)" + + mkdir -p -- "$output_dir" + chmod 0700 "$output_dir" + local output_iso + output_iso="$output_dir/sos-development-live-${source_revision:0:12}.iso" + [[ ! -e "$output_iso" ]] || live_image_fail "output ISO already exists: $output_iso" + [[ ! -e "$output_dir/image-identity.env" ]] || \ + live_image_fail "output identity already exists: $output_dir/image-identity.env" + local work payload_relpath payload_path container_format + work="$output_dir/work" + [[ "$work" == "$output_dir/work" && "$work" != /work ]] || \ + live_image_fail "unsafe live-image work path: $work" + [[ ! -e "$work" ]] || \ + live_image_fail "live-image work path already exists; inspect it and use a new output directory: $work" + mkdir -p -- "$work/iso" "$work/rootfs" + live_image_rootfs="$work/rootfs" + + xorriso -osirrox on -indev "$source_iso" -extract / "$work/iso" >/dev/null + chmod -R u+w "$work/iso" + payload_relpath="$(live_image_find_payload "$work/iso")" + payload_path="$work/iso/$payload_relpath" + container_format="$(live_image_probe_payload "$payload_path")" + live_image_extract_payload "$payload_path" "$container_format" "$live_image_rootfs" "$work" + live_image_verify_fedora_workstation "$live_image_rootfs" + [[ "$live_image_base_release" == "$host_release" ]] || \ + live_image_fail "build host Fedora $host_release does not match source image Fedora $live_image_base_release" + [[ -e "$live_image_rootfs/usr/lib/systemd/system/gdm.service" \ + || -e "$live_image_rootfs/usr/sbin/gdm" \ + || -e "$live_image_rootfs/usr/bin/gdm" ]] || \ + live_image_fail "source live image does not contain GDM" + live_image_install_runtime_packages "$live_image_rootfs" "$live_image_base_release" + live_image_stage_sos "$live_image_rootfs" + local -a development_access_args=( + --root "$live_image_rootfs" + --password-file "$liveuser_password_file" + ) + if [[ "$wifi_autoconnect" == true ]]; then + development_access_args+=( + --networkmanager-profile-file "$networkmanager_profile_file" + ) + fi + live_image_configure_development_access "${development_access_args[@]}" + + local base_name base_bytes base_sha baked_at + base_name="$(basename -- "$source_iso")" + base_bytes="$(stat -c %s "$source_iso")" + base_sha="$actual_source_sha" + baked_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + live_image_write_identity_file \ + "$(live_image_target "$live_image_identity_path")" \ + --source-revision "$source_revision" \ + --source-dirty false \ + --agent-mode offline \ + --fedora-release "$live_image_base_release" \ + --build-host-release "$host_release" \ + --base-iso-filename "$base_name" \ + --base-iso-bytes "$base_bytes" \ + --base-iso-sha256 "$base_sha" \ + --payload-relpath "$payload_relpath" \ + --container-format "$container_format" \ + --wifi-autoconnect "$wifi_autoconnect" \ + --baked-at-utc "$baked_at" + live_image_validate_rootfs_policy "$live_image_rootfs" + live_image_check_rootfs --root "$live_image_rootfs" + + local new_payload payload_bytes payload_sha + new_payload="$work/payload.img" + live_image_repack_payload "$container_format" "$live_image_rootfs" "$new_payload" + payload_bytes="$(stat -c %s "$new_payload")" + payload_sha="$(sha256sum "$new_payload" | cut -d ' ' -f 1)" + live_image_write_identity_file \ + "$work/image-identity.env" \ + --source-revision "$source_revision" \ + --source-dirty false \ + --agent-mode offline \ + --fedora-release "$live_image_base_release" \ + --build-host-release "$host_release" \ + --base-iso-filename "$base_name" \ + --base-iso-bytes "$base_bytes" \ + --base-iso-sha256 "$base_sha" \ + --payload-relpath "$payload_relpath" \ + --payload-bytes "$payload_bytes" \ + --payload-sha256 "$payload_sha" \ + --container-format "$container_format" \ + --wifi-autoconnect "$wifi_autoconnect" \ + --baked-at-utc "$baked_at" + + install -m 0644 "$work/image-identity.env" "$work/iso/sos-image-identity.env" + cp -- "$new_payload" "$work/iso/$payload_relpath" + # Preserve the source volume ID and El Torito/EFI boot. Changing the label + # breaks Fedora's root=live:CDLABEL=... cmdline. + xorriso -report_about WARNING \ + -indev "$source_iso" -outdev "$output_iso" \ + -boot_image any replay \ + -map "$work/iso/$payload_relpath" "/$payload_relpath" \ + -map "$work/iso/sos-image-identity.env" /sos-image-identity.env + implantisomd5 --force "$output_iso" >/dev/null + checkisomd5 "$output_iso" >/dev/null + local output_bytes output_sha + output_bytes="$(stat -c %s "$output_iso")" + output_sha="$(sha256sum "$output_iso" | cut -d ' ' -f 1)" + live_image_write_identity_file \ + "$output_dir/image-identity.env" \ + --source-revision "$source_revision" \ + --source-dirty false \ + --agent-mode offline \ + --fedora-release "$live_image_base_release" \ + --build-host-release "$host_release" \ + --base-iso-filename "$base_name" \ + --base-iso-bytes "$base_bytes" \ + --base-iso-sha256 "$base_sha" \ + --payload-relpath "$payload_relpath" \ + --payload-bytes "$payload_bytes" \ + --payload-sha256 "$payload_sha" \ + --container-format "$container_format" \ + --wifi-autoconnect "$wifi_autoconnect" \ + --baked-at-utc "$baked_at" \ + --output-iso-filename "$(basename -- "$output_iso")" \ + --output-iso-bytes "$output_bytes" \ + --output-iso-sha256 "$output_sha" + sudo rm -r -- "$work" + printf 'linux_live_image_baked=PASS iso=%s bytes=%s sha256=%s boot_kind=development-live promotion_eligible=false not_installed_product=true wifi_autoconnect=%s\n' \ + "$output_iso" "$output_bytes" "$output_sha" "$wifi_autoconnect" +} + +[[ "$#" -ge 1 ]] || { + live_image_usage >&2 + exit 1 +} +live_image_command="$1" +shift +case "$live_image_command" in + doctor) live_image_doctor "$@" ;; + format-identity) live_image_format_identity "$@" ;; + write-offline-user-state) live_image_write_offline_user_state "$@" ;; + check-networkmanager-profile) live_image_check_networkmanager_profile "$@" ;; + configure-development-access) live_image_configure_development_access "$@" ;; + check-payload) live_image_check_payload "$@" ;; + check-rootfs) live_image_check_rootfs "$@" ;; + bake) live_image_bake "$@" ;; + *) + live_image_usage >&2 + exit 1 + ;; +esac diff --git a/vendor/gpui-linux/src/linux/dispatcher.rs b/vendor/gpui-linux/src/linux/dispatcher.rs index 22df579..ef67231 100644 --- a/vendor/gpui-linux/src/linux/dispatcher.rs +++ b/vendor/gpui-linux/src/linux/dispatcher.rs @@ -7,6 +7,10 @@ use util::ResultExt; use std::{ mem::MaybeUninit, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, thread, time::{Duration, Instant}, }; @@ -16,6 +20,8 @@ use gpui::{ PriorityQueueSender, RunnableVariant, TaskTiming, ThreadTaskTimings, profiler, }; +const MAX_MAIN_TASKS_PER_DISPATCH: usize = 64; + struct TimerAfter { duration: Duration, runnable: RunnableVariant, @@ -196,17 +202,17 @@ impl PlatformDispatcher for LinuxDispatcher { pub struct PriorityQueueCalloopSender { sender: PriorityQueueSender, ping: calloop::ping::Ping, + pending: Arc, } impl PriorityQueueCalloopSender { - fn new(tx: PriorityQueueSender, ping: calloop::ping::Ping) -> Self { - Self { sender: tx, ping } - } - fn send(&self, priority: Priority, item: T) -> Result<(), gpui::queue::SendError> { + self.pending.fetch_add(1, Ordering::Release); let res = self.sender.send(priority, item); if res.is_ok() { self.ping.ping(); + } else { + self.pending.fetch_sub(1, Ordering::AcqRel); } res } @@ -222,6 +228,7 @@ pub struct PriorityQueueCalloopReceiver { receiver: PriorityQueueReceiver, source: calloop::ping::PingSource, ping: calloop::ping::Ping, + pending: Arc, } impl PriorityQueueCalloopReceiver { @@ -229,13 +236,19 @@ impl PriorityQueueCalloopReceiver { let (ping, source) = calloop::ping::make_ping().expect("Failed to create a Ping."); let (tx, rx) = PriorityQueueReceiver::new(); + let pending = Arc::new(AtomicUsize::new(0)); ( - PriorityQueueCalloopSender::new(tx, ping.clone()), + PriorityQueueCalloopSender { + sender: tx, + ping: ping.clone(), + pending: Arc::clone(&pending), + }, Self { receiver: rx, source, ping, + pending, }, ) } @@ -284,9 +297,15 @@ impl calloop::EventSource for PriorityQueueCalloopReceiver { let mut is_empty = true; let receiver = self.receiver.clone(); - for runnable in receiver.try_iter() { + // Foreground tasks may schedule more foreground work (for + // example, a continuously animating view). Bound each turn so + // the calloop dispatcher can return to Wayland protocol and + // input sources instead of draining a queue that never becomes + // empty. + for runnable in receiver.try_iter().take(MAX_MAIN_TASKS_PER_DISPATCH) { match runnable { Ok(r) => { + self.pending.fetch_sub(1, Ordering::AcqRel); callback(Event::Msg(r), &mut ()); is_empty = false; } @@ -308,10 +327,12 @@ impl calloop::EventSource for PriorityQueueCalloopReceiver { if disconnected { Ok(PostAction::Remove) - } else if clear_readiness { + } else if self.pending.load(Ordering::Acquire) == 0 { Ok(action) } else { - // Re-notify the ping source so we can try again. + // PriorityQueueReceiver::try_iter may stop before the opaque + // priority queue is empty. Keep the calloop source readable while + // a successful send remains unmatched by a received runnable. self.ping.ping(); Ok(PostAction::Continue) } @@ -397,6 +418,77 @@ mod tests { assert!(data.got_msg); assert!(data.got_closed); } + + #[test] + fn calloop_rewakes_until_all_priority_work_is_drained() { + let mut event_loop = calloop::EventLoop::try_new().unwrap(); + let handle = event_loop.handle(); + let (tx, rx) = PriorityQueueCalloopReceiver::new(); + let pending = Arc::clone(&tx.pending); + + handle + .insert_source(rx, |event, &mut (), received: &mut usize| { + if let Event::Msg(_) = event { + *received += 1; + } + }) + .unwrap(); + + for index in 0..1_000 { + let priority = match index % 3 { + 0 => Priority::High, + 1 => Priority::Medium, + _ => Priority::Low, + }; + tx.send(priority, index).unwrap(); + } + let mut received = 0; + let deadline = Instant::now() + Duration::from_secs(1); + while received < 1_000 && Instant::now() < deadline { + event_loop + .dispatch(Some(Duration::from_millis(10)), &mut received) + .unwrap(); + } + + assert_eq!(received, 1_000); + assert_eq!(pending.load(Ordering::Acquire), 0); + } + + #[test] + fn calloop_yields_between_sustained_foreground_batches() { + let mut event_loop = calloop::EventLoop::try_new().unwrap(); + let handle = event_loop.handle(); + let (tx, rx) = PriorityQueueCalloopReceiver::new(); + let tx = Arc::new(tx); + let callback_tx = Arc::clone(&tx); + let total = MAX_MAIN_TASKS_PER_DISPATCH * 3; + + handle + .insert_source(rx, move |event, &mut (), received: &mut usize| { + if let Event::Msg(_) = event { + *received += 1; + if *received < total { + callback_tx.send(Priority::High, *received).unwrap(); + } + } + }) + .unwrap(); + + tx.send(Priority::High, 0).unwrap(); + let mut received = 0; + event_loop + .dispatch(Some(Duration::ZERO), &mut received) + .unwrap(); + + assert_eq!(received, MAX_MAIN_TASKS_PER_DISPATCH); + + while received < total { + event_loop + .dispatch(Some(Duration::from_millis(10)), &mut received) + .unwrap(); + } + assert_eq!(tx.pending.load(Ordering::Acquire), 0); + } } // running 1 test diff --git a/vendor/gpui-linux/src/linux/wayland/client.rs b/vendor/gpui-linux/src/linux/wayland/client.rs index d1fde0d..7cd4381 100644 --- a/vendor/gpui-linux/src/linux/wayland/client.rs +++ b/vendor/gpui-linux/src/linux/wayland/client.rs @@ -1,5 +1,6 @@ use std::{ cell::{RefCell, RefMut}, + collections::VecDeque, hash::Hash, os::fd::{AsRawFd, BorrowedFd}, path::PathBuf, @@ -303,6 +304,8 @@ pub(crate) struct WaylandClientState { primary_data_offer: Option>, cursor: Cursor, pending_activation: Option, + pending_main_tasks: VecDeque, + pending_frames: VecDeque, event_loop: Option>, pub common: LinuxCommon, } @@ -352,6 +355,19 @@ impl WaylandClientStatePtr { self.0.upgrade().unwrap().borrow().serial_tracker.get(kind) } + fn queue_frame(&self, window: WaylandWindowStatePtr) { + let client = self.get_client(); + let mut state = client.borrow_mut(); + if state + .pending_frames + .iter() + .any(|pending| pending.ptr_eq(&window)) + { + return; + } + state.pending_frames.push_back(window); + } + pub fn set_pending_activation(&self, window: ObjectId) { self.0.upgrade().unwrap().borrow_mut().pending_activation = Some(PendingActivation::Window(window)); @@ -553,29 +569,18 @@ impl WaylandClient { let handle = event_loop.handle(); handle - .insert_source(main_receiver, { - let handle = handle.clone(); - move |event, _, _: &mut WaylandClientStatePtr| { + .insert_source( + main_receiver, + |event, _, state: &mut WaylandClientStatePtr| { if let calloop::channel::Event::Msg(runnable) = event { - handle.insert_idle(|_| { - let start = Instant::now(); - let location = runnable.metadata().location; - let mut timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - - runnable.run(); - - let end = Instant::now(); - timing.end = Some(end); - profiler::add_task_timing(timing); - }); + state + .get_client() + .borrow_mut() + .pending_main_tasks + .push_back(runnable); } - } - }) + }, + ) .unwrap(); let compositor_gpu = detect_compositor_gpu(); @@ -716,6 +721,8 @@ impl WaylandClient { primary_data_offer: None, cursor, pending_activation: None, + pending_main_tasks: VecDeque::new(), + pending_frames: VecDeque::new(), event_loop: Some(event_loop), })); @@ -912,7 +919,51 @@ impl LinuxClient for WaylandClient { .run( None, &mut WaylandClientStatePtr(Rc::downgrade(&self.0)), - |_| {}, + |state| { + // Run the bounded batch staged by the foreground source + // only after every ready calloop source has had its turn. + // This avoids both idle-callback starvation during + // continuous animation and re-entering GPUI from inside a + // Wayland/calloop source callback. + loop { + let runnable = state + .get_client() + .borrow_mut() + .pending_main_tasks + .pop_front(); + let Some(runnable) = runnable else { + break; + }; + let start = Instant::now(); + let location = runnable.metadata().location; + let mut timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + + timing.end = Some(Instant::now()); + profiler::add_task_timing(timing); + } + loop { + // Keep the RefCell borrow in a separate statement so + // it is released before frame rendering reads the + // client GPU state. + let window = state.get_client().borrow_mut().pending_frames.pop_front(); + let Some(window) = window else { + break; + }; + // Drawing may block in the Vulkan Wayland present path + // until a swapchain buffer is released. Keep that wait + // outside protocol dispatch so a newly arriving frame + // callback cannot recursively render and prevent this + // post-dispatch phase from servicing foreground work. + window.frame(); + } + }, ) .log_err(); } @@ -1146,22 +1197,22 @@ delegate_noop!(WaylandClientStatePtr: ignore wp_viewport::WpViewport); impl Dispatch for WaylandClientStatePtr { fn event( - state: &mut WaylandClientStatePtr, + this: &mut WaylandClientStatePtr, _: &wl_callback::WlCallback, event: wl_callback::Event, surface_id: &ObjectId, _: &Connection, _: &QueueHandle, ) { - let client = state.get_client(); - let mut state = client.borrow_mut(); - let Some(window) = get_window(&mut state, surface_id) else { + let client = this.get_client(); + let mut client_state = client.borrow_mut(); + let Some(window) = get_window(&mut client_state, surface_id) else { return; }; - drop(state); + drop(client_state); if let wl_callback::Event::Done { .. } = event { - window.frame(); + this.queue_frame(window); } } } @@ -1549,7 +1600,7 @@ impl Dispatch for WaylandClientStatePtr { }; for (event, window) in dispatched { dispatch_raw_touch(event); - window.frame(); + this.queue_frame(window); } } } @@ -1684,7 +1735,7 @@ impl Dispatch for WaylandClientStatePtr } if let Some((event, window)) = dispatch { dispatch_raw_touch(event); - window.frame(); + this.queue_frame(window); } } }