From 23ff7aea3f7c3881095bb056488a02e794f44f26 Mon Sep 17 00:00:00 2001 From: Diego Carlino <37153329+devteapot@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:46:03 +0200 Subject: [PATCH] Harden Linux baseline acceptance and agent lifecycle - Preserve live agent updates across asynchronous model refreshes - Add focused accessibility waits and safe XWayland display allocation - Add deterministic evidence runners, manifest verification, and VM gate coverage --- apps/experience/src/linux.rs | 168 +- apps/experience/src/linux_accessibility.rs | 157 +- crates/sos-compositor/src/lib.rs | 16 +- crates/sos-compositor/src/xwayland.rs | 30 +- docs/linux-compositor.md | 11 +- docs/linux-stable-host.md | 18 + docs/linux-vm.md | 117 +- docs/progress.md | 2074 +++++++++++++++++ docs/sos-agent.md | 11 + packaging/libexec/sos-agent-login | 152 +- packaging/libexec/sos-login-session | 240 +- services/sos-agent/src/main.ts | 21 +- services/sos-agent/src/runtime.ts | 13 + services/sos-agent/src/stdio-runner.ts | 4 - services/sos-agent/test/runner.test.ts | 216 ++ tests/fixtures/linux-agent-history.json | 15 + tests/fixtures/linux-x11-socket-owner | 19 + tools/evidence-manifest-verify | 291 +++ tools/evidence-run | 112 + tools/install-linux-login-session | 3 +- .../linux-compositor/nested-x11-artifacts-lib | 250 ++ .../test-nested-x11-artifacts | 138 ++ tools/linux-compositor/verify-nested | 114 +- tools/linux-vm/boot-session-evidence-lib | 19 + tools/linux-vm/boot-session-lifecycle-lib | 52 + tools/linux-vm/inventory-sos-runtime | 41 + tools/linux-vm/provision-debian | 14 + tools/linux-vm/test-boot-session-evidence | 47 + tools/linux-vm/test-boot-session-lifecycle | 65 + tools/linux-vm/test-runtime-inventory | 37 + tools/linux-vm/verify-boot-session | 461 +++- tools/test-evidence-manifest-verify | 86 + tools/test-evidence-run | 47 + 33 files changed, 4949 insertions(+), 110 deletions(-) create mode 100644 tests/fixtures/linux-agent-history.json create mode 100755 tests/fixtures/linux-x11-socket-owner create mode 100755 tools/evidence-manifest-verify create mode 100755 tools/evidence-run create mode 100644 tools/linux-compositor/nested-x11-artifacts-lib create mode 100755 tools/linux-compositor/test-nested-x11-artifacts create mode 100644 tools/linux-vm/boot-session-evidence-lib create mode 100644 tools/linux-vm/boot-session-lifecycle-lib create mode 100755 tools/linux-vm/inventory-sos-runtime create mode 100755 tools/linux-vm/test-boot-session-evidence create mode 100755 tools/linux-vm/test-boot-session-lifecycle create mode 100755 tools/linux-vm/test-runtime-inventory create mode 100755 tools/test-evidence-manifest-verify create mode 100755 tools/test-evidence-run diff --git a/apps/experience/src/linux.rs b/apps/experience/src/linux.rs index a87da10..256f164 100644 --- a/apps/experience/src/linux.rs +++ b/apps/experience/src/linux.rs @@ -338,8 +338,8 @@ pub(super) struct LinuxExperienceHost { surface_gestures: HashMap, surface_taps: HashMap, status: Option<(String, bool)>, - provider_refresh: Option<(u64, ExperienceModel)>, - queued_provider_model: Option, + provider_refresh: Option, + model_refresh_queued: bool, next_provider_request_id: u64, provider_access: Option, agent_socket: Option, @@ -413,7 +413,7 @@ impl LinuxExperienceHost { surface_taps: HashMap::new(), status: Some(("Booting committed SOS revision…".into(), true)), provider_refresh: None, - queued_provider_model: None, + model_refresh_queued: false, next_provider_request_id: 1, provider_access, agent_socket, @@ -494,6 +494,10 @@ impl LinuxExperienceHost { } fn request_model_refresh(&mut self, model: ExperienceModel, cx: &mut Context) { + // `self.model` is the canonical live model. Worker renders are snapshots + // of it and must never restore an older conversation when their result + // arrives after a newer streamed agent update. + self.model = model; if self.provider_refresh.is_some() || self.action_in_flight || self.preparing.is_some() @@ -501,16 +505,16 @@ impl LinuxExperienceHost { || self.pending_commit.is_some() || self.pending_presentation.is_some() { - self.queued_provider_model = Some(model); + self.model_refresh_queued = true; return; } let request_id = self.next_provider_request_id; self.next_provider_request_id = self.next_provider_request_id.wrapping_add(1).max(1); match self .worker - .refresh_model(request_id, model.clone(), self.state.clone()) + .refresh_model(request_id, self.model.clone(), self.state.clone()) { - Ok(()) => self.provider_refresh = Some((request_id, model)), + Ok(()) => self.provider_refresh = Some(request_id), Err(error) => { self.status = Some((format!("Provider refresh could not start: {error}"), false)); cx.notify(); @@ -519,8 +523,9 @@ impl LinuxExperienceHost { } fn dispatch_queued_provider_model(&mut self, cx: &mut Context) { - if let Some(model) = self.queued_provider_model.take() { - self.request_model_refresh(model, cx); + if self.model_refresh_queued { + self.model_refresh_queued = false; + self.request_model_refresh(self.model.clone(), cx); } } @@ -1014,7 +1019,10 @@ impl LinuxExperienceHost { self.state = state; self.state_schema_version = state_schema_version; self.active_revision_id = revision_id; - self.model = commit.revision.model; + let (committed_model, agent_changed) = + merge_committed_model(commit.revision.model, &self.model); + self.model = committed_model; + self.model_refresh_queued |= agent_changed; if let Some(access) = &self.provider_access { access.activate(&self.active_revision_id); } @@ -1043,20 +1051,41 @@ impl LinuxExperienceHost { scene, worker_us, } => { - let Some((expected, model)) = self.provider_refresh.take() else { + let Some(expected) = self.provider_refresh.take() else { return; }; if expected != request_id { - self.provider_refresh = Some((expected, model)); + self.provider_refresh = Some(expected); return; } - self.model = model; - self.scene = scene; - self.status = None; - eprintln!( - "sos_provider_model_refreshed request_id={request_id} worker_us={worker_us} revision_id={}", - self.active_revision_id - ); + if !install_refreshed_scene(&mut self.scene, scene, self.model_refresh_queued) { + eprintln!( + "sos_provider_model_refresh_superseded request_id={request_id} worker_us={worker_us} revision_id={}", + self.active_revision_id + ); + } else { + self.status = None; + eprintln!( + "sos_provider_model_refreshed request_id={request_id} worker_us={worker_us} revision_id={}", + self.active_revision_id + ); + if !self.model.agent.busy { + if let Some(message) = self + .model + .agent + .messages + .iter() + .rev() + .find(|message| message.role == AgentMessageRole::Assistant) + { + eprintln!( + "sos_agent_completion_visible messages={} assistant_bytes={}", + self.model.agent.messages.len(), + message.text.len() + ); + } + } + } self.dispatch_queued_provider_model(cx); cx.notify(); } @@ -1065,17 +1094,23 @@ impl LinuxExperienceHost { error, worker_us, } => { - if self - .provider_refresh - .as_ref() - .is_some_and(|(expected, _)| *expected == request_id) - { - self.provider_refresh = None; + let Some(expected) = self.provider_refresh.take() else { + return; + }; + if expected != request_id { + self.provider_refresh = Some(expected); + return; + } + if self.model_refresh_queued { + eprintln!( + "sos_provider_model_rejection_superseded request_id={request_id} worker_us={worker_us} error={error}" + ); + } else { + self.status = Some((format!("Provider update rejected: {error}"), false)); + eprintln!( + "sos_provider_model_rejected request_id={request_id} worker_us={worker_us} error={error}" + ); } - self.status = Some((format!("Provider update rejected: {error}"), false)); - eprintln!( - "sos_provider_model_rejected request_id={request_id} worker_us={worker_us} error={error}" - ); self.dispatch_queued_provider_model(cx); cx.notify(); } @@ -2448,6 +2483,27 @@ fn push_agent_message(model: &mut ExperienceModel, role: AgentMessageRole, text: } } +fn merge_committed_model( + mut committed: ExperienceModel, + live: &ExperienceModel, +) -> (ExperienceModel, bool) { + let agent_changed = committed.agent != live.agent; + committed.agent = live.agent.clone(); + (committed, agent_changed) +} + +fn install_refreshed_scene( + current: &mut Scene, + refreshed: Scene, + newer_model_queued: bool, +) -> bool { + if newer_model_queued { + return false; + } + *current = refreshed; + true +} + fn append_assistant_delta(model: &mut ExperienceModel, delta: &str) { if delta.is_empty() { return; @@ -2691,6 +2747,62 @@ mod tests { assert_eq!(event.phase.as_deref(), Some("move")); } + #[test] + fn live_rewrite_preserves_the_newest_agent_conversation() { + let mut captured = providers_fake::snapshot(); + captured.agent.available = true; + captured.agent.busy = true; + captured.agent.activity = "Using experience installer…".into(); + push_agent_message( + &mut captured, + AgentMessageRole::User, + "Turn this into a spatial time flow".into(), + ); + + let mut live = captured.clone(); + append_assistant_delta(&mut live, "The candidate experience is active."); + live.agent.busy = false; + live.agent.activity = "Ready".into(); + + let mut committed = providers_fake::snapshot(); + committed.greeting = "Candidate revision".into(); + committed.agent = captured.agent.clone(); + let (merged, needs_refresh) = merge_committed_model(committed, &live); + + assert!(needs_refresh); + assert_eq!(merged.greeting, "Candidate revision"); + assert_eq!(merged.agent, live.agent); + assert_eq!(merged.agent.messages.len(), 2); + assert_eq!( + merged.agent.messages[0].text, + "Turn this into a spatial time flow" + ); + assert_eq!( + merged.agent.messages[1].text, + "The candidate experience is active." + ); + assert_eq!(merged.agent.activity, "Ready"); + } + + #[test] + fn stale_model_refresh_does_not_publish_an_older_semantic_scene() { + let mut visible = Scene { + root: SceneNode { + id: Some("current-agent-history".into()), + ..Default::default() + }, + }; + let stale = Scene { + root: SceneNode { + id: Some("stale-agent-history".into()), + ..Default::default() + }, + }; + + assert!(!install_refreshed_scene(&mut visible, stale, true)); + assert_eq!(visible.root.id.as_deref(), Some("current-agent-history")); + } + #[test] fn revision_loader_carries_verified_v3_sidecars_into_the_worker_boundary() { let temporary = tempfile::tempdir().unwrap(); diff --git a/apps/experience/src/linux_accessibility.rs b/apps/experience/src/linux_accessibility.rs index 756e7d9..22abf31 100644 --- a/apps/experience/src/linux_accessibility.rs +++ b/apps/experience/src/linux_accessibility.rs @@ -5,7 +5,7 @@ use std::{ path::{Path, PathBuf}, sync::{Arc, Condvar, Mutex}, thread, - time::Duration, + time::{Duration, Instant}, }; use experience_ir::{Content, Scene, SceneNode, SemanticRole}; @@ -138,19 +138,8 @@ fn handle( Ok(Request::Wait { after_generation, timeout_ms, - }) => { - let current = shared.0.lock().expect("accessibility snapshot lock"); - let current = if current.generation <= after_generation { - shared - .1 - .wait_timeout(current, Duration::from_millis(timeout_ms.min(30_000))) - .expect("accessibility wait") - .0 - } else { - current - }; - json!({"ok": true, "snapshot": current.clone()}) - } + focused, + }) => wait_response(shared, after_generation, timeout_ms, focused.as_deref()), Ok(Request::Action { action }) if valid_action(&action) => { match actions.try_send(action) { Ok(()) => json!({"ok": true}), @@ -175,6 +164,8 @@ enum Request { Wait { after_generation: u64, timeout_ms: u64, + #[serde(default)] + focused: Option, }, Action { #[serde(flatten)] @@ -182,6 +173,71 @@ enum Request { }, } +#[derive(Debug)] +struct WaitOutcome { + snapshot: Snapshot, + elapsed_ms: u64, + timed_out: bool, +} + +fn snapshot_satisfies_wait( + snapshot: &Snapshot, + after_generation: u64, + focused: Option<&str>, +) -> bool { + snapshot.generation > after_generation + && focused.is_none_or(|expected| snapshot.focused.as_deref() == Some(expected)) +} + +fn wait_for_snapshot( + shared: &Arc<(Mutex, Condvar)>, + after_generation: u64, + timeout_ms: u64, + focused: Option<&str>, +) -> WaitOutcome { + let started = Instant::now(); + let timeout = Duration::from_millis(timeout_ms.min(30_000)); + let current = shared.0.lock().expect("accessibility snapshot lock"); + let current = shared + .1 + .wait_timeout_while(current, timeout, |snapshot| { + !snapshot_satisfies_wait(snapshot, after_generation, focused) + }) + .expect("accessibility wait") + .0; + let timed_out = !snapshot_satisfies_wait(¤t, after_generation, focused); + WaitOutcome { + snapshot: current.clone(), + elapsed_ms: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX), + timed_out, + } +} + +fn wait_response( + shared: &Arc<(Mutex, Condvar)>, + after_generation: u64, + timeout_ms: u64, + focused: Option<&str>, +) -> serde_json::Value { + let outcome = wait_for_snapshot(shared, after_generation, timeout_ms, focused); + let wait = json!({ + "after_generation": after_generation, + "expected_focus": focused, + "elapsed_ms": outcome.elapsed_ms, + "timed_out": outcome.timed_out, + }); + if outcome.timed_out { + json!({ + "ok": false, + "error": "accessibility wait timed out", + "snapshot": outcome.snapshot, + "wait": wait, + }) + } else { + json!({"ok": true, "snapshot": outcome.snapshot, "wait": wait}) + } +} + fn valid_action(action: &Action) -> bool { !action.target.is_empty() && matches!( @@ -256,6 +312,17 @@ mod tests { use super::*; use experience_ir::{Interaction, Semantics}; + fn publish_test_snapshot( + shared: &Arc<(Mutex, Condvar)>, + generation: u64, + focused: Option<&str>, + ) { + let mut snapshot = shared.0.lock().unwrap(); + snapshot.generation = generation; + snapshot.focused = focused.map(str::to_owned); + shared.1.notify_all(); + } + #[test] fn semantic_tree_preserves_hierarchy_and_actions() { let scene = Scene { @@ -287,4 +354,66 @@ mod tests { assert!(nodes[0].scrollable); assert!(nodes[1].activate); } + + #[test] + fn focus_wait_skips_a_stale_generation_before_the_focused_generation() { + let shared = Arc::new(( + Mutex::new(Snapshot { + generation: 41, + focused: Some("daily-flow-root".into()), + ..Default::default() + }), + Condvar::new(), + )); + let waiting = shared.clone(); + let waiter = + thread::spawn(move || wait_for_snapshot(&waiting, 41, 1_000, Some("note-draft"))); + + publish_test_snapshot(&shared, 42, Some("music-toggle")); + publish_test_snapshot(&shared, 43, Some("note-draft")); + + let outcome = waiter.join().unwrap(); + assert!(!outcome.timed_out); + assert_eq!(outcome.snapshot.generation, 43); + assert_eq!(outcome.snapshot.focused.as_deref(), Some("note-draft")); + } + + #[test] + fn focus_wait_times_out_on_a_new_generation_with_the_wrong_focus() { + let shared = Arc::new(( + Mutex::new(Snapshot { + generation: 42, + focused: Some("music-toggle".into()), + ..Default::default() + }), + Condvar::new(), + )); + + let response = wait_response(&shared, 41, 0, Some("note-draft")); + assert_eq!(response["ok"], false); + assert_eq!(response["error"], "accessibility wait timed out"); + assert_eq!(response["snapshot"]["generation"], 42); + assert_eq!(response["snapshot"]["focused"], "music-toggle"); + assert_eq!(response["wait"]["after_generation"], 41); + assert_eq!(response["wait"]["expected_focus"], "note-draft"); + assert_eq!(response["wait"]["timed_out"], true); + assert!(response["wait"]["elapsed_ms"].is_u64()); + } + + #[test] + fn focus_wait_requires_a_new_generation_even_when_focus_already_matches() { + let shared = Arc::new(( + Mutex::new(Snapshot { + generation: 41, + focused: Some("note-draft".into()), + ..Default::default() + }), + Condvar::new(), + )); + + let response = wait_response(&shared, 41, 0, Some("note-draft")); + assert_eq!(response["ok"], false); + assert_eq!(response["snapshot"]["generation"], 41); + assert_eq!(response["wait"]["timed_out"], true); + } } diff --git a/crates/sos-compositor/src/lib.rs b/crates/sos-compositor/src/lib.rs index 3805e87..85a0e8e 100644 --- a/crates/sos-compositor/src/lib.rs +++ b/crates/sos-compositor/src/lib.rs @@ -44,6 +44,17 @@ pub fn run() -> Result<()> { let xwayland_display_file = options .optional("--xwayland-display-file") .map(PathBuf::from); + let xwayland_display = options + .optional("--xwayland-display") + .map(|value| { + value + .parse::() + .with_context(|| format!("invalid XWayland display number: {value}")) + }) + .transpose()?; + if xwayland_display.is_some() && xwayland_display_file.is_none() { + bail!("--xwayland-display requires --xwayland-display-file"); + } let shell_token = match ( options.optional("--shell-token"), options.optional("--shell-token-file"), @@ -67,6 +78,7 @@ pub fn run() -> Result<()> { "--ready-file", "--backend", "--xwayland-display-file", + "--xwayland-display", ])?; let mut event_loop: EventLoop<'static, CompositorData> = EventLoop::try_new()?; @@ -104,7 +116,7 @@ pub fn run() -> Result<()> { other => bail!("unsupported compositor backend: {other}"), }; if let Some(display_file) = xwayland_display_file { - xwayland::start(&mut event_loop, &mut data, display_file)?; + xwayland::start(&mut event_loop, &mut data, display_file, xwayland_display)?; } println!( @@ -199,5 +211,5 @@ impl Options { } fn usage() -> &'static str { - "usage: sos-compositor --socket NAME --control-socket PATH (--shell-token TOKEN | --shell-token-file PATH) [--ready-file PATH] [--backend nested|drm] [--xwayland-display-file PATH]" + "usage: sos-compositor --socket NAME --control-socket PATH (--shell-token TOKEN | --shell-token-file PATH) [--ready-file PATH] [--backend nested|drm] [--xwayland-display-file PATH] [--xwayland-display NUMBER]" } diff --git a/crates/sos-compositor/src/xwayland.rs b/crates/sos-compositor/src/xwayland.rs index 51ff7fc..9c88b6e 100644 --- a/crates/sos-compositor/src/xwayland.rs +++ b/crates/sos-compositor/src/xwayland.rs @@ -1,7 +1,10 @@ //! Optional rootless XWayland compatibility envelope. use std::{ - fs::OpenOptions, io::Write as _, os::unix::fs::OpenOptionsExt as _, path::PathBuf, + fs::OpenOptions, + io::{ErrorKind, Write as _}, + os::unix::fs::OpenOptionsExt as _, + path::PathBuf, process::Stdio, }; @@ -29,6 +32,7 @@ pub(crate) fn start( event_loop: &mut EventLoop<'static, CompositorData>, data: &mut CompositorData, display_file: PathBuf, + display_number: Option, ) -> Result<()> { let parent = display_file .parent() @@ -41,9 +45,31 @@ pub(crate) fn start( display_file.display() ); } + if let Some(display_number) = display_number { + for path in [ + PathBuf::from(format!("/tmp/.X11-unix/X{display_number}")), + PathBuf::from(format!("/tmp/.X{display_number}-lock")), + ] { + match path.symlink_metadata() { + Ok(_) => anyhow::bail!( + "refusing preexisting X11 display artifact for :{display_number}: {}", + path.display() + ), + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "inspect X11 display artifact for :{display_number}: {}", + path.display() + ) + }); + } + } + } + } let (xwayland, client) = XWayland::spawn( &data.display_handle, - None, + display_number, std::iter::empty::<(String, String)>(), true, Stdio::null(), diff --git a/docs/linux-compositor.md b/docs/linux-compositor.md index 96c628a..0df49d6 100644 --- a/docs/linux-compositor.md +++ b/docs/linux-compositor.md @@ -137,12 +137,21 @@ recovery, and maps `weston-simple-shm` as the compatibility client. It requires: quiesced; - a new authenticated PID after forced host failure; - shell/native-compatibility role classification and fixed placement; -- an opt-in real `Xwayland` process and bounded rootless `xmessage` window. +- an opt-in real `Xwayland` process whose `Xwayland -version` probe succeeds, + whose exact PID is a child of the compositor, and whose titled rootless + `xmessage` surface is mapped at the bounded policy position; and +- exact, preflight-empty Xvfb and XWayland display allocations that never + overwrite another session's socket or lock; and +- deterministic teardown proving that every gate-owned PID, both exact + campaign socket/lock pairs, and the disposable run directory are absent. Expected leading output: ```text linux_nested_compositor_passed activation_pid=... restarted_pid=... revision_id=... evidence=nested_backend_submit +xwayland_pid=... parent_pid=... client_pid=... display=:... version=Xwayland ... +xwayland_surface=... title="SOS XWayland compatibility gate" ... +linux_nested_cleanup_passed owned_pids_absent=true run_dir_absent=true x_socket_absent=true x_lock_absent=true xvfb_display=:... xwayland_display=:... ``` The gate passes both on the ARM64 Ubuntu 24.04 development host and inside the diff --git a/docs/linux-stable-host.md b/docs/linux-stable-host.md index c2644e1..07bf82f 100644 --- a/docs/linux-stable-host.md +++ b/docs/linux-stable-host.md @@ -168,6 +168,24 @@ SOS_AGENT_MODEL=gpt-5.6-sol \ /usr/local/libexec/sos/sos-agent-login ``` +The launcher emits a bounded evidence contract suitable for the first +interactive GDM gate. `sos_login_session_ready` records the exact initial +revision, permanent host PID, login-session owner PID, UID/user, Shell surface, +and DRM-page-flip evidence after authority and host readiness. Separate +`sos_login_process_identity` records give PID/PPID/UID/user/executable identity +for the launcher, session, compositor, provider/platform authority, supervisor, +host proxy, actual experience host, authoring broker, and agent. A background +observer emits `sos_login_rewrite_passed` only after it sees a different valid +revision, unchanged host PID, equal authority revision, and a changed persisted +history SHA-256; the record includes both revision IDs and both history +identities without printing conversation or credential contents. The selectable +logout chord emits `sos_login_logout_observed`, and the EXIT path emits +`sos_login_cleanup_passed` only after all recorded top-level/component PIDs, +every same-UID matching product executable/agent command, and the private +runtime directory are absent. Capture the child journal itself for +the exact KMS connector/page-flip and mapped-Shell records; these markers do not +turn a static install into an interactive PASS. + The installer completes authentication before offering a successful handoff. If credentials are later removed, the SOS login refuses to start and its journal names the login helper rather than presenting a composer backed by no agent. diff --git a/docs/linux-vm.md b/docs/linux-vm.md index 144dc82..a05ad80 100644 --- a/docs/linux-vm.md +++ b/docs/linux-vm.md @@ -82,12 +82,72 @@ rsync -a \ ssh -p 2222 sos@127.0.0.1 '~/sos/tools/linux-vm/provision-debian' ``` +Acceptance runners can capture each host command without a handwritten command +description: + +```sh +./tools/evidence-run --root "$evidence_root" --name phase-e-verify-boot-session -- \ + env SOS_LINUX_VM_ROOT="$vm_root" SOS_LINUX_VM_GUEST_ROOT=/home/sos/sos \ + ./tools/linux-vm/verify-boot-session +``` + +The evidence runner refuses to overwrite a record, rejects likely secret-bearing +arguments, preserves the command's exit status, and atomically renames each +member of one matched `.raw`/`.meta` pair. Metadata contains the literal +shell-escaped argv, each +individual argument, working directory, UTC and monotonic boundaries, elapsed +nanoseconds, and status. Put multiline remote bodies in a tracked script or a +separately identified input file; do not replace literal argv with descriptions +such as `ssh ...`, and never put credentials in argv. Finalize all such pairs +before generating the sorted, self-excluding manifest. + +Generate the manifest with the existing campaign generator, then audit it with +the standalone read-only verifier. The verifier takes both paths explicitly, +validates the three-column TSV schema, C-byte order and uniqueness, safe +relative paths, the exact self-excluding finalized file set, sizes and SHA-256 +values, and byte-identical deterministic regeneration. It is an independent +Python implementation and is invoked as an executable, so external acceptance +does not depend on a multiline `python -c` argument: + +```sh +./tools/a33xctl evidence-manifest-generate \ + --root "$evidence_root" --output "$evidence_root/manifest.tsv" +./tools/evidence-manifest-verify \ + --root "$evidence_root" --manifest "$evidence_root/manifest.tsv" +``` + +### Phase F privilege and private-runtime matrix + +Phase F keeps evidence privilege explicit. The campaign must first pass +`sudo -n true`; no command may prompt. Metadata inventory records paths, +types, sizes, modes, numeric owners and, where needed, device/inode identity, +but never credential or private-runtime file contents. + +| Context | Operations | +| --- | --- | +| Login user, no sudo | Credential/config `find` and `stat`; both login helpers; installer top level; the GDM SOS session; same-UID process identity; `tools/linux-vm/inventory-sos-runtime --root /run/user/$(id -u)`. | +| Login user, readable system interfaces | Installed-payload `stat`/`sha256sum` and absence checks; `systemctl is-active`, `get-default`, and `show`; `loginctl show-seat`/`show-session`; `ps`, same-UID `pgrep`, `id`, and `getent`. | +| Explicit `sudo -n`, read-only | Bounded AccountsService metadata/identity; `/etc/gdm3/daemon.conf` metadata, hash, and exact `DefaultSession`; bounded system journal records; cross-UID `/proc` executable identity; unreadable GDM-greeter runtime metadata. | +| Explicit `sudo -n`, mutation | Root-owned installation cleanup or restoration and `systemctl set-default`, `enable`, `disable`, `start`, or `stop`. Logout remains selectable-session `Ctrl+Alt+Backspace`, never privileged session termination. | + +The runtime helper scans only top-level names matching the product-created +`sos-session.XXXXXX` form and descends only those exact matches. It neither +walks nor reports unrelated `/run/user/` trees such as +`systemd/inaccessible`, and it rejects root execution so the ownership/access +boundary remains under test. A Phase F capture is therefore: + +```sh +ssh -p 2222 sos@127.0.0.1 \ + 'cd /home/sos/sos && tools/linux-vm/inventory-sos-runtime --root /run/user/$(id -u)' +``` + `provision-debian` refuses non-Debian-13 guests, installs the pinned GPUI/Zed -Linux development libraries plus Weston/Xvfb/Mesa and the direct -GBM/libinput/libseat/udev/seatd stack, installs Rust 1.95.0 with rustfmt and -Clippy through Debian's `rustup` package, fetches the locked dependency graph, -and links the four session binaries plus both compositor backends. Log out and -back in if it adds render/input group membership. +Linux development libraries plus GStreamer test sources/PNG encoding, +Weston/Xvfb/XWayland/X11 utilities/Mesa, and the direct +GBM/libinput/libseat/udev/seatd stack. It also installs Rust 1.95.0 with +rustfmt and Clippy through Debian's `rustup` package, fetches the locked +dependency graph, and links the four session binaries plus both compositor +backends. Log out and back in if it adds render/input group membership. ## Automated acceptance gate @@ -159,6 +219,29 @@ reboots the VM. Set `SOS_LINUX_VM_GUEST_ROOT` when the guest worktree uses a different absolute path. SSH remains only the test controller; it does not launch or own the compositor session. +Before installation, the verifier prints the absolute guest source root and +SHA-256 identities for `tools/linux-vm/provision-debian` and the actual agent +manifest at `services/sos-agent/package.json`. It runs the locked agent package +sequence `npm ci --ignore-scripts`, `npm run check`, `npm test`, and a final +`npm run build`, then reports the built +`services/sos-agent/dist/agent-runner.cjs` path, byte size, and SHA-256. The +provisioner runs and reports the same sequence, so an acceptance capture need +not infer test or bundle completion from a later consumer. + +The resident-agent subgate waits on accessibility snapshot generations for at +most the authoring broker's 30-second operation timeout. It passes only when +one semantic snapshot contains the exact user request, the exact packaged faux +provider completion, `Ready`, and an empty editable composer after the new +revision is presented. It also requires the same completion in the persisted +agent history. A successful run prints the complete initial/final semantic JSON, +complete before/after daemon-status JSON, an exact request/completion object +derived from the persisted JSON plus its path/size/SHA-256, and safe PID/PPID/ +UID/user/executable identities for the session owner, compositor, platform +authority, supervisor, host proxy, experience host, authoring broker, and +resident agent. It never prints credential values. Failure prints the same +semantic/status/history diagnostics plus the bounded journals. The timeout is a +bound on an explicit completion predicate, not a sleep used as evidence. + The verifier requires a clean reference guest with no existing `/var/lib/sos`, `/etc/sos`, `/usr/local/libexec/sos`, or SOS unit files. It proves active logind seat0/tty1 ownership, a recovery-view page flip before provider startup, @@ -172,6 +255,30 @@ installation it created. Its leading result is: linux_boot_session_passed ... evidence=drm_page_flip ``` +The suspend/output lifecycle subgate emits a pass checkpoint for each VT +request and log match, freezer command and kernel entry/exit match, connector +request and disconnect/reconnect match, and same-PID liveness assertion. A +readable `/sys/power/mem_sleep` is captured before the freezer test; its one +selected `s2idle` or `deep` mode must match a new kernel entry followed by a +kernel suspend exit. If that sysfs selector is unavailable, the same supported +mode and ordered entry/exit pair must be unambiguous in the new kernel journal. +A successful lifecycle run also prints the terminal VT, both virtual-connector +states, `pm_test`, available and selected `mem_sleep` mode, unchanged owner PID, +and the exact direct-session pause/activation, KMS initialization/disconnect, and +significant page-flip journal records. The boot contract prints the exact +logind-session fields and process identities before destructive recovery tests. +A normal uninstall passes only after all installed SOS paths, units, service +accounts, the IPC group and login membership, and matching processes are +absent; both normal and failure cleanup print the same +`linux_boot_cleanup_audit status=passed|failed ...` category contract, and the +normal path retains `linux_boot_cleanup_passed` as its terminal marker. A +failure emits `linux_boot_lifecycle_failed phase=...` with the assertion line, +current session PID, VT/connector/`pm_test`/`mem_sleep` state, relevant +compositor journal, and kernel PM journal before the verifier restores the +disposable guest. A nonzero SSH result without that marker is classified as +lifecycle +transport/bootstrap failure rather than as one of the product assertions. + ## Current status The gate passes in a KVM-accelerated ARM64 Debian 13.6 guest on kernel diff --git a/docs/progress.md b/docs/progress.md index 7a19df9..4ddf605 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -8997,3 +8997,2077 @@ SHA-256 run its separate one-sideload Core 1 no-Zygote readiness, exact Pi authority, credential-clear, leak/crash/AVC, manifest, and soak gate. No Core hardware claim is made here. + +## 2026-08-18 — Invalidate Linux x86-64 r2 at the provisioning boundary + +**Goal / result:** Re-run the Debian 13 x86-64 VM acceptance campaign from +exact source revision `e05f91bb6f0b0a9299b914138d6cd0966b9c82d5e`. Phase A +(`tools/linux-vm/provision-debian`) returned status 0 in 5.45034 seconds, but +its raw npm/build output was not captured, so it is not evidence-complete. +Phase B (`tools/linux-agent-e2e`) passed in 15.2916 seconds, changing the +active revision from +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0`. +The generated `services/sos-agent/dist/agent-runner.cjs` was 1,878,811 bytes +with SHA-256 +`3eee6e7922fb82e344277793a435bb8edd36a2c183050b638a3c6ca13d3bc99a`. +Phase C (`tools/linux-compositor/verify-nested`) then failed at its command +preflight in 0.252858 seconds because `gst-launch-1.0` was absent. Phases D, +E, and F were not run. Total measured r2 wall time was 112.077606266 seconds; +the VM accepted `systemctl poweroff`, QEMU exited, and its QMP socket was +absent at final capture. + +**Changed / decision:** The Debian provisioner now explicitly installs +`gstreamer1.0-tools`, `gstreamer1.0-plugins-base`, and +`gstreamer1.0-plugins-good`, which provide the verifier's GStreamer command, +`videotestsrc`, and PNG encoder. A command/package audit also made `python3`, +`xwayland`, and `x11-utils` explicit for the nested and direct verifiers; +`x11-utils` supplies `xmessage` and its XTest runtime dependency. Weston and +`weston-simple-shm`, Xvfb, jq, Cargo/Rust, and seatd were already explicit in +the provisioner, while GDM, rsync, SSH, and the systemd base are explicit in +the reference VM contract: cloud-init explicitly installs the first three, +and the Debian generic image boots under systemd. Relying on the GNOME image +to pull these gate tools transitively was rejected. Installing only +`gstreamer1.0-tools` was also insufficient because the verifier requires the +separate source and PNG plugin elements. + +**Evidence / confidence boundary:** The finalized nine-file evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r2`. Its 778-byte +`manifest.tsv` has SHA-256 +`ec10443093134e0897c4b641e6c43d30b2232e990f480f97b4ad1c71cebd0ed4`. +Independent verification passed; the 115-byte verifier output at +`/tmp/sos-linux-x86_64-acceptance-20260818-r2-manifest-verify.txt` has +SHA-256 +`823a2c8b3df3c89849dfbb22a263fe318b0b996965e81e62439e48f26f485602`. +This r2 result establishes only the Phase B host contract; it does not pass +provisioning, compositor, direct DRM, boot/GDM, physical hardware, or latency +gates. + +**Remaining risk / next gate:** Static host checks can prove the provisioner +syntax and explicit package contract, but the changed package installation +and GStreamer element discovery remain untested until a VM run. Re-run r3 +from an exact recorded source state, capture complete Phase A output, and +require `gst-launch-1.0` plus the `videotestsrc`, `pngenc`, and `filesink` +elements before repeating the nested, direct DRM, and boot/GDM phases. Close +and independently verify all evidence before any PASS; make no physical +hardware or latency claim from the VM campaign. + +## 2026-08-18 — Invalidate Linux x86-64 r3 at the boot lifecycle boundary + +**Goal / result:** Repeat the Debian 13 x86-64 acceptance campaign after +making the missing GStreamer/X11 dependencies explicit. The run used HEAD +`e05f91bb6f0b0a9299b914138d6cd0966b9c82d5e` with dirty changes to +`docs/linux-vm.md`, `docs/progress.md`, and +`tools/linux-vm/provision-debian`. The deterministic initial and final diffs +were byte-identical: 5,473 bytes with SHA-256 +`66fd73aaa41dd3b66e84bd88983d86c594154be9bef27a27854947df7e822256`. +Provisioning passed in 40.82 seconds. Phase A captured +`npm ci --ignore-scripts` passing in 4.33 seconds and `npm run check` passing +in 2.70 seconds, but the runner's external logging wrapper stopped before +capturing `npm test`, the required final `npm run build`, or a bundle +path/byte-size/SHA-256 identity. Phase A is therefore incomplete, not PASS. + +Phase B passed in 7.35 seconds and changed revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0`. +Phase C passed in 33.70 seconds: activation PID 8876 recovered as PID 9323 +on revision +`2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0` +with `nested_backend_submit` evidence. Phase D passed in 14.65 seconds: +activation PID 9666 recovered as PID 9936 on revision +`250b157308407df4ed48c8e45351e69a0d82534ba001b6ff214c6a3348c0a326`, +and its transcript contains three armed-revision DRM page-flip fences. Phase E +failed in 61.66 seconds after its resident-agent/Luau subgate had passed on +revision +`3eef9a00f71362045a3c167454b293d25cc5d565e4eddcecee1de49e3690a9c7` +with experience-host PID 1005, agent PID 951, authoring-broker PID 937, and +positive `drm_page_flip` evidence. Its only terminal diagnostic was +`error: packaged compositor did not survive suspend/output lifecycle +campaign`. Phase F was not run. Total measured wall time was 325.86 seconds. + +**Failure diagnosis / rejected evidence:** The captured Phase E artifact does +not support selecting which lifecycle assertion failed. The remote body wrote +no checkpoint, assertion line, compositor journal excerpt, kernel PM excerpt, +counter value, connector state, or current PID; its outer command substitution +replaced every nonzero remote result with the same generic error. The lack of +a Python traceback or sysfs-shell diagnostic makes a silent readiness, journal +count/match, or PID equality assertion more likely than a VT ioctl, freezer +write, or connector write failure, but it does not distinguish VT pause, +freezer resume, VT activation, kernel entry/exit matching, disconnect, +reconnect, or process liveness. Elapsed time is not used to infer the failed +loop. Cleanup is not the originating failure: the EXIT restoration ran after +the nonzero result, and the later audit found `graphical.target`, active GDM +and seatd, an inactive SOS target, no matching product process, and no +`/var/lib/sos` or `/etc/sos`. That audit did not enumerate every installed +agent/share path, unit, account, or group, so full uninstall cleanliness also +remains insufficiently evidenced. Treating the generic wrapper message as a +compositor crash, or the wrapper's omissions as product behavior, was +rejected. + +The guest-source audit also failed because it hashed `package.json` at the +guest repository root, while the only manifest is +`services/sos-agent/package.json`. That external command is invalid evidence; +it does not show a missing synced manifest. The repository-owned provisioner +and boot verifier now run the complete agent sequence in order—locked install, +check, test, and a final build—require a nonempty bundle, and print its correct +repository-relative path, byte size, and SHA-256. The boot verifier also prints +the absolute guest root plus hashes of the correctly addressed provisioner and +agent manifest. No change was made to the runner's external logging wrapper. + +**Verifier decision:** Keep every existing lifecycle criterion. The boot +verifier now emits phase-specific pass evidence and, on failure, a +`linux_boot_lifecycle_failed` marker naming the exact VT request/log match, +freezer command/kernel match, connector request/log match, campaign-log +presence, or same-PID liveness phase. Its failure record includes the assertion +line, expected/current PID, active VT, connector and `pm_test` state, relevant +compositor journal, and kernel PM journal before EXIT restoration. The generic +outer error now distinguishes a marked remote assertion from an unmarked +transport/bootstrap failure. This is diagnostic hardening, not a claim that +the unknown r3 product/verifier failure is fixed. No VM or device was operated +while making this change. + +Host-side validation passed `bash -n` for the provisioner and boot verifier, +separate `bash -n` parsing of the 56-line build and 160-line lifecycle remote +heredocs, an exact audit of all 18 lifecycle phase markers, and +`git diff --check`. The full local agent sequence completed in 8.3 seconds: +all 12 Node tests passed, and the final bundle was 1,878,811 bytes with +SHA-256 +`3eee6e7922fb82e344277793a435bb8edd36a2c183050b638a3c6ca13d3bc99a`. +ShellCheck was unavailable on this host, so it is not claimed. The new +guest/boot and lifecycle paths remain VM-unexecuted pending r4. + +**Evidence / confidence boundary:** The finalized r3 evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r3`. Its 1,701-byte +`manifest.tsv` has SHA-256 +`050fe0a46e0e2604a9a3fbff974bff0061ffebc2de3e0ddd5136829569a330bb`; +all 19 rows passed independent verification. The 97-byte external verifier +output at +`/tmp/sos-linux-x86_64-acceptance-20260818-r3-manifest-verify.txt` has +SHA-256 +`e68d5216630367277e141d79f62fca73b1ab404bdcbaf598bc3d0b7144920b0c`. +The run establishes provisioning plus the Phase B/C/D functional results and +the Phase E agent/visible-revision subgate only. It makes no physical-hardware, +panel/touch latency, VM freezer or platform suspend/resume, memory, thermal, or +overall Linux acceptance claim. + +**Remaining risk / exact r4 gate:** Start from a clean reference Debian 13 +x86-64 guest and freshly record this repaired dirty source identity before and +after the run. Capture provisioning and the verifier's exact +`npm-ci,check,test,build` package marker with the correct manifest and bundle +paths, bundle byte size, and SHA-256; do not substitute the provisioner's +earlier build for the verifier's packaged build. Repeat Phases B, C, and D. +Run Phase E once and require the resident-agent revision activation, every +phase-specific VT/freezer/kernel-log/output/same-PID checkpoint, the remaining +boot recovery assertions, the final `linux_boot_session_passed` line, reboot +to graphical target, and an exhaustive absence audit covering all installed +SOS paths, units, users, group, and processes. If any lifecycle phase fails, +stop after collecting its new marker and diagnostics; do not infer a root +cause or patch the compositor without those logs. Run the previously specified +Phase F only after Phase E passes. Close every artifact before atomically +creating a self-excluding deterministic manifest, independently verify every +row, and retain the VM-only confidence boundary. + +## 2026-08-18 — Diagnose Linux x86-64 r4 semantic completion loss + +**Goal / result:** Audit the complete r4 evidence and repair the packaged-agent +completion loss without weakening the boot gate. The campaign used source HEAD +`e05f91bb6f0b0a9299b914138d6cd0966b9c82d5e` and an unchanged initial/final +dirty diff of 27,803 bytes, SHA-256 +`80d2bd22cdbef846de78d7eba56334a5b5df280d29e8e3b9bde2cf50bd630d65`. +Provisioning, Phase A, and Phase E used the same tested final agent bundle: +1,878,811 bytes, SHA-256 +`3eee6e7922fb82e344277793a435bb8edd36a2c183050b638a3c6ca13d3bc99a`. +Phase B passed in 7.47 seconds and changed revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0`. +Phase C passed in 16.95 seconds, recovered PID 3923 as PID 4307, and captured +its nested submit fences. Phase D passed in 13.82 seconds, recovered PID 4752 +as PID 5030 on revision +`250b157308407df4ed48c8e45351e69a0d82534ba001b6ff214c6a3348c0a326`, +and captured three `drm_page_flip` fences. + +Phase E failed after 75.05 seconds. Its packaged agent submitted and activated +the request through all three expected tools, the active revision changed to a +different valid SHA-256 revision without changing the host PID, the new scene +had positive `drm_page_flip` presentation evidence, and agent/broker ownership +checks passed. The terminal generation-11 semantic snapshot contained the +exact `YOU message`, `agent-status` `Ready`, and an empty editable composer, +but no completed `SOS message`; the verifier reported `agent completion +missing from semantic snapshot`. No lifecycle checkpoint ran and Phase F was +not run. + +**Diagnosis / fix:** The failure is a host model-ordering race, not an omitted +agent completion, semantic filter, or verifier delay. The tested faux provider +emits the exact completion text before `completed`; the agent server persists +the ordered messages before sending `completed`; the Rust bridge consumes the +single socket in FIFO order; and the Linux host sets `Ready` only for that +`completed` update. The r4 state therefore proves that completion crossed the +agent boundary and was later lost in the host. `LinuxExperienceHost` kept the +model snapshot paired with an asynchronous render request and restored it when +that render completed. A render started before the text delta could therefore +land between the delta and `completed`; `completed` then marked the restored +no-assistant model `Ready`. Candidate commit also restored the model captured +at prepare time, exposing the same loss across the live rewrite boundary. + +The host now treats `self.model` as the canonical live model, regards worker +renders only as scene snapshots, suppresses a stale scene when a newer model is +queued, and renders the latest canonical model next. Candidate commit merges +the current agent conversation into the new revision model, preserving both +the old user turn and new assistant turn while allowing the candidate's other +model fields to win. A completion-visible journal marker records message count +and assistant byte count. Unit tests cover live-rewrite conversation merge and +stale-scene suppression. The boot verifier now waits on semantic generation +events, within the existing 30-second authoring/accessibility contract, for +the exact user message, exact completion, `Ready`, and empty composer; it also +requires persisted completion history and records its identity. Failure now +captures both semantic snapshots, both daemon statuses, persisted-history +identity/presence, and agent, broker, and session journals before restoration. + +The related GDM live-rewrite cleanup path remains bounded: one worker thread +owns at most one prepared Luau runtime, commit takes that candidate and replaces +(and drops) the old runtime, and discard drops the prepared runtime; no process +is spawned per candidate. The activation retains the exact experience-host PID. +At service teardown, the system-session owner terminates and waits for its +supervisor, provider, and compositor children, while systemd's mixed kill mode +bounds any remaining service process tree. No cleanup change was needed for the +semantic repair. + +The repository-owned nested gate now uses the valid `Xwayland -version` probe, +records the exact Xwayland PID and compositor parent, requires the exact titled +mapped surface, and emits a cleanup marker only after every owned PID and the +temporary directory are absent. The r4 runner's `Xwayland --version` command +was invalid. Its aggregate raw files also lacked per-command status/timing, an +initial source raw ledger, exact Xwayland PID/surface capture, and a positive +deterministic nested-cleanup record. Those wrapper/ledger behaviors are not +repository-owned and were not patched here. + +**Validation / rejected approaches:** Host-only validation passed +`cargo check -p sos-experience --features linux-host --tests`, both new focused +Rust tests, `cargo clippy -p sos-experience --features linux-host --tests --no-deps -- -D warnings`, +`cargo fmt --all -- --check`, `bash -n` for the provisioner, boot verifier, and +nested verifier, separate parsing of the boot verifier's build, +agent, and lifecycle heredocs, and `git diff --check`. The ordinary focused +Rust test command compiled but could not link because this host lacks +`libxkbcommon` and `libxkbcommon-x11`; rerunning with temporary empty linker +stubs exercised both pure unit tests successfully. The full library run with +those stubs passed 27 of 28 tests, including both new tests, but the unrelated +pre-existing `embedded_experience_is_valid` test rejected +`audio.set_volume` as a disallowed provider action. The local agent +`npm run check && npm test && npm run build` sequence passed all 12 tests and +reproduced the 1,878,811-byte bundle with the SHA-256 above. ShellCheck was +unavailable, and this host has no Xwayland executable, so the nested gate was +syntax-checked but not locally executed. No VM or device was operated. +Arbitrary sleeps, accepting `Ready` +without the assistant, changing the packaged agent/protocol, filtering out the +user history, and patching external runner wrappers were rejected because +they either weaken acceptance or do not address the proven stale overwrite. + +**Evidence / confidence boundary:** Cleanup restored `graphical.target` and +active GDM, seatd, and SSH, removed all five SOS service users, left the `sos` +login user outside `sos-ipc`, and removed the product paths; the later cleanup +reported already-absent units and paths. Poweroff completed and the final host +audit found no QEMU PID or QMP socket. The direct supported minimum campaign +interval is 419.43 seconds. The finalized evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r4`. Its 22-row, +1,940-byte `manifest.tsv` has SHA-256 +`fe588687084870304b55ae973b8e096d8772a898feb5677670d7bd7a45ac50a1`. +The independent 472-byte verifier record at +`/tmp/sos-linux-x86_64-acceptance-20260818-r4-manifest-verify.txt` has SHA-256 +`4c5b9ac3e10b2122478b7c1b8c5ad9e827c47dc393d6c3463f743663b49d59ff`. +R4 did not retain the Phase E agent/broker/session journals, exact before/after +revision and PID values, or persisted message file, so those details cannot be +retrospectively claimed. The r4 B/C/D results apply only to its recorded source +tree; the repair is locally tested but VM-unexecuted and makes no physical +hardware, panel/touch latency, freezer, suspend/resume, thermal, memory, or +overall acceptance claim. + +**Remaining risk / exact r5 gate:** Begin with a clean Debian 13 x86-64 VM and +record an initial source ledger before any guest mutation: HEAD, status, dirty +file list, diff byte size, and diff SHA-256; capture the matching final ledger. +For every runner command record raw output, wall and monotonic start/end, and +exit status. Use `Xwayland -version`. Require identical provision/Phase A/E +bundle paths, size, and SHA-256 after the full install/check/test/build sequence. +Repeat B, C, and D, including the exact nested Xwayland PID/parent/display, +titled surface record, and positive cleanup marker. In E, require exact +before/after revisions and host/agent/broker PIDs, all tool and presentation +markers, the event-driven exact semantic completion predicate, persisted +history path/size/SHA-256, no prompt failure, and then every VT, freezer, +kernel-log, output, and same-PID lifecycle checkpoint plus the remaining boot +recovery assertions and terminal boot PASS. Run F only after E passes. Restore +and audit all installed paths, units, users, group membership, and processes; +close every artifact before atomically generating a sorted, self-excluding +manifest and independently verify every row. Retain the VM-only boundary. + +## 2026-08-18 — Diagnose Linux x86-64 r8 selected suspend-mode mismatch + +**Goal / result:** Audit the complete r8 Debian 13 x86-64 campaign, retain its +successful semantic/history repair, and correct only the lifecycle verifier's +selected-mode assumption. The run used source HEAD +`e05f91bb6f0b0a9299b914138d6cd0966b9c82d5e` with dirty paths +`apps/experience/src/linux.rs`, `docs/linux-compositor.md`, +`docs/linux-vm.md`, `docs/progress.md`, +`tools/linux-compositor/verify-nested`, `tools/linux-vm/provision-debian`, and +`tools/linux-vm/verify-boot-session`. The independently captured initial and +final diffs were byte-identical: 61,327 bytes, SHA-256 +`0fdc344aa5f076dec91adabdee1b22502f772616543066e6e7bcd14e1b9f6860`. +The Phase E guest-source marker bound `/home/sos/sos` to provisioner SHA-256 +`30db0f10af039e4fb25bb497e7b098f6a8a7d44ff78651cd54440a740ba0dc3e` +and `services/sos-agent/package.json` SHA-256 +`4691b055beb89a5e42c92aa3fec14ef456d3c39538270c48b2e44144e8d276f5`. + +Provisioning passed in 17.86 seconds. Phase A then passed the separately +captured locked install in 3.99 seconds, TypeScript check in 2.25 seconds, all +12 Node tests in 2.77 seconds, and final build in 2.46 seconds (11.47 seconds +in total). Provisioning and Phase E also ran the complete +`npm-ci,check,test,build` sequence. The tested and packaged +`services/sos-agent/dist/agent-runner.cjs` was 1,878,811 bytes with SHA-256 +`3eee6e7922fb82e344277793a435bb8edd36a2c183050b638a3c6ca13d3bc99a`. + +Phase B passed in 6.84 seconds and changed revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0`. +Phase C passed in 21.13 seconds on that active revision: PID 4063 activated +and recovered as PID 4517; XWayland PID 2921 had compositor parent 2887 and +client PID 4614 on display `:1`; and the initial, activated, and recovered +`nested_backend_submit` fences were respectively commit/submit sequences +`1/611`, `16/625`, and `114/701`. The exact titled compatibility surface and +positive owned-PID/run-directory cleanup marker were present. Phase D passed +in 14.38 seconds: PID 4871 activated revision +`250b157308407df4ed48c8e45351e69a0d82534ba001b6ff214c6a3348c0a326` +and recovered as PID 5148, with initial, activated, and recovered +`drm_page_flip` fences `1/37`, `143/51`, and `4033/76`. + +Phase E returned status 1 after 52.73 seconds, but its resident-agent semantic +subgate passed completely before the lifecycle failure. It booted revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +and activated +`3eef9a00f71362045a3c167454b293d25cc5d565e4eddcecee1de49e3690a9c7` +without changing supervisor-reported host PID 1010. The actual experience +process was PID 1014, the resident agent PID 951, authoring broker PID 941, +session owner PID 883, and compositor PID 957. The initial and activated DRM +presentation fences were `1/26` and `310/54`, with the activated fence armed +after commit sequence 309. All three bounded tools ran, the exact semantic +snapshot contained the user request, exact assistant completion, `Ready`, and +empty editable composer, and the completion-visible marker reported two +messages and 35 assistant bytes. Persisted history +`/var/lib/sos-agent/messages.json` was 58,158 bytes, SHA-256 +`c82e8aed3c3ae90664e68dc9ed99f3db73bc61e2c22a860592a4a1f891ccfe66`, +with the exact completion present. + +**Exact failure / decision:** The lifecycle subgate passed 8 of its 18 +checkpoints: active session, readiness log, valid owner PID, VT pause request +to tty2, pause-log increment `0 -> 1`, freezer/resume command, VT resume +request to tty1, and activation-log increment `0 -> 1`. It then failed +`freezer-entry-log-match` at line 108 because both pre/post counters matched +only the hard-coded `PM: suspend entry (s2idle)`. The Debian +`6.12.101+deb13-amd64` kernel instead recorded the complete actual sequence +`PM: suspend entry (deep)`, `PM: suspend debug: Waiting for 5 second(s).`, and +`PM: suspend exit`. The compositor recorded `direct session paused` at +10:34:11.399728 UTC and `direct session activated` at 10:34:16.615247 UTC; +the failure capture retained tty1, connected `Virtual-1`, disconnected +`Virtual-2`, `pm_test=[none]`, and unchanged current/expected main PID 883. +This is a verifier false negative at one exact kernel-log predicate, not +evidence that the compositor, output, or session owner failed. + +The lifecycle gate now captures the selected `/sys/power/mem_sleep` value when +that selector exists, accepts only selected `s2idle` or `deep`, and requires a +new exact entry for that mode followed by a subsequent suspend exit. If the +selector is unavailable, the new journal interval must unambiguously contain +one of those two modes and its ordered entry/exit pair. The same 18 named +checkpoints remain; the entry and exit records now include the actual mode and +entry/paired-cycle counts, and the remote parser must match the host-side +SHA-256 before it is sourced. A shared pure shell parser and focused test cover +both modes, wrong-mode rejection, missing/unsupported selection, an unmatched +entry, an exit before entry, and replacement by a later different-mode entry. +Failure evidence now also captures `mem_sleep` and the resolved mode. No VT +pause/reactivation, compositor-log, same-PID liveness, connector +disconnect/reconnect, or output-log assertion was removed or relaxed. + +Hard-coding `deep` was rejected because it would reproduce the same bug on an +`s2idle` host. Accepting any suspend entry independently of the selected mode, +or counting an entry and exit without ordering them, was rejected because it +could join unrelated evidence. Forcing `s2idle` before the test was rejected +because the gate must prove the kernel mode the guest actually selected. +Dropping the compositor pause/reactivation or later output/liveness checks was +rejected because r8 did not invalidate those acceptance criteria. + +The repository-owned normal uninstall now asserts and emits one positive +marker only after every installed SOS product/runtime path, all five units, +all five service accounts, `sos-ipc`, the login user's IPC membership, and +matching installed-product processes are absent while graphical target, GDM, +and seatd are restored. The failure cleanup emits category-specific true/false +absence evidence. R8 itself did not have those markers: its restoration file +contains only `graphical.target`, three `active` values, and a monotonic start, +without a terminal status or explicit product path/unit/account/group/process +absence audit. The later host audit proves QEMU, its PID file, QMP socket, and +port 2222 were absent, but cannot retrospectively close that guest-uninstall +evidence gap. + +**Validation / evidence boundary:** Host-only validation passed the focused +shell parser test, direct replay of r8's raw Phase E log as one `deep` entry, +zero `s2idle` entries, and one ordered `deep` cycle, `bash -n` for the helper, +its test, and the complete boot verifier, separate syntax parsing of the +lifecycle and cleanup remote bodies, +an audit retaining all 18 lifecycle phase markers, `git diff --check`, +`cargo check -p sos-experience --features linux-host --tests`, both focused +semantic/history Rust unit tests, and `cargo fmt --all -- --check`. ShellCheck +was unavailable. No VM or device was operated for this repair. R8's +individually finalized `.meta` commands total 128.90 seconds; the directly +captured inclusive campaign interval was 243.48 seconds. Model-weighted cost +was not available. Phase F was not run. + +The finalized evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r8`. Its sorted, +self-excluding `manifest.tsv` (excluding itself and `ready.tmp`) has exactly +38 rows and 3,211 bytes, SHA-256 +`39e9875670abb7ce0b7994287b06d8ef95af77f492ca3ee8ee02c30aaa212836`; +all rows, sizes, hashes, sort order, and exact file set were independently +verified. The external 339-byte verifier record is +`/tmp/sos-linux-x86_64-acceptance-20260818-r8-manifest-verify.txt`, SHA-256 +`45a83d592ca8a0088dc22039e93c274c1a488439f3ff9ed24a9905376c49b262`. +R8 establishes A-D and the Phase E semantic/history subgate for its exact +source. It does not pass all of E or overall acceptance and makes no physical +suspend/resume, physical hardware, panel/touch latency, thermal, memory, or +long-soak claim; the observed freezer transaction was a VM `pm_test=freezer` +operation, not physical suspend. + +**Remaining risk / exact r9 gate:** Start from the clean Debian 13 x86-64 +reference guest and record byte-identical initial/final source ledgers, +including the new lifecycle helper and test. Repeat provisioning and A-D with +the exact command timings, revisions, PIDs, fences, bundle identity, XWayland +surface, and cleanup evidence above. Run E once. Require the semantic revision, +unchanged host PID, exact completion snapshot, persisted-history identity, and +no prompt failure; then require a captured sysfs selected mode or an +unambiguous journal fallback, its exact matching kernel entry followed by an +exit, all 18 lifecycle checkpoints, owner PID 883-equivalent unchanged across +freezer and output operations, both connector log transitions, all remaining +boot recovery assertions, and terminal `linux_boot_session_passed`. Require +the new positive exhaustive uninstall marker after graphical/GDM/seatd +restoration. Run the previously specified Phase F only after E passes. Close +all evidence before atomically generating the sorted self-excluding manifest, +independently verify every row, and retain the VM-only confidence boundary. + +## 2026-08-18 — Reject Linux x86-64 r9 at the evidence boundary and harden A–F capture + +**Goal / result:** Audit the complete r9 Debian 13 x86-64 campaign and preserve +its product result without promoting incomplete evidence to overall acceptance. +The run used source HEAD `e05f91bb6f0b0a9299b914138d6cd0966b9c82d5` with +the same nine dirty paths before and after: `apps/experience/src/linux.rs`, +`docs/linux-compositor.md`, `docs/linux-vm.md`, `docs/progress.md`, +`tools/linux-compositor/verify-nested`, +`tools/linux-vm/boot-session-lifecycle-lib`, +`tools/linux-vm/provision-debian`, +`tools/linux-vm/test-boot-session-lifecycle`, and +`tools/linux-vm/verify-boot-session`. The independently captured initial and +final binary diffs were identical at 78,278 bytes, SHA-256 +`49b0134654f6cca518b843c34dba2bfb86cd02f52fd5f789ca987fc66440a342`. +The two untracked inputs were +`tools/linux-vm/boot-session-lifecycle-lib`, 1,177 bytes, SHA-256 +`1fbdf514e46b401dc1e20d78d1d5db54f2db591d35b67855dd093b71b5153212`, +and `tools/linux-vm/test-boot-session-lifecycle`, 2,425 bytes, SHA-256 +`c757a2b792fe6c3384e3e13781bc79614e7fa510d666776bea7e50cb15ff095c`. +The synchronized guest ledger matched the host ledger. The guest was Debian 13 +x86-64 under KVM on kernel `6.12.101+deb13-amd64`. + +Provisioning's captured command was +`ssh -p 2222 -o BatchMode=yes -o ConnectTimeout=5 -o +StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null sos@127.0.0.1 +'~/sos/tools/linux-vm/provision-debian'`; it returned 0 in +23.743505117 seconds. Phase A returned 0 for `npm ci --ignore-scripts` in +4.132710939 seconds, `npm run check` in 2.205432787 seconds, all 12 tests in +2.717067491 seconds, and final `npm run build` in 2.482883943 seconds. Its +bundle was `/home/sos/sos/services/sos-agent/dist/agent-runner.cjs`, 1,878,811 +bytes, SHA-256 +`3eee6e7922fb82e344277793a435bb8edd36a2c183050b638a3c6ca13d3bc99a`; +provisioning and Phase E produced the same size and hash. + +Phase B's product command `cd /home/sos/sos && ./tools/linux-agent-e2e` +returned 0 in 6.824779893 seconds and changed revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0` +through the exact context/validate/submit sequence. Phase C's product command +`cd /home/sos/sos && ./tools/linux-compositor/verify-nested` returned 0 in +14.599715379 seconds. PID 8132 activated the same `2303ba94…` revision and +recovered as PID 8510; its initial, activated, and recovered +`nested_backend_submit` fences were commit/submit sequences `1/280`, `14/293`, +and `108/362`. XWayland PID 7441 had compositor parent 7410 and client PID 8609 +on display `:1`; the exact `SOS XWayland compatibility gate` surface mapped, +the IME/native-input checks passed, and both internal and external owned-process/ +temporary-root cleanup markers passed. + +Phase D's product command +`cd /home/sos/sos && ./tools/linux-vm/verify-direct-session` returned 0 in +14.769011403 seconds. PID 9509 activated revision +`250b157308407df4ed48c8e45351e69a0d82534ba001b6ff214c6a3348c0a326` +and recovered as PID 9806. The initial, activated, and recovered +`drm_page_flip` fences were `1/34`, `148/48`, and `3808/67`, with two captured +recovery-view page flips. The input/quiesce/output assertions passed, and +cleanup restored `graphical.target`, GDM, seatd, and SSH with no owned process +or temporary root left. + +**Phase E functional result:** The exact captured host command +`SOS_LINUX_VM_ROOT=/home/carlid/dev/sos/.cache/linux-vm +SOS_LINUX_VM_GUEST_ROOT=/home/sos/sos +./tools/linux-vm/verify-boot-session` returned 0 in 63.090603739 seconds. It +booted revision `31f8e1d3…`, then the resident faux Pi flow activated revision +`3eef9a00f71362045a3c167454b293d25cc5d565e4eddcecee1de49e3690a9c7` +without changing supervisor-reported host PID 1108; agent PID 957 and authoring +broker PID 942 completed all three tools. Persisted history was 58,158 bytes, +SHA-256 `90790d824fa411d49a811b811bb1d99d4417334c6b3c94224e3f7a16c4c75ab9`, +and its exact completion predicate passed. + +The lifecycle owner remained PID 878 across all 18 named checkpoints. Sysfs +reported `s2idle [deep]`, selecting `deep`; the new kernel interval contained +one matching entry and one ordered paired exit. VT pause/resume moved tty1 to +tty2 and back with compositor counters `0 -> 1` in each direction. The primary +connector disconnect counter changed `0 -> 1`, reconnect/KMS initialization +changed `1 -> 2`, and the same owner PID survived both. The later boot campaign +activated `2303ba94…` in host PID 1108, recovered the host as PID 1913, +restarted the whole session as owner PID 2023/host PID 2108 after provider +failure, and recovered an uncatchably killed lifecycle owner as PID 2299. +Session 1 remained the active logind seat, `NRestarts=2`, and the terminal +record was `linux_boot_session_passed`. + +The captured E presentation history was initial boot `1/18`, agent revision +`48/46`, direct candidate `173/66`, rollback to the agent revision `195/81`, +rollback forward to the candidate `219/94`, same-revision host recovery +`230/108`, provider-triggered whole-session recovery `1/11`, and lifecycle-owner +recovery `1/12`; every record used `drm_page_flip`. Normal uninstall emitted +the positive exhaustive marker only after all product paths and units, five +service accounts, `sos-ipc`, login membership, and matching processes were +absent with graphical target, GDM, and seatd active. The independent post-E +guest audit agreed, and poweroff left no target QEMU process, PID file, QMP +socket, port-2222 listener, or SSH response. The preserved overlay ended at +38,925,500,416 apparent bytes; its backing image remained unchanged and image +checks reported no corruption. + +**Evidence failure / rejected approaches:** Overall r9 is FAIL at Phase E's +evidence contract, and Phase F was not run. The successful E transcript omitted +the complete semantic snapshot that simultaneously proved the exact request, +exact assistant completion, `Ready`, and empty editable composer; it also +omitted the successful before/after daemon status, persisted exact values, full +session/component PID-UID-parentage identities, terminal connector state, and +matching compositor connector records. Those values existed in assertions or +failure diagnostics but were discarded on success. In addition, many `.meta` +commands literally contained descriptive abbreviations such as `ssh ...` or +`audit ...`, so they are not replayable command records, and +`post-poweroff-observation.meta` had no `post-poweroff-observation.raw` partner. +Treating assertions with no emitted value as raw evidence, reconstructing +literal commands from prose, or accepting manifest integrity as proof of +capture completeness was rejected. The product-functional E result and its 18 +checkpoints remain useful, but they do not retroactively close those omissions. + +The finalized immutable evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r9`. Its sorted, +self-excluding `manifest.tsv` has 138 rows, 13,054 bytes, SHA-256 +`83424b0982756984214d17132d8e6227005837efd9c913a3afe4a33b1e6922c7`. +Every listed row, size, hash, sort order, and the exact listed file set passed +independent verification, while the separate pair audit correctly failed on +the missing `.raw`. The 904-byte external verifier record at +`/tmp/sos-linux-x86_64-acceptance-20260818-r9-manifest-verify.txt` has SHA-256 +`96a598c24bc77515c47451ca8cf6c9cb6d883f948e6426a1404c597821810d4e`. +The directly captured inclusive interval was 1,059.667801969 seconds; +model-weighted cost was unavailable and is not inferred. R9 establishes +provisioning and functional A–E only for its exact source. It makes no Phase F, +physical-hardware, platform-suspend, latency, memory, thermal, or overall Linux +acceptance claim. + +**Repository hardening / validation target:** Successful Phase E now emits the +same exact semantic snapshots, daemon statuses, and persisted request/completion +contract that failure diagnosis receives, plus safe process PID/PPID/UID/user/ +executable identities across session, compositor, platform authority, +supervisor, host proxy, experience host, broker, and agent. Lifecycle success +emits terminal VT/connector/power-mode state and exact matching compositor +records; normal and failure cleanup share the same audit vocabulary. The GDM +launcher now emits initial revision/session/process/DRM-Shell identities, +observes only a changed history hash plus exact old/new revision, authority, and +unchanged-host invariants, then emits explicit logout and exhaustive owned-PID, +matching-product-process, and runtime cleanup markers. It does not print +credentials or arbitrary conversation +content. A repository evidence runner records literal argv, UTC/monotonic times, +status, and a matched `.raw`/`.meta` pair with individually atomic final renames +while refusing overwrites and +likely secret-bearing arguments. Handwritten descriptive command metadata and +putting local VM absolute paths into product launchers were rejected. + +Host-only validation passed `bash -n` for the evidence runner/tests, boot +evidence helper/test, complete boot verifier, GDM installer, and login launcher; +separate `bash -n` parsing passed for the verifier's build, agent, lifecycle, +boot assertion, and uninstall remote bodies. `tools/test-evidence-run` proved +success and status-23 capture, matched pairs, literal-argument escaping, +non-overwrite behavior, secret-assignment refusal, and temporary-file cleanup. +`tools/linux-vm/test-boot-session-evidence` proved the exact persisted +request/completion fixture plus all successful E/GDM output markers, and the +existing lifecycle parser suite still passed both supported modes and ordered +entry/exit matching. `git diff --check` passed. A fresh read-only audit verified +all 138 r9 manifest rows and the external verifier identities above without +modifying the archive. ShellCheck was unavailable. No VM or device was operated +for this hardening, so every new runtime evidence path remains unexecuted. + +**Remaining risk / exact r10 full A–F gate:** Start from a clean Debian 13 +x86-64 reference guest and a newly recorded exact source ledger; r9's diff and +untracked identities cannot identify this hardening. Use `tools/evidence-run` +for every host command, including expected failures and audits, and require +literal argv, one `.raw` for every `.meta`, no secret-bearing argv, and no +temporary output. Run the exact provisioner; Phase A `npm ci --ignore-scripts`, +`npm run check`, `npm test`, and final `npm run build`; Phase B +`tools/linux-agent-e2e`; Phase C `tools/linux-compositor/verify-nested`; Phase D +`tools/linux-vm/verify-direct-session`; and Phase E +`tools/linux-vm/verify-boot-session`. Re-require all prior functional criteria, +bundle identities, revisions, PIDs, presentation fences, cleanup, selected-mode +and 18-checkpoint evidence. Additionally require E's full successful semantic/ +status/history records, all safe process identities and parentage, exact +connector state/logs, shared cleanup audit, and terminal PASS before proceeding. + +Only then run Phase F once: install through +`tools/install-linux-login-session install`, select SOS through GDM, prove the +authenticated active logind seat and exact initial `sos_login_session_ready`, +all process and mapped-Shell/connector/page-flip identities, complete one visible +resident-agent rewrite, and require `sos_login_rewrite_passed` with different +old/new 64-hex revisions, unchanged host PID, equal authority, and changed +history identity. Capture the exact semantic completion separately without +credential content. Inject the selectable-session `Ctrl+Alt+Backspace` logout, +require `sos_login_logout_observed`, `sos_login_cleanup_passed`, return of the +GDM greeter, absence of every recorded product PID/private runtime, and final +GNOME/guest restoration. Power off, prove final source and VM state, close all +files, atomically generate the sorted self-excluding manifest, and independently +verify exact file set, rows, sizes, hashes, order, and no later writes. Retain +the VM-only boundary regardless of r10's result. + +## 2026-08-18 — Reject Linux x86-64 r16 at shared-agent login and repair bundled OAuth startup + +**Goal / source and environment identity:** Run the complete Debian 13 +x86-64 A–F acceptance sequence from exact HEAD +`e05f91bb6f0b0a9299b914138d6cd0966b9c82d5`. Initial and final source captures +were identical. The tracked diff artifact was 116,374 bytes with SHA-256 +`59eb84e3ee4963baf153fae12a97a5f6ddaeb650dc2c23df293aeaf60e16eb99` +and covered `apps/experience/src/linux.rs`, `docs/linux-compositor.md`, +`docs/linux-stable-host.md`, `docs/linux-vm.md`, `docs/progress.md`, +`packaging/libexec/sos-login-session`, `tools/install-linux-login-session`, +`tools/linux-compositor/verify-nested`, `tools/linux-vm/provision-debian`, and +`tools/linux-vm/verify-boot-session`. The seven-file untracked ledger was also +identical at both boundaries: + +- `tests/fixtures/linux-agent-history.json`: 235 bytes, + `03505f460c2b67a38e27aa82e85d379a4f4562b0ff4bfc8ce421a8513b314cf5`; +- `tools/evidence-run`: 3,984 bytes, + `50c08af1e093137097f030300ba7dd8fcf91163d04f2e0c4f1c55dcd8cd7d131`; +- `tools/linux-vm/boot-session-evidence-lib`: 798 bytes, + `d4762ab73af50aec3ca089253475ba7af836866c118b65c347296fabb698c14e`; +- `tools/linux-vm/boot-session-lifecycle-lib`: 1,177 bytes, + `1fbdf514e46b401dc1e20d78d1d5db54f2db591d35b67855dd093b71b5153212`; +- `tools/linux-vm/test-boot-session-evidence`: 1,739 bytes, + `79a97e996e63f4716f5b49c555930e86469cdb6479c968bad3b3f096f284eddf`; +- `tools/linux-vm/test-boot-session-lifecycle`: 2,425 bytes, + `c757a2b792fe6c3384e3e13781bc79614e7fa510d666776bea7e50cb15ff095c`; +- `tools/test-evidence-run`: 1,808 bytes, + `72b41a3457322d7c61334f66f84dc6008790662f6ccda089934a1df3aac26809`. + +The 713-byte aggregate hash ledger itself had SHA-256 +`eabfefa7b82c7f34bc4e3dc66d28049d6544d6721561c2774f47e89f26fd766b`. +Host and guest per-file hashes matched after sync. Preflight found x86-64 KVM +read/write access, no prior VM PID/QMP socket/overlay owner/port-2222 listener, +and refused SSH as expected. The VM used native KVM QEMU PID 2013590 and the +unchanged 436,404,224-byte backing image with SHA-256 +`d4e6f5d1e9f571c198a65b45ab1adae6c5734607614e72f9661d84ce5881e5fc`. + +**Provisioning and phases A–D:** Provisioning returned 0 in 18.164301362 +seconds, proved the Debian 13 dependency set, Rust 1.95.0 and Node 24.18.0, +ran the locked agent sequence, and emitted the package marker. The separately +captured Phase A commands all passed: `npm ci --ignore-scripts` in 3.984572920 +seconds, `npm run check` in 2.345506303 seconds, all 12 tests in +2.982993384 seconds, and the final `npm run build` in 2.566962298 seconds. +The resulting `services/sos-agent/dist/agent-runner.cjs` was 1,878,811 bytes +with SHA-256 +`3eee6e7922fb82e344277793a435bb8edd36a2c183050b638a3c6ca13d3bc99a`. + +Phase B passed in 6.794630287 seconds and changed the active revision from +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0` +through the exact context/validate/submit flow. Phase C passed in +14.876430552 seconds: activation PID 3902 recovered as PID 4268 on revision +`2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0`; +the initial, activated, and recovered `nested_backend_submit` presentation +fences were commit/submit sequences `1/367`, `16/382`, and `94/447`. +XWayland PID 3077 had compositor parent 3046 and client PID 4366, the bounded +surface and native IME/text checks passed with 198 quiesce-suppressed events, +and owned-process/run-root cleanup passed. Phase D passed in 14.310455846 +seconds: activation PID 4698 recovered as PID 4997 on revision +`250b157308407df4ed48c8e45351e69a0d82534ba001b6ff214c6a3348c0a326`; +its three `drm_page_flip` fences were `1/36`, `146/50`, and `3783/71`, with +recovery-view page flips before both initial and recovered presentation. + +**Phase E:** The boot/session command returned 0 in 62.628998357 seconds. +The resident faux Pi request changed revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `3eef9a00f71362045a3c167454b293d25cc5d565e4eddcecee1de49e3690a9c7` +without changing supervisor-reported host-proxy PID 1011. The successful +semantic capture changed from generation 2 with the empty editable agent +composer to generation 11 with exact request `Turn this into a spatial time +flow`, exact completion `The candidate experience is active.`, `Ready`, and +an empty editable composer. Persisted history was 58,154 bytes with SHA-256 +`e9d7f05c277810b8554d417dbbf37a7751e44fe316defcea026060fd18da1c66`; +each exact request/completion occurred once. + +The active logind identity was session 1, seat0/tty1, Wayland, leader and +session owner PID 894, user `sos-compositor` UID 995. Safe component identities +were compositor PID 945/PPID 894/UID 995; platform authority PID 1008/PPID +894/UID 994; supervisor PID 1010/PPID 894/UID 993; host proxy PID 1011/PPID +1010/UID 993; experience host PID 1015/PPID 894/UID 992; authoring broker PID +943/PPID 1/UID 993; and resident Node agent PID 955/PPID 1/UID 991. Sysfs +reported `s2idle [deep]` and the verifier selected `deep`. All 18/18 named +lifecycle markers passed while owner PID 894 remained unchanged: VT pause and +resume each advanced `0 -> 1`, one ordered freezer entry/exit pair matched, +the primary connector disconnect advanced `0 -> 1`, reconnect/KMS +initialization advanced `1 -> 2`, and post-transition liveness passed. Terminal +state was tty1, Virtual-1 connected and Virtual-2 disconnected. + +Every captured presentation fence used `drm_page_flip`: initial `1/19`, agent +revision `61/43`, candidate revision `226/62`, rollback to the agent revision +`248/79`, forward to the candidate `272/92`, same-revision host recovery +`287/110`, whole-session recovery `1/11`, and lifecycle-owner recovery `1/11`. +The terminal PASS recorded original owner PID 894, activation host PID 1011, +recovered host PID 2039, restarted owner PID 2148, restarted host PID 2240, +lifecycle-recovered owner PID 2426, final revision +`2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0`, +and `NRestarts=2`. Normal cleanup proved every product path/unit, account, +group, login membership, and matching process absent with graphical target, +GDM, and seatd active. + +**Phase F failure and confidence boundary:** The one installer command ran for +157.090773063 seconds, completed the release build and locked npm install/build, +installed the selectable-session payload, and then invoked its required +`sos-agent-login --if-needed`. That helper failed before emitting a device-code +event with `sos_agent_failed error=Cannot read properties of undefined +(reading 'endsWith')`. No successful OAuth login, interactive GDM SOS login, +resident rewrite, logout, or selectable-session cleanup evidence exists; +the terminal record's `phase_f_login_not_attempted=true` refers to the GDM +login phase, not to invocation of the failed helper. The r16 capture also did +not inspect `${HOME}/.local/state/sos/agent`, so it cannot establish whether +the old launcher left only empty directories or any partial user-state file. +The bounded partial uninstall proved the installed system paths absent, GDM, +seatd, SSH, and graphical target active, and no SOS/Node process or private +runtime. Orderly poweroff left no QEMU process, PID file, QMP socket, +port-2222 listener, or SSH response. The healthy preserved overlay ended at +39,067,648,000 apparent bytes; its backing size/hash were unchanged. + +The supported campaign interval is 558.557128403 seconds, computed directly +from captured monotonic endpoints 330329367062733 and 330887924191136. Record +`096-measured-timings` is separately failed evidence: its literal +single-argument Python program contained backslash-plus-`n` text and raised a +`SyntaxError`. That argument was composed by the external runner; no +repository timing helper generated it, so patching `tools/evidence-run` or a +product script was rejected. Elapsed values above come from the individual +metadata and the endpoint subtraction, never from that failed consolidation. + +The finalized evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r16`. Its sorted, +self-excluding `manifest.tsv` has 194 rows, 19,679 bytes, and SHA-256 +`8f441c8efd9a65952bf4bee967ebdacfed5992b7578622516ee64437ef2dcdeb`. +Independent verification passed exact file set, format, order, uniqueness, +sizes, hashes, and deterministic regeneration. The 249-byte verifier output at +`/tmp/sos-linux-x86_64-acceptance-20260818-r16-manifest-verify.txt` has SHA-256 +`18afed3b7b84cda96ad5da1de0d79801b079c7906c2faa7f3b9e65fc819337ea`. +Model-weighted cost was unavailable. + +**Root cause / changed code:** Pi 0.84.1's OAuth loader uses +`import.meta.url.endsWith(".js")` only when no statically bundled OAuth loader +has been registered. SOS already called the historical +`registerBunOAuthFlows()` entry point from its Android/stdio path, so that path +used embedded OAuth modules. Linux `sos-agent-login` enters the separate CLI +path through `runCli()` and never made the registration. In the esbuild +CommonJS artifact, `import.meta` becomes an empty `import_meta` object; its +optional `url` value is therefore `undefined`, producing the exact r16 +`endsWith` exception before any network prompt. The missing value was Pi's +module-resolution URL, not `HOME`, `XDG_STATE_HOME`, the credential path, or an +OAuth response field. + +The shared provider-model constructor now idempotently registers Pi's static +OAuth flows before any provider can log in, refresh, or derive request auth. +This preserves the existing Android/stdio behavior and fixes both Linux CLI +login and the latent Linux resident-agent refresh path without changing the Pi +pin, OAuth protocol, or bundle format. Moving only the stdio call, converting +the artifact to ESM, upgrading Pi without evidence, or translating every auth +error into a generic bundle failure were rejected: the shared registration is +the narrow cross-platform boundary, and real provider/authentication failures +still propagate with their original nonzero status and diagnostic. + +`sos-agent-login` no longer pre-creates the per-user state tree before OAuth. +It validates an existing OAuth document offline through the same JSON store, +checks config content without sourcing it, and deterministically classifies +credentials and config as absent, invalid, or preserved. A valid credential +with absent/mismatched config repairs only the config rather than repeating +OAuth. A new successful flow must leave a regular nonempty credential file; +the helper atomically writes only `config.env`, preserves any real credential/ +config on failure, reports `sos_agent_login_incomplete`, and removes only +empty directories newly created by that failed attempt. It neither reads +credentials into evidence nor deletes them. The installer's path usage remains +coherent: it invokes the installed helper as the desktop user, and both that +helper and the selectable-session launcher resolve the same +`${XDG_STATE_HOME:-$HOME/.local/state}/sos/agent/{auth.json,config.env}` paths. + +Host-only repair validation passed `npm run check`, `npm test` with 14/14 +tests, and a final `npm run build`. The generated local repair bundle at +`services/sos-agent/dist/agent-runner.cjs` is 1,879,763 bytes with SHA-256 +`eb4e2aaea63b41042744579a25da380b9a783b4624687d5be273217611dc59ff`; +it remains generated/ignored and is not an r16 acceptance artifact. +`cargo test --locked -p sos-linux-session` passed six library tests and one +integration test, `cargo fmt --all -- --check` passed, and +`tools/test-evidence-run`, `tools/linux-vm/test-boot-session-evidence`, and +`tools/linux-vm/test-boot-session-lifecycle` passed their evidence, semantic/ +GDM-marker, and both-mode ordered-lifecycle contracts. `bash -n` passed all +changed login/install/evidence/VM shell scripts and `git diff --check` passed. +ShellCheck was unavailable. The new packaged-CommonJS regression exercised the +exact Linux `sos-agent-login --if-needed` route with `XDG_STATE_HOME` absent and +mocked only the three OpenAI device-flow responses; it observed the device +code, persisted a mode-0600 OAuth document and config, and did not reach Pi's +dynamic `import.meta.url` loader. Repeat preflight validated the stored JSON +offline, and a stale config was repaired without another OAuth request. +Failure regressions preserved exit status 23 and the original auth diagnostic, +removed a new empty state tree, and retained an existing credential +byte-for-byte. No VM, device, live credential, or live provider request was +used for the repair. + +**Decision / remaining risk / exact r17 gate:** Overall r16 is FAIL at Phase F; +A–E remain evidence for this exact superseded source only. The embedded-flow +fix and state semantics have host regression coverage but still need a clean +Debian 13 VM run. R17 must record a new complete source ledger, prove clean VM +ownership, repeat provisioning; Phase A's locked install/check/all tests/final +build plus bundle path/bytes/SHA; Phase B's exact old/new revision and bounded +tool sequence; Phase C's activation/recovery PIDs, three nested fences, +XWayland/IME/input and cleanup; Phase D's activation/recovery PIDs, three DRM +fences, recovery views and restoration; and Phase E's full semantic/status/ +history records, exact process/session parentage, selected supported suspend +mode, all 18 lifecycle markers, unchanged owner checks, connector/KMS records, +recovery identities, exhaustive cleanup, and terminal PASS. + +Only after A–E pass may R17 run Phase F once. Require the installer and one +`sos-agent-login --if-needed` to emit deterministic preflight, a real device +code and successful credential/config completion; inspect only regular-file, +ownership, mode, nonempty, lock/temp absence, and bounded state markers without +capturing credential content. Then select SOS through GDM, prove the active +authenticated logind seat, exact session/process/Shell/connector/page-flip +identities, one visible resident-agent rewrite with different old/new 64-hex +revisions, unchanged host PID, equal authority and changed history identity, +and the exact semantic completion. Inject the selectable-session logout, +require logout and exhaustive cleanup markers, GDM greeter return, absence of +every recorded PID/private runtime, and final GNOME/guest restoration. On any +login failure, require the original nonzero auth failure plus deterministic +`sos_agent_login_incomplete` state and prove either a newly empty tree was +removed or prior credentials were explicitly preserved; never delete a real +credential as cleanup. Finally power off, prove source/overlay/backing/QEMU +state, close all evidence, atomically generate and independently verify the +sorted self-excluding manifest, and capture timings from valid command +metadata. Even a complete R17 PASS is VM-only: it makes no physical-hardware, +platform-suspend, production-GPU, latency, memory, thermal, or model-cost claim. + +## 2026-08-18 — Reject Linux x86-64 r17 at the Bash 5.2 login-state regression + +**Goal / source and evidence identity:** R17 attempted the complete Debian 13 +x86-64 A–F acceptance sequence from HEAD +`e05f91bb6f0b0a9299b914138d6cd0966b9c82d5`. Initial and final HEAD, tracked +status, tracked-diff size/hash, and untracked pathname lists matched. The +captured tracked diff was 152,484 bytes with SHA-256 +`fcfd386590dcac488a139efcdea1651659800d810b61f0c45257d190b17bd6d6`. +Source closure is nevertheless incomplete: the seven untracked paths received +per-file size/hash records only at the initial boundary, not at the final +boundary, so the matching final pathname list does not prove their final +contents. Also, `003-source-tracked-diff-identity.raw` mislabeled the diff's +mtime as `sha256`; the separate initial records `003-source-tracked-diff-size` +and `004-source-tracked-diff-sha` and final records 067/068 contain the valid +size and digest. This new repair supersedes the captured r17 source. + +The guest prelaunch audit found an already existing empty +`/home/sos/.local/state/sos/agent` directory owned by `sos`/UID 1000 with mode +0700 and no credential available. The post-failure audit found the same empty +directory and no child entry. That path was pre-existing guest state, not the +test's temporary `fresh-home`: the test stopped at the missing-marker +assertion, then removed its temporary root in `finally`, so r17 does not prove +whether the fresh test tree had already been removed before that assertion. + +**Provisioning failure / confidence boundary:** Provisioning returned status 1 +after 31.454518893 seconds. Its locked agent sequence passed TypeScript checking +and the test-internal build, then ran 14 tests with 13 passes and one failure: +`Linux login cleans new empty state and preserves existing credentials` +expected `sos_agent_login_incomplete credentials=absent config=absent +state_dir=absent`, while stderr contained only +`sos_agent_failed error=mock authentication rejected`. The authentication +status remained 23. The test-internal build produced the expected ignored +`services/sos-agent/dist/agent-runner.cjs`, 1,879,763 bytes with SHA-256 +`eb4e2aaea63b41042744579a25da380b9a783b4624687d5be273217611dc59ff`. +Provisioning stopped before its separate final `npm run build` and package +marker, so no independent Phase A build artifact exists. Phases B–F were not +run and no selectable-session or live-provider login was attempted. + +The VM was powered off orderly and cleanup recorded no PID file, QMP socket, +QEMU process, port-2222 listener, or overlay error. The healthy overlay's +apparent size changed from 39,067,648,000 to 39,079,641,088 bytes; the unchanged +backing image was 436,404,224 bytes with SHA-256 +`d4e6f5d1e9f571c198a65b45ab1adae6c5734607614e72f9661d84ce5881e5fc`. +Because the run stopped during provisioning, there is no A–F acceptance result +and no supported campaign duration or model-weighted cost. The evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r17`. Its self-excluding +`manifest.tsv` has 94 sorted unique rows, 9,527 bytes, and SHA-256 +`01caea262d34add2c9d01982b47bfa605547bcc2c3edcef500efea5d7c57960f`. +A fresh read-only audit passed exact file set, format, order, uniqueness, sizes, +hashes, and deterministic regeneration. The existing 255-byte external +verifier output at +`/tmp/sos-linux-x86_64-acceptance-20260818-r17-manifest-verify.txt` has SHA-256 +`f3e8c3620f913c4db52f7635a0747a149caf2b5c230cee29978f9da4478c25e8`. + +**Root cause / changed code:** The failure was not stdout/stderr loss, a stale +launcher path, credential deletion, or the mock runner masking its exit. The +EXIT trap began with status 23, classified credentials as absent, and then +stopped while assigning config state. `sos_agent_login_config_file_state` used +a bare `return` on its absent/invalid path. When that function ran through a +command substitution inside an EXIT trap, Bash 5.2 propagated the pre-trap +status 23 from the bare return; active `errexit` then aborted the trap before +empty-directory policy and the incomplete marker. Bash 5.3 returned 0 in the +same path, explaining the earlier local pass. A host-side reproduction on GNU +Bash 5.2.21 observed assignment status 23 for the bare return and 0 for +`return 0`; Bash 5.3.9 observed 0 for both. + +`packaging/libexec/sos-agent-login` now gives config-state classification an +explicit successful return, captures the login command's exact status, and +calls a common finalizer directly after authentication rejection rather than +depending on implicit `errexit`/EXIT behavior. The finalizer disables errexit, +re-inspects credentials and config, removes only non-pre-existing directories +that are still empty, always attempts the deterministic incomplete marker, and +exits with the original authentication status. The EXIT trap remains the guard +for other failures. It does not unlink a credential or config file: any +pre-existing empty state directory remains, and any pre-existing valid files +remain byte-for-byte. + +The regression in `services/sos-agent/test/runner.test.ts` now declares Bash +5.2 compatibility, proves all three failure-state policies, and retains status +23 plus the original auth diagnostic in each case: a fresh empty tree is +removed and reports `absent/absent/absent`; a pre-existing empty state +directory remains empty and reports `absent/absent/preserved`; and +pre-existing valid credential/config files survive byte-for-byte and report +`preserved/preserved/preserved`. A host-side Bash 5.2.21 container exercised +the packaged script independently and passed the same three cases. + +Validation passed `npm run check`, full `npm test` (14/14), and a separate final +`npm run build`; the ignored bundle remained 1,879,763 bytes with SHA-256 +`eb4e2aaea63b41042744579a25da380b9a783b4624687d5be273217611dc59ff`. +`tools/test-evidence-run`, `tools/linux-vm/test-boot-session-evidence`, and +`tools/linux-vm/test-boot-session-lifecycle` passed. `bash -n` passed all 12 +changed/new login, session, evidence, install, compositor, provision, and boot +shell files, and `git diff --check` passed. ShellCheck was unavailable. These +are host-only repair checks; no VM, device, live credential, or provider was +used. + +**Decision / remaining risk / exact r18 gate:** R17 is FAIL at provisioning and +is rejected in full. Its source closure is incomplete, its test-created fresh +tree was not retained for audit, it has no separate Phase A final build, and +B–F have no evidence. The repair has real Bash 5.2 regression coverage but is +not Debian-guest or selectable-session evidence. R18 must start a clean Debian +13 x86-64 VM transaction with a new exact source ledger, including initial and +final per-file size/hash identities for every untracked input. Provisioning and +Phase A must run `npm ci --ignore-scripts`, `npm run check`, all 14 tests, and a +separate final `npm run build`, record the bundle identity/package marker, and +prove on the guest that auth rejection returns its original status and marker: +newly created empty directories are absent, a pre-existing empty state +directory is preserved, and pre-existing valid credentials/config remain +byte-identical. No failure cleanup may unlink a nonempty, invalid, linked, or +pre-existing state object. + +Only after A passes may R18 run Phase B `tools/linux-agent-e2e`, Phase C +`tools/linux-compositor/verify-nested`, Phase D +`tools/linux-vm/verify-direct-session`, and Phase E +`tools/linux-vm/verify-boot-session`, with all prior exact revisions, bounded Pi +tool sequence, semantic/history records, process parentage, presentation +fences, selected suspend mode, 18 lifecycle markers, recovery identities, and +exhaustive cleanup criteria. Only after A–E pass may Phase F install and run +one real `sos-agent-login --if-needed`, prove bounded credential/config state, +the authenticated GDM SOS session and visible resident rewrite, inject logout, +prove return to the greeter and absence of every session PID/private runtime, +and restore GNOME. Then power off, prove final source/overlay/backing/QEMU +state, close evidence before atomically generating the sorted self-excluding +manifest, and independently verify exact set/order/size/hash regeneration. +R18 remains VM-only regardless of result and makes no physical-hardware, +production-GPU, platform-suspend, latency, memory, thermal, or model-cost claim. + +## 2026-08-18 — Reject Linux x86-64 r19 at the Phase F AccountsService evidence boundary + +**Goal / exact source closure:** R19 ran the full Debian 13 x86-64 campaign +through A–E and the first part of F from HEAD +`e05f91bb6f0b0a9299b914138d6cd0966b9c82d5`. Initial and final HEAD, porcelain +status, tracked diff, untracked pathname list, per-file untracked identities, +and generated host bundle identity were byte-identical. The 162,993-byte +tracked diff had SHA-256 +`3925dd8172497b6ee2b5ffd9c0392dcef117a45c442507e15547764c94b6fd38` +and covered `apps/experience/src/linux.rs`, `docs/linux-compositor.md`, +`docs/linux-stable-host.md`, `docs/linux-vm.md`, `docs/progress.md`, +`docs/sos-agent.md`, `packaging/libexec/sos-agent-login`, +`packaging/libexec/sos-login-session`, `services/sos-agent/src/main.ts`, +`services/sos-agent/src/runtime.ts`, `services/sos-agent/src/stdio-runner.ts`, +`services/sos-agent/test/runner.test.ts`, +`tools/install-linux-login-session`, `tools/linux-compositor/verify-nested`, +`tools/linux-vm/provision-debian`, and +`tools/linux-vm/verify-boot-session`. The full initial/final untracked ledger +was: + +- `tests/fixtures/linux-agent-history.json`: 235 bytes, mode 0644, + UID:GID 1000:1000, SHA-256 + `03505f460c2b67a38e27aa82e85d379a4f4562b0ff4bfc8ce421a8513b314cf5`; +- `tools/evidence-run`: 3,984 bytes, mode 0755, UID:GID 1000:1000, + SHA-256 + `50c08af1e093137097f030300ba7dd8fcf91163d04f2e0c4f1c55dcd8cd7d131`; +- `tools/linux-vm/boot-session-evidence-lib`: 798 bytes, mode 0644, + UID:GID 1000:1000, SHA-256 + `d4762ab73af50aec3ca089253475ba7af836866c118b65c347296fabb698c14e`; +- `tools/linux-vm/boot-session-lifecycle-lib`: 1,177 bytes, mode 0644, + UID:GID 1000:1000, SHA-256 + `1fbdf514e46b401dc1e20d78d1d5db54f2db591d35b67855dd093b71b5153212`; +- `tools/linux-vm/test-boot-session-evidence`: 1,739 bytes, mode 0755, + UID:GID 1000:1000, SHA-256 + `79a97e996e63f4716f5b49c555930e86469cdb6479c968bad3b3f096f284eddf`; +- `tools/linux-vm/test-boot-session-lifecycle`: 2,425 bytes, mode 0755, + UID:GID 1000:1000, SHA-256 + `c757a2b792fe6c3384e3e13781bc79614e7fa510d666776bea7e50cb15ff095c`; +- `tools/test-evidence-run`: 1,808 bytes, mode 0755, UID:GID 1000:1000, + SHA-256 + `72b41a3457322d7c61334f66f84dc6008790662f6ccda089934a1df3aac26809`. + +The matching initial/final 403-byte stat ledgers had SHA-256 +`965440ff3f6e2425beedfca43b9846d8b14217b7db6ded01123e4df88e866902`, +the matching 713-byte hash ledgers had SHA-256 +`eabfefa7b82c7f34bc4e3dc66d28049d6544d6721561c2774f47e89f26fd766b`, +and the matching 1,116-byte combined ledgers had SHA-256 +`6970cc6b189ec83d387c405240d7ca38f5b73511b6e9b2dad708babb8b066bed`. +Direct comparison passed for every boundary record. The host bundle also +remained 1,879,763 bytes with SHA-256 +`eb4e2aaea63b41042744579a25da380b9a783b4624687d5be273217611dc59ff`. + +Preflight proved Debian 13.6, kernel `6.12.101+deb13-amd64`, native KVM QEMU +PID 2307838, +graphical target plus GDM, seatd, and SSH active, no installed SOS unit, and an +otherwise empty existing `/home/sos/.local/state/sos/agent` directory owned by +`sos` with mode 0700. The clean QMP status was running. Provisioning returned +0 in 16.793291913 seconds and reported Rust 1.95.0, Node 24.18.0, the locked +package sequence, and the expected package marker. + +**Phases A–D:** The four separately captured Phase A commands returned 0: +`npm ci --ignore-scripts` took 4.170410455 seconds, `npm run check` took +2.548634142 seconds, all 14 tests passed in 3.269105927 seconds, and the final +`npm run build` took 2.717819988 seconds. The guest bundle was exactly +1,879,763 bytes with SHA-256 +`eb4e2aaea63b41042744579a25da380b9a783b4624687d5be273217611dc59ff`; +the package manifest SHA-256 was +`4691b055beb89a5e42c92aa3fec14ef456d3c39538270c48b2e44144e8d276f5`. + +Phase B passed in 6.989749502 seconds and changed revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0` +through the exact context, validate, and submit sequence. Phase C passed in +30.389650558 seconds on that revision: activation PID 4562 recovered as PID +4964; initial, activated, and recovered `nested_backend_submit` fences were +commit/submit `1/556`, `14/568`, and `100/638`. XWayland PID 3483 had +compositor parent 3451 and client PID 5061 on display `:1`; window 4194340, +title `SOS XWayland compatibility gate`, position 280,140, and +`override_redirect=false` supplied the bounded compatibility surface. Native +text/IME checks passed with 176 input-quiesce-suppressed events, and +`linux_nested_cleanup_passed` proved owned PIDs and the run directory absent. + +Phase D passed in 14.682525935 seconds: activation PID 5397 recovered as PID +5691 on revision +`250b157308407df4ed48c8e45351e69a0d82534ba001b6ff214c6a3348c0a326`. +Its initial, activated, and recovered `drm_page_flip` fences were `1/35`, +`147/49`, and `3790/70`, with a recovery-view page flip before initial shell +presentation and another before recovered-shell presentation. The bounded +verifier returned 0 through its GDM-restoring EXIT path; the following boot +phase began normally. This is VM VirtIO DRM evidence, not physical GPU or +latency evidence. + +**Phase E exact evidence:** The boot/session verifier returned 0 in +64.929758005 seconds. Its semantic snapshot moved from generation 2 with the +empty editable stock composer to generation 10 with exact request `Turn this +into a spatial time flow`, exact completion `The candidate experience is +active.`, `Ready`, and an empty editable composer. The complete persisted +history was 58,154 bytes with SHA-256 +`02dfbeb7118ff49e9a7a5e12a85471cc0f1380575927cecf44ff0a6918a8bde8`; +the exact request and completion each occurred once. Supervisor status changed +revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `3eef9a00f71362045a3c167454b293d25cc5d565e4eddcecee1de49e3690a9c7` +with host-proxy PID 1010 unchanged. + +Active logind session 1 was Wayland on seat0/tty1, leader PID 877, user +`sos-compositor`, UID 995. Safe identities were session owner PID 877/PPID +1/UID 995/user `sos-compositor`; compositor 952/877/995/`sos-compositor`; +platform authority 1007/877/994/`sos-provider`; supervisor +1009/877/993/`sos-supervisor`; host proxy +1010/1009/993/`sos-supervisor`; experience host 1014/877/992/`sos-host`; +authoring broker 941/1/993/`sos-supervisor`; and resident Node agent +955/1/991/`sos-agent`, with their exact installed executable paths captured. +Sysfs exposed +`s2idle [deep]` and the verifier selected `deep`. All 18 named lifecycle +checkpoints passed with owner PID 877 unchanged: active session, readiness-log +match, lifecycle-owner identity, VT pause request and `0 -> 1` log match, +freezer-resume command, VT resume request and `0 -> 1` log match, one `deep` +freezer entry and its paired exit, post-freezer liveness, campaign-log +presence, disconnect request and `0 -> 1` match, post-disconnect liveness, +reconnect request and `1 -> 2` KMS match, and post-reconnect liveness. Terminal +state was tty1, `Virtual-1` connected, and `Virtual-2` disconnected; the exact +disconnect record named connector handle 37 and CRTC handle 36 before KMS +reinitialization of `Virtual-1`. + +Every selected fence was `drm_page_flip`: initial `1/19`, agent revision +`44/46`, candidate revision `208/65`, rollback to the agent revision `230/80`, +forward to the candidate `257/92`, same-revision host recovery `271/107`, +whole-session recovery `1/12`, and lifecycle-owner recovery `1/13`. Terminal +`linux_boot_session_passed` recorded original owner PID 877, activation +host-proxy PID 1010, recovered host-proxy PID 2044, restarted owner PID 2152, +restarted host-proxy PID 2240, lifecycle-recovered owner PID 2426, final +revision +`2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0`, +and `NRestarts=2`. Normal cleanup proved all owned product paths and units, +accounts, IPC group, login membership, and matching processes absent with +graphical target, GDM, and seatd restored. + +**Phase F partial success and decisive failure:** The one normal-user installer +command returned 0 in 81.759022404 seconds. It completed the release build and +locked npm install/build, installed the selectable-session payload, performed +one real `openai-codex` device-code authorization successfully, saved the +credential, and emitted `sos_agent_login_configured provider=openai-codex +model=gpt-5.6-sol`. The one-time code and credential contents are deliberately +not repeated here. Bounded metadata only showed +`/home/sos/.local/state/sos/agent` as a 4,096-byte directory, mode 0700, +`sos:sos`; `auth.json` as a nonempty 2,091-byte regular file, mode 0600, +`sos:sos`; and `config.env` as a 60-byte regular file, mode 0600, `sos:sos`. +No content or hash of either private file was captured. Installed metadata was +`/usr/local/libexec/sos/sos-login-session` 20,993 bytes/mode 0755, +`/usr/local/libexec/sos/sos-agent-login` 8,423 bytes/mode 0755, +`/usr/share/wayland-sessions/sos.desktop` 214 bytes/mode 0644, and +`/usr/local/libexec/sos-agent/dist/agent-runner.cjs` 1,879,763 bytes/mode +0775, all `root:root`; the installed bundle hash matched Phase A. GDM, seatd, +and SSH remained active. `/etc/gdm3/daemon.conf` was a 554-byte mode-0644 +`root:root` regular file with SHA-256 +`ceee968ce021213814ef4f87e19f6e76fcb0333170786dd0c006760ad61810af`. + +The next strict evidence command attempted an unprivileged +`find /var/lib/AccountsService/users -maxdepth 1 -type f ...`. It returned +status 1 with `Permission denied` in 0.129130664 seconds because the external +runner omitted `sudo`. The campaign correctly stopped before selecting SOS in +GDM. There is therefore no authenticated GDM SOS session, mapped Shell, +selectable-session DRM page flip, resident rewrite, logout, or GDM-session +cleanup evidence. Installation and successful provider authorization are not +a substitute for any of those missing predicates. + +Terminal cleanup deliberately preserved the credential and configuration with +the same bounded size/mode/ownership metadata and did not read, hash, or delete +them. It removed the installed `/usr/local/libexec/sos`, +`/usr/local/libexec/sos-agent`, `/usr/share/sos`, `/usr/share/doc/sos`, and +`sos.desktop` payloads; their absence checks passed. It restored +`graphical.target`, enabled and started seatd, kept GDM and SSH active, observed +seat0 at greeter session `c1`, found no matching product process and no +`/run/user/1000/sos-session.*`, and proved the GDM configuration size/hash +unchanged. QMP was still running before an orderly poweroff; afterward there +was no QEMU process, PID file, QMP socket, port-2222 listener, or SSH response. +The healthy overlay changed from 39,089,209,344 to 41,453,486,080 apparent +bytes with zero check errors. Its 436,404,224-byte backing image remained +SHA-256 +`d4e6f5d1e9f571c198a65b45ab1adae6c5734607614e72f9661d84ce5881e5fc`. +Final source and bundle closure matched the initial records exactly. + +The supported campaign interval is exactly 1,871.073933310 seconds, from +captured monotonic nanoseconds 334081083420078 through 335952157353388; it is +not inferred from a tool timeout. Model-weighted cost was unavailable. The +finalized evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r19`. Its sorted, +self-excluding `manifest.tsv` has 300 unique rows, 31,018 bytes, and SHA-256 +`432d86078aac3da6a53c8b14dc86159b8303d5aaea7b96917da782c9b4fb23fd`. +The campaign's 118-byte external verifier record was captured at +`/tmp/sos-linux-x86_64-acceptance-20260818-r19-manifest-audit/008-manifest-verify.raw` +with SHA-256 +`4afef51bc030945f2e5e32e322bca362d07f8aeb545f3dae4d609abb1f1403ed`; +that temporary verifier record is no longer present. A fresh read-only audit +of the sealed evidence independently verified all 300 listed paths, sizes, +and hashes, exact file-set equality, C-sort order, uniqueness, 150 matched +`.meta`/`.raw` pairs, self-exclusion, and deterministic regeneration of the +same manifest digest. + +**Phase F privilege-contract audit:** The failed AccountsService command was +composed by the external campaign runner, not by a repository script. +`tools/evidence-run` faithfully captured its literal unprivileged remote argv +and status; it does not choose privilege. Repository search found no +Phase F/AccountsService inventory helper to repair. The installer already +rejects root, invokes the installed login helper as the desktop user, and uses +`sudo` only for root-owned installation writes; `sos-agent-login` and +`sos-login-session` also reject root and enforce user ownership of credential, +state, and runtime paths. Changing these product boundaries, teaching the +evidence recorder to inject privilege, or weakening the strict command failure +was rejected. No product code changed for this runner-only omission. + +The complete r20 command privilege contract is: + +- Run credential preflight, both login helpers, the installer top level, the + GDM SOS session, credential/config `find` and `stat`, the user's + `/run/user/` inventory, and same-UID PID/executable checks as the + authenticated desktop user. Record only credential/config path, regular-file + type, nonempty byte size, owner/group, mode, and lock/temp absence—never + content or hashes. Root would mask the ownership and access properties under + test. +- Run readable installed-payload `stat`/`sha256sum`, payload-absence `find`, + `systemctl is-active|get-default|show`, `loginctl show-seat|show-session`, + `ps`/same-UID `pgrep`, and read-only `id`/`getent` user/group inventories as + the normal user. These interfaces require no privilege and their + unprivileged readability is part of the contract. Avoid `systemctl status` + because it mixes status with journal output. +- Use explicit noninteractive `sudo -n` for every AccountsService inventory + or identity under `/var/lib/AccountsService/users`, GDM configuration + metadata/hash reads under `/etc/gdm3`, bounded system-journal reads needed + for GDM/session/KMS evidence, any cross-UID `/proc` executable identity, and + any GDM-greeter runtime path that cannot be read by the login user. Do not + capture AccountsService file contents; use path/type/size/mode/owner and a + hash only when before/after equality is required. Bound journal capture to + the recorded post-authentication GDM interval and exact UID/session/process + selectors so no device code or credential material enters evidence. +- Use explicit noninteractive `sudo -n` for all campaign-authored root state + changes: + `systemctl set-default|enable|disable|start|stop`, root-installed payload + removal, and any necessary configuration restoration. The normal-user + installer may retain its narrowly internal `sudo` calls only after a + separate `sudo -n true` gate proves passwordless noninteractive escalation. + Logout itself must use the selectable-session `Ctrl+Alt+Backspace` path, not + privileged session termination. Phase F creates no system user or group, so + user/group operations are inventories only. + +**Decision / remaining risk / exact r20 gate:** Overall r19 is **FAIL** because +Phase F stopped before GDM; A–E pass only for this exact source. It makes no +interactive GDM acceptance, physical-hardware, production-GPU, +platform-suspend, latency, memory, thermal, or model-cost claim. Continuing +after the failed evidence command, silently retrying it with privilege in the +same campaign, treating OAuth/install success as session acceptance, deleting +the new real credential during cleanup, or patching unrelated product code +were rejected. The credential is intentionally preserved on the healthy +overlay; remaining risk is the entire first authenticated GDM session, +resident real-provider rewrite, DRM presentation, logout/cleanup, and GNOME +restoration path. + +R20 must open one fresh complete A–F transaction with a new exact initial/final +source ledger, all untracked per-file identities, clean VM/QEMU ownership, and +the privilege contract above. Repeat provisioning and Phase A's locked +install, check, all 14 tests, final build, and bundle identity; Phase B's exact +old/new revision and bounded tool +sequence; Phase C's PIDs, XWayland/surface/IME records, three nested fences, +recovery, and cleanup; Phase D's PIDs, three DRM fences, recovery views, and +GDM restoration; and Phase E's full semantic/status/history records, process +parentage, selected supported suspend mode, all 18 lifecycle checkpoints, +connector/KMS records, all recovery identities, exhaustive cleanup, and +terminal PASS. + +Only after A–E pass may Phase F begin. Before any authentication-capable +command, inspect `auth.json` and `config.env` metadata as user and require +regular, nonempty, mode-0600 `sos:sos` files with no lock/temp sibling. Run the +repository `packaging/libexec/sos-agent-login --if-needed` as the normal user +with `SOS_AGENT_MAIN=/home/sos/sos/services/sos-agent/dist/agent-runner.cjs` +bound to the exact Phase A guest bundle and explicit +`SOS_AGENT_PROVIDER=openai-codex`/`SOS_AGENT_MODEL=gpt-5.6-sol`; require offline +credential validation and +`sos_agent_login_ready`. If it reports absent/invalid state or attempts +`action=authenticate`, stop F: r20 is authorized to reuse the preserved +credential and must not start a second device-code flow. Then run the installer +once as user after `sudo -n true` and require the installed helper's same +ready/no-reauth result plus exact installed payload identity. +After that preserved-credential preflight passes, select SOS through GDM once. +Require the authenticated active logind seat, `sos_login_session_ready`, all +safe process identities, mapped +Shell and connector/page-flip journal records, one visible resident-agent +rewrite, `sos_login_rewrite_passed` with different old/new 64-hex revisions, +unchanged host PID, equal authority, changed history SHA-256, and the exact +semantic completion without credential content. + +Inject `Ctrl+Alt+Backspace`; require `sos_login_logout_observed`, +`sos_login_cleanup_passed`, return of the GDM greeter, absence of every +recorded product PID and private runtime, and preservation of credential/config +metadata. Select GNOME through GDM to restore the prior desktop selection and +prove the AccountsService and GDM before/after identities under `sudo -n`, +normal graphical/GDM/seatd/SSH state, and no installed product after bounded +cleanup. Then power off, prove overlay/backing/QEMU and final source closure, +finalize every evidence file, atomically generate the sorted self-excluding +manifest, and independently verify exact file set, rows, sizes, hashes, order, +uniqueness, and no later writes. R20 remains a VM-only gate regardless of its +result. + +## 2026-08-18 — Reject Linux x86-64 r24 at nested accessibility focus causality + +**Goal / exact source closure:** R24 attempted the complete Debian 13 x86-64 +A–F campaign from +`/home/carlid/.t3/worktrees/sos/t3code-e2a4bacf`, branch +`t3code/rerun-linux-baseline`, HEAD +`e05f91bb6f0b0a9299b914138d6cd0966b9c82d5`. Initial and final HEAD, +branch, status, tracked and staged diffs, untracked list, and untracked +stat/hash ledgers were byte-identical. The 181,932-byte tracked diff had +SHA-256 +`dd5d886a965f180e74012bd240aa0d201876bb8e0e8389d07ef03577408c8d3d`; +the 803-byte status had +`1a23051d37d6a0ee49857c40dcb4e66247ac75611911967c422bb8984f668125`, +the empty staged diff had +`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`, +and the 251-byte untracked list had +`c867d921f64628fce65e26989c7828ca987ddf7c1e95ca7787fa1f33bf379c48`. +The matching UID:GID 1000:1000 untracked inputs were: + +- `tests/fixtures/linux-agent-history.json`: 235 bytes/0644, + `03505f460c2b67a38e27aa82e85d379a4f4562b0ff4bfc8ce421a8513b314cf5`; +- `tools/evidence-run`: 3,984 bytes/0755, + `50c08af1e093137097f030300ba7dd8fcf91163d04f2e0c4f1c55dcd8cd7d131`; +- `tools/linux-vm/boot-session-evidence-lib`: 798 bytes/0644, + `d4762ab73af50aec3ca089253475ba7af836866c118b65c347296fabb698c14e`; +- `tools/linux-vm/boot-session-lifecycle-lib`: 1,177 bytes/0644, + `1fbdf514e46b401dc1e20d78d1d5db54f2db591d35b67855dd093b71b5153212`; +- `tools/linux-vm/test-boot-session-evidence`: 1,739 bytes/0755, + `79a97e996e63f4716f5b49c555930e86469cdb6479c968bad3b3f096f284eddf`; +- `tools/linux-vm/test-boot-session-lifecycle`: 2,425 bytes/0755, + `c757a2b792fe6c3384e3e13781bc79614e7fa510d666776bea7e50cb15ff095c`; +- `tools/test-evidence-run`: 1,808 bytes/0755, + `72b41a3457322d7c61334f66f84dc6008790662f6ccda089934a1df3aac26809`. + +The matching 382-byte stat ledger had +`a3e04dc7bea3c6977ae65a6e177397b6107af79b307dafd595b77e09a93a3ee3` +and the matching 713-byte hash ledger had +`eabfefa7b82c7f34bc4e3dc66d28049d6544d6721561c2774f47e89f26fd766b`. +Records 173–178 passed direct comparisons. The host bundle remained +1,879,763 bytes/0755 with SHA-256 +`eb4e2aaea63b41042744579a25da380b9a783b4624687d5be273217611dc59ff`. + +**Phase 0 and phases A–B:** Preflight found no prior runtime path, +port-2222 listener, QMP socket, or overlay owner and SSH was refused as +expected. Native-KVM QEMU PID 2936448 ran `sos-debian13` with 8 vCPUs, 12,288 +MiB and VirtIO GPU. The guest was Debian 13.6, kernel +`6.12.101+deb13-amd64`, x86-64, with graphical target, GDM, seatd, and SSH +active and greeter c1 on seat0/tty1. Provisioning returned 0 in +22.321426278 seconds, proved Rust 1.95.0 and Node 24.18.0, passed its locked +14-test agent sequence, and emitted the package marker. + +Phase A's separately captured commands all returned 0: +`npm ci --ignore-scripts` in 4.075127657 seconds, `npm run check` in +2.496308333 seconds, 14/14 tests in 3.277501580 seconds, and the separate +final `npm run build` in 2.763783005 seconds. Guest and host bundle copies +were 1,879,763 bytes with SHA-256 +`eb4e2aaea63b41042744579a25da380b9a783b4624687d5be273217611dc59ff`; +guest `package.json` had +`4691b055beb89a5e42c92aa3fec14ef456d3c39538270c48b2e44144e8d276f5`. +Phase B returned 0 in 7.037623651 seconds and changed revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0` +through the exact context/validate/submit flow. + +**Phase C failure / confidence boundary:** `verify-nested` returned status 1 +in 15.016399917 seconds at source line 375. Permanent host PID 4672 booted the +initial revision above and activated the Phase B revision without a PID +change. Initial and activated `nested_backend_submit` fences passed at +commit/submit `1/294` and `19/310`. Activation dropped zero host events and +suppressed 196 compositor events while restoring Shell focus. Native text +focused `note-draft`, submitted exact `wayland`, and committed requests 1–8 +through authority revision 10. Accessibility handled `next`, `previous`, +`activate`, and `scroll_forward`; their asynchronous work committed requests +9 and 10 through authority revisions 11 and 12. It then logged exact +`sos_accessibility_action kind=focus target=note-draft`. + +The focus action acknowledgement and subsequent wait both had `.ok=true` +(lines 372 and 374 completed), but the returned wait snapshot failed exact +`.snapshot.focused == "note-draft"` at line 375. The old failure path did not +preserve baseline generation, returned generation, or returned focus, so no +more specific snapshot value can be claimed. No focus-false event, host exit, +revision change, stale-scene installation, or compositor focus loss was +recorded. XWayland mapping, native IME, recovery, and the third fence were not +reached. Phases D–F were not run. No GDM selection/configuration change was +attempted; closure retained active graphical/GDM/seatd/SSH and the same c1 +Debian-gdm Wayland greeter, leader 925, on seat0/tty1. + +Credential metadata was identical preflight, after A, and at closure: +`/home/sos/.local/state/sos/agent` was a 4,096-byte mode-0700 `sos:sos` +directory, `auth.json` a 2,091-byte mode-0600 `sos:sos` regular file, and +`config.env` a 60-byte mode-0600 `sos:sos` regular file; no private content or +hash was captured. Cleanup recorded and removed gate-owned `/tmp/.X1-lock` and +`/tmp/.X11-unix/X1`, then powered off orderly. No QEMU process, PID file, QMP +socket, port-2222 listener, SSH response, or overlay owner remained. The +healthy overlay grew from 41,468,035,072 to 41,503,555,584 apparent bytes and +had zero check errors. Its 436,404,224-byte backing remained SHA-256 +`d4e6f5d1e9f571c198a65b45ab1adae6c5734607614e72f9661d84ce5881e5fc`. +Campaign duration was exactly 1262.945136789 seconds from captured monotonic +342069270751830 through 343332215888619, never inferred from a timeout. +Model-weighted cost was unavailable. + +The finalized evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r24`. Its sorted, +self-excluding manifest has 360 rows, 35,721 bytes, SHA-256 +`d29c69495a198ed0082dfb7890da06b3504773d4862e0eaf44aaba5e4e8da26d`. +Independent exact-set/order/uniqueness/size/hash/pairing/self-exclusion/ +manifest-last/regeneration audit passed. External verifier outputs are +`/tmp/sos-linux-x86_64-acceptance-20260818-r24-manifest-audit/002-manifest-verify.raw` +(118 bytes, +`ba2fa7ad50c90d61696f0929cd461670d86ec9100512b2a0f8238c6f594a7e81`) +and `009-external-exact-audit.raw` in that directory (199 bytes, +`ab4ae1dfcd6d3a3aa4435611b301e102274435ec28b8011feca21be207630177`). + +**Root cause / changed code:** This was a command-ack versus semantic +generation race, not stale model state or focus loss. Action `.ok=true` only +means `try_send` accepted the action. Old `wait` blocked only while generation +was at/below the baseline, then returned `.ok=true` for the first newer +snapshot—even on timeout—without binding it to focus. Earlier activate/scroll +commits could publish that unrelated generation before the focus action's +render. The focus handler itself assigns `semantic_focus = note-draft` before +its recorded action log. + +`apps/experience/src/linux_accessibility.rs` now accepts an optional exact +`focused` wait predicate and loops on the condition variable until both a +strictly newer generation and exact focus exist. Timeout returns `.ok=false` +with full terminal snapshot, baseline generation, expected focus, captured +monotonic elapsed milliseconds, and `timed_out=true`. Focused tests cover a +stale/wrong intermediate generation followed by focused generation, newer +wrong-focus timeout, and matching-but-not-new timeout. +`tools/linux-compositor/verify-nested` requests `focused=note-draft`, +independently checks strict generation advance plus exact focus, and prints the +compact full diagnostic response on failure; success emits the exact baseline, +focused generation, and measured wait milliseconds. Arbitrary sleep, +any-generation acceptance, weakened focus, and changing the correct semantic +model were rejected. Prior semantic-history and stale-scene fixes remain +intact. + +Host checks passed +`cargo check --locked -p sos-experience --features linux-host --tests`, +`cargo fmt --all -- --check`, `bash -n tools/linux-compositor/verify-nested`, +and `git diff --check`. The first focused `cargo test` attempt compiled but +could not link because this host lacks the development linker names +`libxkbcommon.so` and `libxkbcommon-x11.so`. A removed-after-use temporary +link directory mapped those names to the host's +`/usr/lib64/libxkbcommon.so.0` and the repository-adjacent Android emulator's +`libxkbcommon-x11.so.0`; with only `LIBRARY_PATH`/`LD_LIBRARY_PATH` pointed at +that directory, the four focused `linux_accessibility::tests` passed. A wider +library run passed 30/31 tests, including both pre-existing semantic-history +tests, but the unrelated pre-existing +`tests::embedded_experience_is_valid` failed because the default experience +contains disallowed `audio.set_volume`; neither that experience nor its +provider registry changed in this repair. ShellCheck was unavailable. No VM, +compositor, device, credential, or provider was used for the repair. + +**Decision / exact r25 gate:** R24 is **FAIL** at Phase C; Phase 0/A/B apply +only to superseded r24 source, C is incomplete, and D–F have no result. R25 +must run one fresh complete Debian 13 x86-64 A–F transaction with exact +initial/final source and untracked identities, clean VM ownership, metadata- +only credential audit, bundle identity, and deterministic manifest closure. +Repeat provisioning; A's locked install/check/all tests/separate build; and +B's exact different initial/active revisions and context/validate/submit flow. + +Phase C must record baseline and terminal accessibility generations, prove +strict advance with exact `focused=note-draft`, and retain all timeout fields +on failure. It must pass native text/clipboard/IME, providers, exact XWayland +PID/parent/client/display/bounded titled surface, activation/recovery PIDs, +three nested fences, suppressed-event count, revision/authority agreement, and +owned-process/run-root cleanup. D must pass activation/recovery PIDs, three +VirtIO DRM fences, recovery-view ordering, and GDM restoration. E must pass +exact semantic/status/history and process parentage, selected supported +suspend mode, all 18 lifecycle checkpoints, connector/KMS transitions, all +three recovery classes, exhaustive cleanup, and terminal PASS. + +Before F login, capture GDM before/active/restored identity and require +`DefaultSession=sos.desktop`. F must metadata-validate preserved credentials +without private content/hashes or unauthorized reauthentication, use the +exact A bundle/provider/model, install once, and prove authenticated GDM SOS, +`sos_login_session_ready`, mapped Shell/DRM, one visible resident rewrite with +different revisions, unchanged host PID, equal authority, changed history, +and exact semantic completion. Finally inject selectable-session +`Ctrl+Alt+Backspace`, prove logout, exhaustive session/PID/runtime cleanup, +credential preservation, greeter return, GNOME/GDM restoration and normal +graphical/GDM/seatd/SSH; power off; close all evidence; atomically generate +and independently verify the exact sorted self-excluding manifest. R25 remains +VM-only and makes no physical-hardware, production-GPU, platform-suspend, +latency, memory, thermal, or model-cost claim. + +## 2026-08-18 — Reject Linux x86-64 r27 cleanup false PASS and bind exact X11 artifacts + +**Goal / exact source closure:** R27 attempted the complete Debian 13 x86-64 +A–F campaign from +`/home/carlid/.t3/worktrees/sos/t3code-e2a4bacf`, branch +`t3code/rerun-linux-baseline`, HEAD +`e05f91bb6f0b0a9299b914138d6cd0966b9c82d5`. Initial and final source +ledgers were byte-identical: each is 204,914 bytes with SHA-256 +`bb9fc6c2d159cdb01dd17156650d5e9c705ac318f03250637ecbdd8f099a2545`. +They retained the same tracked/staged/untracked identities and the same +1,879,763-byte host bundle with SHA-256 +`eb4e2aaea63b41042744579a25da380b9a783b4624687d5be273217611dc59ff`. + +**Phase 0 and phases A–B:** Preflight found no prior target PID file, QMP +socket, overlay owner, or port-2222 listener. Native-KVM QEMU PID 3224105 ran +`sos-debian13` with 8 host x86-64 CPUs, 12,288 MiB, and VirtIO GPU. QMP +`query-status`, `query-cpus-fast`, and `query-memory-size-summary`, each run +with exact environment +`SOS_LINUX_VM_ROOT=/home/carlid/dev/sos/.cache/linux-vm` and +`SOS_LINUX_VM_NAME=sos-debian13`, proved running state and that topology. The +guest was Debian 13, kernel `6.12.101+deb13-amd64`, x86-64, with graphical +target, GDM, seatd, and SSH active and Debian-gdm greeter c1 on seat0/tty1. +Provisioning returned 0 in 16.319233258 seconds, retained Rust 1.95.0 and Node +24.18.0, passed its locked 14-test package sequence, and reproduced the exact +bundle identity above plus package SHA-256 +`4691b055beb89a5e42c92aa3fec14ef456d3c39538270c48b2e44144e8d276f5`. + +Phase A's separately captured commands all returned 0: `npm ci +--ignore-scripts` in 3.868347065 seconds, `npm run check` in 2.332535612 +seconds, 14/14 tests in 3.100291290 seconds, and the separate final `npm run +build` in 2.516708170 seconds. Guest bundle mode after the final build was +0775 with the same bytes and hash. Phase B returned 0 in 6.881949451 seconds +and changed revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0` +through the exact `get_experience_context`, `validate_experience`, and +`submit_experience` flow. + +**Phase C functional PASS, cleanup FAIL:** `tools/linux-compositor/verify-nested` +returned 0 in 18.072912334 seconds. Accessibility advanced generation 23 to +24 with exact `focused=note-draft` and measured `wait_elapsed_ms=0`. Permanent +host PID 4632 activated the Phase B revision and recovered after forced failure +as PID 5016. The three `nested_backend_submit` fences were initial +commit/submit 1/460, activation 15/473, and recovery 101/542. Activation +suppressed 166 compositor events while restoring Shell focus. Native text, +clipboard, providers, revision/authority agreement, and compatibility mapping +passed; final native text was `Caffè ☕️ – 明日のデザイン你号é`. The IME +attached through input-method-v2, published cursor rectangle `241,434 0x28`, +and selected `你号` from candidates `你好`/`你号`. + +Compositor PID 3656 owned rootless XWayland child PID 3688 on display `:1`; +the exact `xmessage` client was PID 5119. XWayland 24.1.6 mapped the titled +`SOS XWayland compatibility gate` surface as window 4194340 at x=280, y=140, +`override_redirect=false`. The verifier then emitted +`linux_nested_cleanup_passed owned_pids_absent=true run_dir_absent=true`, but +the immediately captured inventory still contained `/tmp/.X11-unix/X1`, a +socket owned by `sos:sos`, and `/tmp/.X1-lock`. Only Debian-gdm XWayland +`:1024` remained in the process audit; `/tmp/.X11-unix/X1024`, X1025, and +their locks were preexisting GDM artifacts, with X1025 confirmed owned by +`Debian-gdm:Debian-gdm`. Therefore the cleanup marker was a false PASS even +though functional Phase C passed. Phases D–F were not run. + +The closure command removed only the stale campaign X1 pair before its +ill-scoped attempt to remove Debian-gdm X1025 failed with `Operation not +permitted`; X1024 and X1025 remained. That shared-display cleanup approach is +rejected: campaign cleanup must never glob, infer from a general inventory, or +remove GDM's X1024/X1025 artifacts. Credential metadata remained unchanged: +`/home/sos/.local/state/sos/agent` was a 4,096-byte mode-0700 `sos:sos` +directory containing only the 2,091-byte mode-0600 `auth.json` and 60-byte +mode-0600 `config.env`; no credential content or hash was captured. Exact-env +QMP `system_powerdown` completed orderly in 3.040179416 seconds, after which no +target QEMU process, PID file, QMP socket, overlay owner, or port-2222 listener +remained. The healthy overlay grew from 41,516,662,784 to +41,522,888,704 apparent bytes and retained zero check errors. Its +436,404,224-byte backing remained SHA-256 +`d4e6f5d1e9f571c198a65b45ab1adae6c5734607614e72f9661d84ce5881e5fc`. +Campaign duration was exactly 605.520675054 seconds from captured monotonic +345422699265400 through 346028219940454; model-weighted cost was unavailable. + +The finalized evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r27`. Its sorted, +self-excluding manifest has 178 rows, 17,312 bytes, SHA-256 +`577a479682482913b6c2891335cf06380cf4fc002c8c735b28afac77139be011`. +The external verifier output is +`/tmp/sos-linux-x86_64-acceptance-20260818-r27-manifest-verify.raw` (118 bytes, +SHA-256 +`4f3052aa26290686181b4fcf5caacdb0dc37a776ef30e3431ba51b5191b3c0bf`), +which records PASS and the exact 178-file set. + +**Root cause / changed code:** Smithay allocates the X11 lock and listening +sockets in the compositor before spawning the XWayland child; the lock records +the compositor PID and the child inherits the listening descriptors. The r27 +`:1` pair was therefore the inner Smithay/XWayland allocation, not Xvfb's +outer display. Terminating the compositor by signal bypassed Rust `Drop` +cleanup, while the verifier proved only owned-PID and run-directory absence, +so the exact X11 pair escaped its PASS predicate. Dynamic Xvfb and Smithay +display selection also did not preserve before/created identities. + +`sos-compositor` now accepts an optional exact `--xwayland-display` alongside +the display evidence file, rejects any preexisting socket or lock before +asking Smithay to spawn, and passes that exact display to Smithay. The nested +verifier selects distinct unused displays in its bounded 64–127 campaign +range for Xvfb and XWayland, records exact pre-start absence, captures created +device/inode/UID/mode identities tied to the Xvfb or compositor lock PID, and +requires the published XWayland display to equal the allocation. Its EXIT +trap captures early-start artifacts when possible, terminates and waits all +campaign owners, proves no exact X server or socket/lock owner remains, +preserves any absent-before but uncaptured, replaced, wrong-owner, or +wrong-type object, removes only matching campaign identities, and asserts +both exact pairs absent before emitting cleanup PASS. No glob or cleanup path +can reach GDM X1024/X1025. The earlier generation-bound focus repair remains +unchanged. + +The shell fixture suite covers absent-created-cleaned, preexisting refusal and +alternate selection, wrong type, wrong expected owner, live-owner refusal, +preservation of exact X1024/X1025 baselines, and suppression of the cleanup +marker while an artifact remains. It passed with +`nested_x11_artifact_tests_passed ... gdm_x1024_x1025_preserved=true +false_cleanup_pass_absent=true`. Host checks passed `cargo check --locked -p +sos-compositor --lib`, `cargo fmt --all -- --check`, Bash syntax for the +verifier/library/fixture/test, the fixture test, and `git diff --check`. +`cargo test --locked -p sos-compositor --lib` first compiled but could not +link because this host lacks the development name `libxkbcommon.so`; a +removed-after-use temporary directory mapped it to `/lib64/libxkbcommon.so.0`, +after which all 10 compositor library tests passed. ShellCheck and host Xvfb +were unavailable, so no nested compositor, VM, device, credential, or provider +was used for this repair. The remaining risk is the unrerun integration of +the exact display allocation with Debian's real Xvfb and XWayland processes; +r28 Phase C is the next gate for that behavior and for the final absence +marker. + +**Decision / exact r28 gate:** R27 is **FAIL** at Phase C cleanup. Its Phase +0/A/B results and Phase C functional result apply only to superseded r27 +source; C has no acceptable terminal PASS and D–F have no result. R28 must run +one fresh complete Debian 13 x86-64 A–F transaction with exact initial/final +source and untracked identities, metadata-only credential audit, exact bundle +identity, and deterministic manifest closure. Every VM start/status/topology/ +poweroff QMP command must carry +`SOS_LINUX_VM_ROOT=/home/carlid/dev/sos/.cache/linux-vm` and +`SOS_LINUX_VM_NAME=sos-debian13`. Repeat provisioning; A's locked install, +check, all tests, and separate build; and B's different initial/active +revisions through the exact context/validate/submit sequence. + +Phase C must retain the generation-bound focus, native text/clipboard/IME, +provider, three-fence, activation/recovery PID, exact XWayland child/client/ +display/titled-surface, suppressed-event, and authority proofs. It must also +record preexisting and created identities for both exact Xvfb and XWayland +socket/lock pairs, prove all owners terminated, prove only the run-created +expected-owner/type identities were removed, assert both pairs absent before +cleanup PASS, and prove Debian-gdm X1024/X1025 identities unchanged. D must +pass activation/recovery PIDs, three VirtIO DRM fences, recovery-view ordering, +and GDM restoration. E must pass exact semantic/status/history and parentage, +the selected supported suspend mode, all 18 lifecycle checkpoints, connector/ +KMS transitions, all three recovery classes, exhaustive cleanup, and terminal +PASS. + +Before F login, capture GDM before/active/restored identity and require exact +`DefaultSession=sos.desktop`. F must metadata-validate preserved credentials +without content/hashes or reauthentication, use the exact A bundle/provider/ +model, install once, and prove authenticated GDM SOS, +`sos_login_session_ready`, mapped Shell/DRM, one visible resident rewrite with +different revisions, unchanged host PID, equal authority, changed history, +and exact semantic completion. Then inject selectable-session +`Ctrl+Alt+Backspace`, prove logout and exhaustive session/PID/runtime cleanup, +credential preservation, greeter return, GNOME/GDM restoration and normal +graphical/GDM/seatd/SSH; power off through the exact QMP environment; close all +evidence; atomically generate and independently verify the sorted, +self-excluding exact manifest. R28 remains VM-only and makes no physical- +hardware, production-GPU, platform-suspend, latency, memory, thermal, or +model-cost claim. + +## 2026-08-18 — Reject Linux x86-64 r28 at safe Phase F preflight and harden evidence closure + +**Goal / exact source closure:** R28 attempted one complete Debian 13 x86-64 +A–F transaction from +`/home/carlid/.t3/worktrees/sos/t3code-e2a4bacf`, branch +`t3code/rerun-linux-baseline`, HEAD +`e05f91bb6f0b0a9299b914138d6cd0966b9c82d5`. Initial and final worktree, +HEAD, branch, status, tracked diff, staged diff, untracked list, host bundle, +and every untracked identity compared byte-for-byte. The matching 1,070-byte +porcelain status had SHA-256 +`5b68a6d9a092e0fcfa675cf6ccd7f369317f0bbc5f8cea39fdf7004dd0e47084`; +the matching 221,864-byte tracked diff had SHA-256 +`d394dfd16207c4e3d33e8b93c8dbf9744d7bd3ae4a8ce49751a35b948158e23f`; +the staged diff was empty; and the matching 386-byte untracked list had +SHA-256 +`e05d10e39f04b550de19c871b4172b8624ece45f5e399cf008fec912091c92d9`. +The tracked source set was `apps/experience/src/linux.rs`, +`apps/experience/src/linux_accessibility.rs`, +`crates/sos-compositor/src/lib.rs`, `crates/sos-compositor/src/xwayland.rs`, +`docs/linux-compositor.md`, `docs/linux-stable-host.md`, `docs/linux-vm.md`, +`docs/progress.md`, `docs/sos-agent.md`, +`packaging/libexec/sos-agent-login`, +`packaging/libexec/sos-login-session`, `services/sos-agent/src/main.ts`, +`services/sos-agent/src/runtime.ts`, +`services/sos-agent/src/stdio-runner.ts`, +`services/sos-agent/test/runner.test.ts`, +`tools/install-linux-login-session`, +`tools/linux-compositor/verify-nested`, +`tools/linux-vm/provision-debian`, and +`tools/linux-vm/verify-boot-session`. + +The complete initial/final untracked ledger was: + +- `tests/fixtures/linux-agent-history.json`: 235 bytes, mode 0644, + UID:GID 1000:1000, SHA-256 + `03505f460c2b67a38e27aa82e85d379a4f4562b0ff4bfc8ce421a8513b314cf5`; +- `tests/fixtures/linux-x11-socket-owner`: 314 bytes, mode 0755, + UID:GID 1000:1000, SHA-256 + `fb6a3cd131fd1e23678dbcca7b0dd02581eae266056755ae7db7ad1430428537`; +- `tools/evidence-run`: 3,984 bytes, mode 0755, UID:GID 1000:1000, + SHA-256 + `50c08af1e093137097f030300ba7dd8fcf91163d04f2e0c4f1c55dcd8cd7d131`; +- `tools/linux-compositor/nested-x11-artifacts-lib`: 10,878 bytes, mode + 0644, UID:GID 1000:1000, SHA-256 + `4fbadeb9de4fc1449b1e9277fd82837515e94d912c06717afa08480e5a6502af`; +- `tools/linux-compositor/test-nested-x11-artifacts`: 5,116 bytes, mode + 0755, UID:GID 1000:1000, SHA-256 + `85ee379b8ea04f76d24f7e5a430e4546cb45c8378870a374312598672976fcf8`; +- `tools/linux-vm/boot-session-evidence-lib`: 798 bytes, mode 0644, + UID:GID 1000:1000, SHA-256 + `d4762ab73af50aec3ca089253475ba7af836866c118b65c347296fabb698c14e`; +- `tools/linux-vm/boot-session-lifecycle-lib`: 1,177 bytes, mode 0644, + UID:GID 1000:1000, SHA-256 + `1fbdf514e46b401dc1e20d78d1d5db54f2db591d35b67855dd093b71b5153212`; +- `tools/linux-vm/test-boot-session-evidence`: 1,739 bytes, mode 0755, + UID:GID 1000:1000, SHA-256 + `79a97e996e63f4716f5b49c555930e86469cdb6479c968bad3b3f096f284eddf`; +- `tools/linux-vm/test-boot-session-lifecycle`: 2,425 bytes, mode 0755, + UID:GID 1000:1000, SHA-256 + `c757a2b792fe6c3384e3e13781bc79614e7fa510d666776bea7e50cb15ff095c`; +- `tools/test-evidence-run`: 1,808 bytes, mode 0755, UID:GID 1000:1000, + SHA-256 + `72b41a3457322d7c61334f66f84dc6008790662f6ccda089934a1df3aac26809`. + +The host bundle remained 1,879,763 bytes, mode 0755, with SHA-256 +`eb4e2aaea63b41042744579a25da380b9a783b4624687d5be273217611dc59ff`. +No source changed during the captured campaign. + +**Phase 0 and phases A–B — PASS:** Preflight found no target PID file, QMP +socket, overlay owner, or port-2222 listener. Native-KVM QEMU PID 3240598 ran +`sos-debian13` with 8 host x86-64 CPUs, 12,288 MiB and one two-output VirtIO +GPU. QMP status/topology/memory, and later poweroff, all used the exact +environment `SOS_LINUX_VM_ROOT=/home/carlid/dev/sos/.cache/linux-vm` and +`SOS_LINUX_VM_NAME=sos-debian13`. The guest was Debian 13.6, kernel +`6.12.101+deb13-amd64`, x86-64, with graphical target, GDM, seatd and SSH +active and Debian-gdm greeter c1 on seat0/tty1. Provisioning returned 0 in +20.078820958 seconds, retained Rust 1.95.0, Node 24.18.0 and npm 11.16.0, +passed its locked 14-test package sequence, and reproduced package SHA-256 +`4691b055beb89a5e42c92aa3fec14ef456d3c39538270c48b2e44144e8d276f5` +and the exact bundle identity above. + +Phase A's four separately captured commands returned 0: `npm ci +--ignore-scripts` in 4.179793623 seconds, `npm run check` in 2.427226988 +seconds, 14/14 direct Node tests in 0.837249602 seconds, and the separate final +`npm run build` in 2.664366553 seconds. The final guest bundle was the same +1,879,763 bytes and hash, mode 0775. Phase B returned 0 in 7.026354362 seconds +and changed revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0` +through the exact `get_experience_context`, `validate_experience`, and +`submit_experience` sequence. + +**Phase C — PASS, including r27 cleanup regression:** The nested verifier +returned 0 in 22.265124628 seconds. Accessibility advanced generation 18 to +19 with exact `focused=note-draft` and measured `wait_elapsed_ms=0`. +Permanent host PID 7157 activated the Phase B revision and recovered after +forced failure as PID 7515. Initial, activation, and recovery +`nested_backend_submit` fences were commit/submit `1/609`, `14/622`, and +`94/689`. Native text, clipboard, provider and authority proofs passed; input +quiesce suppressed 154 events and terminal native text was +`Caffè ☕️ – 明日のデザイン你号é`. XWayland 24.1.6 PID 5977 had compositor +parent PID 5954 and exact client PID 7615 on `:65`; window 4194340 titled +`SOS XWayland compatibility gate` mapped at 280,140 with +`override_redirect=false`. The IME attached through input-method-v2, reported +cursor rectangle `241,434 0x28`, and selected `你号` from the bounded +candidate history. + +The dual-display cleanup contract allocated Xvfb `:64` and XWayland `:65` +only after exact socket/lock absence. X64's created socket and lock identities +were `34:790:1000:c1ff` and `34:789:1000:8124` with lock PID 5902; X65's were +`34:802:1000:c1fd` and `34:801:1000:81b4` with lock PID 5954. Terminal checks +proved both exact pairs and every owner absent before +`linux_nested_cleanup_passed`. The unrelated Debian-gdm X1024 socket/lock +remained byte-identical in metadata as device/inode `34:23`/`34:22`, +UID:GID 112:116, modes 0775/0444; X1025 likewise remained `34:25`/`34:24`, +112:116, 0775/0444. The only terminal shared X11 artifacts were those four +GDM paths. This closes r27's false cleanup PASS for the real Debian Xvfb and +XWayland integration. + +**Phases D–E — PASS:** Phase D returned 0 in 14.722285438 seconds. Host PID +8356 activated revision +`250b157308407df4ed48c8e45351e69a0d82534ba001b6ff214c6a3348c0a326` +and recovered as PID 8650. Its initial, activation and recovery VirtIO +`drm_page_flip` fences were `1/35`, `150/49`, and `3758/73`, with recovery +view page flips ordered before initial and recovered shell presentation. +Graphical target, GDM, seatd and SSH were active afterward and greeter c2 was +active on seat0/tty1. + +Phase E returned 0 in 67.137888800 seconds and reached terminal +`linux_boot_session_passed`. The system session began at boot revision +`31f8e1d3…`; its exact semantic snapshot advanced generation 2 to 11 and +contained once each request `Turn this into a spatial time flow` and +completion `The candidate experience is active.`, exact `Ready`, and an empty +composer. The agent activation revision was +`3eef9a00f71362045a3c167454b293d25cc5d565e4eddcecee1de49e3690a9c7` +with unchanged host proxy PID 1006, resident agent PID 951 and broker PID 938. +Persisted semantic history was 58,158 bytes at +`/var/lib/sos-agent/messages.json`, SHA-256 +`5daebaaae3010da1ca7fc705a5d6ee9cace5bb927c125d8d11fbe7c645bf8753`, +with one exact request and one exact completion. + +The initial process chain was session owner 880, compositor 952, platform +authority 1003, supervisor 1005, host proxy 1006, experience host 1010, +broker 938 and agent 951 with the recorded separated UIDs and parentage. The +DRM commit/submit sequence covered boot `1/23`, agent revision `188/51`, +rollback `360/71`, reactivation `391/91`, second rollback `422/104`, host +recovery `436/120`, full-service recovery `1/12`, and lifecycle recovery +`1/11`. The final revision was the Phase B revision; the three recovery +classes reported recovered host PID 2055, restarted main/host PIDs 2163/2253, +and lifecycle-recovered main PID 2439 with `NRestarts=2`. + +All 18 lifecycle checkpoints passed. Sysfs exposed `s2idle [deep]` and the +selected `deep` mode matched one ordered kernel freezer entry/exit pair; +VT tty1→tty2→tty1, pause/activation, same-PID 880 liveness, Virtual-1 +disconnect/reconnect and KMS reinitialization all matched their new logs. +Cleanup proved all campaign-installed paths, units, accounts, group, login +membership and product processes absent, restored graphical target/GDM/seatd, +and emitted both cleanup PASS markers. Post-E checks independently confirmed +the exhaustive absence set, normal graphical/GDM/seatd/SSH state, greeter c1, +and unchanged credential metadata. + +**Phase F — preflight FAIL with no state change:** The credential directory +was 4,096 bytes/mode 0700 `sos:sos`; its only bounded files were the 2,091-byte +mode-0600 `auth.json` and 60-byte mode-0600 `config.env`, with no lock or temp +sibling. The next normal-user preflight ran literal +`find /run/user/1000 -xdev -maxdepth 4 -printf ...`. It enumerated unrelated +desktop and systemd paths, then attempted to descend +`/run/user/1000/systemd/inaccessible/dir`; the intentional mode-000 directory +returned `Permission denied`, so the command exited 1 in 0.135765488 seconds. +The strict campaign stopped immediately. No authentication helper, installer, +GDM selection or configuration mutation, SOS login, resident rewrite, logout, +or GNOME restoration action ran. + +Failure audit found no GDM backup, no installed SOS payload or selectable +session, no `sos-session.*` runtime, and no product process. The GDM +configuration remained 554 bytes/mode 0644 `root:root` with SHA-256 +`ceee968ce021213814ef4f87e19f6e76fcb0333170786dd0c006760ad61810af`; +AccountsService still reported user 1000 with empty XSession and automatic +login false; credential metadata was unchanged; graphical target, GDM, seatd +and SSH remained active; and Debian-gdm greeter c1 remained the active +seat0/tty1 session. Retrying with a broad privileged walk, ignoring the +nonzero result, or continuing into authentication/install after failed +preflight were rejected. + +**Closure and evidence:** Exact-env QMP `system_powerdown` returned 0, QEMU +exited in the captured 2.016178055-second wait, and no target process, PID +file, QMP socket, port-2222 listener or SSH response remained. The healthy +overlay at +`/home/carlid/dev/sos/.cache/linux-vm/sos-debian13.qcow2` grew from +41,522,888,704 to 45,140,410,368 apparent bytes, remained a mode-0644 +`carlid:carlid` regular qcow2, and had zero check errors. Its +436,404,224-byte backing +`/home/carlid/dev/sos/.cache/linux-vm-base/debian-13-generic-amd64.qcow2` +remained mode 0644 `carlid:carlid` with SHA-256 +`d4e6f5d1e9f571c198a65b45ab1adae6c5734607614e72f9661d84ce5881e5fc`. +Final source/bundle identities exactly matched initial capture. Campaign +duration was exactly 2,390.133760129 seconds from captured monotonic +347196711048235 through 349586844808364, never from a timeout; model-weighted +cost was unavailable. + +The sealed evidence root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r28`. Its finalized sorted, +self-excluding `manifest.tsv` has 938 rows, 95,305 bytes and SHA-256 +`5a891d7f1ae0f233495d52ef48e0a90455c20e58f03e38c88a12faf32ffce0d6`. +Repository generation, repository verification and byte-identical +deterministic regeneration all passed. The separate runner external audit did +not inspect a bad row: its multiline Python body was passed through `-c` with +literal `\n` characters and Python rejected the argv with `SyntaxError`. The +gate-captured record was +`/tmp/sos-linux-x86_64-acceptance-20260818-r28-manifest-audit/009-external-exact-audit.raw`, +2,944 bytes, SHA-256 +`9dc23b4ccfee2c675d647eb725692cf353bf3a9e37a2e48c651d451c67ddddc1`; +that temporary record is no longer available. Therefore external audit is +FAIL even though the internal exact checks passed. + +**Evidence-harness repair:** `tools/linux-vm/inventory-sos-runtime` is a +normal-user, metadata-only inventory that accepts an explicit runtime root, +matches only exact product-created top-level `sos-session.XXXXXX` names, and +descends only those matches. It rejects root and never touches unrelated +`systemd/inaccessible`; its fixture proves exact-match inclusion, invalid-name +exclusion, safe behavior beside a real mode-000 unrelated subtree, absence of +file contents, empty cleanup state and deterministic output. The Phase F +privilege matrix in `docs/linux-vm.md` now assigns user-owned runtime and +credential checks to the login user, readable system APIs to the normal user, +and bounded AccountsService/GDM/journal/cross-UID metadata plus root mutations +to explicit noninteractive `sudo -n`. + +`tools/evidence-manifest-verify` is a standalone executable, read-only Python +implementation independent of the Bash generator in `tools/a33xctl`. With +explicit `--root` and `--manifest`, it validates the exact three-column schema, +canonical decimal sizes/lowercase SHA-256, safe UTF-8 relative paths, strict +C-byte sorting and uniqueness, exact self/temporary-excluding regular-file +set, stable byte sizes and hashes, and byte-identical deterministic +regeneration. Its fixtures cover valid/read-only/deterministic operation, +separate size and SHA-256 mismatches, malformed and noncanonical schema, +duplicate and unsorted paths, traversal, missing files and self-inclusion. This rejects inline +Python `-c`, coupling the external audit to the generator implementation, or +modifying the sealed evidence during verification. + +Host-only checks passed Bash syntax across all 18 changed/new shell files, +temporary-cache Python bytecode compilation, `tools/test-evidence-run`, both +boot-session parser fixtures, the nested-X11 fixture, +`tools/linux-vm/test-runtime-inventory`, +`tools/test-evidence-manifest-verify`, and `tests/a33xctl-host-test.sh`. +`npm run check` and all 14 agent tests passed. `cargo fmt --all -- --check`, +both relevant Cargo checks, all 10 compositor library tests, all 7 Linux-host +tests and all 4 Linux accessibility tests passed. The Rust tests used a +removed-after-use temporary linker directory for this host's versioned +libxkbcommon libraries. A fresh standalone audit of the sealed r28 root +independently returned 938 files, 95,305 bytes and the exact manifest and +regeneration SHA-256 above; `git diff --check` also passed. ShellCheck was +unavailable. No VM, device, credential, provider, or evidence file was +modified by this repair validation. + +**Decision / remaining risk / exact r29 gate:** Overall r28 is **FAIL** at +Phase F preflight. A–E pass only for the exact r28 source; there is no accepted +selectable SOS login, real-provider rewrite, logout or restoration result, and +the original external audit body did not execute. R28 makes no physical-hardware, +production-GPU, platform-suspend, latency, memory, thermal or model-cost +claim. Remaining risk is the entire Phase F state-changing path plus real-guest +use of the new narrow inventory and standalone manifest verifier. + +R29 must run one fresh complete Debian 13 x86-64 A–F transaction with a new +exact initial/final source ledger and per-untracked identities. Every VM start, +status/topology and poweroff QMP command must carry exact +`SOS_LINUX_VM_ROOT=/home/carlid/dev/sos/.cache/linux-vm` and +`SOS_LINUX_VM_NAME=sos-debian13`. Repeat provisioning; A's locked install, +check, all tests and separate final build; B's different initial/active +revisions through exact context/validate/submit; C's generation-bound focus, +native text/clipboard/IME/providers, exact Xvfb/XWayland allocations and +cleanup, X1024/X1025 preservation, PIDs, three fences and authority; D's PIDs, +three DRM fences, recovery ordering and GDM restoration; and E's exact +semantic/status/history/parentage, selected supported `deep` mode, all 18 +lifecycle checkpoints, connector/KMS transitions, all three recovery classes, +exhaustive cleanup and terminal PASS. + +Only after A–E pass may F first capture exact GDM before identity and require +the effective `DefaultSession=sos.desktop` under bounded `sudo -n`; a missing +or different value is a pre-authentication stop. Run +`tools/linux-vm/inventory-sos-runtime --root /run/user/1000` as user before +install/login and require `sessions=0 entries=0`; never use a general +`find /run/user/1000`. Metadata-validate the preserved credentials without +contents/hashes or reauthentication; bind the login helper to the exact A +bundle and explicit provider/model; install once; prove authenticated GDM SOS, +`sos_login_session_ready`, mapped Shell/DRM, one visible rewrite with different +revisions, unchanged host PID, equal authority, changed history and exact +semantic completion. Inject selectable-session `Ctrl+Alt+Backspace`; prove +logout, every recorded PID absent, the tested exact runtime inventory empty, +credential preservation, greeter return, GNOME selection, exact GDM restored +identity and normal graphical/GDM/seatd/SSH state. Then power off through the +exact QMP environment, finalize all evidence, generate the manifest atomically, +and execute `tools/evidence-manifest-verify --root "$evidence_root" --manifest +"$evidence_root/manifest.tsv"` directly as the independent external audit; +PASS requires exact set/schema/order/uniqueness/size/hash/self-exclusion and +deterministic-regeneration output from that executable. + +## 2026-08-18 — Complete x86-64 Linux software-baseline rerun (r36) + +**Goal:** Revalidate the entire Linux path after the shared agent/runtime +changes on the existing Fedora-hosted Debian 13 x86-64 VM, including a real +selectable GDM SOS session, keyboard-driven live rewrite, logout, restoration, +and deterministic evidence closure. This is a VM software-baseline result, not +a physical-hardware, production-GPU, latency, thermal, or Framework claim. + +**Exact source and agent:** The campaign ran HEAD +`e05f91bb6f0b0a9299b914138d6cd0966b9c82d5e`. Its initial and final tracked +binary diff were both 242,583 bytes with SHA-256 +`9d3ed9181acde9b0c148c72c1fd26b5f97b391837d02b70450e47dcd912025f9`; +the recorded status and all 14 untracked file hashes also matched. The guest +agent bundle was 1,879,763 bytes, mode 0775, SHA-256 +`eb4e2aaea63b41042744579a25da380b9a783b4624687d5be273217611dc59ff`. + +**Agent and compositor phases — PASS:** Provisioning returned 0 in +28.626504643 seconds. The four independent agent commands returned 0: +`npm ci --ignore-scripts` in 3.745931603 seconds, `npm run check` in +2.301657383 seconds, all 14/14 direct Node tests in 2.964738243 seconds, and +the final `npm run build` in 2.528092434 seconds. `./tools/linux-agent-e2e` +returned 0 in 7.003554611 seconds with exact context/validate/submit ordering +and revision +`31f8e1d31b6e2c91a8a0b0829e5f29934440c64ed8f535bb86d81a5a836c49e5` +to `2303ba94d14063341fe75ff71666b7aa3a8dcd1e1e9f80616708ae8147a302f0`. + +The nested verifier returned 0 in 18.564213213 seconds. It proved strict +generation-bound `focused=note-draft`, exact Xvfb `:64` and XWayland `:65` +ownership and cleanup, XWayland PID/parent/client identity, the titled mapped +compatibility surface, native text/clipboard/IME/provider behavior, and three +ordered `nested_backend_submit` fences. Activation kept host PID 4428 and +forced recovery used PID 4792 at revision `2303ba94…302f0`. + +The direct DRM verifier returned 0 in 14.419425927 seconds. Host PID 5165 +activated revision +`250b157308407df4ed48c8e45351e69a0d82534ba001b6ff214c6a3348c0a326` +and recovered as PID 5436. Initial, activated, and recovery-view presentation +all produced ordered real VirtIO `drm_page_flip` evidence before normal GDM, +seatd, graphical-target, and SSH restoration. + +The boot-owned verifier returned 0 in 63.169640884 seconds and emitted +`linux_boot_session_passed`. Its exact semantic history contained the request +and completion once, `Ready`, and an empty composer. The boot revision +`31f8e1d3…` changed to +`3eef9a00f71362045a3c167454b293d25cc5d565e4eddcecee1de49e3690a9c7` +with host PID 1010 unchanged, then completed rollback/recovery to the Phase-B +revision. The separated process chain was session owner 892, compositor 950, +platform authority 1007, supervisor 1009, host proxy 1010, experience host +1014, broker 941, and agent 955. All 18 lifecycle checkpoints passed in the +selected supported `deep` suspend mode, including stable main PID, freezer +entry/exit, VT, connector/KMS cycle, DRM recovery fences, three recovery +classes, and exhaustive native cleanup. + +**Interactive GDM session — PASS:** The preserved 2,091-byte mode-0600 +`auth.json` and 60-byte mode-0600 `config.env` passed metadata-only checks; +`sos-agent-login --if-needed` reported credential/login ready without +reauthentication. The tested runtime inventory was empty before install. The +single installer returned 0 in 91.511604761 seconds and installed the exact +agent bundle. GDM needed the complete selector set: `DefaultSession=sos.desktop` +and `XSession=sos` alone first launched GNOME; adding AccountsService +`Session=sos` and `SessionType=wayland` selected SOS. That rejected partial +selector setup was restored inside the same transaction before acceptance. + +The accepted Wayland login was logind session 37 on seat0/tty2. It mapped the +Shell with initial revision `31f8e1d3…`, `drm_page_flip`, and the recorded +process identities: login launcher 3745, session owner 3761, compositor 3763, +platform authority 3815, supervisor 3821, host proxy 3823, experience host +3828, authoring broker 3953, and resident agent 3956. QMP delivered real +tablet/keyboard input to type and submit `make a spatial time flow`. The agent +executed `get_experience_context`, `validate_experience`, and +`submit_experience`; revision +`94434a2f7171affe44fd63ac3532c9dc850ec7800460680c75c10f18a30ebc92` +presented at DRM commit/submit 55817/155. `sos_login_rewrite_passed` proved the +same host PID 3823, matching authority revision, visible two-message +completion, and changed 76,662-byte history with SHA-256 +`5b6ea4f239df09de1aa058f5d79024362324abb787cddc6d2615cd230b7ff686`. +The final screenshot visibly shows the generated `SPATIAL DAY` time-flow UI. + +QMP `Ctrl+Alt+Backspace` triggered the product logout path. It emitted +`linux_login_session_stopped reason=user_logout`, +`sos_login_logout_observed ... status=0 rewrite_observed=true`, and +`sos_login_cleanup_passed` with top-level/component/product-process/runtime +absence. The exact original GDM file was restored at 554 bytes with SHA-256 +`ceee968ce021213814ef4f87e19f6e76fcb0333170786dd0c006760ad61810af`; +the temporary AccountsService selector and backup were removed; the installed +SOS payload/session entry were removed; the tested final runtime inventory was +`sessions=0 entries=0`; and the preserved credential metadata remained exact. +Graphical target, GDM, seatd, and SSH were active and the screenshot showed the +Debian 13 greeter on seat0/tty1. + +Three read-only evidence invocations were corrected without product mutation: +shell redirection prevented one privileged `/proc` environment read; one +combined cleanup record supplied the runtime inventory's obsolete `--uid` +form before the succeeding exact `--root /run/user/1000` record; and one +closure record named the old backing path before the succeeding exact backing +identity record. These records are retained in the manifest; none is used as +the acceptance proof. + +**Closure and evidence:** QEMU PID 3745281 powered off orderly. The PID file, +QMP socket, ports 2222/5901, SSH response, and `/proc/3745281` were absent. +The overlay remained a healthy 100-GiB virtual qcow2 with zero check errors, +57,713,347,072 apparent bytes and 57,715,726,848 allocated bytes. Its +436,404,224-byte backing remained SHA-256 +`d4e6f5d1e9f571c198a65b45ab1adae6c5734607614e72f9661d84ce5881e5fc`. +Captured monotonic campaign duration was exactly 1,121.175424324 seconds; +model-weighted cost was unavailable. + +The sealed root is +`/home/carlid/sos-linux-x86_64-acceptance-20260818-r36`. Its sorted, +self-excluding manifest has 163 rows, 15,476 bytes, SHA-256 +`6a46f0965702228d17176bfd5e4141b09bd17060b9d298206a280cea67d69ab3`. +`tools/evidence-manifest-verify` independently passed exact file set, schema, +ordering, uniqueness, size, hash, self-exclusion, and byte-identical +regeneration. External verifier output is +`/tmp/sos-linux-x86_64-acceptance-20260818-r36-manifest-verify.txt`, 360 bytes, +SHA-256 `e83f77e988d9597a900693c6e03db60f675f37b6c308635fe52dceeb35f35eac`. + +**Decision / remaining risk / next gate:** The complete x86-64 Debian VM +software baseline is **PASS** for this exact source and bundle: agent package, +agent E2E, nested compositor, direct DRM, boot-owned session, and interactive +GDM login/rewrite/logout/cleanup all passed. The baseline does not close any +physical Framework, production-GPU, suspend-platform, latency, memory, +thermal, or model-cost gate. The next gate may move to the Framework only +with a fresh exact artifact/source ledger and hardware-specific acceptance +evidence; no Linux software rerun is required unless source or bundle identity +changes again. diff --git a/docs/sos-agent.md b/docs/sos-agent.md index 241d0c9..a9a1229 100644 --- a/docs/sos-agent.md +++ b/docs/sos-agent.md @@ -216,6 +216,17 @@ This helper currently supports the subscription-backed `openai-codex` device flow. Keep using the appliance credential/drop-in procedure below for API-key providers. +The helper validates `auth.json` offline through the agent's credential store +and inspects `config.env` exactly before an `--if-needed` login, reporting each +as `absent`, `invalid`, or `preserved`. A valid credential with missing or +mismatched config repairs only the config and does not repeat OAuth. +Authentication does not pre-create the agent state directory. If it fails, the +helper returns the original agent/login status, removes only empty directories +created by that attempt, and emits `sos_agent_login_incomplete`; any existing +credential or configuration file is preserved for explicit repair. A +successful provider flow must leave a regular, nonempty `auth.json` before the +helper atomically installs `config.env`. + Without credentials, the graphical login refuses to start and names the helper required to repair it. An unexpected agent or broker exit ends the SOS login so it cannot silently present a dead agent as available. diff --git a/packaging/libexec/sos-agent-login b/packaging/libexec/sos-agent-login index bd7956a..07e652b 100755 --- a/packaging/libexec/sos-agent-login +++ b/packaging/libexec/sos-agent-login @@ -31,22 +31,156 @@ command -v node >/dev/null 2>&1 || sos_agent_login_fail "Node.js is not installe sos_agent_login_fail "model ID contains unsupported characters" umask 077 -install -d -m 0700 "$sos_agent_login_state_home" \ - "$sos_agent_login_state_home/sos" "$sos_agent_login_state_dir" -if [[ "${1:-}" == --if-needed && -s "$sos_agent_login_credentials" && -s "$sos_agent_login_config" ]]; then +sos_agent_login_state_home_preexisting=false +sos_agent_login_sos_dir_preexisting=false +sos_agent_login_state_dir_preexisting=false +[[ ! -d "$sos_agent_login_state_home" ]] || sos_agent_login_state_home_preexisting=true +[[ ! -d "$sos_agent_login_state_home/sos" ]] || sos_agent_login_sos_dir_preexisting=true +[[ ! -d "$sos_agent_login_state_dir" ]] || sos_agent_login_state_dir_preexisting=true +sos_agent_login_config_tmp="" +sos_agent_login_credential_validation=unknown + +sos_agent_login_file_state() { + local sos_agent_login_file="$1" + if [[ -f "$sos_agent_login_file" \ + && ! -L "$sos_agent_login_file" \ + && -O "$sos_agent_login_file" \ + && -r "$sos_agent_login_file" \ + && -s "$sos_agent_login_file" ]]; then + printf preserved + elif [[ -e "$sos_agent_login_file" || -L "$sos_agent_login_file" ]]; then + printf invalid + else + printf absent + fi +} + +sos_agent_login_config_file_state() { + local sos_agent_login_file="$1" + local -a sos_agent_login_config_lines=() + if [[ "$(sos_agent_login_file_state "$sos_agent_login_file")" != preserved ]]; then + sos_agent_login_file_state "$sos_agent_login_file" + return 0 + fi + mapfile -t sos_agent_login_config_lines <"$sos_agent_login_file" + if [[ "${#sos_agent_login_config_lines[@]}" -eq 2 \ + && "${sos_agent_login_config_lines[0]}" == "SOS_AGENT_PROVIDER=$sos_agent_login_provider" \ + && "${sos_agent_login_config_lines[1]}" == "SOS_AGENT_MODEL=$sos_agent_login_model" ]]; then + printf preserved + else + printf invalid + fi +} + +sos_agent_login_write_config() { + [[ -d "$sos_agent_login_state_dir" && -O "$sos_agent_login_state_dir" ]] || \ + sos_agent_login_fail "agent credential directory is missing or is not owned by the login user: $sos_agent_login_state_dir" + sos_agent_login_config_tmp="$sos_agent_login_state_dir/.config.env.$$" + printf 'SOS_AGENT_PROVIDER=%s\nSOS_AGENT_MODEL=%s\n' \ + "$sos_agent_login_provider" "$sos_agent_login_model" >"$sos_agent_login_config_tmp" + chmod 0600 "$sos_agent_login_config_tmp" + mv -- "$sos_agent_login_config_tmp" "$sos_agent_login_config" + sos_agent_login_config_tmp="" +} + +# Finish explicitly after authentication errors so reporting does not depend on +# the shell's errexit behavior. The EXIT trap remains a guard for other errors. +sos_agent_login_finish() { + local sos_agent_login_status="$1" + local sos_agent_login_credentials_state sos_agent_login_config_state + local sos_agent_login_directory_state + trap - EXIT + set +e + if [[ -n "$sos_agent_login_config_tmp" ]]; then + rm -f -- "$sos_agent_login_config_tmp" + fi + if [[ "$sos_agent_login_status" -ne 0 ]]; then + sos_agent_login_credentials_state="$(sos_agent_login_file_state "$sos_agent_login_credentials")" + if [[ "$sos_agent_login_credential_validation" == invalid \ + && "$sos_agent_login_credentials_state" == preserved ]]; then + sos_agent_login_credentials_state=invalid + fi + sos_agent_login_config_state="$(sos_agent_login_config_file_state "$sos_agent_login_config")" + if [[ "$sos_agent_login_credentials_state" == absent \ + && "$sos_agent_login_config_state" == absent ]]; then + [[ "$sos_agent_login_state_dir_preexisting" == true ]] || \ + rmdir -- "$sos_agent_login_state_dir" 2>/dev/null || true + [[ "$sos_agent_login_sos_dir_preexisting" == true ]] || \ + rmdir -- "$sos_agent_login_state_home/sos" 2>/dev/null || true + [[ "$sos_agent_login_state_home_preexisting" == true ]] || \ + rmdir -- "$sos_agent_login_state_home" 2>/dev/null || true + fi + sos_agent_login_directory_state=preserved + [[ -e "$sos_agent_login_state_dir" ]] || sos_agent_login_directory_state=absent + printf 'sos_agent_login_incomplete credentials=%s config=%s state_dir=%s retry=%s\n' \ + "$sos_agent_login_credentials_state" \ + "$sos_agent_login_config_state" \ + "$sos_agent_login_directory_state" \ + /usr/local/libexec/sos/sos-agent-login >&2 + fi + exit "$sos_agent_login_status" +} + +# ShellCheck cannot infer that EXIT invokes this callback. +# shellcheck disable=SC2317 +sos_agent_login_cleanup() { + local sos_agent_login_status="$?" + sos_agent_login_finish "$sos_agent_login_status" +} +trap sos_agent_login_cleanup EXIT + +sos_agent_login_credentials_state="$(sos_agent_login_file_state "$sos_agent_login_credentials")" +sos_agent_login_config_state="$(sos_agent_login_config_file_state "$sos_agent_login_config")" +if [[ "$sos_agent_login_credentials_state" == invalid ]]; then + sos_agent_login_fail "credential path is empty, non-regular, linked, or not owned by this user; preserved for repair: $sos_agent_login_credentials" +fi +if [[ "$sos_agent_login_credentials_state" == preserved ]]; then + sos_agent_login_credential_status=0 + node "$sos_agent_login_main" credential-status \ + --provider "$sos_agent_login_provider" \ + --credentials "$sos_agent_login_credentials" || sos_agent_login_credential_status=$? + if [[ "$sos_agent_login_credential_status" -ne 0 ]]; then + sos_agent_login_credential_validation=invalid + printf 'sos_agent_login_preflight_failed error=existing credential store is invalid; preserved for repair: %s\n' \ + "$sos_agent_login_credentials" >&2 + exit "$sos_agent_login_credential_status" + fi + sos_agent_login_credential_validation=valid +fi +if [[ "${1:-}" == --if-needed \ + && "$sos_agent_login_credentials_state" == preserved \ + && "$sos_agent_login_config_state" == preserved ]]; then printf 'sos_agent_login_ready credentials=%s\n' "$sos_agent_login_credentials" exit 0 fi +if [[ "${1:-}" == --if-needed \ + && "$sos_agent_login_credentials_state" == preserved ]]; then + printf 'sos_agent_login_preflight credentials=preserved config=%s action=write-config\n' \ + "$sos_agent_login_config_state" + sos_agent_login_write_config + printf 'sos_agent_login_configured provider=%s model=%s\n' \ + "$sos_agent_login_provider" "$sos_agent_login_model" + exit 0 +fi +if [[ "${1:-}" == --if-needed ]]; then + printf 'sos_agent_login_preflight credentials=%s config=%s action=authenticate\n' \ + "$sos_agent_login_credentials_state" "$sos_agent_login_config_state" +fi +sos_agent_login_auth_status=0 node "$sos_agent_login_main" login \ --provider "$sos_agent_login_provider" \ --credentials "$sos_agent_login_credentials" \ - --device-code + --device-code || sos_agent_login_auth_status=$? +if [[ "$sos_agent_login_auth_status" -ne 0 ]]; then + sos_agent_login_finish "$sos_agent_login_auth_status" +fi -sos_agent_login_config_tmp="$sos_agent_login_state_dir/.config.env.$$" -printf 'SOS_AGENT_PROVIDER=%s\nSOS_AGENT_MODEL=%s\n' \ - "$sos_agent_login_provider" "$sos_agent_login_model" >"$sos_agent_login_config_tmp" -chmod 0600 "$sos_agent_login_config_tmp" -mv -- "$sos_agent_login_config_tmp" "$sos_agent_login_config" +[[ -f "$sos_agent_login_credentials" \ + && ! -L "$sos_agent_login_credentials" \ + && -O "$sos_agent_login_credentials" \ + && -s "$sos_agent_login_credentials" ]] || \ + sos_agent_login_fail "agent login returned success without a regular nonempty credential file at $sos_agent_login_credentials" +sos_agent_login_write_config printf 'sos_agent_login_configured provider=%s model=%s\n' \ "$sos_agent_login_provider" "$sos_agent_login_model" diff --git a/packaging/libexec/sos-login-session b/packaging/libexec/sos-login-session index 8985826..e9d981c 100755 --- a/packaging/libexec/sos-login-session +++ b/packaging/libexec/sos-login-session @@ -13,6 +13,8 @@ sos_login_fail() { sos_login_fail "XDG_RUNTIME_DIR must be an absolute path supplied by the display manager" [[ -d "$XDG_RUNTIME_DIR" && -O "$XDG_RUNTIME_DIR" ]] || \ sos_login_fail "XDG_RUNTIME_DIR must exist and belong to the login user" +[[ -n "${XDG_SESSION_ID:-}" ]] || \ + sos_login_fail "XDG_SESSION_ID must be supplied by the display manager" sos_login_bin_dir="${SOS_INSTALL_ROOT:-/usr/local/libexec/sos}" sos_login_default_source="${SOS_DEFAULT_EXPERIENCE:-/usr/share/sos/experiences/default.luau}" @@ -86,9 +88,25 @@ sos_login_supervisor_socket="$sos_login_revision_root/run/supervisor.sock" sos_login_session_pid="" sos_login_authoring_pid="" sos_login_agent_pid="" +sos_login_monitor_pid="" +sos_login_initial_revision="" +sos_login_initial_host_pid="" +sos_login_owned_component_pids=() # ShellCheck cannot infer that EXIT invokes this callback. # shellcheck disable=SC2317 sos_login_cleanup() { + local sos_login_exit_status="$?" + local sos_login_top_level_absent=true + local sos_login_components_absent=true + local sos_login_matching_processes_absent=true + local sos_login_runtime_absent=true + local sos_login_process_path sos_login_process_uid sos_login_process_executable + trap - EXIT HUP INT TERM + set +e + if [[ -n "$sos_login_monitor_pid" ]] && kill -0 "$sos_login_monitor_pid" 2>/dev/null; then + kill -TERM "$sos_login_monitor_pid" 2>/dev/null || true + wait "$sos_login_monitor_pid" 2>/dev/null || true + fi for sos_login_cleanup_pid in \ "$sos_login_agent_pid" \ "$sos_login_authoring_pid" \ @@ -103,9 +121,54 @@ sos_login_cleanup() { "$sos_login_session_pid"; do [[ -z "$sos_login_cleanup_pid" ]] || wait "$sos_login_cleanup_pid" 2>/dev/null || true done + for sos_login_cleanup_pid in \ + "$sos_login_agent_pid" \ + "$sos_login_authoring_pid" \ + "$sos_login_session_pid"; do + if [[ -n "$sos_login_cleanup_pid" ]] && kill -0 "$sos_login_cleanup_pid" 2>/dev/null; then + sos_login_top_level_absent=false + fi + done + for sos_login_cleanup_pid in "${sos_login_owned_component_pids[@]}"; do + if [[ -n "$sos_login_cleanup_pid" ]] && kill -0 "$sos_login_cleanup_pid" 2>/dev/null; then + sos_login_components_absent=false + fi + done + for sos_login_process_path in /proc/[0-9]*; do + sos_login_process_uid="$(stat -c '%u' "$sos_login_process_path" 2>/dev/null)" || continue + [[ "$sos_login_process_uid" == "$(id -u)" ]] || continue + sos_login_process_executable="$(readlink -f "$sos_login_process_path/exe" 2>/dev/null)" || continue + if [[ "$sos_login_process_executable" == "$sos_login_session_bin" \ + || "$sos_login_process_executable" == "$sos_login_compositor_bin" \ + || "$sos_login_process_executable" == "$sos_login_provider_bin" \ + || "$sos_login_process_executable" == "$sos_login_supervisor_bin" \ + || "$sos_login_process_executable" == "$sos_login_host_bin" \ + || "$sos_login_process_executable" == "$sos_login_authoring_bin" ]]; then + sos_login_matching_processes_absent=false + fi + if tr '\0' '\n' <"$sos_login_process_path/cmdline" 2>/dev/null \ + | grep -Fxq "$sos_login_agent_main"; then + sos_login_matching_processes_absent=false + fi + done if [[ "$sos_login_runtime" == "$XDG_RUNTIME_DIR"/sos-session.* && -d "$sos_login_runtime" ]]; then - rm -r -- "$sos_login_runtime" + rm -r -- "$sos_login_runtime" || sos_login_runtime_absent=false + fi + [[ ! -e "$sos_login_runtime" ]] || sos_login_runtime_absent=false + if [[ "$sos_login_top_level_absent" == true \ + && "$sos_login_components_absent" == true \ + && "$sos_login_matching_processes_absent" == true \ + && "$sos_login_runtime_absent" == true ]]; then + printf 'sos_login_cleanup_passed top_level_pids_absent=true component_pids_absent=true matching_product_processes_absent=true runtime_absent=true\n' + else + printf 'sos_login_cleanup_failed top_level_pids_absent=%s component_pids_absent=%s matching_product_processes_absent=%s runtime_absent=%s\n' \ + "$sos_login_top_level_absent" \ + "$sos_login_components_absent" \ + "$sos_login_matching_processes_absent" \ + "$sos_login_runtime_absent" >&2 + [[ "$sos_login_exit_status" -ne 0 ]] || sos_login_exit_status=1 fi + exit "$sos_login_exit_status" } # ShellCheck cannot infer that the signal traps invoke this callback. # shellcheck disable=SC2317 @@ -201,6 +264,176 @@ sos_login_wait_for_socket "$sos_login_agent_socket" \ printf 'sos_login_agent_started pid=%s socket=%s\n' \ "$sos_login_agent_pid" "$sos_login_agent_socket" +sos_login_status_revision() { + sed -n 's/.*"active_revision"[[:space:]]*:[[:space:]]*"\([0-9a-f]*\)".*/\1/p' <<<"$1" +} + +sos_login_status_host_pid() { + sed -n 's/.*"host_pid"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' <<<"$1" +} + +sos_login_authority_revision() { + sed -n 's/.*"revision_id"[[:space:]]*:[[:space:]]*"\([0-9a-f]*\)".*/\1/p' \ + "$sos_login_authority_file" | head -n 1 +} + +sos_login_process_identity() { + local sos_login_role="$1" + local sos_login_pid="$2" + local sos_login_ppid sos_login_uid sos_login_user sos_login_executable + [[ "$sos_login_pid" =~ ^[1-9][0-9]*$ ]] || \ + sos_login_fail "invalid $sos_login_role PID: $sos_login_pid" + sos_login_ppid="$(ps -o ppid= -p "$sos_login_pid" | xargs)" + sos_login_uid="$(ps -o uid= -p "$sos_login_pid" | xargs)" + sos_login_user="$(ps -o user= -p "$sos_login_pid" | xargs)" + sos_login_executable="$(readlink -f "/proc/$sos_login_pid/exe")" + printf 'sos_login_process_identity role=%s pid=%s ppid=%s uid=%s user=%s executable=%s\n' \ + "$sos_login_role" \ + "$sos_login_pid" \ + "$sos_login_ppid" \ + "$sos_login_uid" \ + "$sos_login_user" \ + "$sos_login_executable" +} + +sos_login_status="" +for _ in {1..3000}; do + sos_login_status="$($sos_login_supervisor_bin daemon-status \ + --root "$sos_login_revision_root" 2>/dev/null || true)" + sos_login_initial_revision="$(sos_login_status_revision "$sos_login_status")" + sos_login_initial_host_pid="$(sos_login_status_host_pid "$sos_login_status")" + if [[ "$sos_login_initial_revision" =~ ^[0-9a-f]{64}$ \ + && "$sos_login_initial_host_pid" =~ ^[1-9][0-9]*$ ]]; then + break + fi + kill -0 "$sos_login_session_pid" 2>/dev/null || \ + sos_login_fail "SOS session exited before publishing revision and host identity" + sleep 0.01 +done +[[ "$sos_login_initial_revision" =~ ^[0-9a-f]{64}$ \ + && "$sos_login_initial_host_pid" =~ ^[1-9][0-9]*$ ]] || \ + sos_login_fail "SOS session did not publish revision and host identity" +[[ "$(sos_login_authority_revision)" == "$sos_login_initial_revision" ]] || \ + sos_login_fail "initial supervisor and authority revisions differ" +printf 'sos_login_initial_status=%s\n' "$sos_login_status" + +sos_login_compositor_pid="$(pgrep -P "$sos_login_session_pid" -f "^$sos_login_compositor_bin")" +sos_login_provider_pid="$(pgrep -P "$sos_login_session_pid" -f "^$sos_login_provider_bin")" +sos_login_supervisor_pid="$(pgrep -P "$sos_login_session_pid" -f "^$sos_login_supervisor_bin")" +sos_login_actual_host_pid="$(pgrep -P "$sos_login_session_pid" -f "^$sos_login_host_bin")" +sos_login_owned_component_pids=( + "$sos_login_compositor_pid" + "$sos_login_provider_pid" + "$sos_login_supervisor_pid" + "$sos_login_initial_host_pid" + "$sos_login_actual_host_pid" +) +for sos_login_direct_child_pid in \ + "$sos_login_compositor_pid" \ + "$sos_login_provider_pid" \ + "$sos_login_supervisor_pid" \ + "$sos_login_actual_host_pid"; do + [[ "$(ps -o ppid= -p "$sos_login_direct_child_pid" | xargs)" == "$sos_login_session_pid" ]] || \ + sos_login_fail "SOS component parentage does not match the session owner" +done +[[ "$(ps -o ppid= -p "$sos_login_initial_host_pid" | xargs)" == "$sos_login_supervisor_pid" ]] || \ + sos_login_fail "host proxy parentage does not match the revision supervisor" +for sos_login_launcher_child_pid in "$sos_login_authoring_pid" "$sos_login_agent_pid"; do + [[ "$(ps -o ppid= -p "$sos_login_launcher_child_pid" | xargs)" == "$BASHPID" ]] || \ + sos_login_fail "agent component parentage does not match the login launcher" +done +sos_login_process_identity login-launcher "$BASHPID" +sos_login_process_identity session-owner "$sos_login_session_pid" +sos_login_process_identity compositor "$sos_login_compositor_pid" +sos_login_process_identity platform-authority "$sos_login_provider_pid" +sos_login_process_identity supervisor "$sos_login_supervisor_pid" +sos_login_process_identity host-proxy "$sos_login_initial_host_pid" +sos_login_process_identity experience-host "$sos_login_actual_host_pid" +sos_login_process_identity authoring-broker "$sos_login_authoring_pid" +sos_login_process_identity resident-agent "$sos_login_agent_pid" +sos_login_logind_active="$(loginctl show-session "$XDG_SESSION_ID" -p Active --value)" +sos_login_logind_seat="$(loginctl show-session "$XDG_SESSION_ID" -p Seat --value)" +sos_login_logind_type="$(loginctl show-session "$XDG_SESSION_ID" -p Type --value)" +sos_login_logind_leader="$(loginctl show-session "$XDG_SESSION_ID" -p Leader --value)" +sos_login_logind_name="$(loginctl show-session "$XDG_SESSION_ID" -p Name --value)" +sos_login_logind_user="$(loginctl show-session "$XDG_SESSION_ID" -p User --value)" +[[ "$sos_login_logind_active" == yes \ + && -n "$sos_login_logind_seat" \ + && "$sos_login_logind_type" == wayland \ + && "$sos_login_logind_leader" =~ ^[1-9][0-9]*$ \ + && "$sos_login_logind_name" == "$(id -un)" \ + && "$sos_login_logind_user" == "$(id -u)" ]] || \ + sos_login_fail "GDM logind session identity is incomplete or inconsistent" +printf 'sos_login_logind_identity session=%s active=%s seat=%s type=%s leader=%s name=%s user=%s\n' \ + "$XDG_SESSION_ID" \ + "$sos_login_logind_active" \ + "$sos_login_logind_seat" \ + "$sos_login_logind_type" \ + "$sos_login_logind_leader" \ + "$sos_login_logind_name" \ + "$sos_login_logind_user" +printf 'sos_login_session_ready revision_id=%s host_pid=%s session_pid=%s logind_session=%s uid=%s user=%s desktop=SOS type=wayland surface=Shell evidence=drm_page_flip\n' \ + "$sos_login_initial_revision" \ + "$sos_login_initial_host_pid" \ + "$sos_login_session_pid" \ + "$XDG_SESSION_ID" \ + "$(id -u)" \ + "$(id -un)" +printf 'sos_login_surface_identity role=Shell revision_id=%s compositor_pid=%s evidence=drm_page_flip\n' \ + "$sos_login_initial_revision" "$sos_login_compositor_pid" + +sos_login_initial_history_bytes=absent +sos_login_initial_history_sha256=absent +if [[ -f "$sos_login_agent_state_dir/messages.json" ]]; then + sos_login_initial_history_bytes="$(stat -c '%s' "$sos_login_agent_state_dir/messages.json")" + sos_login_initial_history_sha256="$(sha256sum "$sos_login_agent_state_dir/messages.json" | awk '{ print $1 }')" +fi +sos_login_rewrite_evidence="$sos_login_runtime/rewrite-evidence" +sos_login_monitor_rewrite() { + local sos_login_monitor_status sos_login_monitor_revision sos_login_monitor_host + local sos_login_monitor_authority sos_login_monitor_history_bytes sos_login_monitor_history_sha256 + local sos_login_monitor_result + while kill -0 "$sos_login_session_pid" 2>/dev/null; do + sos_login_monitor_status="$($sos_login_supervisor_bin daemon-status \ + --root "$sos_login_revision_root" 2>/dev/null || true)" + sos_login_monitor_revision="$(sos_login_status_revision "$sos_login_monitor_status")" + sos_login_monitor_host="$(sos_login_status_host_pid "$sos_login_monitor_status")" + if [[ "$sos_login_monitor_revision" =~ ^[0-9a-f]{64}$ \ + && "$sos_login_monitor_revision" != "$sos_login_initial_revision" \ + && -f "$sos_login_agent_state_dir/messages.json" ]]; then + sos_login_monitor_history_bytes="$(stat -c '%s' "$sos_login_agent_state_dir/messages.json")" + sos_login_monitor_history_sha256="$(sha256sum "$sos_login_agent_state_dir/messages.json" | awk '{ print $1 }')" + if [[ "$sos_login_monitor_history_sha256" != "$sos_login_initial_history_sha256" ]]; then + sos_login_monitor_authority="$(sos_login_authority_revision)" + sos_login_monitor_result=failed + if [[ "$sos_login_monitor_host" == "$sos_login_initial_host_pid" \ + && "$sos_login_monitor_authority" == "$sos_login_monitor_revision" ]]; then + sos_login_monitor_result=passed + fi + printf 'sos_login_rewrite_%s initial_revision=%s active_revision=%s initial_host_pid=%s active_host_pid=%s host_pid_unchanged=%s authority_revision=%s authority_equal=%s history_path=%s history_initial_bytes=%s history_initial_sha256=%s history_bytes=%s history_sha256=%s history_changed=true\n' \ + "$sos_login_monitor_result" \ + "$sos_login_initial_revision" \ + "$sos_login_monitor_revision" \ + "$sos_login_initial_host_pid" \ + "$sos_login_monitor_host" \ + "$([[ "$sos_login_monitor_host" == "$sos_login_initial_host_pid" ]] && printf true || printf false)" \ + "$sos_login_monitor_authority" \ + "$([[ "$sos_login_monitor_authority" == "$sos_login_monitor_revision" ]] && printf true || printf false)" \ + "$sos_login_agent_state_dir/messages.json" \ + "$sos_login_initial_history_bytes" \ + "$sos_login_initial_history_sha256" \ + "$sos_login_monitor_history_bytes" \ + "$sos_login_monitor_history_sha256" \ + | tee "$sos_login_rewrite_evidence" + return + fi + fi + sleep 0.25 + done +} +sos_login_monitor_rewrite & +sos_login_monitor_pid=$! + set +e sos_login_finished_pid="" wait -n -p sos_login_finished_pid \ @@ -210,6 +443,11 @@ if [[ "$sos_login_finished_pid" != "$sos_login_session_pid" ]]; then printf 'sos_login_session_failed component=background-agent pid=%s status=%s\n' \ "$sos_login_finished_pid" "$sos_login_status" >&2 [[ "$sos_login_status" -ne 0 ]] || sos_login_status=1 +else + printf 'sos_login_logout_observed session_pid=%s status=%s rewrite_observed=%s\n' \ + "$sos_login_session_pid" \ + "$sos_login_status" \ + "$([[ -s "$sos_login_rewrite_evidence" ]] && printf true || printf false)" fi set -e exit "$sos_login_status" diff --git a/services/sos-agent/src/main.ts b/services/sos-agent/src/main.ts index ccb5eb5..12d3fe7 100644 --- a/services/sos-agent/src/main.ts +++ b/services/sos-agent/src/main.ts @@ -122,18 +122,37 @@ async function login(): Promise { } } +async function credentialStatus(): Promise { + const provider = supportedProvider( + option("--provider") ?? process.env.SOS_AGENT_PROVIDER ?? "openai-codex", + ); + const credentialPath = required("--credentials"); + const stored = await new JsonCredentialStore(credentialPath).read(provider); + if (!stored) throw new Error(`no ${provider} credential is stored at ${credentialPath}`); + if (provider === "openai-codex" && stored.type !== "oauth") { + throw new Error(`the ${provider} credential at ${credentialPath} is not OAuth`); + } + process.stdout.write(`sos_agent_credential_ready provider=${provider} path=${credentialPath}\n`); +} + export async function runCli(): Promise { const command = process.argv[2]; if (command === "login") { await login(); return; } + if (command === "credential-status") { + await credentialStatus(); + return; + } if (command === "prompt") { const exitCode = await promptAgent(required("--socket"), required("--request")); process.exitCode = exitCode; return; } - if (command !== "serve") throw new Error("usage: sos-agent serve|login|prompt [options]"); + if (command !== "serve") { + throw new Error("usage: sos-agent serve|login|credential-status|prompt [options]"); + } const socketPath = required("--socket"); const backend = new UnixAuthoringBackend(required("--authoring-socket")); diff --git a/services/sos-agent/src/runtime.ts b/services/sos-agent/src/runtime.ts index ad36008..02aaa6b 100644 --- a/services/sos-agent/src/runtime.ts +++ b/services/sos-agent/src/runtime.ts @@ -11,6 +11,7 @@ import { type CredentialStore, type MutableModels, } from "@earendil-works/pi-ai"; +import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth"; import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic"; import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex"; import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"; @@ -20,6 +21,17 @@ import { createAuthoringTools, type AuthoringBackend } from "./authoring.js"; export type SupportedProvider = "openai" | "anthropic" | "openai-codex" | "openrouter"; +let bundledOAuthFlowsRegistered = false; + +function registerBundledOAuthFlows(): void { + if (bundledOAuthFlowsRegistered) return; + // Pi's historical Bun entry point statically embeds the OAuth modules. SOS + // also needs it for its single-file Node/CommonJS bundle: that bundle has no + // import.meta.url from which Pi's fallback loader could resolve a module. + registerBunOAuthFlows(); + bundledOAuthFlowsRegistered = true; +} + export interface AgentRuntimeOptions { backend: AuthoringBackend; systemPrompt: string; @@ -60,6 +72,7 @@ export function createProviderModels( provider: SupportedProvider, credentials?: CredentialStore, ): MutableModels { + registerBundledOAuthFlows(); const models = createModels(credentials ? { credentials } : undefined); switch (provider) { case "openai": diff --git a/services/sos-agent/src/stdio-runner.ts b/services/sos-agent/src/stdio-runner.ts index 907a203..8ba4eea 100644 --- a/services/sos-agent/src/stdio-runner.ts +++ b/services/sos-agent/src/stdio-runner.ts @@ -6,7 +6,6 @@ import { type AuthPrompt, type Credential, } from "@earendil-works/pi-ai"; -import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AuthoringBackend } from "./authoring.js"; import { @@ -485,9 +484,6 @@ async function prompt(request: PromptRequest, systemPrompt: string): Promise { - // The registration name is historical; it statically includes Pi's OAuth - // implementations so a single-file Node bundle can perform Codex login. - registerBunOAuthFlows(); const request = await readRequest(); switch (request.action) { case "catalog": diff --git a/services/sos-agent/test/runner.test.ts b/services/sos-agent/test/runner.test.ts index a5c9ad4..2acaa18 100644 --- a/services/sos-agent/test/runner.test.ts +++ b/services/sos-agent/test/runner.test.ts @@ -66,6 +66,206 @@ test("the packaged runner applies the bounded faux Pi contract", async () => { } }); +test("the packaged Linux login uses the embedded Codex OAuth flow", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "sos-agent-linux-login-")); + const home = path.join(directory, "home"); + const bin = path.join(directory, "bin"); + const mockFetch = path.join(directory, "mock-fetch.mjs"); + const credentialPath = path.join(home, ".local/state/sos/agent/auth.json"); + const configPath = path.join(home, ".local/state/sos/agent/config.env"); + try { + await Promise.all([fs.mkdir(home), fs.mkdir(bin)]); + await fs.writeFile( + path.join(bin, "id"), + "#!/bin/sh\nif [ \"$1\" = -u ]; then printf '1000\\n'; else exec /usr/bin/id \"$@\"; fi\n", + { mode: 0o755 }, + ); + await fs.writeFile( + mockFetch, + [ + "const encoded = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');", + "const accessToken = `${encoded({ alg: 'none' })}.${encoded({ 'https://api.openai.com/auth': { chatgpt_account_id: 'account-test' } })}.signature`;", + "globalThis.fetch = async (input) => {", + " const url = String(input);", + " if (url.endsWith('/api/accounts/deviceauth/usercode')) return Response.json({ device_auth_id: 'device-test', user_code: 'CODE-TEST', interval: 0 });", + " if (url.endsWith('/api/accounts/deviceauth/token')) return Response.json({ authorization_code: 'authorization-test', code_verifier: 'verifier-test' });", + " if (url.endsWith('/oauth/token')) return Response.json({ access_token: accessToken, refresh_token: 'refresh-test', expires_in: 3600 });", + " throw new Error(`unexpected OAuth request: ${url}`);", + "};", + "", + ].join("\n"), + ); + const environment: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + NODE_OPTIONS: `--import=${mockFetch}`, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + SOS_AGENT_MAIN: path.resolve("dist/agent-runner.cjs"), + }; + delete environment.XDG_STATE_HOME; + + const result = await runChild( + "bash", + [path.resolve("../../packaging/libexec/sos-agent-login"), "--if-needed"], + environment, + ); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /sos_agent_login_preflight credentials=absent config=absent/); + assert.match(result.stdout, /Open this URL in your browser:/); + assert.match(result.stdout, /Enter code: CODE-TEST/); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /endsWith/); + const document = JSON.parse(await fs.readFile(credentialPath, "utf8")) as Record< + string, + Record + >; + const credential = document["openai-codex"]; + assert.equal(credential?.type, "oauth"); + assert.match(String(credential?.access), /^[^.]+\.[^.]+\.signature$/); + assert.equal(credential?.refresh, "refresh-test"); + assert.equal(typeof credential?.expires, "number"); + assert.equal(credential?.accountId, "account-test"); + assert.equal( + await fs.readFile(configPath, "utf8"), + "SOS_AGENT_PROVIDER=openai-codex\nSOS_AGENT_MODEL=gpt-5.6-sol\n", + ); + assert.equal((await fs.stat(credentialPath)).mode & 0o777, 0o600); + assert.equal((await fs.stat(configPath)).mode & 0o777, 0o600); + + const ready = await runChild( + "bash", + [path.resolve("../../packaging/libexec/sos-agent-login"), "--if-needed"], + environment, + ); + assert.equal(ready.code, 0, ready.stderr); + assert.match(ready.stdout, /sos_agent_credential_ready provider=openai-codex/); + assert.match(ready.stdout, /sos_agent_login_ready credentials=/); + assert.doesNotMatch(ready.stdout, /Enter code:/); + + await fs.writeFile(configPath, "SOS_AGENT_PROVIDER=openai-codex\nSOS_AGENT_MODEL=stale\n"); + const repaired = await runChild( + "bash", + [path.resolve("../../packaging/libexec/sos-agent-login"), "--if-needed"], + environment, + ); + assert.equal(repaired.code, 0, repaired.stderr); + assert.match( + repaired.stdout, + /sos_agent_login_preflight credentials=preserved config=invalid action=write-config/, + ); + assert.doesNotMatch(repaired.stdout, /Enter code:/); + assert.equal( + await fs.readFile(configPath, "utf8"), + "SOS_AGENT_PROVIDER=openai-codex\nSOS_AGENT_MODEL=gpt-5.6-sol\n", + ); + } finally { + await fs.rm(directory, { recursive: true }); + } +}); + +test("failed Linux login cleans new empty state and preserves existing credentials", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "sos-agent-login-state-")); + const bin = path.join(directory, "bin"); + const main = path.join(directory, "agent-runner.cjs"); + try { + await fs.mkdir(bin); + await Promise.all([ + fs.writeFile( + path.join(bin, "id"), + "#!/bin/sh\nif [ \"$1\" = -u ]; then printf '1000\\n'; else exec /usr/bin/id \"$@\"; fi\n", + { mode: 0o755 }, + ), + fs.writeFile( + path.join(bin, "node"), + [ + "#!/bin/sh", + "if [ \"${2:-}\" = credential-status ]; then exit 0; fi", + "printf 'sos_agent_failed error=mock authentication rejected\\n' >&2", + "exit 23", + "", + ].join("\n"), + { mode: 0o755 }, + ), + fs.writeFile(main, "// readable mock runner\n"), + ]); + const launcher = path.resolve("../../packaging/libexec/sos-agent-login"); + const baseEnvironment: NodeJS.ProcessEnv = { + ...process.env, + // Debian 13 runs Bash 5.2; retain its documented compatibility level on newer hosts. + BASH_COMPAT: "5.2", + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + SOS_AGENT_MAIN: main, + }; + delete baseEnvironment.XDG_STATE_HOME; + + const freshHome = path.join(directory, "fresh-home"); + await fs.mkdir(freshHome); + const fresh = await runChild("bash", [launcher, "--if-needed"], { + ...baseEnvironment, + HOME: freshHome, + }); + assert.equal(fresh.code, 23); + assert.equal( + fresh.stderr, + "sos_agent_failed error=mock authentication rejected\n" + + "sos_agent_login_incomplete credentials=absent config=absent state_dir=absent " + + "retry=/usr/local/libexec/sos/sos-agent-login\n", + ); + await assert.rejects( + fs.access(path.join(freshHome, ".local/state/sos/agent")), + (error: NodeJS.ErrnoException) => error.code === "ENOENT", + ); + await assert.rejects( + fs.access(path.join(freshHome, ".local/state")), + (error: NodeJS.ErrnoException) => error.code === "ENOENT", + ); + + const emptyHome = path.join(directory, "empty-home"); + const emptyState = path.join(emptyHome, ".local/state/sos/agent"); + await fs.mkdir(emptyState, { recursive: true, mode: 0o700 }); + const empty = await runChild("bash", [launcher, "--if-needed"], { + ...baseEnvironment, + HOME: emptyHome, + }); + assert.equal(empty.code, 23); + assert.equal( + empty.stderr, + "sos_agent_failed error=mock authentication rejected\n" + + "sos_agent_login_incomplete credentials=absent config=absent state_dir=preserved " + + "retry=/usr/local/libexec/sos/sos-agent-login\n", + ); + assert.equal((await fs.stat(emptyState)).isDirectory(), true); + assert.deepEqual(await fs.readdir(emptyState), []); + + const existingHome = path.join(directory, "existing-home"); + const existingState = path.join(existingHome, ".local/state/sos/agent"); + const existingCredential = path.join(existingState, "auth.json"); + const existingConfig = path.join(existingState, "config.env"); + const preservedCredential = '{"openai-codex":{"type":"oauth","access":"existing"}}\n'; + const preservedConfig = + "SOS_AGENT_PROVIDER=openai-codex\nSOS_AGENT_MODEL=gpt-5.6-sol\n"; + await fs.mkdir(existingState, { recursive: true, mode: 0o700 }); + await Promise.all([ + fs.writeFile(existingCredential, preservedCredential, { mode: 0o600 }), + fs.writeFile(existingConfig, preservedConfig, { mode: 0o600 }), + ]); + const existing = await runChild("bash", [launcher], { + ...baseEnvironment, + HOME: existingHome, + }); + assert.equal(existing.code, 23); + assert.equal( + existing.stderr, + "sos_agent_failed error=mock authentication rejected\n" + + "sos_agent_login_incomplete credentials=preserved config=preserved " + + "state_dir=preserved retry=/usr/local/libexec/sos/sos-agent-login\n", + ); + assert.equal(await fs.readFile(existingCredential, "utf8"), preservedCredential); + assert.equal(await fs.readFile(existingConfig, "utf8"), preservedConfig); + } finally { + await fs.rm(directory, { recursive: true }); + } +}); + test("the shared request contract rejects oversized prompts", () => { assert.throws( () => @@ -161,3 +361,19 @@ function exchange(arguments_: string[], request: unknown): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, arguments_, { env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk)); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => (stderr += chunk)); + child.once("error", reject); + child.once("close", (code) => resolve({ code, stdout, stderr })); + }); +} diff --git a/tests/fixtures/linux-agent-history.json b/tests/fixtures/linux-agent-history.json new file mode 100644 index 0000000..30d25e4 --- /dev/null +++ b/tests/fixtures/linux-agent-history.json @@ -0,0 +1,15 @@ +[ + { + "role": "user", + "content": "Turn this into a spatial time flow" + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "The candidate experience is active." + } + ] + } +] diff --git a/tests/fixtures/linux-x11-socket-owner b/tests/fixtures/linux-x11-socket-owner new file mode 100755 index 0000000..32b5444 --- /dev/null +++ b/tests/fixtures/linux-x11-socket-owner @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -euo pipefail + +[[ "$#" -eq 1 ]] || { + printf 'usage: tests/fixtures/linux-x11-socket-owner SOCKET\n' >&2 + exit 2 +} + +exec python3 - "$1" <<'PY' +import signal +import socket +import sys + +listener = socket.socket(socket.AF_UNIX) +listener.bind(sys.argv[1]) +listener.listen() +signal.pause() +PY diff --git a/tools/evidence-manifest-verify b/tools/evidence-manifest-verify new file mode 100755 index 0000000..9270740 --- /dev/null +++ b/tools/evidence-manifest-verify @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 + +"""Read-only, exact verifier for finalized SOS evidence manifests.""" + +from __future__ import annotations + +import argparse +import fnmatch +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import sys + + +TEMPORARY_BASENAMES = ( + ".a33xctl-manifest.*", + "*.tmp", + "*.part", + "*.partial", + "*.swp", + "*~", +) +SIZE_RE = re.compile(rb"(?:0|[1-9][0-9]*)\Z") +SHA256_RE = re.compile(rb"[0-9a-f]{64}\Z") + + +class AuditError(Exception): + def __init__(self, code: str, detail: str) -> None: + super().__init__(detail) + self.code = code + self.detail = detail + + +def fail(code: str, detail: str) -> None: + raise AuditError(code, detail) + + +def display_path(path: str) -> str: + return json.dumps(path, ensure_ascii=True, separators=(",", ":")) + + +def temporary_path(path: Path) -> bool: + return any(fnmatch.fnmatchcase(path.name, pattern) for pattern in TEMPORARY_BASENAMES) + + +def resolve_inputs(root_arg: str, manifest_arg: str) -> tuple[Path, Path]: + try: + root = Path(root_arg).resolve(strict=True) + except OSError as error: + fail("root", f"cannot resolve evidence root: {error}") + if not root.is_dir(): + fail("root", f"evidence root is not a directory: {root}") + + try: + manifest = Path(manifest_arg).resolve(strict=True) + except OSError as error: + fail("manifest", f"cannot resolve evidence manifest: {error}") + if not manifest.is_file(): + fail("manifest", f"evidence manifest is not a regular file: {manifest}") + try: + manifest.relative_to(root) + except ValueError: + fail("manifest", "evidence manifest must be inside its evidence root") + return root, manifest + + +def validate_relative_path(path_bytes: bytes, line_number: int) -> str: + try: + path = path_bytes.decode("utf-8", errors="strict") + except UnicodeDecodeError: + fail("schema", f"line {line_number}: path is not valid UTF-8") + parts = path.split("/") + if not path or path.startswith("/") or any(part in ("", ".", "..") for part in parts): + fail("unsafe_path", f"line {line_number}: unsafe relative path {display_path(path)}") + if "\0" in path or "\t" in path or "\n" in path or "\r" in path: + fail("unsafe_path", f"line {line_number}: path contains a control delimiter") + return path + + +def parse_manifest(manifest_bytes: bytes, manifest_relative: str) -> list[tuple[str, int, str]]: + if not manifest_bytes: + fail("schema", "manifest is empty") + if not manifest_bytes.endswith(b"\n"): + fail("schema", "manifest must end with one newline-delimited row") + + rows: list[tuple[str, int, str]] = [] + previous: bytes | None = None + for line_number, line in enumerate(manifest_bytes[:-1].split(b"\n"), start=1): + fields = line.split(b"\t") + if len(fields) != 3: + fail("schema", f"line {line_number}: expected exactly three tab-separated fields") + path_bytes, size_bytes, sha_bytes = fields + path = validate_relative_path(path_bytes, line_number) + if path == manifest_relative: + fail("self_included", f"line {line_number}: manifest lists itself") + if not SIZE_RE.fullmatch(size_bytes): + fail("schema", f"line {line_number}: byte size is not canonical decimal") + if not SHA256_RE.fullmatch(sha_bytes): + fail("schema", f"line {line_number}: SHA-256 is not 64 lowercase hexadecimal digits") + if previous is not None and previous >= path_bytes: + fail("ordering", f"line {line_number}: paths are duplicated or not C-byte sorted") + previous = path_bytes + rows.append((path, int(size_bytes), sha_bytes.decode("ascii"))) + if not rows: + fail("schema", "manifest contains no evidence rows") + return rows + + +def stat_identity(value: os.stat_result) -> tuple[int, int, int, int, int]: + return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns) + + +def open_readonly(path: Path) -> int: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + return os.open(path, flags) + except OSError as error: + fail("read", f"cannot open evidence file {display_path(str(path))}: {error}") + + +def stable_file_identity(path: Path) -> tuple[int, str]: + descriptor = open_readonly(path) + digest = hashlib.sha256() + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + fail("file_type", f"evidence path is not a regular file: {display_path(str(path))}") + while chunk := os.read(descriptor, 1024 * 1024): + digest.update(chunk) + after = os.fstat(descriptor) + except OSError as error: + fail("read", f"cannot hash evidence file {display_path(str(path))}: {error}") + finally: + os.close(descriptor) + + try: + path_after = path.lstat() + except OSError as error: + fail("changed", f"evidence path disappeared while hashing {display_path(str(path))}: {error}") + if stat_identity(before) != stat_identity(after) or stat_identity(after) != stat_identity(path_after): + fail("changed", f"evidence changed while hashing {display_path(str(path))}") + return after.st_size, digest.hexdigest() + + +def stable_manifest_bytes(path: Path) -> tuple[bytes, tuple[int, int, int, int, int]]: + descriptor = open_readonly(path) + retained = bytearray() + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode): + fail("manifest", "evidence manifest is not a regular file") + while chunk := os.read(descriptor, 1024 * 1024): + retained.extend(chunk) + after = os.fstat(descriptor) + except OSError as error: + fail("manifest", f"cannot read evidence manifest: {error}") + finally: + os.close(descriptor) + try: + path_after = path.lstat() + except OSError as error: + fail("changed", f"evidence manifest disappeared while reading: {error}") + identity = stat_identity(after) + if stat_identity(before) != identity or identity != stat_identity(path_after): + fail("changed", "evidence manifest changed while reading") + return bytes(retained), identity + + +def inventory(root: Path, manifest: Path) -> list[tuple[str, int, str]]: + files: list[tuple[bytes, str, Path]] = [] + + def walk_error(error: OSError) -> None: + fail("walk", f"cannot inventory evidence root: {error}") + + for directory, _, names in os.walk(root, topdown=True, followlinks=False, onerror=walk_error): + directory_path = Path(directory) + for name in names: + path = directory_path / name + if path == manifest or temporary_path(path): + continue + try: + path_stat = path.lstat() + except OSError as error: + fail("walk", f"cannot stat evidence path {display_path(str(path))}: {error}") + if not stat.S_ISREG(path_stat.st_mode): + continue + relative = path.relative_to(root).as_posix() + if "\0" in relative or "\t" in relative or "\n" in relative or "\r" in relative: + fail("unsafe_path", "evidence file path contains a control delimiter") + try: + key = relative.encode("utf-8", errors="strict") + except UnicodeEncodeError: + fail("unsafe_path", "evidence file path is not valid UTF-8") + files.append((key, relative, path)) + + rows: list[tuple[str, int, str]] = [] + for _, relative, path in sorted(files, key=lambda item: item[0]): + size, sha256 = stable_file_identity(path) + rows.append((relative, size, sha256)) + return rows + + +def bounded_paths(paths: list[str]) -> str: + retained = paths[:8] + suffix = "" if len(paths) <= len(retained) else f",...(+{len(paths) - len(retained)})" + return "[" + ",".join(display_path(path) for path in retained) + suffix + "]" + + +def render(rows: list[tuple[str, int, str]]) -> bytes: + return b"".join( + path.encode("utf-8") + + b"\t" + + str(size).encode("ascii") + + b"\t" + + sha256.encode("ascii") + + b"\n" + for path, size, sha256 in rows + ) + + +def audit(root_arg: str, manifest_arg: str) -> None: + root, manifest = resolve_inputs(root_arg, manifest_arg) + manifest_bytes, manifest_identity = stable_manifest_bytes(manifest) + manifest_relative = manifest.relative_to(root).as_posix() + listed = parse_manifest(manifest_bytes, manifest_relative) + actual = inventory(root, manifest) + try: + manifest_after = manifest.lstat() + except OSError as error: + fail("changed", f"evidence manifest disappeared during audit: {error}") + if stat_identity(manifest_after) != manifest_identity: + fail("changed", "evidence manifest changed during audit") + + listed_paths = {path for path, _, _ in listed} + actual_paths = {path for path, _, _ in actual} + if listed_paths != actual_paths: + missing = sorted(actual_paths - listed_paths, key=lambda path: path.encode("utf-8")) + extra = sorted(listed_paths - actual_paths, key=lambda path: path.encode("utf-8")) + fail( + "file_set", + f"unlisted={bounded_paths(missing)} nonexistent={bounded_paths(extra)}", + ) + + actual_by_path = {path: (size, sha256) for path, size, sha256 in actual} + for path, expected_size, expected_sha256 in listed: + actual_size, actual_sha256 = actual_by_path[path] + if actual_size != expected_size or actual_sha256 != expected_sha256: + fail( + "identity", + f"identity mismatch for {display_path(path)}: " + f"listed_bytes={expected_size} actual_bytes={actual_size} " + f"listed_sha256={expected_sha256} actual_sha256={actual_sha256}", + ) + + regenerated = render(actual) + if regenerated != manifest_bytes: + fail("regeneration", "deterministic regeneration differs from manifest bytes") + + manifest_sha256 = hashlib.sha256(manifest_bytes).hexdigest() + regenerated_sha256 = hashlib.sha256(regenerated).hexdigest() + print("evidence_manifest_audit=PASS") + print(f"root={root}") + print(f"manifest={manifest}") + print(f"files={len(actual)}") + print(f"manifest_bytes={len(manifest_bytes)}") + print(f"manifest_sha256={manifest_sha256}") + print(f"regenerated_sha256={regenerated_sha256}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", required=True, help="finalized evidence directory") + parser.add_argument("--manifest", required=True, help="manifest inside the evidence directory") + arguments = parser.parse_args() + try: + audit(arguments.root, arguments.manifest) + except AuditError as error: + print( + f"evidence_manifest_audit=FAIL code={error.code} " + f"detail={json.dumps(error.detail, ensure_ascii=True, separators=(',', ':'))}", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/evidence-run b/tools/evidence-run new file mode 100755 index 0000000..0dd3174 --- /dev/null +++ b/tools/evidence-run @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +set -euo pipefail + +evidence_fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +evidence_usage='usage: tools/evidence-run --root ABSOLUTE_DIR --name RECORD_NAME -- COMMAND [ARG ...]' +evidence_root="" +evidence_name="" +while [[ "$#" -gt 0 ]]; do + case "$1" in + --root) + [[ "$#" -ge 2 ]] || evidence_fail "$evidence_usage" + evidence_root="$2" + shift 2 + ;; + --name) + [[ "$#" -ge 2 ]] || evidence_fail "$evidence_usage" + evidence_name="$2" + shift 2 + ;; + --) + shift + break + ;; + *) + evidence_fail "$evidence_usage" + ;; + esac +done + +[[ "$evidence_root" == /* && "$evidence_root" != *$'\n'* ]] || \ + evidence_fail "evidence root must be an absolute single-line path" +[[ "$evidence_name" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || \ + evidence_fail "record name must contain only lowercase letters, digits, dot, underscore, or dash" +[[ "$#" -gt 0 ]] || evidence_fail "$evidence_usage" + +# Literal argv belongs in evidence, but credentials do not. Campaign commands +# must refer to private credential files instead of placing secret material in +# environment assignments or options. +shopt -s nocasematch +for evidence_argument in "$@"; do + [[ "$evidence_argument" != *$'\n'* && "$evidence_argument" != *$'\r'* ]] || \ + evidence_fail "command arguments must be single-line values" + if [[ "$evidence_argument" =~ (^|[-_])(API[-_]?KEY|PASSWORD|SECRET|TOKEN|CREDENTIALS?)= ]]; then + evidence_fail "refusing to record a potentially secret-bearing argument; pass a credential-file path instead" + fi +done +shopt -u nocasematch + +for evidence_command in date mkdir mv pwd python3 rm tee; do + command -v "$evidence_command" >/dev/null 2>&1 || \ + evidence_fail "required command not found: $evidence_command" +done + +mkdir -p -- "$evidence_root" +evidence_root="$(cd "$evidence_root" && pwd -P)" +evidence_raw="$evidence_root/$evidence_name.raw" +evidence_meta="$evidence_root/$evidence_name.meta" +[[ ! -e "$evidence_raw" && ! -e "$evidence_meta" ]] || \ + evidence_fail "refusing to overwrite evidence record: $evidence_name" + +evidence_nonce="$BASHPID-$RANDOM" +evidence_raw_tmp="$evidence_root/.$evidence_name.raw.tmp-$evidence_nonce" +evidence_meta_tmp="$evidence_root/.$evidence_name.meta.tmp-$evidence_nonce" +evidence_cleanup_temporary() { + rm -f -- "$evidence_raw_tmp" "$evidence_meta_tmp" +} +trap evidence_cleanup_temporary EXIT HUP INT TERM + +evidence_cwd="$(pwd -P)" +printf -v evidence_command_literal '%q ' "$@" +evidence_command_literal="${evidence_command_literal% }" +evidence_utc_start="$(date --utc +%Y-%m-%dT%H:%M:%S.%NZ)" +evidence_monotonic_start_ns="$(python3 -c 'import time; print(time.monotonic_ns())')" + +set +e +"$@" 2>&1 | tee "$evidence_raw_tmp" +evidence_pipeline_status=("${PIPESTATUS[@]}") +set -e +evidence_status="${evidence_pipeline_status[0]}" +evidence_tee_status="${evidence_pipeline_status[1]}" +[[ "$evidence_tee_status" -eq 0 ]] || \ + evidence_fail "could not capture raw output for $evidence_name" + +evidence_monotonic_end_ns="$(python3 -c 'import time; print(time.monotonic_ns())')" +evidence_utc_end="$(date --utc +%Y-%m-%dT%H:%M:%S.%NZ)" +evidence_elapsed_ns=$((evidence_monotonic_end_ns - evidence_monotonic_start_ns)) +mv -- "$evidence_raw_tmp" "$evidence_raw" + +{ + printf 'command=%s\n' "$evidence_command_literal" + printf 'cwd=%q\n' "$evidence_cwd" + printf 'argv_count=%s\n' "$#" + evidence_index=0 + for evidence_argument in "$@"; do + printf 'argv_%s=%q\n' "$evidence_index" "$evidence_argument" + evidence_index=$((evidence_index + 1)) + done + printf 'utc_start=%s\n' "$evidence_utc_start" + printf 'utc_end=%s\n' "$evidence_utc_end" + printf 'monotonic_start_ns=%s\n' "$evidence_monotonic_start_ns" + printf 'monotonic_end_ns=%s\n' "$evidence_monotonic_end_ns" + printf 'elapsed_ns=%s\n' "$evidence_elapsed_ns" + printf 'status=%s\n' "$evidence_status" +} >"$evidence_meta_tmp" +mv -- "$evidence_meta_tmp" "$evidence_meta" +trap - EXIT HUP INT TERM +exit "$evidence_status" diff --git a/tools/install-linux-login-session b/tools/install-linux-login-session index a4e7866..defb0cd 100755 --- a/tools/install-linux-login-session +++ b/tools/install-linux-login-session @@ -13,7 +13,8 @@ sos_install_fail() { [[ "$#" -eq 1 && "$1" == install ]] || \ sos_install_fail "usage: tools/install-linux-login-session install" [[ "$(id -u)" -ne 0 ]] || sos_install_fail "run this command as a normal user; it invokes sudo only for installation" -for sos_install_command in cargo install node npm pkg-config sudo; do +for sos_install_command in \ + cargo install loginctl node npm pgrep pkg-config ps readlink sha256sum stat sudo tee; do command -v "$sos_install_command" >/dev/null 2>&1 || \ sos_install_fail "required command not found: $sos_install_command" done diff --git a/tools/linux-compositor/nested-x11-artifacts-lib b/tools/linux-compositor/nested-x11-artifacts-lib new file mode 100644 index 0000000..859b0a5 --- /dev/null +++ b/tools/linux-compositor/nested-x11-artifacts-lib @@ -0,0 +1,250 @@ +# Exact X11 display artifact accounting for the nested compositor gate. + +sos_nested_x_artifact_identity() { + local sos_nested_x_path="$1" + if [[ ! -e "$sos_nested_x_path" && ! -L "$sos_nested_x_path" ]]; then + printf 'absent\n' + return 0 + fi + stat -c '%d:%i:%u:%f' -- "$sos_nested_x_path" +} + +sos_nested_x_assign() { + local sos_nested_x_name="$1" + local sos_nested_x_value="$2" + printf -v "$sos_nested_x_name" '%s' "$sos_nested_x_value" +} + +sos_nested_x_value() { + local sos_nested_x_name="$1" + printf '%s' "${!sos_nested_x_name-}" +} + +sos_nested_x_paths() { + local sos_nested_x_prefix="$1" + local sos_nested_x_display="$2" + local sos_nested_x_root="${SOS_NESTED_X11_ROOT:-/tmp}" + [[ "$sos_nested_x_prefix" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || { + printf 'error: invalid X11 artifact state prefix: %s\n' "$sos_nested_x_prefix" >&2 + return 1 + } + [[ "$sos_nested_x_display" =~ ^[0-9]+$ ]] || { + printf 'error: invalid X11 display number: %s\n' "$sos_nested_x_display" >&2 + return 1 + } + sos_nested_x_assign "${sos_nested_x_prefix}_display" "$sos_nested_x_display" + sos_nested_x_assign \ + "${sos_nested_x_prefix}_socket" \ + "$sos_nested_x_root/.X11-unix/X$sos_nested_x_display" + sos_nested_x_assign \ + "${sos_nested_x_prefix}_lock" \ + "$sos_nested_x_root/.X${sos_nested_x_display}-lock" +} + +sos_nested_x_record_display() { + local sos_nested_x_prefix="$1" + local sos_nested_x_display="$2" + local sos_nested_x_expected_uid="$3" + local sos_nested_x_socket sos_nested_x_lock + local sos_nested_x_socket_before sos_nested_x_lock_before + + sos_nested_x_paths "$sos_nested_x_prefix" "$sos_nested_x_display" || return 1 + sos_nested_x_socket="$(sos_nested_x_value "${sos_nested_x_prefix}_socket")" + sos_nested_x_lock="$(sos_nested_x_value "${sos_nested_x_prefix}_lock")" + sos_nested_x_socket_before="$(sos_nested_x_artifact_identity "$sos_nested_x_socket")" || return 1 + sos_nested_x_lock_before="$(sos_nested_x_artifact_identity "$sos_nested_x_lock")" || return 1 + sos_nested_x_assign "${sos_nested_x_prefix}_expected_uid" "$sos_nested_x_expected_uid" + sos_nested_x_assign "${sos_nested_x_prefix}_socket_before" "$sos_nested_x_socket_before" + sos_nested_x_assign "${sos_nested_x_prefix}_lock_before" "$sos_nested_x_lock_before" + sos_nested_x_assign "${sos_nested_x_prefix}_socket_created" '' + sos_nested_x_assign "${sos_nested_x_prefix}_lock_created" '' + + if [[ "$sos_nested_x_socket_before" != absent || "$sos_nested_x_lock_before" != absent ]]; then + printf 'error: refusing preexisting X11 display artifacts for :%s: socket=%s lock=%s\n' \ + "$sos_nested_x_display" "$sos_nested_x_socket_before" "$sos_nested_x_lock_before" >&2 + return 1 + fi +} + +sos_nested_x_select_display() { + local sos_nested_x_prefix="$1" + local sos_nested_x_first="$2" + local sos_nested_x_last="$3" + local sos_nested_x_expected_uid="$4" + local sos_nested_x_display sos_nested_x_socket sos_nested_x_lock + + [[ "$sos_nested_x_first" =~ ^[0-9]+$ && "$sos_nested_x_last" =~ ^[0-9]+$ ]] || { + printf 'error: invalid X11 display range: %s..%s\n' \ + "$sos_nested_x_first" "$sos_nested_x_last" >&2 + return 1 + } + for ((sos_nested_x_display = sos_nested_x_first; sos_nested_x_display <= sos_nested_x_last; sos_nested_x_display++)); do + sos_nested_x_paths "$sos_nested_x_prefix" "$sos_nested_x_display" || return 1 + sos_nested_x_socket="$(sos_nested_x_value "${sos_nested_x_prefix}_socket")" + sos_nested_x_lock="$(sos_nested_x_value "${sos_nested_x_prefix}_lock")" + if [[ "$(sos_nested_x_artifact_identity "$sos_nested_x_socket")" == absent \ + && "$(sos_nested_x_artifact_identity "$sos_nested_x_lock")" == absent ]]; then + sos_nested_x_record_display \ + "$sos_nested_x_prefix" "$sos_nested_x_display" "$sos_nested_x_expected_uid" + return + fi + done + printf 'error: no unused X11 display in exact range %s..%s\n' \ + "$sos_nested_x_first" "$sos_nested_x_last" >&2 + return 1 +} + +sos_nested_x_capture_created() { + local sos_nested_x_prefix="$1" + local sos_nested_x_expected_lock_pid="$2" + local sos_nested_x_display sos_nested_x_socket sos_nested_x_lock sos_nested_x_expected_uid + local sos_nested_x_socket_identity sos_nested_x_lock_identity sos_nested_x_lock_pid + + sos_nested_x_display="$(sos_nested_x_value "${sos_nested_x_prefix}_display")" + sos_nested_x_socket="$(sos_nested_x_value "${sos_nested_x_prefix}_socket")" + sos_nested_x_lock="$(sos_nested_x_value "${sos_nested_x_prefix}_lock")" + sos_nested_x_expected_uid="$(sos_nested_x_value "${sos_nested_x_prefix}_expected_uid")" + [[ -n "$sos_nested_x_display" && -n "$sos_nested_x_socket" && -n "$sos_nested_x_lock" ]] || { + printf 'error: X11 display state was not recorded before startup\n' >&2 + return 1 + } + [[ "$(sos_nested_x_value "${sos_nested_x_prefix}_socket_before")" == absent \ + && "$(sos_nested_x_value "${sos_nested_x_prefix}_lock_before")" == absent ]] || { + printf 'error: X11 display :%s was not absent before startup\n' "$sos_nested_x_display" >&2 + return 1 + } + [[ -S "$sos_nested_x_socket" && ! -L "$sos_nested_x_socket" ]] || { + printf 'error: X11 display :%s socket is absent or not a socket: %s\n' \ + "$sos_nested_x_display" "$sos_nested_x_socket" >&2 + return 1 + } + [[ -f "$sos_nested_x_lock" && ! -L "$sos_nested_x_lock" ]] || { + printf 'error: X11 display :%s lock is absent or not a regular file: %s\n' \ + "$sos_nested_x_display" "$sos_nested_x_lock" >&2 + return 1 + } + [[ "$(stat -c '%u' -- "$sos_nested_x_socket")" == "$sos_nested_x_expected_uid" \ + && "$(stat -c '%u' -- "$sos_nested_x_lock")" == "$sos_nested_x_expected_uid" ]] || { + printf 'error: X11 display :%s artifacts have an unexpected owner\n' \ + "$sos_nested_x_display" >&2 + return 1 + } + sos_nested_x_lock_pid="$(tr -d '[:space:]' <"$sos_nested_x_lock")" + [[ "$sos_nested_x_lock_pid" == "$sos_nested_x_expected_lock_pid" ]] || { + printf 'error: X11 display :%s lock PID %s does not match expected owner %s\n' \ + "$sos_nested_x_display" "$sos_nested_x_lock_pid" "$sos_nested_x_expected_lock_pid" >&2 + return 1 + } + fuser -s "$sos_nested_x_socket" || { + printf 'error: X11 display :%s socket has no live owner during capture\n' \ + "$sos_nested_x_display" >&2 + return 1 + } + + sos_nested_x_socket_identity="$(sos_nested_x_artifact_identity "$sos_nested_x_socket")" || return 1 + sos_nested_x_lock_identity="$(sos_nested_x_artifact_identity "$sos_nested_x_lock")" || return 1 + sos_nested_x_assign "${sos_nested_x_prefix}_socket_created" "$sos_nested_x_socket_identity" + sos_nested_x_assign "${sos_nested_x_prefix}_lock_created" "$sos_nested_x_lock_identity" +} + +sos_nested_x_display_owner_pids() { + local sos_nested_x_display="$1" + ps -eo pid=,comm=,args= | awk -v display=":$sos_nested_x_display" ' + $2 == "Xvfb" || $2 == "Xwayland" { + for (field = 3; field <= NF; field++) { + if ($field == display) { + print $1 + break + } + } + } + ' +} + +sos_nested_x_cleanup_created() { + local sos_nested_x_prefix="$1" + local sos_nested_x_display sos_nested_x_socket sos_nested_x_lock sos_nested_x_expected_uid + local sos_nested_x_socket_created sos_nested_x_lock_created + local sos_nested_x_path sos_nested_x_kind sos_nested_x_expected sos_nested_x_actual + local sos_nested_x_owner_pids sos_nested_x_status=0 + + sos_nested_x_display="$(sos_nested_x_value "${sos_nested_x_prefix}_display")" + [[ -n "$sos_nested_x_display" ]] || return 0 + sos_nested_x_socket="$(sos_nested_x_value "${sos_nested_x_prefix}_socket")" + sos_nested_x_lock="$(sos_nested_x_value "${sos_nested_x_prefix}_lock")" + sos_nested_x_expected_uid="$(sos_nested_x_value "${sos_nested_x_prefix}_expected_uid")" + sos_nested_x_socket_created="$(sos_nested_x_value "${sos_nested_x_prefix}_socket_created")" + sos_nested_x_lock_created="$(sos_nested_x_value "${sos_nested_x_prefix}_lock_created")" + + sos_nested_x_owner_pids="$(sos_nested_x_display_owner_pids "$sos_nested_x_display")" + if [[ -n "$sos_nested_x_owner_pids" ]]; then + printf 'error: X11 display :%s still has X server owners: %s\n' \ + "$sos_nested_x_display" "$(xargs <<<"$sos_nested_x_owner_pids")" >&2 + sos_nested_x_status=1 + fi + for sos_nested_x_path in "$sos_nested_x_socket" "$sos_nested_x_lock"; do + if [[ -e "$sos_nested_x_path" || -L "$sos_nested_x_path" ]] \ + && fuser -s "$sos_nested_x_path"; then + printf 'error: X11 display :%s artifact still has a process owner: %s\n' \ + "$sos_nested_x_display" "$sos_nested_x_path" >&2 + sos_nested_x_status=1 + fi + done + + for sos_nested_x_kind in socket lock; do + if [[ "$sos_nested_x_kind" == socket ]]; then + sos_nested_x_path="$sos_nested_x_socket" + sos_nested_x_expected="$sos_nested_x_socket_created" + else + sos_nested_x_path="$sos_nested_x_lock" + sos_nested_x_expected="$sos_nested_x_lock_created" + fi + if [[ ! -e "$sos_nested_x_path" && ! -L "$sos_nested_x_path" ]]; then + continue + fi + sos_nested_x_actual="$(sos_nested_x_artifact_identity "$sos_nested_x_path")" || { + sos_nested_x_status=1 + continue + } + if [[ -z "$sos_nested_x_expected" || "$sos_nested_x_actual" != "$sos_nested_x_expected" ]]; then + printf 'error: preserving unproven or replaced X11 %s for :%s: expected=%s actual=%s\n' \ + "$sos_nested_x_kind" "$sos_nested_x_display" \ + "${sos_nested_x_expected:-uncaptured}" "$sos_nested_x_actual" >&2 + sos_nested_x_status=1 + continue + fi + if [[ "$(stat -c '%u' -- "$sos_nested_x_path")" != "$sos_nested_x_expected_uid" ]]; then + printf 'error: preserving wrong-owner X11 %s for :%s\n' \ + "$sos_nested_x_kind" "$sos_nested_x_display" >&2 + sos_nested_x_status=1 + continue + fi + if [[ "$sos_nested_x_kind" == socket ]]; then + [[ -S "$sos_nested_x_path" && ! -L "$sos_nested_x_path" ]] || { + printf 'error: preserving wrong-type X11 socket for :%s\n' \ + "$sos_nested_x_display" >&2 + sos_nested_x_status=1 + } + else + [[ -f "$sos_nested_x_path" && ! -L "$sos_nested_x_path" ]] || { + printf 'error: preserving wrong-type X11 lock for :%s\n' \ + "$sos_nested_x_display" >&2 + sos_nested_x_status=1 + } + fi + done + [[ "$sos_nested_x_status" -eq 0 ]] || return "$sos_nested_x_status" + + for sos_nested_x_path in "$sos_nested_x_socket" "$sos_nested_x_lock"; do + if [[ -e "$sos_nested_x_path" || -L "$sos_nested_x_path" ]]; then + rm -- "$sos_nested_x_path" || sos_nested_x_status=1 + fi + done + if [[ -e "$sos_nested_x_socket" || -L "$sos_nested_x_socket" \ + || -e "$sos_nested_x_lock" || -L "$sos_nested_x_lock" ]]; then + printf 'error: X11 display :%s artifact cleanup did not reach exact absence\n' \ + "$sos_nested_x_display" >&2 + sos_nested_x_status=1 + fi + return "$sos_nested_x_status" +} diff --git a/tools/linux-compositor/test-nested-x11-artifacts b/tools/linux-compositor/test-nested-x11-artifacts new file mode 100755 index 0000000..eaf3dd9 --- /dev/null +++ b/tools/linux-compositor/test-nested-x11-artifacts @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +set -euo pipefail + +test_tools_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +test_repo_root="$(cd "$test_tools_dir/../.." && pwd)" +# shellcheck source=tools/linux-compositor/nested-x11-artifacts-lib +source "$test_tools_dir/nested-x11-artifacts-lib" + +test_root="$(mktemp -d /tmp/sos-nested-x11-test.XXXXXX)" +test_owner_pid="" +cleanup_test() { + if [[ -n "$test_owner_pid" ]] && kill -0 "$test_owner_pid" 2>/dev/null; then + kill "$test_owner_pid" 2>/dev/null || true + wait "$test_owner_pid" 2>/dev/null || true + fi + [[ "$test_root" == /tmp/sos-nested-x11-test.* ]] && rm -r -- "$test_root" +} +trap cleanup_test EXIT + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +start_fixture() { + local test_socket="$1" + local test_lock="$2" + "$test_repo_root/tests/fixtures/linux-x11-socket-owner" "$test_socket" & + test_owner_pid=$! + printf '%10s\n' "$test_owner_pid" >"$test_lock" + for _ in {1..200}; do + [[ -S "$test_socket" ]] && break + kill -0 "$test_owner_pid" 2>/dev/null || fail 'fixture socket owner exited early' + sleep 0.01 + done + [[ -S "$test_socket" ]] || fail 'fixture socket was not created' +} + +stop_fixture() { + kill "$test_owner_pid" + wait "$test_owner_pid" 2>/dev/null || true + test_owner_pid="" +} + +case_root="$test_root/absent-created-cleaned" +mkdir -p "$case_root/.X11-unix" +printf 'gdm socket 1024\n' >"$case_root/.X11-unix/X1024" +printf 'gdm lock 1024\n' >"$case_root/.X1024-lock" +printf 'gdm socket 1025\n' >"$case_root/.X11-unix/X1025" +printf 'gdm lock 1025\n' >"$case_root/.X1025-lock" +SOS_NESTED_X11_ROOT="$case_root" +export SOS_NESTED_X11_ROOT +sos_nested_x_record_display absent_case 71 "$(id -u)" +start_fixture "$absent_case_socket" "$absent_case_lock" +sos_nested_x_capture_created absent_case "$test_owner_pid" +stop_fixture +sos_nested_x_cleanup_created absent_case +[[ ! -e "$absent_case_socket" && ! -L "$absent_case_socket" ]] +[[ ! -e "$absent_case_lock" && ! -L "$absent_case_lock" ]] +[[ "$(cat "$case_root/.X11-unix/X1024")" == 'gdm socket 1024' ]] +[[ "$(cat "$case_root/.X1024-lock")" == 'gdm lock 1024' ]] +[[ "$(cat "$case_root/.X11-unix/X1025")" == 'gdm socket 1025' ]] +[[ "$(cat "$case_root/.X1025-lock")" == 'gdm lock 1025' ]] + +case_root="$test_root/preexisting-refused" +mkdir -p "$case_root/.X11-unix" +SOS_NESTED_X11_ROOT="$case_root" +printf 'baseline\n' >"$case_root/.X72-lock" +if sos_nested_x_record_display refused_case 72 "$(id -u)" 2>/dev/null; then + fail 'preexisting display artifact was accepted' +fi +[[ "$(cat "$case_root/.X72-lock")" == baseline ]] +sos_nested_x_select_display selected_case 72 73 "$(id -u)" +[[ "$selected_case_display" == 73 ]] +[[ "$(cat "$case_root/.X72-lock")" == baseline ]] + +case_root="$test_root/wrong-type" +mkdir -p "$case_root/.X11-unix" +SOS_NESTED_X11_ROOT="$case_root" +sos_nested_x_record_display wrong_type_case 74 "$(id -u)" +printf 'not a socket\n' >"$wrong_type_case_socket" +printf '%10s\n' "$$" >"$wrong_type_case_lock" +if sos_nested_x_capture_created wrong_type_case "$$" 2>/dev/null; then + fail 'wrong-type display artifact was captured' +fi +if sos_nested_x_cleanup_created wrong_type_case 2>/dev/null; then + fail 'wrong-type display artifact was cleaned' +fi +[[ -f "$wrong_type_case_socket" && -f "$wrong_type_case_lock" ]] + +case_root="$test_root/wrong-owner" +mkdir -p "$case_root/.X11-unix" +SOS_NESTED_X11_ROOT="$case_root" +sos_nested_x_record_display wrong_owner_case 75 "$(( $(id -u) + 1 ))" +start_fixture "$wrong_owner_case_socket" "$wrong_owner_case_lock" +if sos_nested_x_capture_created wrong_owner_case "$test_owner_pid" 2>/dev/null; then + fail 'wrong-owner display artifacts were captured' +fi +stop_fixture +if sos_nested_x_cleanup_created wrong_owner_case 2>/dev/null; then + fail 'wrong-owner display artifacts were cleaned' +fi +[[ -S "$wrong_owner_case_socket" && -f "$wrong_owner_case_lock" ]] + +case_root="$test_root/no-false-pass" +mkdir -p "$case_root/.X11-unix" +SOS_NESTED_X11_ROOT="$case_root" +sos_nested_x_record_display marker_case 76 "$(id -u)" +start_fixture "$marker_case_socket" "$marker_case_lock" +sos_nested_x_capture_created marker_case "$test_owner_pid" +set +e +marker_output="$({ + marker_status=0 + sos_nested_x_cleanup_created marker_case || marker_status=1 + if [[ "$marker_status" -eq 0 ]]; then + printf 'linux_nested_cleanup_passed\n' + fi + exit "$marker_status" +} 2>/dev/null)" +marker_status=$? +set -e +[[ "$marker_status" -ne 0 ]] +[[ "$marker_output" != *linux_nested_cleanup_passed* ]] +[[ -S "$marker_case_socket" && -f "$marker_case_lock" ]] +stop_fixture +sos_nested_x_cleanup_created marker_case + +for test_marker in \ + 'sos_nested_x_select_display gate_xvfb_x11' \ + 'sos_nested_x_select_display gate_xwayland_x11' \ + 'sos_nested_x_cleanup_created gate_xvfb_x11' \ + 'sos_nested_x_cleanup_created gate_xwayland_x11' \ + 'x_socket_absent=true x_lock_absent=true'; do + grep -Fq "$test_marker" "$test_tools_dir/verify-nested" +done + +printf 'nested_x11_artifact_tests_passed absent_created_cleaned=true preexisting_refused_preserved=true alternate_selected=true wrong_owner_type_preserved=true gdm_x1024_x1025_preserved=true false_cleanup_pass_absent=true\n' diff --git a/tools/linux-compositor/verify-nested b/tools/linux-compositor/verify-nested index 662d6a4..dad082b 100755 --- a/tools/linux-compositor/verify-nested +++ b/tools/linux-compositor/verify-nested @@ -4,12 +4,16 @@ set -euo pipefail gate_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$gate_root" -for gate_command in cargo gst-launch-1.0 jq python3 sed weston weston-simple-shm Xvfb Xwayland xmessage; do +# shellcheck source=tools/linux-compositor/nested-x11-artifacts-lib +source "$gate_root/tools/linux-compositor/nested-x11-artifacts-lib" +for gate_command in awk cargo fuser gst-launch-1.0 id jq pgrep ps python3 sed stat tr weston weston-simple-shm Xvfb Xwayland xargs xmessage; do command -v "$gate_command" >/dev/null 2>&1 || { printf 'error: required command not found: %s\n' "$gate_command" >&2 exit 1 } done +gate_xwayland_version="$(Xwayland -version 2>&1)" +grep -F 'Xwayland' <<<"$gate_xwayland_version" >/dev/null gate_run_dir="$(mktemp -d /tmp/sos-nested-compositor.XXXXXX)" gate_runtime_dir="$gate_run_dir/runtime" @@ -45,8 +49,28 @@ gate_compatibility_pid="" gate_input_pid="" gate_ime_pid="" gate_x11_pid="" +gate_xwayland_pid="" +gate_expected_uid="$(id -u)" cleanup_nested_gate() { + local gate_status="$?" + local gate_cleanup_status=0 + trap - EXIT + set +e + if [[ -n "${gate_xwayland_x11_display-}" \ + && -z "${gate_xwayland_x11_socket_created-}" \ + && -n "$gate_compositor_pid" ]] \ + && kill -0 "$gate_compositor_pid" 2>/dev/null \ + && [[ -S "$gate_xwayland_x11_socket" && -f "$gate_xwayland_x11_lock" ]]; then + sos_nested_x_capture_created gate_xwayland_x11 "$gate_compositor_pid" || gate_cleanup_status=1 + fi + if [[ -n "${gate_xvfb_x11_display-}" \ + && -z "${gate_xvfb_x11_socket_created-}" \ + && -n "$gate_xvfb_pid" ]] \ + && kill -0 "$gate_xvfb_pid" 2>/dev/null \ + && [[ -S "$gate_xvfb_x11_socket" && -f "$gate_xvfb_x11_lock" ]]; then + sos_nested_x_capture_created gate_xvfb_x11 "$gate_xvfb_pid" || gate_cleanup_status=1 + fi if [[ -n "$gate_session_pid" ]] && kill -0 "$gate_session_pid" 2>/dev/null; then SOS_LINUX_REVISION_ROOT="$gate_revision_root" \ ./tools/sosctl linux-stop >/dev/null 2>&1 || true @@ -58,17 +82,36 @@ cleanup_nested_gate() { "$gate_x11_pid" \ "$gate_compatibility_pid" \ "$gate_compositor_pid" \ + "$gate_xwayland_pid" \ "$gate_weston_pid" \ "$gate_xvfb_pid"; do if [[ -n "$gate_pid" ]] && kill -0 "$gate_pid" 2>/dev/null; then kill "$gate_pid" 2>/dev/null || true wait "$gate_pid" 2>/dev/null || true fi + for _ in {1..200}; do + ! kill -0 "$gate_pid" 2>/dev/null && break + sleep 0.01 + done + if [[ -n "$gate_pid" ]] && kill -0 "$gate_pid" 2>/dev/null; then + printf 'error: nested gate owned PID survived cleanup: %s\n' "$gate_pid" >&2 + gate_cleanup_status=1 + fi done + sos_nested_x_cleanup_created gate_xwayland_x11 || gate_cleanup_status=1 + sos_nested_x_cleanup_created gate_xvfb_x11 || gate_cleanup_status=1 if [[ "$gate_run_dir" == /tmp/sos-nested-compositor.* && -d "$gate_run_dir" ]]; then chmod -R u+w "$gate_run_dir" 2>/dev/null || true - rm -r -- "$gate_run_dir" + rm -r -- "$gate_run_dir" || gate_cleanup_status=1 + fi + [[ ! -e "$gate_run_dir" ]] || gate_cleanup_status=1 + if [[ "$gate_status" -eq 0 && "$gate_cleanup_status" -eq 0 ]]; then + printf 'linux_nested_cleanup_passed owned_pids_absent=true run_dir_absent=true x_socket_absent=true x_lock_absent=true xvfb_display=:%s xwayland_display=:%s\n' \ + "$gate_xvfb_x11_display" "$gate_xwayland_x11_display" + elif [[ "$gate_status" -eq 0 ]]; then + gate_status=1 fi + exit "$gate_status" } trap cleanup_nested_gate EXIT @@ -197,18 +240,27 @@ cargo build --locked \ -p provider-state-service --bin sos-provider-state-service \ -p sos-linux-session --bin sos-linux-session -Xvfb -displayfd 1 -screen 0 1280x800x24 \ - >"$gate_run_dir/display" 2>"$gate_run_dir/xvfb.log" & +sos_nested_x_select_display gate_xvfb_x11 64 127 "$gate_expected_uid" +printf 'linux_nested_x11_preflight role=xvfb display=:%s socket=%s socket_before=%s lock=%s lock_before=%s\n' \ + "$gate_xvfb_x11_display" "$gate_xvfb_x11_socket" \ + "$gate_xvfb_x11_socket_before" "$gate_xvfb_x11_lock" "$gate_xvfb_x11_lock_before" +Xvfb ":$gate_xvfb_x11_display" -screen 0 1280x800x24 \ + >"$gate_run_dir/xvfb.out" 2>"$gate_run_dir/xvfb.log" & gate_xvfb_pid=$! for _ in {1..200}; do - [[ -s "$gate_run_dir/display" ]] && break + [[ -S "$gate_xvfb_x11_socket" && -f "$gate_xvfb_x11_lock" ]] && break kill -0 "$gate_xvfb_pid" 2>/dev/null || { sed -n '1,120p' "$gate_run_dir/xvfb.log" exit 1 } sleep 0.01 done -gate_display=":$(tr -d '\r\n' < "$gate_run_dir/display")" +sos_nested_x_capture_created gate_xvfb_x11 "$gate_xvfb_pid" +gate_display=":$gate_xvfb_x11_display" +printf '%s\n' "$gate_xvfb_x11_display" >"$gate_run_dir/display" +printf 'linux_nested_x11_created role=xvfb display=%s socket_identity=%s lock_identity=%s lock_pid=%s uid=%s\n' \ + "$gate_display" "$gate_xvfb_x11_socket_created" \ + "$gate_xvfb_x11_lock_created" "$gate_xvfb_pid" "$gate_expected_uid" DISPLAY="$gate_display" XDG_RUNTIME_DIR="$gate_runtime_dir" \ weston --backend=x11-backend.so --socket=wayland-outer \ @@ -224,6 +276,10 @@ for _ in {1..500}; do sleep 0.01 done +sos_nested_x_select_display gate_xwayland_x11 64 127 "$gate_expected_uid" +printf 'linux_nested_x11_preflight role=xwayland display=:%s socket=%s socket_before=%s lock=%s lock_before=%s\n' \ + "$gate_xwayland_x11_display" "$gate_xwayland_x11_socket" \ + "$gate_xwayland_x11_socket_before" "$gate_xwayland_x11_lock" "$gate_xwayland_x11_lock_before" DISPLAY="$gate_display" \ XDG_RUNTIME_DIR="$gate_runtime_dir" \ WAYLAND_DISPLAY=wayland-outer \ @@ -234,6 +290,7 @@ RUST_LOG=sos_compositor=info \ --control-socket "$gate_control_socket" \ --shell-token "$gate_token" \ --xwayland-display-file "$gate_xwayland_display_file" \ + --xwayland-display "$gate_xwayland_x11_display" \ >"$gate_run_dir/compositor.out" 2>"$gate_run_dir/compositor.log" & gate_compositor_pid=$! for _ in {1..1000}; do @@ -248,6 +305,15 @@ for _ in {1..1000}; do done [[ -S "$gate_runtime_dir/wayland-sos" ]] [[ -S "$gate_control_socket" ]] +for _ in {1..500}; do + [[ -S "$gate_xwayland_x11_socket" && -f "$gate_xwayland_x11_lock" ]] && break + kill -0 "$gate_compositor_pid" 2>/dev/null || break + sleep 0.01 +done +sos_nested_x_capture_created gate_xwayland_x11 "$gate_compositor_pid" +printf 'linux_nested_x11_created role=xwayland display=:%s socket_identity=%s lock_identity=%s lock_pid=%s uid=%s\n' \ + "$gate_xwayland_x11_display" "$gate_xwayland_x11_socket_created" \ + "$gate_xwayland_x11_lock_created" "$gate_compositor_pid" "$gate_expected_uid" DISPLAY="$gate_display" \ XDG_RUNTIME_DIR="$gate_runtime_dir" \ @@ -347,9 +413,23 @@ for gate_accessibility_action in next previous activate scroll_forward; do done gate_accessibility_generation="$(accessibility_call '{"method":"snapshot"}' | jq -r '.snapshot.generation')" [[ "$(accessibility_call '{"method":"action","kind":"focus","target":"note-draft"}' | jq -r '.ok')" == true ]] -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 ]] +gate_accessibility_wait="$( + accessibility_call \ + "{\"method\":\"wait\",\"after_generation\":$gate_accessibility_generation,\"timeout_ms\":2000,\"focused\":\"note-draft\"}" +)" +if ! jq -e \ + --argjson after_generation "$gate_accessibility_generation" \ + '.ok and .snapshot.generation > $after_generation and .snapshot.focused == "note-draft"' \ + <<<"$gate_accessibility_wait" >/dev/null; then + printf 'error: accessibility focus did not propagate after generation %s: %s\n' \ + "$gate_accessibility_generation" \ + "$(jq -c . <<<"$gate_accessibility_wait")" >&2 + false +fi +printf 'linux_accessibility_focus_passed target=note-draft after_generation=%s generation=%s wait_elapsed_ms=%s\n' \ + "$gate_accessibility_generation" \ + "$(jq -r '.snapshot.generation' <<<"$gate_accessibility_wait")" \ + "$(jq -r '.wait.elapsed_ms' <<<"$gate_accessibility_wait")" [[ "$(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 ]] @@ -492,8 +572,13 @@ for _ in {1..500}; do sleep 0.01 done [[ "$(cat "$gate_xwayland_display_file")" =~ ^:[0-9]+$ ]] +[[ "$(cat "$gate_xwayland_display_file")" == ":$gate_xwayland_x11_display" ]] +gate_xwayland_pid="$(pgrep -P "$gate_compositor_pid" -x Xwayland)" +[[ "$gate_xwayland_pid" =~ ^[1-9][0-9]*$ ]] +[[ "$(ps -o ppid= -p "$gate_xwayland_pid" | xargs)" == "$gate_compositor_pid" ]] DISPLAY="$(cat "$gate_xwayland_display_file")" \ - xmessage -buttons OK:0 -default OK 'SOS XWayland compatibility gate' \ + xmessage -title 'SOS XWayland compatibility gate' \ + -buttons OK:0 -default OK 'SOS XWayland compatibility gate' \ >"$gate_run_dir/xwayland-client.log" 2>&1 & gate_x11_pid=$! for _ in {1..500}; do @@ -503,6 +588,8 @@ for _ in {1..500}; do done grep -q 'rootless XWayland ready' "$gate_run_dir/compositor.log" grep -q 'mapped bounded XWayland window' "$gate_run_dir/compositor.log" +gate_xwayland_surface="$(grep 'mapped bounded XWayland window' "$gate_run_dir/compositor.log" | tail -n 1)" +grep -F 'title="SOS XWayland compatibility gate"' <<<"$gate_xwayland_surface" >/dev/null XDG_RUNTIME_DIR="$gate_runtime_dir" WAYLAND_DISPLAY=wayland-sos \ weston-simple-shm >"$gate_run_dir/compatibility.log" 2>&1 & @@ -558,7 +645,12 @@ printf 'authority_revision=%s\n' "$gate_authority_revision" printf 'native_text=%s input_quiesce_suppressed_events=%s\n' \ "$(jq -r '.current.state.draft' "$gate_revision_root/authority.json")" \ "$gate_suppressed_events" +printf 'xwayland_pid=%s parent_pid=%s client_pid=%s display=%s version=%s\n' \ + "$gate_xwayland_pid" "$gate_compositor_pid" "$gate_x11_pid" \ + "$(cat "$gate_xwayland_display_file")" \ + "$(sed -n '1p' <<<"$gate_xwayland_version")" +printf 'xwayland_surface=%s\n' "$gate_xwayland_surface" grep -E 'attached input-method|cursor-rectangle|selected=Some' "$gate_run_dir/ime.log" grep -E \ - 'authenticated SOS shell|mapped fixed-policy|presented armed shell revision' \ + 'authenticated SOS shell|mapped fixed-policy|rootless XWayland ready|mapped bounded XWayland window|presented armed shell revision' \ "$gate_run_dir/compositor.log" diff --git a/tools/linux-vm/boot-session-evidence-lib b/tools/linux-vm/boot-session-evidence-lib new file mode 100644 index 0000000..f0e1adc --- /dev/null +++ b/tools/linux-vm/boot-session-evidence-lib @@ -0,0 +1,19 @@ +# Shared, pure evidence formatting helpers for the boot-session gate. + +sos_agent_history_contract() { + local sos_history_request="$1" + local sos_history_completion="$2" + jq -ce \ + --arg request "$sos_history_request" \ + --arg completion "$sos_history_completion" ' + [paths(strings) as $path | select(getpath($path) == $request) | $path] as $request_paths + | [paths(strings) as $path | select(getpath($path) == $completion) | $path] as $completion_paths + | select(($request_paths | length) > 0 and ($completion_paths | length) > 0) + | { + request: getpath($request_paths[-1]), + completion: getpath($completion_paths[-1]), + request_matches: ($request_paths | length), + completion_matches: ($completion_paths | length) + } + ' +} diff --git a/tools/linux-vm/boot-session-lifecycle-lib b/tools/linux-vm/boot-session-lifecycle-lib new file mode 100644 index 0000000..d018319 --- /dev/null +++ b/tools/linux-vm/boot-session-lifecycle-lib @@ -0,0 +1,52 @@ +# Shared, pure parsing helpers for the boot-session suspend lifecycle gate. + +sos_selected_mem_sleep_mode() { + [[ "$#" -eq 1 ]] || return 2 + awk ' + { + for (field = 1; field <= NF; field += 1) { + if ($field ~ /^\[[^][]+\]$/) { + selected = substr($field, 2, length($field) - 2) + selected_count += 1 + } + } + } + END { + if (selected_count != 1 || (selected != "s2idle" && selected != "deep")) { + exit 1 + } + print selected + } + ' <<<"$1" +} + +sos_suspend_entry_count() { + [[ "$#" -eq 1 ]] || return 2 + case "$1" in + s2idle|deep) ;; + *) return 2 ;; + esac + awk -v expected_entry="PM: suspend entry ($1)" ' + index($0, expected_entry) { count += 1 } + END { print count + 0 } + ' +} + +sos_suspend_cycle_count() { + [[ "$#" -eq 1 ]] || return 2 + case "$1" in + s2idle|deep) ;; + *) return 2 ;; + esac + awk -v expected_entry="PM: suspend entry ($1)" ' + index($0, "PM: suspend entry (") { + awaiting_exit = index($0, expected_entry) > 0 + next + } + awaiting_exit && index($0, "PM: suspend exit") { + cycles += 1 + awaiting_exit = 0 + } + END { print cycles + 0 } + ' +} diff --git a/tools/linux-vm/inventory-sos-runtime b/tools/linux-vm/inventory-sos-runtime new file mode 100755 index 0000000..2d218dc --- /dev/null +++ b/tools/linux-vm/inventory-sos-runtime @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +[[ "$#" -eq 2 && "$1" == --root ]] || \ + fail "usage: tools/linux-vm/inventory-sos-runtime --root /run/user/UID" +[[ "$(id -u)" -ne 0 ]] || fail "run the SOS runtime inventory as the login user, not root" +command -v find >/dev/null 2>&1 || fail "required command not found: find" +command -v realpath >/dev/null 2>&1 || fail "required command not found: realpath" +command -v sort >/dev/null 2>&1 || fail "required command not found: sort" + +runtime_root="$(realpath -e -- "$2")" +[[ "$runtime_root" == /* && -d "$runtime_root" ]] || fail "runtime root must be an existing absolute directory" +[[ -O "$runtime_root" ]] || fail "runtime root must belong to the login user" + +export LC_ALL=C +shopt -s nullglob +candidates=("$runtime_root"/sos-session.*) +sessions=0 +entries=0 + +for candidate in "${candidates[@]}"; do + basename="${candidate##*/}" + [[ "$basename" =~ ^sos-session\.[[:alnum:]]{6}$ ]] || continue + sessions=$((sessions + 1)) + while IFS= read -r -d '' path; do + [[ "$path" != *$'\t'* && "$path" != *$'\n'* && "$path" != *$'\r'* ]] || \ + fail "SOS runtime path contains a control delimiter" + find "$path" -maxdepth 0 -printf \ + 'path=%p\ttype=%y\tbytes=%s\tmode=%m\tuid=%U\tgid=%G\tdevice_inode=%D:%i\n' + entries=$((entries + 1)) + done < <(find "$candidate" -xdev -print0 | sort -z) +done + +printf 'sos_runtime_inventory=PASS root=%s sessions=%s entries=%s\n' \ + "$runtime_root" "$sessions" "$entries" diff --git a/tools/linux-vm/provision-debian b/tools/linux-vm/provision-debian index b0453bf..aaa1620 100755 --- a/tools/linux-vm/provision-debian +++ b/tools/linux-vm/provision-debian @@ -20,6 +20,9 @@ vm_dependencies=( g++ gcc git + gstreamer1.0-plugins-base + gstreamer1.0-plugins-good + gstreamer1.0-tools jq libasound2-dev libfontconfig-dev @@ -45,11 +48,14 @@ vm_dependencies=( musl-tools pipewire pkgconf + python3 rustup seatd weston + x11-utils xdg-desktop-portal xvfb + xwayland ) sudo apt-get update @@ -108,7 +114,15 @@ cargo build --locked -p sos-linux-session --bin sos-agent-authoring ( cd services/sos-agent npm ci --ignore-scripts + npm run check + npm test npm run build + [[ -s dist/agent-runner.cjs ]] + printf 'sos_agent_package_passed package_path=%s bundle_path=%s bundle_bytes=%s bundle_sha256=%s sequence=npm-ci,check,test,build\n' \ + services/sos-agent/package.json \ + services/sos-agent/dist/agent-runner.cjs \ + "$(stat -c '%s' dist/agent-runner.cjs)" \ + "$(sha256sum dist/agent-runner.cjs | awk '{ print $1 }')" ) printf 'Debian 13 Linux host/compositor dependencies, Rust %s, and Node %s are ready.\n' \ diff --git a/tools/linux-vm/test-boot-session-evidence b/tools/linux-vm/test-boot-session-evidence new file mode 100755 index 0000000..57af262 --- /dev/null +++ b/tools/linux-vm/test-boot-session-evidence @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -euo pipefail + +vm_tools_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +vm_repo_root="$(cd "$vm_tools_dir/../.." && pwd)" +# shellcheck source=tools/linux-vm/boot-session-evidence-lib +source "$vm_tools_dir/boot-session-evidence-lib" + +vm_contract="$(sos_agent_history_contract \ + 'Turn this into a spatial time flow' \ + 'The candidate experience is active.' \ + <"$vm_repo_root/tests/fixtures/linux-agent-history.json")" +[[ "$vm_contract" == '{"request":"Turn this into a spatial time flow","completion":"The candidate experience is active.","request_matches":1,"completion_matches":1}' ]] + +if sos_agent_history_contract \ + 'Turn this into a spatial time flow' \ + 'A completion that was not persisted.' \ + <"$vm_repo_root/tests/fixtures/linux-agent-history.json" >/dev/null; then + printf 'error: missing persisted completion passed the evidence contract\n' >&2 + exit 1 +fi + +for vm_marker in \ + linux_boot_agent_initial_snapshot \ + linux_boot_agent_final_snapshot \ + linux_boot_agent_before_status \ + linux_boot_agent_after_status \ + linux_boot_agent_history_contract \ + linux_boot_session_identity \ + linux_boot_process_identity \ + 'linux_boot_lifecycle_state status=passed' \ + 'linux_boot_cleanup_audit status=passed'; do + grep -Fq "$vm_marker" "$vm_tools_dir/verify-boot-session" +done +for vm_marker in \ + sos_login_session_ready \ + sos_login_logind_identity \ + sos_login_initial_status \ + sos_login_surface_identity \ + sos_login_rewrite_ \ + sos_login_logout_observed \ + sos_login_cleanup_passed; do + grep -Fq "$vm_marker" "$vm_repo_root/packaging/libexec/sos-login-session" +done + +printf 'boot_session_evidence_contract_passed history_exact=true success_markers=true gdm_markers=true\n' diff --git a/tools/linux-vm/test-boot-session-lifecycle b/tools/linux-vm/test-boot-session-lifecycle new file mode 100755 index 0000000..121127a --- /dev/null +++ b/tools/linux-vm/test-boot-session-lifecycle @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +vm_tools_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=tools/linux-vm/boot-session-lifecycle-lib +source "$vm_tools_dir/boot-session-lifecycle-lib" + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +assert_equal() { + local expected="$1" + local actual="$2" + local description="$3" + [[ "$actual" == "$expected" ]] || \ + fail "$description: expected '$expected', got '$actual'" +} + +[[ "$#" -eq 0 ]] || fail "usage: tools/linux-vm/test-boot-session-lifecycle" + +assert_equal deep "$(sos_selected_mem_sleep_mode 's2idle [deep]')" \ + 'deep mem_sleep selection' +assert_equal s2idle "$(sos_selected_mem_sleep_mode '[s2idle] deep')" \ + 's2idle mem_sleep selection' +if sos_selected_mem_sleep_mode 's2idle deep' >/dev/null 2>&1; then + fail 'mem_sleep text without a selected mode was accepted' +fi +if sos_selected_mem_sleep_mode 's2idle [disk]' >/dev/null 2>&1; then + fail 'unsupported selected mem_sleep mode was accepted' +fi + +vm_deep_cycle_log='kernel: PM: suspend entry (deep) +kernel: PM: suspend debug: Waiting for 5 second(s). +kernel: PM: suspend exit' +assert_equal 1 "$(sos_suspend_entry_count deep <<<"$vm_deep_cycle_log")" \ + 'deep entry count' +assert_equal 0 "$(sos_suspend_entry_count s2idle <<<"$vm_deep_cycle_log")" \ + 'mode-specific s2idle entry count' +assert_equal 1 "$(sos_suspend_cycle_count deep <<<"$vm_deep_cycle_log")" \ + 'deep entry/exit cycle count' +assert_equal 0 "$(sos_suspend_cycle_count s2idle <<<"$vm_deep_cycle_log")" \ + 'wrong-mode cycle count' + +vm_s2idle_cycle_log='kernel: PM: suspend exit +kernel: PM: suspend entry (s2idle) +kernel: unrelated message +kernel: PM: suspend exit +kernel: PM: suspend entry (s2idle)' +assert_equal 2 "$(sos_suspend_entry_count s2idle <<<"$vm_s2idle_cycle_log")" \ + 's2idle entry count' +assert_equal 1 "$(sos_suspend_cycle_count s2idle <<<"$vm_s2idle_cycle_log")" \ + 'only ordered, complete s2idle cycle count' + +vm_replaced_mode_log='kernel: PM: suspend entry (deep) +kernel: PM: suspend entry (s2idle) +kernel: PM: suspend exit' +assert_equal 0 "$(sos_suspend_cycle_count deep <<<"$vm_replaced_mode_log")" \ + 'different later entry invalidates an unpaired deep entry' +assert_equal 1 "$(sos_suspend_cycle_count s2idle <<<"$vm_replaced_mode_log")" \ + 'later s2idle entry pairs with its subsequent exit' + +printf 'linux_boot_lifecycle_parser_tests_passed modes=s2idle,deep ordered_entry_exit=true\n' diff --git a/tools/linux-vm/test-runtime-inventory b/tools/linux-vm/test-runtime-inventory new file mode 100755 index 0000000..c9c3bbb --- /dev/null +++ b/tools/linux-vm/test-runtime-inventory @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +inventory="$repo_root/tools/linux-vm/inventory-sos-runtime" +test_root="$(mktemp -d /tmp/sos-runtime-inventory-test.XXXXXX)" +cleanup() { + chmod 700 "$test_root/systemd/inaccessible/dir" 2>/dev/null || true + rm -rf -- "$test_root" +} +trap cleanup EXIT + +mkdir -p "$test_root/systemd/inaccessible/dir" +printf 'unrelated-hidden-value\n' >"$test_root/systemd/inaccessible/dir/hidden" +chmod 000 "$test_root/systemd/inaccessible/dir" +mkdir -p "$test_root/sos-session.A1b2C3/cache" +printf 'private-runtime-content\n' >"$test_root/sos-session.A1b2C3/session-token" +mkdir -p "$test_root/sos-session.not-exact/ignored" + +first_output="$($inventory --root "$test_root")" +second_output="$($inventory --root "$test_root")" +[[ "$first_output" == "$second_output" ]] +grep -F "path=$test_root/sos-session.A1b2C3" <<<"$first_output" >/dev/null +grep -F "path=$test_root/sos-session.A1b2C3/cache" <<<"$first_output" >/dev/null +grep -F "path=$test_root/sos-session.A1b2C3/session-token" <<<"$first_output" >/dev/null +grep -F "sos_runtime_inventory=PASS root=$test_root sessions=1 entries=3" <<<"$first_output" >/dev/null +! grep -F 'systemd' <<<"$first_output" >/dev/null +! grep -F 'not-exact' <<<"$first_output" >/dev/null +! grep -F 'unrelated-hidden-value' <<<"$first_output" >/dev/null +! grep -F 'private-runtime-content' <<<"$first_output" >/dev/null + +rm -rf -- "$test_root/sos-session.A1b2C3" "$test_root/sos-session.not-exact" +empty_output="$($inventory --root "$test_root")" +grep -F "sos_runtime_inventory=PASS root=$test_root sessions=0 entries=0" <<<"$empty_output" >/dev/null + +printf 'sos_runtime_inventory_tests=PASS exact_top_level_only=true unrelated_inaccessible_skipped=true contents_absent=true deterministic=true\n' diff --git a/tools/linux-vm/verify-boot-session b/tools/linux-vm/verify-boot-session index 096ed24..cc2970c 100755 --- a/tools/linux-vm/verify-boot-session +++ b/tools/linux-vm/verify-boot-session @@ -26,7 +26,7 @@ fail() { exit 1 } -for vm_command in rsync ssh; do +for vm_command in awk rsync sha256sum ssh; do command -v "$vm_command" >/dev/null 2>&1 || fail "required command not found: $vm_command" done [[ "$#" -eq 0 ]] || fail "usage: tools/linux-vm/verify-boot-session" @@ -77,6 +77,7 @@ sudo systemctl enable seatd.service sudo systemctl start seatd.service sudo systemctl start gdm.service sudo rm -f -- \ + /etc/systemd/system/sos-session.target.wants/sos-agent.target \ /etc/systemd/system/sos-session.service \ /etc/systemd/system/sos-session.target \ /etc/systemd/system/sos-agent-authoring.service \ @@ -96,6 +97,56 @@ done sudo gpasswd -d sos sos-ipc >/dev/null 2>&1 || true sudo groupdel sos-ipc 2>/dev/null || true sudo systemctl daemon-reload +vm_cleanup_paths_absent=true +for vm_path in \ + /usr/local/libexec/sos \ + /usr/local/libexec/sos-agent \ + /usr/share/sos \ + /usr/share/doc/sos \ + /etc/sos \ + /var/lib/sos \ + /var/lib/sos-agent \ + /run/sos \ + /run/sos-agent \ + /run/sos-agent-authoring \ + /run/credentials/sos-session.service \ + /etc/systemd/system/sos-session.target.wants/sos-agent.target \ + /etc/systemd/system/sos-session.service \ + /etc/systemd/system/sos-session.target \ + /etc/systemd/system/sos-agent-authoring.service \ + /etc/systemd/system/sos-agent.service \ + /etc/systemd/system/sos-agent.target; do + sudo test ! -e "$vm_path" || vm_cleanup_paths_absent=false +done +vm_cleanup_accounts_absent=true +for vm_user in sos-agent sos-host sos-supervisor sos-provider sos-compositor; do + ! id "$vm_user" >/dev/null 2>&1 || vm_cleanup_accounts_absent=false +done +vm_cleanup_group_absent=true +! getent group sos-ipc >/dev/null || vm_cleanup_group_absent=false +vm_cleanup_login_membership_absent=true +! id -nG sos | tr ' ' '\n' | grep -Fxq sos-ipc || vm_cleanup_login_membership_absent=false +vm_cleanup_processes_absent=true +for vm_process_pattern in \ + '/usr/local/libexec/sos($|/)' \ + '/usr/local/libexec/sos-agent($|/)'; do + ! pgrep -f -- "$vm_process_pattern" >/dev/null || vm_cleanup_processes_absent=false +done +vm_cleanup_audit_status=failed +if [[ "$vm_cleanup_paths_absent" == true \ + && "$vm_cleanup_accounts_absent" == true \ + && "$vm_cleanup_group_absent" == true \ + && "$vm_cleanup_login_membership_absent" == true \ + && "$vm_cleanup_processes_absent" == true ]]; then + vm_cleanup_audit_status=passed +fi +printf 'linux_boot_cleanup_audit status=%s paths_and_units_absent=%s accounts_absent=%s group_absent=%s login_membership_absent=%s processes_absent=%s\n' \ + "$vm_cleanup_audit_status" \ + "$vm_cleanup_paths_absent" \ + "$vm_cleanup_accounts_absent" \ + "$vm_cleanup_group_absent" \ + "$vm_cleanup_login_membership_absent" \ + "$vm_cleanup_processes_absent" REMOTE_CLEANUP fi exit "$vm_status" @@ -119,6 +170,7 @@ for path in \ /etc/sos \ /var/lib/sos \ /var/lib/sos-agent \ + /etc/systemd/system/sos-session.target.wants/sos-agent.target \ /etc/systemd/system/sos-session.service \ /etc/systemd/system/sos-session.target \ /etc/systemd/system/sos-agent-authoring.service \ @@ -144,14 +196,67 @@ rsync -a --delete \ -e "$vm_ssh_transport" \ "$vm_repo_root/" "$vm_destination:$vm_guest_root/" -vm_ssh "cd '$vm_guest_root' && cargo build --locked \ +vm_expected_provision_sha256="$(sha256sum "$vm_repo_root/tools/linux-vm/provision-debian" | awk '{ print $1 }')" +vm_expected_agent_package_sha256="$(sha256sum "$vm_repo_root/services/sos-agent/package.json" | awk '{ print $1 }')" +vm_ssh 'bash -s' -- \ + "$vm_guest_root" "$vm_expected_provision_sha256" "$vm_expected_agent_package_sha256" <<'REMOTE_BUILD' +set -Eeuo pipefail +vm_guest_root="$1" +vm_expected_provision_sha256="$2" +vm_expected_agent_package_sha256="$3" +vm_build_phase=source-paths +report_build_failure() { + local vm_status="$?" + local vm_line="$1" + trap - ERR + printf 'linux_boot_build_failed phase=%s line=%s status=%s\n' \ + "$vm_build_phase" "$vm_line" "$vm_status" >&2 + exit "$vm_status" +} +trap 'report_build_failure "$LINENO"' ERR + +cd "$vm_guest_root" +[[ -f tools/linux-vm/provision-debian ]] +[[ -f services/sos-agent/package.json ]] +[[ -f services/sos-agent/package-lock.json ]] +vm_actual_provision_sha256="$(sha256sum tools/linux-vm/provision-debian | awk '{ print $1 }')" +vm_actual_agent_package_sha256="$(sha256sum services/sos-agent/package.json | awk '{ print $1 }')" +[[ "$vm_actual_provision_sha256" == "$vm_expected_provision_sha256" ]] +[[ "$vm_actual_agent_package_sha256" == "$vm_expected_agent_package_sha256" ]] +printf 'linux_boot_guest_source_identity root=%s provision_path=%s provision_sha256=%s package_path=%s package_sha256=%s\n' \ + "$vm_guest_root" \ + tools/linux-vm/provision-debian \ + "$vm_actual_provision_sha256" \ + services/sos-agent/package.json \ + "$vm_actual_agent_package_sha256" + +vm_build_phase=rust-session-binaries +cargo build --locked \ -p sos-compositor --features direct-backend --bin sos-compositor \ -p sos-experience --features linux-host --bin sos-experience-host \ -p revision-supervisor --bin sos-revision-supervisor \ -p provider-state-service --bin sos-provider-state-service \ - -p sos-linux-session --bin sos-linux-session \ - && cargo build --locked -p sos-linux-session --bin sos-agent-authoring \ - && cd services/sos-agent && npm ci --ignore-scripts && npm run build" + -p sos-linux-session --bin sos-linux-session +vm_build_phase=rust-agent-authoring +cargo build --locked -p sos-linux-session --bin sos-agent-authoring + +cd services/sos-agent +vm_build_phase=agent-npm-ci +npm ci --ignore-scripts +vm_build_phase=agent-check +npm run check +vm_build_phase=agent-test +npm test +vm_build_phase=agent-final-build +npm run build +vm_build_phase=agent-bundle-identity +[[ -s dist/agent-runner.cjs ]] +printf 'linux_boot_agent_package_passed package_path=%s bundle_path=%s bundle_bytes=%s bundle_sha256=%s sequence=npm-ci,check,test,build\n' \ + services/sos-agent/package.json \ + services/sos-agent/dist/agent-runner.cjs \ + "$(stat -c '%s' dist/agent-runner.cjs)" \ + "$(sha256sum dist/agent-runner.cjs | awk '{ print $1 }')" +REMOTE_BUILD vm_cleanup_required=true vm_ssh 'bash -s' -- "$vm_guest_root" <<'REMOTE_INSTALL' @@ -251,10 +356,52 @@ vm_ssh 'sudo systemctl reboot' >/dev/null 2>&1 || true wait_for_vm_down wait_for_vm_up -vm_agent_evidence="$(vm_ssh 'bash -s' <<'REMOTE_AGENT' -set -euo pipefail +vm_expected_evidence_lib_sha256="$(sha256sum "$vm_repo_root/tools/linux-vm/boot-session-evidence-lib" | awk '{ print $1 }')" +vm_agent_evidence="$(vm_ssh 'bash -s' -- \ + "$vm_guest_root/tools/linux-vm/boot-session-evidence-lib" \ + "$vm_expected_evidence_lib_sha256" <<'REMOTE_AGENT' +set -Eeuo pipefail +vm_evidence_lib="$1" +vm_expected_evidence_lib_sha256="$2" +[[ -f "$vm_evidence_lib" ]] +vm_actual_evidence_lib_sha256="$(sha256sum "$vm_evidence_lib" | awk '{ print $1 }')" +[[ "$vm_actual_evidence_lib_sha256" == "$vm_expected_evidence_lib_sha256" ]] +printf 'linux_boot_evidence_parser_identity path=%s sha256=%s\n' \ + "$vm_evidence_lib" "$vm_actual_evidence_lib_sha256" +# shellcheck source=tools/linux-vm/boot-session-evidence-lib +source "$vm_evidence_lib" vm_bin_dir=/usr/local/libexec/sos vm_revision_root=/var/lib/sos/revisions +vm_agent_phase=readiness +vm_agent_initial_snapshot=unavailable +vm_agent_final_snapshot=unavailable +vm_before_status=unavailable +vm_after_status=unavailable +report_agent_failure() { + local vm_status="$?" + local vm_line="$1" + trap - ERR + set +e + printf 'linux_boot_agent_failed phase=%s line=%s status=%s\n' \ + "$vm_agent_phase" "$vm_line" "$vm_status" >&2 + printf 'linux_boot_agent_initial_snapshot=%s\n' "$vm_agent_initial_snapshot" >&2 + printf 'linux_boot_agent_final_snapshot=%s\n' "$vm_agent_final_snapshot" >&2 + printf 'linux_boot_agent_before_status=%s\n' "$vm_before_status" >&2 + printf 'linux_boot_agent_after_status=%s\n' "$vm_after_status" >&2 + if sudo test -f /var/lib/sos-agent/messages.json; then + printf 'linux_boot_agent_history path=/var/lib/sos-agent/messages.json bytes=%s sha256=%s completion_present=%s\n' \ + "$(sudo stat -c '%s' /var/lib/sos-agent/messages.json)" \ + "$(sudo sha256sum /var/lib/sos-agent/messages.json | awk '{ print $1 }')" \ + "$(sudo grep -Fq 'The candidate experience is active.' /var/lib/sos-agent/messages.json && printf true || printf false)" >&2 + else + printf 'linux_boot_agent_history path=/var/lib/sos-agent/messages.json present=false\n' >&2 + fi + sudo journalctl -b -u sos-agent.service --no-pager -n 120 >&2 || true + sudo journalctl -b -u sos-agent-authoring.service --no-pager -n 120 >&2 || true + sudo journalctl -b -t sos-linux-session --no-pager -n 320 >&2 || true + exit "$vm_status" +} +trap 'report_agent_failure "$LINENO"' ERR for _ in {1..3000}; do if systemctl is-active --quiet sos-session.service \ && systemctl is-active --quiet sos-agent-authoring.service \ @@ -268,6 +415,7 @@ for _ in {1..3000}; do fi sleep 0.02 done +vm_agent_phase=service-contract systemctl is-active --quiet sos-session.service systemctl is-active --quiet sos-agent-authoring.service systemctl is-active --quiet sos-agent.service @@ -295,12 +443,13 @@ vm_before_status="$(sudo -u sos-supervisor "$vm_bin_dir/sos-revision-supervisor" --root "$vm_revision_root")" vm_before_revision="$(jq -er '.active_revision' <<<"$vm_before_status")" vm_before_host="$(jq -er '.host_pid' <<<"$vm_before_status")" -vm_agent_snapshot="$(accessibility_call '{"method":"snapshot"}')" +vm_agent_initial_snapshot="$(accessibility_call '{"method":"snapshot"}')" if ! jq -e '.ok and any(.snapshot.nodes[]; .id == "agent-prompt" and .editable)' \ - <<<"$vm_agent_snapshot" >/dev/null; then - printf 'agent composer missing from semantic snapshot: %s\n' "$vm_agent_snapshot" >&2 - exit 1 + <<<"$vm_agent_initial_snapshot" >/dev/null; then + printf 'agent composer missing from semantic snapshot: %s\n' "$vm_agent_initial_snapshot" >&2 + false fi +vm_agent_phase=semantic-submit [[ "$(accessibility_call '{"method":"action","kind":"set_value","target":"agent-prompt","value":"Turn this into a spatial time flow"}' | jq -r '.ok')" == true ]] for _ in {1..500}; do if accessibility_call '{"method":"snapshot"}' \ @@ -312,6 +461,7 @@ done accessibility_call '{"method":"snapshot"}' \ | jq -e 'any(.snapshot.nodes[]; .id == "agent-prompt" and .value == "Turn this into a spatial time flow")' >/dev/null [[ "$(accessibility_call '{"method":"action","kind":"submit","target":"agent-prompt"}' | jq -r '.ok')" == true ]] +vm_agent_phase=revision-activation for _ in {1..3000}; do vm_after_status="$(sudo -u sos-supervisor "$vm_bin_dir/sos-revision-supervisor" daemon-status \ --root "$vm_revision_root")" @@ -336,19 +486,79 @@ for vm_tool in get_experience_context validate_experience submit_experience; do done sudo journalctl -b -t sos-linux-session --no-pager \ | grep "presented armed shell revision.*revision_id=\"$vm_after_revision\".*evidence=\"drm_page_flip\"" >/dev/null -for _ in {1..1000}; do - if accessibility_call '{"method":"snapshot"}' \ - | jq -e 'any(.snapshot.nodes[]; .id == "agent-status" and .value == "Ready") and any(.snapshot.nodes[]; .label == "SOS message" and (.value | contains("candidate experience is active")))' >/dev/null; then - break - fi - sleep 0.02 -done +vm_agent_phase=visible-completion vm_agent_final_snapshot="$(accessibility_call '{"method":"snapshot"}')" -if ! jq -e 'any(.snapshot.nodes[]; .id == "agent-status" and .value == "Ready") and any(.snapshot.nodes[]; .label == "SOS message" and (.value | contains("candidate experience is active")))' \ +# The authoring broker and accessibility service each cap an operation at 30 +# seconds. Wait on semantic generations within that contract instead of +# sampling on an arbitrary sleep interval. +vm_agent_completion_deadline=$((SECONDS + 30)) +while ! jq -e ' + any(.snapshot.nodes[]; .id == "agent-status" and .value == "Ready") + and any(.snapshot.nodes[]; .label == "YOU message" and .value == "Turn this into a spatial time flow") + and any(.snapshot.nodes[]; .label == "SOS message" and .value == "The candidate experience is active.") + and any(.snapshot.nodes[]; .id == "agent-prompt" and .editable and .value == "") + ' <<<"$vm_agent_final_snapshot" >/dev/null; do + vm_agent_remaining=$((vm_agent_completion_deadline - SECONDS)) + (( vm_agent_remaining > 0 )) || break + vm_agent_generation="$(jq -er '.snapshot.generation' <<<"$vm_agent_final_snapshot")" + vm_agent_final_snapshot="$(accessibility_call \ + "{\"method\":\"wait\",\"after_generation\":$vm_agent_generation,\"timeout_ms\":$((vm_agent_remaining * 1000))}")" +done +if ! jq -e ' + any(.snapshot.nodes[]; .id == "agent-status" and .value == "Ready") + and any(.snapshot.nodes[]; .label == "YOU message" and .value == "Turn this into a spatial time flow") + and any(.snapshot.nodes[]; .label == "SOS message" and .value == "The candidate experience is active.") + and any(.snapshot.nodes[]; .id == "agent-prompt" and .editable and .value == "") + ' \ <<<"$vm_agent_final_snapshot" >/dev/null; then printf 'agent completion missing from semantic snapshot: %s\n' "$vm_agent_final_snapshot" >&2 - exit 1 + false fi +! sudo journalctl -b -t sos-linux-session --no-pager \ + | grep 'sos_agent_prompt_failed' >/dev/null +vm_agent_phase=completion-evidence +sudo test -f /var/lib/sos-agent/messages.json +vm_agent_history_contract="$(sudo cat /var/lib/sos-agent/messages.json \ + | sos_agent_history_contract \ + 'Turn this into a spatial time flow' \ + 'The candidate experience is active.')" +[[ -n "$vm_agent_history_contract" ]] +printf 'linux_boot_agent_history path=/var/lib/sos-agent/messages.json bytes=%s sha256=%s completion_present=true\n' \ + "$(sudo stat -c '%s' /var/lib/sos-agent/messages.json)" \ + "$(sudo sha256sum /var/lib/sos-agent/messages.json | awk '{ print $1 }')" +printf 'linux_boot_agent_initial_snapshot=%s\n' "$vm_agent_initial_snapshot" +printf 'linux_boot_agent_final_snapshot=%s\n' "$vm_agent_final_snapshot" +printf 'linux_boot_agent_before_status=%s\n' "$vm_before_status" +printf 'linux_boot_agent_after_status=%s\n' "$vm_after_status" +printf 'linux_boot_agent_history_contract=%s\n' "$vm_agent_history_contract" + +agent_process_identity() { + local vm_role="$1" + local vm_pid="$2" + local vm_ppid vm_uid vm_user vm_executable + [[ "$vm_pid" =~ ^[1-9][0-9]*$ ]] + vm_ppid="$(ps -o ppid= -p "$vm_pid" | xargs)" + vm_uid="$(ps -o uid= -p "$vm_pid" | xargs)" + vm_user="$(ps -o user= -p "$vm_pid" | xargs)" + vm_executable="$(sudo readlink -f "/proc/$vm_pid/exe")" + printf 'linux_boot_process_identity role=%s pid=%s ppid=%s uid=%s user=%s executable=%s\n' \ + "$vm_role" "$vm_pid" "$vm_ppid" "$vm_uid" "$vm_user" "$vm_executable" +} +vm_session_owner_pid="$(systemctl show sos-session.service -p MainPID --value)" +vm_compositor_pid="$(pgrep -P "$vm_session_owner_pid" -f '^/usr/local/libexec/sos/sos-compositor')" +vm_platform_pid="$(pgrep -P "$vm_session_owner_pid" -f '^/usr/local/libexec/sos/sos-provider-state-service')" +vm_supervisor_pid="$(pgrep -P "$vm_session_owner_pid" -f '^/usr/local/libexec/sos/sos-revision-supervisor')" +vm_actual_host_pid="$(pgrep -u sos-host -f '^/usr/local/libexec/sos/sos-experience-host')" +agent_process_identity session-owner "$vm_session_owner_pid" +agent_process_identity compositor "$vm_compositor_pid" +agent_process_identity platform-authority "$vm_platform_pid" +agent_process_identity supervisor "$vm_supervisor_pid" +agent_process_identity host-proxy "$vm_after_host" +agent_process_identity experience-host "$vm_actual_host_pid" +agent_process_identity authoring-broker "$vm_broker_pid" +agent_process_identity resident-agent "$vm_agent_pid" +sudo journalctl -b -t sos-linux-session --no-pager \ + | grep -E 'sos_agent_prompt_completed|sos_agent_completion_visible' | tail -n 2 || true printf 'linux_boot_agent_luau_passed host_pid=%s initial_revision=%s active_revision=%s agent_pid=%s broker_pid=%s input=text_session effect=agent.prompt tools=context,validate,submit evidence=drm_page_flip\n' \ "$vm_after_host" "$vm_before_revision" "$vm_after_revision" "$vm_agent_pid" "$vm_broker_pid" REMOTE_AGENT @@ -361,8 +571,23 @@ printf '%s\n' "$vm_agent_evidence" # Linux's userspace freeze/resume test, and switch back before the unsupported # final platform power stage. Device hotplug is exercised independently below; # QEMU virtio-net does not resume from the deeper `pm_test=devices` level. -vm_lifecycle_main_before="$(vm_ssh 'bash -s' <<'REMOTE_LIFECYCLE_SUSPEND' -set -euo pipefail +vm_expected_lifecycle_lib_sha256="$(sha256sum "$vm_repo_root/tools/linux-vm/boot-session-lifecycle-lib" | awk '{ print $1 }')" +vm_lifecycle_main_before="$(vm_ssh 'bash -s' -- \ + "$vm_guest_root/tools/linux-vm/boot-session-lifecycle-lib" \ + "$vm_expected_lifecycle_lib_sha256" <<'REMOTE_LIFECYCLE_SUSPEND' +set -Eeuo pipefail +vm_lifecycle_lib="$1" +vm_expected_lifecycle_lib_sha256="$2" +[[ -f "$vm_lifecycle_lib" ]] +vm_actual_lifecycle_lib_sha256="$(sha256sum "$vm_lifecycle_lib" | awk '{ print $1 }')" +[[ "$vm_actual_lifecycle_lib_sha256" == "$vm_expected_lifecycle_lib_sha256" ]] +printf 'linux_boot_lifecycle_parser_identity path=%s sha256=%s\n' \ + "$vm_lifecycle_lib" "$vm_actual_lifecycle_lib_sha256" >&2 +# shellcheck source=tools/linux-vm/boot-session-lifecycle-lib +source "$vm_lifecycle_lib" +vm_lifecycle_phase=initialize +vm_mem_sleep_modes=unavailable +vm_suspend_mode=unavailable switch_vt() { sudo python3 - "$1" <<'PY' import fcntl @@ -381,12 +606,39 @@ finally: os.close(fd) PY } +report_lifecycle_failure() { + local vm_status="$?" + local vm_line="$1" + trap - ERR + set +e + printf 'linux_boot_lifecycle_failed phase=%s line=%s status=%s expected_main_pid=%s current_main_pid=%s\n' \ + "$vm_lifecycle_phase" "$vm_line" "$vm_status" \ + "${vm_main_before:-unknown}" \ + "$(systemctl show sos-session.service -p MainPID --value 2>/dev/null)" >&2 + printf 'linux_boot_lifecycle_state active_vt=%s virtual_1=%s virtual_2=%s pm_test=%s mem_sleep="%s" suspend_mode=%s\n' \ + "$(cat /sys/class/tty/tty0/active 2>/dev/null)" \ + "$(cat /sys/class/drm/card0-Virtual-1/status 2>/dev/null)" \ + "$(cat /sys/class/drm/card0-Virtual-2/status 2>/dev/null)" \ + "$(cat /sys/power/pm_test 2>/dev/null)" \ + "$(cat /sys/power/mem_sleep 2>/dev/null)" \ + "${vm_suspend_mode:-unavailable}" >&2 + sudo systemctl status sos-session.service --no-pager -l >&2 || true + sudo ps -o pid,ppid,user,stat,args \ + -p "${vm_main_before:-0}" --ppid "${vm_main_before:-0}" >&2 || true + sudo journalctl -b -t sos-linux-session --no-pager -n 240 >&2 || true + sudo journalctl -k -b --no-pager \ + | grep -Ei 'PM: suspend (entry|exit)|PM: suspend debug|drm|virtio.?gpu' \ + | tail -n 120 >&2 || true + exit "$vm_status" +} restore_lifecycle_state() { set +e sudo sh -c 'echo none > /sys/power/pm_test; echo on > /sys/class/drm/card0-Virtual-1/status; echo off > /sys/class/drm/card0-Virtual-2/status; echo change > /sys/class/drm/card0/uevent' switch_vt 1 } trap restore_lifecycle_state EXIT +trap 'report_lifecycle_failure "$LINENO"' ERR +vm_lifecycle_phase=readiness-wait for _ in {1..3000}; do if systemctl is-active --quiet sos-session.service \ && sudo journalctl -b -t sos-linux-session --no-pager -n 240 \ @@ -395,59 +647,142 @@ for _ in {1..3000}; do fi sleep 0.02 done +vm_lifecycle_phase=session-active systemctl is-active --quiet sos-session.service +printf 'linux_boot_lifecycle_phase phase=session-active status=passed\n' >&2 +vm_lifecycle_phase=readiness-log-match +sudo journalctl -b -t sos-linux-session --no-pager -n 240 \ + | grep 'linux_system_session_ready.*evidence=drm_page_flip' >/dev/null +printf 'linux_boot_lifecycle_phase phase=readiness-log-match status=passed\n' >&2 +vm_lifecycle_phase=lifecycle-owner-pid vm_main_before="$(systemctl show sos-session.service -p MainPID --value)" [[ "$vm_main_before" =~ ^[1-9][0-9]*$ ]] -vm_entries_before="$(sudo journalctl -k -b --no-pager | grep -c 'PM: suspend entry (s2idle)' || true)" -vm_exits_before="$(sudo journalctl -k -b --no-pager | grep -c 'PM: suspend exit' || true)" +printf 'linux_boot_lifecycle_phase phase=lifecycle-owner-pid status=passed main_pid=%s\n' \ + "$vm_main_before" >&2 +if [[ -r /sys/power/mem_sleep ]]; then + vm_mem_sleep_modes="$(cat /sys/power/mem_sleep)" + vm_suspend_mode="$(sos_selected_mem_sleep_mode "$vm_mem_sleep_modes")" +fi +printf 'linux_boot_lifecycle_suspend_mode source=%s available_modes="%s" selected_mode=%s\n' \ + "$([[ "$vm_mem_sleep_modes" == unavailable ]] && printf kernel-log || printf sysfs)" \ + "$vm_mem_sleep_modes" "$vm_suspend_mode" >&2 +vm_kernel_cursor="$(sudo journalctl -k -b -n 1 --show-cursor --no-pager -o cat \ + | sed -n 's/^-- cursor: //p' | tail -n 1)" +[[ -n "$vm_kernel_cursor" ]] vm_pauses_before="$(sudo journalctl -b -t sos-linux-session --no-pager | grep -c 'direct session paused' || true)" vm_activations_before="$(sudo journalctl -b -t sos-linux-session --no-pager | grep -c 'direct session activated' || true)" +vm_lifecycle_phase=vt-pause-request switch_vt 2 +printf 'linux_boot_lifecycle_phase phase=vt-pause-request status=passed active_vt=%s\n' \ + "$(cat /sys/class/tty/tty0/active)" >&2 +vm_lifecycle_phase=vt-pause-log-match for _ in {1..1000}; do vm_pauses_after="$(sudo journalctl -b -t sos-linux-session --no-pager | grep -c 'direct session paused' || true)" (( vm_pauses_after > vm_pauses_before )) && break sleep 0.02 done (( vm_pauses_after > vm_pauses_before )) +printf 'linux_boot_lifecycle_phase phase=vt-pause-log-match status=passed before=%s after=%s\n' \ + "$vm_pauses_before" "$vm_pauses_after" >&2 +vm_lifecycle_phase=freezer-resume-command sudo sh -c 'echo freezer > /sys/power/pm_test; echo mem > /sys/power/state; echo none > /sys/power/pm_test' +printf 'linux_boot_lifecycle_phase phase=freezer-resume-command status=passed\n' >&2 +vm_lifecycle_phase=vt-resume-request switch_vt 1 +printf 'linux_boot_lifecycle_phase phase=vt-resume-request status=passed active_vt=%s\n' \ + "$(cat /sys/class/tty/tty0/active)" >&2 +vm_lifecycle_phase=vt-resume-log-match for _ in {1..1000}; do vm_activations_after="$(sudo journalctl -b -t sos-linux-session --no-pager | grep -c 'direct session activated' || true)" (( vm_activations_after > vm_activations_before )) && break sleep 0.02 done (( vm_activations_after > vm_activations_before )) -vm_entries_after="$(sudo journalctl -k -b --no-pager | grep -c 'PM: suspend entry (s2idle)' || true)" -vm_exits_after="$(sudo journalctl -k -b --no-pager | grep -c 'PM: suspend exit' || true)" -(( vm_entries_after > vm_entries_before )) -(( vm_exits_after > vm_exits_before )) +printf 'linux_boot_lifecycle_phase phase=vt-resume-log-match status=passed before=%s after=%s\n' \ + "$vm_activations_before" "$vm_activations_after" >&2 +vm_suspend_log="$(sudo journalctl -k -b --after-cursor="$vm_kernel_cursor" --no-pager -o cat)" +if [[ "$vm_suspend_mode" == unavailable ]]; then + vm_s2idle_entries="$(sos_suspend_entry_count s2idle <<<"$vm_suspend_log")" + vm_deep_entries="$(sos_suspend_entry_count deep <<<"$vm_suspend_log")" + if (( vm_s2idle_entries > 0 && vm_deep_entries == 0 )); then + vm_suspend_mode=s2idle + elif (( vm_deep_entries > 0 && vm_s2idle_entries == 0 )); then + vm_suspend_mode=deep + else + false + fi + printf 'linux_boot_lifecycle_suspend_mode source=kernel-log available_modes="unavailable" selected_mode=%s\n' \ + "$vm_suspend_mode" >&2 +fi +vm_entries_after="$(sos_suspend_entry_count "$vm_suspend_mode" <<<"$vm_suspend_log")" +vm_cycles_after="$(sos_suspend_cycle_count "$vm_suspend_mode" <<<"$vm_suspend_log")" +vm_lifecycle_phase=freezer-entry-log-match +(( vm_entries_after > 0 )) +printf 'linux_boot_lifecycle_phase phase=freezer-entry-log-match status=passed mode=%s entries=%s\n' \ + "$vm_suspend_mode" "$vm_entries_after" >&2 +vm_lifecycle_phase=freezer-exit-log-match +(( vm_cycles_after > 0 )) +printf 'linux_boot_lifecycle_phase phase=freezer-exit-log-match status=passed mode=%s paired_cycles=%s\n' \ + "$vm_suspend_mode" "$vm_cycles_after" >&2 +vm_lifecycle_phase=post-freezer-process-liveness [[ "$(systemctl show sos-session.service -p MainPID --value)" == "$vm_main_before" ]] +printf 'linux_boot_lifecycle_phase phase=post-freezer-process-liveness status=passed main_pid=%s\n' \ + "$vm_main_before" >&2 +vm_lifecycle_phase=campaign-log-presence sudo journalctl -b -t sos-linux-session --no-pager | grep 'direct session paused' >/dev/null sudo journalctl -b -t sos-linux-session --no-pager | grep 'direct session activated' >/dev/null +printf 'linux_boot_lifecycle_phase phase=campaign-log-presence status=passed\n' >&2 vm_disconnects_before="$(sudo journalctl -b -t sos-linux-session --no-pager | grep -c 'disconnected DRM output' || true)" vm_outputs_before="$(sudo journalctl -b -t sos-linux-session --no-pager | grep -c 'initialized direct KMS output' || true)" +vm_lifecycle_phase=output-disconnect-request sudo sh -c 'echo off > /sys/class/drm/card0-Virtual-1/status; echo change > /sys/class/drm/card0/uevent' +printf 'linux_boot_lifecycle_phase phase=output-disconnect-request status=passed\n' >&2 +vm_lifecycle_phase=output-disconnect-log-match for _ in {1..1000}; do vm_disconnects_after="$(sudo journalctl -b -t sos-linux-session --no-pager | grep -c 'disconnected DRM output' || true)" (( vm_disconnects_after > vm_disconnects_before )) && break sleep 0.02 done (( vm_disconnects_after > vm_disconnects_before )) +printf 'linux_boot_lifecycle_phase phase=output-disconnect-log-match status=passed before=%s after=%s\n' \ + "$vm_disconnects_before" "$vm_disconnects_after" >&2 +vm_lifecycle_phase=post-disconnect-process-liveness [[ "$(systemctl show sos-session.service -p MainPID --value)" == "$vm_main_before" ]] +printf 'linux_boot_lifecycle_phase phase=post-disconnect-process-liveness status=passed main_pid=%s\n' \ + "$vm_main_before" >&2 +vm_lifecycle_phase=output-reconnect-request sudo sh -c 'echo on > /sys/class/drm/card0-Virtual-1/status; echo change > /sys/class/drm/card0/uevent' +printf 'linux_boot_lifecycle_phase phase=output-reconnect-request status=passed\n' >&2 +vm_lifecycle_phase=output-reconnect-log-match for _ in {1..1000}; do vm_outputs_after="$(sudo journalctl -b -t sos-linux-session --no-pager | grep -c 'initialized direct KMS output' || true)" (( vm_outputs_after > vm_outputs_before )) && break sleep 0.02 done (( vm_outputs_after > vm_outputs_before )) +printf 'linux_boot_lifecycle_phase phase=output-reconnect-log-match status=passed before=%s after=%s\n' \ + "$vm_outputs_before" "$vm_outputs_after" >&2 +vm_lifecycle_phase=post-reconnect-process-liveness [[ "$(systemctl show sos-session.service -p MainPID --value)" == "$vm_main_before" ]] +printf 'linux_boot_lifecycle_phase phase=post-reconnect-process-liveness status=passed main_pid=%s\n' \ + "$vm_main_before" >&2 +printf 'linux_boot_lifecycle_state status=passed active_vt=%s virtual_1=%s virtual_2=%s pm_test=%s mem_sleep="%s" selected_mode=%s main_pid=%s\n' \ + "$(cat /sys/class/tty/tty0/active)" \ + "$(cat /sys/class/drm/card0-Virtual-1/status)" \ + "$(cat /sys/class/drm/card0-Virtual-2/status)" \ + "$(cat /sys/power/pm_test)" \ + "$vm_mem_sleep_modes" \ + "$vm_suspend_mode" \ + "$vm_main_before" >&2 +sudo journalctl -b -t sos-linux-session --no-pager \ + | grep -E 'direct session (paused|activated)|disconnected DRM output|initialized direct KMS output|completed significant DRM page flip' \ + | tail -n 24 >&2 trap - EXIT printf '%s\n' "$vm_main_before" REMOTE_LIFECYCLE_SUSPEND -)" || fail "packaged compositor did not survive suspend/output lifecycle campaign" +)" || fail "boot-session lifecycle transport returned nonzero; a remote assertion has a preceding linux_boot_lifecycle_failed phase, while an absent marker means transport/bootstrap failure" [[ "$vm_lifecycle_main_before" =~ ^[1-9][0-9]*$ ]] || \ fail "invalid lifecycle compositor-owner PID" @@ -572,6 +907,34 @@ vm_host_credential="$(sudo find /run/sos -maxdepth 1 -type f \ [[ "$(sudo stat -c '%a %U' "$vm_compositor_credential")" == '400 sos-compositor' ]] [[ "$(sudo stat -c '%a %U' "$vm_host_credential")" == '400 sos-host' ]] +boot_process_identity() { + local vm_role="$1" + local vm_pid="$2" + local vm_ppid vm_uid vm_user vm_executable + [[ "$vm_pid" =~ ^[1-9][0-9]*$ ]] + vm_ppid="$(ps -o ppid= -p "$vm_pid" | xargs)" + vm_uid="$(ps -o uid= -p "$vm_pid" | xargs)" + vm_user="$(ps -o user= -p "$vm_pid" | xargs)" + vm_executable="$(sudo readlink -f "/proc/$vm_pid/exe")" + printf 'linux_boot_process_identity role=%s pid=%s ppid=%s uid=%s user=%s executable=%s\n' \ + "$vm_role" "$vm_pid" "$vm_ppid" "$vm_uid" "$vm_user" "$vm_executable" +} +printf 'linux_boot_session_identity session=%s active=%s seat=%s tty=%s type=%s leader=%s user=%s uid=%s\n' \ + "$vm_session" \ + "$(loginctl show-session "$vm_session" -p Active --value)" \ + "$(loginctl show-session "$vm_session" -p Seat --value)" \ + "$(loginctl show-session "$vm_session" -p TTY --value)" \ + "$(loginctl show-session "$vm_session" -p Type --value)" \ + "$(loginctl show-session "$vm_session" -p Leader --value)" \ + "$(loginctl show-session "$vm_session" -p Name --value)" \ + "$(loginctl show-session "$vm_session" -p User --value)" +boot_process_identity session-owner "$vm_main_before" +boot_process_identity compositor "$vm_compositor_pid" +boot_process_identity platform-authority "$vm_provider_pid" +boot_process_identity supervisor "$vm_supervisor_pid" +boot_process_identity host-proxy "$vm_host_before" +boot_process_identity experience-host "$vm_actual_host_before" + vm_candidate="$(sudo -u sos-supervisor "$vm_bin_dir/sos-revision-supervisor" install \ --root "$vm_revision_root" \ --source /var/lib/sos/staging/daily-flow.luau \ @@ -745,8 +1108,8 @@ printf 'linux_boot_session_passed session=%s main_pid=%s activation_pid=%s recov "$vm_main_after" "$vm_host_after_restart" "$vm_main_final" "$vm_candidate" "$vm_restart_count" printf 'activation=%s\n' "$vm_activation" sudo journalctl -b -t sos-linux-session --no-pager \ - | grep -E 'sos_compositor_presenting|linux_system_session_ready|presented armed shell revision' \ - | tail -n 24 + | grep -E 'linux_system_session_component|initialized direct DRM/libinput session|initialized direct KMS output|disconnected DRM output|completed significant DRM page flip|authenticated SOS shell control connection|mapped fixed-policy XDG toplevel role=Shell|sos_compositor_presenting|linux_system_session_ready|presented armed shell revision' \ + | tail -n 80 REMOTE_VERIFY )" || fail "boot-session evidence run failed" @@ -765,6 +1128,7 @@ done systemctl is-active --quiet gdm.service systemctl is-active --quiet seatd.service sudo rm -f -- \ + /etc/systemd/system/sos-session.target.wants/sos-agent.target \ /etc/systemd/system/sos-session.service \ /etc/systemd/system/sos-session.target \ /etc/systemd/system/sos-agent-authoring.service \ @@ -793,6 +1157,41 @@ done sudo gpasswd -d sos sos-ipc >/dev/null 2>&1 || true sudo groupdel sos-ipc sudo systemctl daemon-reload +for vm_path in \ + /usr/local/libexec/sos \ + /usr/local/libexec/sos-agent \ + /usr/share/sos \ + /usr/share/doc/sos \ + /etc/sos \ + /var/lib/sos \ + /var/lib/sos-agent \ + /run/sos \ + /run/sos-agent \ + /run/sos-agent-authoring \ + /run/credentials/sos-session.service; do + sudo test ! -e "$vm_path" +done +for vm_unit_path in \ + /etc/systemd/system/sos-session.target.wants/sos-agent.target \ + /etc/systemd/system/sos-session.service \ + /etc/systemd/system/sos-session.target \ + /etc/systemd/system/sos-agent-authoring.service \ + /etc/systemd/system/sos-agent.service \ + /etc/systemd/system/sos-agent.target; do + sudo test ! -e "$vm_unit_path" +done +for vm_user in sos-agent sos-host sos-supervisor sos-provider sos-compositor; do + ! id "$vm_user" >/dev/null 2>&1 +done +! getent group sos-ipc >/dev/null +! id -nG sos | tr ' ' '\n' | grep -Fxq sos-ipc +for vm_process_pattern in \ + '/usr/local/libexec/sos($|/)' \ + '/usr/local/libexec/sos-agent($|/)'; do + ! pgrep -f -- "$vm_process_pattern" >/dev/null +done +printf 'linux_boot_cleanup_audit status=passed paths_and_units_absent=true accounts_absent=true group_absent=true login_membership_absent=true processes_absent=true graphical_target=true gdm_active=true seatd_active=true\n' +printf 'linux_boot_cleanup_passed paths_absent=true units_absent=true accounts_absent=true group_absent=true login_membership_absent=true processes_absent=true graphical_target=true gdm_active=true seatd_active=true\n' REMOTE_UNINSTALL vm_cleanup_complete=true diff --git a/tools/test-evidence-manifest-verify b/tools/test-evidence-manifest-verify new file mode 100755 index 0000000..c9a77cd --- /dev/null +++ b/tools/test-evidence-manifest-verify @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +generator="$repo_root/tools/a33xctl" +verifier="$repo_root/tools/evidence-manifest-verify" +test_root="$(mktemp -d /tmp/sos-evidence-manifest-test.XXXXXX)" +trap 'rm -rf -- "$test_root"' EXIT + +expect_failure() { + local expected_code="$1" root="$2" manifest="$3" output + if output="$($verifier --root "$root" --manifest "$manifest" 2>&1)"; then + printf 'manifest fixture unexpectedly passed: %s\n' "$expected_code" >&2 + exit 1 + fi + grep -F "evidence_manifest_audit=FAIL code=$expected_code " <<<"$output" >/dev/null +} + +make_case() { + local name="$1" + cp -a "$test_root/evidence" "$test_root/$name" +} + +mkdir -p "$test_root/evidence/nested" +printf 'alpha\n' >"$test_root/evidence/a.txt" +printf 'bravo\n' >"$test_root/evidence/nested/b.txt" +printf 'unfinished\n' >"$test_root/evidence/ignored.partial" +manifest="$test_root/evidence/manifest.tsv" +"$generator" evidence-manifest-generate --root "$test_root/evidence" --output "$manifest" >/dev/null +[[ "$(cut -f1 "$manifest")" == $'a.txt\nnested/b.txt' ]] + +before_identity="$(find "$test_root/evidence" -type f -printf '%P\t%s\t%T@\n' | LC_ALL=C sort | sha256sum)" +first_output="$($verifier --root "$test_root/evidence" --manifest "$manifest")" +second_output="$($verifier --root "$test_root/evidence" --manifest "$manifest")" +[[ "$first_output" == "$second_output" ]] +grep -Fx 'evidence_manifest_audit=PASS' <<<"$first_output" >/dev/null +grep -Fx 'files=2' <<<"$first_output" >/dev/null +grep -Fx "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)" <<<"$first_output" >/dev/null +grep -Fx "regenerated_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)" <<<"$first_output" >/dev/null +after_identity="$(find "$test_root/evidence" -type f -printf '%P\t%s\t%T@\n' | LC_ALL=C sort | sha256sum)" +[[ "$before_identity" == "$after_identity" ]] + +make_case sha256 +printf 'ALPHA\n' >"$test_root/sha256/a.txt" +expect_failure identity "$test_root/sha256" "$test_root/sha256/manifest.tsv" + +make_case size +printf 'larger-alpha\n' >"$test_root/size/a.txt" +expect_failure identity "$test_root/size" "$test_root/size/manifest.tsv" + +make_case malformed +printf 'a.txt\t6\n' >"$test_root/malformed/manifest.tsv" +expect_failure schema "$test_root/malformed" "$test_root/malformed/manifest.tsv" + +make_case duplicate +sed -n '1p' "$test_root/duplicate/manifest.tsv" >>"$test_root/duplicate/manifest.tsv" +expect_failure ordering "$test_root/duplicate" "$test_root/duplicate/manifest.tsv" + +make_case unsorted +tac "$test_root/unsorted/manifest.tsv" >"$test_root/unsorted/manifest.tsv.new" +mv "$test_root/unsorted/manifest.tsv.new" "$test_root/unsorted/manifest.tsv" +expect_failure ordering "$test_root/unsorted" "$test_root/unsorted/manifest.tsv" + +make_case unsafe +first_identity="$(cut -f2- "$test_root/unsafe/manifest.tsv" | sed -n '1p')" +printf '../a.txt\t%s\n' "$first_identity" >"$test_root/unsafe/manifest.tsv" +sed -n '2p' "$test_root/evidence/manifest.tsv" >>"$test_root/unsafe/manifest.tsv" +expect_failure unsafe_path "$test_root/unsafe" "$test_root/unsafe/manifest.tsv" + +make_case missing +sed -n '1p' "$test_root/missing/manifest.tsv" >"$test_root/missing/manifest.tsv.new" +mv "$test_root/missing/manifest.tsv.new" "$test_root/missing/manifest.tsv" +expect_failure file_set "$test_root/missing" "$test_root/missing/manifest.tsv" + +make_case self +manifest_identity="$(stat --printf='%s' "$test_root/self/manifest.tsv")\t$(sha256sum "$test_root/self/manifest.tsv" | cut -d' ' -f1)" +printf 'manifest.tsv\t%b\n' "$manifest_identity" >>"$test_root/self/manifest.tsv" +expect_failure self_included "$test_root/self" "$test_root/self/manifest.tsv" + +make_case noncanonical +sed '1s/\t6\t/\t06\t/' "$test_root/noncanonical/manifest.tsv" >"$test_root/noncanonical/manifest.tsv.new" +mv "$test_root/noncanonical/manifest.tsv.new" "$test_root/noncanonical/manifest.tsv" +expect_failure schema "$test_root/noncanonical" "$test_root/noncanonical/manifest.tsv" + +printf 'evidence_manifest_verifier_tests=PASS fixtures=valid,read_only,deterministic,size,sha256,schema,unique,sorted,safe,exact_set,self_excluding,canonical\n' diff --git a/tools/test-evidence-run b/tools/test-evidence-run new file mode 100755 index 0000000..a828e2a --- /dev/null +++ b/tools/test-evidence-run @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -euo pipefail + +test_repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_run="$test_repo_root/tools/evidence-run" +test_root="$(mktemp -d /tmp/sos-evidence-run-test.XXXXXX)" +cleanup_test() { + [[ "$test_root" == /tmp/sos-evidence-run-test.* ]] && rm -r -- "$test_root" +} +trap cleanup_test EXIT + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +"$test_run" --root "$test_root" --name success -- \ + bash -c 'printf "value=%s\\n" "$1"' bash 'space and $literal' +[[ "$(cat "$test_root/success.raw")" == 'value=space and $literal' ]] +grep -Fx 'status=0' "$test_root/success.meta" >/dev/null +grep -Fx 'argv_count=5' "$test_root/success.meta" >/dev/null +grep -Fx 'argv_4=space\ and\ \$literal' "$test_root/success.meta" >/dev/null +grep -Fx "cwd=$(printf '%q' "$test_repo_root")" "$test_root/success.meta" >/dev/null + +set +e +"$test_run" --root "$test_root" --name failure -- \ + bash -c 'printf "bounded failure\\n" >&2; exit 23' +test_failure_status=$? +set -e +[[ "$test_failure_status" -eq 23 ]] +grep -Fx 'bounded failure' "$test_root/failure.raw" >/dev/null +grep -Fx 'status=23' "$test_root/failure.meta" >/dev/null + +if "$test_run" --root "$test_root" --name success -- true >/dev/null 2>&1; then + fail "runner overwrote an existing record" +fi +if "$test_run" --root "$test_root" --name secret -- \ + env api_token=do-not-record true >/dev/null 2>&1; then + fail "runner accepted a secret-bearing argument" +fi +[[ ! -e "$test_root/secret.raw" && ! -e "$test_root/secret.meta" ]] +if find "$test_root" -maxdepth 1 -type f -name '*.tmp-*' | grep . >/dev/null; then + fail "runner left temporary files" +fi + +printf 'evidence_run_contract_passed success_status=0 failure_status=23 matched_pairs=2 overwrite_refused=true secret_argument_refused=true temporary_files_absent=true\n'