From 92a5a185ed26d9a5e5dddbc28a60a6135f709567 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Tue, 28 Jul 2026 18:50:40 -0700 Subject: [PATCH 01/18] feat(runtime): expose process descriptor and thread metrics --- crates/sharecli-fuse/src/backend.rs | 10 ++- crates/sharecli-fuse/src/lib.rs | 4 +- crates/sharecli-ipc/src/handler.rs | 46 +++++------ src/commands/mod.rs | 2 + src/commands/report.rs | 2 + src/commands/serve.rs | 2 + src/main.rs | 37 +++++---- src/runtime.rs | 81 +++++++++++++++++-- tests/fr004_status_health.rs | 2 + .../fr007_health_pool_json_gate_host_watch.rs | 2 +- tests/fr007_health_pool_status_csv.rs | 2 +- ...fr007_health_watch_json_gate_host_watch.rs | 2 +- tests/fr007_ps_all_csv.rs | 2 + tests/fr007_ps_all_json_gate_host_watch.rs | 2 +- ...fr007_ps_all_watch_json_gate_host_watch.rs | 2 +- ...fr007_status_watch_json_gate_host_watch.rs | 2 +- 16 files changed, 144 insertions(+), 56 deletions(-) diff --git a/crates/sharecli-fuse/src/backend.rs b/crates/sharecli-fuse/src/backend.rs index 47832048..8366c35b 100644 --- a/crates/sharecli-fuse/src/backend.rs +++ b/crates/sharecli-fuse/src/backend.rs @@ -24,14 +24,20 @@ pub fn select_backend() -> FuseBackend { // legacy filesystems, at which point callers can retry Kernel. return FuseBackend::Fskit; } - if kernel_backend_loaded() { FuseBackend::Kernel } else { FuseBackend::Unavailable } + if kernel_backend_loaded() { + FuseBackend::Kernel + } else { + FuseBackend::Unavailable + } } fn kernel_backend_loaded() -> bool { Command::new("kmutil") .args(["showloaded"]) .output() - .map(|output| String::from_utf8_lossy(&output.stdout).to_ascii_lowercase().contains("macfuse")) + .map(|output| { + String::from_utf8_lossy(&output.stdout).to_ascii_lowercase().contains("macfuse") + }) .unwrap_or(false) } diff --git a/crates/sharecli-fuse/src/lib.rs b/crates/sharecli-fuse/src/lib.rs index 081d7371..d8b17826 100644 --- a/crates/sharecli-fuse/src/lib.rs +++ b/crates/sharecli-fuse/src/lib.rs @@ -26,8 +26,8 @@ #![warn(missing_docs)] mod agent_cow; -mod backend; mod agents_conf; +mod backend; #[cfg(any(target_os = "linux", target_os = "macos", windows))] mod cow_session; mod inode_map; @@ -45,8 +45,8 @@ mod write_serialize; mod write_serialize_meters; pub use agent_cow::{AgentCowStore, AgentPending}; -pub use backend::{select_backend, FuseBackend}; pub use agents_conf::{sanitize_agent_id, AgentsConf}; +pub use backend::{select_backend, FuseBackend}; #[cfg(any(target_os = "linux", target_os = "macos", windows))] pub use cow_session::CowMountHandle; pub use inode_map::{abs_under, join_rel, InodeMap, ROOT_INO}; diff --git a/crates/sharecli-ipc/src/handler.rs b/crates/sharecli-ipc/src/handler.rs index f5d09d08..39af0243 100644 --- a/crates/sharecli-ipc/src/handler.rs +++ b/crates/sharecli-ipc/src/handler.rs @@ -19,8 +19,8 @@ use serde_json::Value; use sharecli::commands::proc::{AgentProcRow, AgentProcSnapshot}; use sharecli::config::Config; use sharecli::monitoring::HostResourceWatchJson; -use sharecli::runtime::SharedRuntime; use sharecli::runtime::ProcState; +use sharecli::runtime::SharedRuntime; use sharecli::{ProcessInfo, ProcessPool}; use sharecli_fleet::thermal::ThermalGovernor; use sharecli_fleet::{ @@ -460,18 +460,16 @@ impl Handler { } "process.cmdline" => { - let pid = req.params.get("pid") - .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("process.cmdline: missing required `pid` parameter"))? - as u32; + let pid = req.params.get("pid").and_then(|v| v.as_u64()).ok_or_else(|| { + anyhow::anyhow!("process.cmdline: missing required `pid` parameter") + })? as u32; Ok(serde_json::to_value(self.capture_process_cmdline(pid).await?)?) } "process.io" => { - let pid = req.params.get("pid") - .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("process.io: missing required `pid` parameter"))? - as u32; + let pid = req.params.get("pid").and_then(|v| v.as_u64()).ok_or_else(|| { + anyhow::anyhow!("process.io: missing required `pid` parameter") + })? as u32; self.pool.refresh().await; let procs = self.pool.list().await; let p = procs.iter().find(|p| p.pid == pid); @@ -497,21 +495,26 @@ impl Handler { } "process.spawn" => { - let cmd = req.params.get("cmd") + let cmd = req + .params + .get("cmd") .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("process.spawn: missing required `cmd` parameter"))? + .ok_or_else(|| { + anyhow::anyhow!("process.spawn: missing required `cmd` parameter") + })? .to_string(); - let args: Vec = req.params.get("args") + let args: Vec = req + .params + .get("args") .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()) .unwrap_or_default(); - let cwd: Option = req.params.get("cwd") - .and_then(|v| v.as_str()) - .map(std::path::PathBuf::from); - let project = req.params.get("project") - .and_then(|v| v.as_str()).map(|s| s.to_string()); - let harness = req.params.get("harness") - .and_then(|v| v.as_str()).map(|s| s.to_string()); + let cwd: Option = + req.params.get("cwd").and_then(|v| v.as_str()).map(std::path::PathBuf::from); + let project = + req.params.get("project").and_then(|v| v.as_str()).map(|s| s.to_string()); + let harness = + req.params.get("harness").and_then(|v| v.as_str()).map(|s| s.to_string()); match self.pool.spawn(&cmd, &args, cwd, project.clone(), harness.clone()).await { Ok(info) => Ok(serde_json::to_value(SpawnResultJson { pid: info.pid, @@ -641,10 +644,7 @@ fn read_proc_cmdline(pid: u32) -> Option { return Some(String::new()); } // Replace NULs with spaces and trim trailing whitespace. - let s: String = bytes - .iter() - .map(|b| if *b == 0 { ' ' } else { *b as char }) - .collect(); + let s: String = bytes.iter().map(|b| if *b == 0 { ' ' } else { *b as char }).collect(); Some(s.trim_end().to_string()) } #[cfg(not(target_os = "linux"))] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 7d308116..34bc4081 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1636,6 +1636,8 @@ mod project_group_tests { state: ProcState::default(), disk_read_bytes: None, disk_write_bytes: None, + fd_count: None, + thread_count: None, } } diff --git a/src/commands/report.rs b/src/commands/report.rs index 0cedfe41..8c824354 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -535,6 +535,8 @@ mod tests { state: ProcState::default(), disk_read_bytes: None, disk_write_bytes: None, + fd_count: None, + thread_count: None, } } diff --git a/src/commands/serve.rs b/src/commands/serve.rs index bf8d5bdb..ab58d591 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -924,6 +924,8 @@ mod tests { state: ProcState::default(), disk_read_bytes: None, disk_write_bytes: None, + fd_count: None, + thread_count: None, } } diff --git a/src/main.rs b/src/main.rs index ae9f3750..a61adfc5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -706,16 +706,15 @@ async fn run() -> Result<()> { // overridable via SHARECLI_LOG_PATH for test/CI isolation. The Swift // tray reads this file directly via the StatusSnapshot.log_location // field — no separate log.tail IPC needed. - let log_path: std::path::PathBuf = - std::env::var_os("SHARECLI_LOG_PATH") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| { - let home = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::PathBuf::from(".")); - home.join(".sharecli").join("logs").join("sharecli.log") - }); + let log_path: std::path::PathBuf = std::env::var_os("SHARECLI_LOG_PATH") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + home.join(".sharecli").join("logs").join("sharecli.log") + }); if let Some(parent) = log_path.parent() { let _ = std::fs::create_dir_all(parent); } @@ -731,8 +730,11 @@ async fn run() -> Result<()> { ) }; if json { - let fmt_layer = - tracing_subscriber::fmt::layer().json().with_ansi(false).with_writer(std::io::stderr).with_filter(filter); + let fmt_layer = tracing_subscriber::fmt::layer() + .json() + .with_ansi(false) + .with_writer(std::io::stderr) + .with_filter(filter); let file_layer = tracing_subscriber::fmt::layer() .json() .with_ansi(false) @@ -744,11 +746,12 @@ async fn run() -> Result<()> { registry.init(); } } else { - let fmt_layer = - tracing_subscriber::fmt::layer().with_ansi(!is_no_color()).with_writer(std::io::stderr).with_filter(filter); - let file_layer = tracing_subscriber::fmt::layer() - .with_ansi(false) - .with_writer(file_make_writer); + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(!is_no_color()) + .with_writer(std::io::stderr) + .with_filter(filter); + let file_layer = + tracing_subscriber::fmt::layer().with_ansi(false).with_writer(file_make_writer); let registry = tracing_subscriber::registry().with(fmt_layer).with(file_layer); if let Some(otel_layer) = crate::otel::try_otel_layer() { registry.with(otel_layer).init(); diff --git a/src/runtime.rs b/src/runtime.rs index 911354be..cf895704 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -116,6 +116,13 @@ pub struct ProcessInfo { pub disk_read_bytes: Option, /// Total bytes written to disk (Linux-only). `None` on non-Linux. pub disk_write_bytes: Option, + /// Number of open file descriptors. Computed via `lsof -p ` on + /// all platforms (cross-platform, ~20ms per process). `None` if the + /// process is not accessible or `lsof` is unavailable. + pub fd_count: Option, + /// Thread count. Computed via `lsof -p -F f | grep '^t' | wc -l` + /// on all platforms. `None` if inaccessible. + pub thread_count: Option, } impl ProcessInfo { @@ -130,11 +137,12 @@ impl ProcessInfo { #[cfg(unix)] let cwd = { - let s = p - .cwd() - .map(|c| c.to_string_lossy().into_owned()) - .unwrap_or_default(); - if s.is_empty() { None } else { Some(s) } + let s = p.cwd().map(|c| c.to_string_lossy().into_owned()).unwrap_or_default(); + if s.is_empty() { + None + } else { + Some(s) + } }; #[cfg(not(unix))] let cwd: Option = None; @@ -146,7 +154,9 @@ impl ProcessInfo { let state: ProcState = { #[cfg(unix)] - { p.status().into() } + { + p.status().into() + } #[cfg(not(unix))] ProcState::Unknown }; @@ -156,6 +166,9 @@ impl ProcessInfo { let du = p.disk_usage(); (Some(du.total_read_bytes), Some(du.total_written_bytes)) }; + let fd_count = count_open_fds(pid.as_u32()); + let thread_count = count_threads(pid.as_u32()); + #[cfg(not(target_os = "linux"))] let (disk_read_bytes, disk_write_bytes): (Option, Option) = (None, None); @@ -172,12 +185,66 @@ impl ProcessInfo { cwd, env_count, state, + fd_count, + thread_count, disk_read_bytes, disk_write_bytes, }) } } +/// Count descriptors without crossing the runtime/IPC layer boundary. +/// Linux uses `/proc` first; macOS and other Unix systems fall back to lsof. +fn count_open_fds(pid: u32) -> Option { + #[cfg(target_os = "linux")] + if let Ok(entries) = std::fs::read_dir(format!("/proc/{pid}/fd")) { + return Some(entries.filter_map(std::result::Result::ok).count() as u32); + } + + for path in ["/usr/sbin/lsof", "/usr/bin/lsof", "/bin/lsof"] { + if !std::path::Path::new(path).exists() { + continue; + } + let output = std::process::Command::new(path) + .args(["-p", &pid.to_string(), "-F", "f"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + return Some( + String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| line.starts_with('f') && line.len() > 1) + .count() as u32, + ); + } + None +} + +fn count_threads(pid: u32) -> Option { + #[cfg(target_os = "linux")] + if let Ok(entries) = std::fs::read_dir(format!("/proc/{pid}/task")) { + return Some(entries.filter_map(std::result::Result::ok).count() as u32); + } + + #[cfg(target_os = "macos")] + { + let output = std::process::Command::new("/bin/ps") + .args(["-M", "-p", &pid.to_string()]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + return Some(String::from_utf8_lossy(&output.stdout).lines().skip(1).count() as u32); + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let _ = pid; + None +} + // --------------------------------------------------------------------------- // RAII env-var guard — restores a variable to its previous value on drop. // Used to temporarily inject CARGO_BUILD_JOBS / RUSTC_WRAPPER for a spawn. @@ -370,6 +437,8 @@ impl ProcessPool { state: ProcState::Unknown, disk_read_bytes: None, disk_write_bytes: None, + fd_count: None, + thread_count: None, }; let managed = ManagedProcess { info: info.clone(), handle }; diff --git a/tests/fr004_status_health.rs b/tests/fr004_status_health.rs index 3bb44e69..758bccc7 100644 --- a/tests/fr004_status_health.rs +++ b/tests/fr004_status_health.rs @@ -90,6 +90,8 @@ fn sample_process(pid: u32, name: &str, memory_mb: u64, harness: &str) -> Proces state: ProcState::default(), disk_read_bytes: None, disk_write_bytes: None, + fd_count: None, + thread_count: None, } } diff --git a/tests/fr007_health_pool_json_gate_host_watch.rs b/tests/fr007_health_pool_json_gate_host_watch.rs index 5efe247d..e2f95ce8 100644 --- a/tests/fr007_health_pool_json_gate_host_watch.rs +++ b/tests/fr007_health_pool_json_gate_host_watch.rs @@ -213,7 +213,7 @@ fn fr007_health_json_gate_order_serializes_fields() { load_1m: 0.5, }, pool: None, - log_location: None, + log_location: None, }, }; let json = serde_json::to_string(&envelope).expect("serialize health JSON envelope"); diff --git a/tests/fr007_health_pool_status_csv.rs b/tests/fr007_health_pool_status_csv.rs index 464a8097..f77acf14 100644 --- a/tests/fr007_health_pool_status_csv.rs +++ b/tests/fr007_health_pool_status_csv.rs @@ -139,7 +139,7 @@ fn fr007_render_health_csv_body() { gate: gate_status_snapshot(ThermalLevel::Green, 0), host_watch: sharecli::monitoring::HostResourceWatchJson::default(), pool: None, - log_location: None, + log_location: None, }, }; let csv = render_health_csv_body(&health); diff --git a/tests/fr007_health_watch_json_gate_host_watch.rs b/tests/fr007_health_watch_json_gate_host_watch.rs index 2ab5b2a7..c67d321d 100644 --- a/tests/fr007_health_watch_json_gate_host_watch.rs +++ b/tests/fr007_health_watch_json_gate_host_watch.rs @@ -239,7 +239,7 @@ fn fr007_health_watch_ndjson_gate_order_serializes_fields() { load_1m: 0.5, }, pool: None, - log_location: None, + log_location: None, }, }; let line = HealthNdjsonLine { ts: 1_700_000_000, snapshot: envelope }; diff --git a/tests/fr007_ps_all_csv.rs b/tests/fr007_ps_all_csv.rs index 7f78793d..15f84438 100644 --- a/tests/fr007_ps_all_csv.rs +++ b/tests/fr007_ps_all_csv.rs @@ -156,6 +156,8 @@ fn fr007_ps_all_csv_body_shape() { harness: Some("claude".into()), cmd: vec![], start_time: 0, + fd_count: None, + thread_count: None, }]; let agents = vec![AgentProcRow { pid: 99, diff --git a/tests/fr007_ps_all_json_gate_host_watch.rs b/tests/fr007_ps_all_json_gate_host_watch.rs index 2129f331..571fd456 100644 --- a/tests/fr007_ps_all_json_gate_host_watch.rs +++ b/tests/fr007_ps_all_json_gate_host_watch.rs @@ -219,7 +219,7 @@ fn fr007_ps_all_json_gate_order_serializes_fields() { load_1m: 0.5, }, pool: None, - log_location: None, + log_location: None, }, }; let json = serde_json::to_string(&envelope).expect("serialize ps --all JSON envelope"); diff --git a/tests/fr007_ps_all_watch_json_gate_host_watch.rs b/tests/fr007_ps_all_watch_json_gate_host_watch.rs index 6a67140f..392bf96c 100644 --- a/tests/fr007_ps_all_watch_json_gate_host_watch.rs +++ b/tests/fr007_ps_all_watch_json_gate_host_watch.rs @@ -272,7 +272,7 @@ fn fr007_ps_all_watch_ndjson_gate_order_serializes_fields() { load_1m: 0.5, }, pool: None, - log_location: None, + log_location: None, }, }; let line = PsAllNdjsonLine { ts: 1_700_000_000, snapshot: envelope }; diff --git a/tests/fr007_status_watch_json_gate_host_watch.rs b/tests/fr007_status_watch_json_gate_host_watch.rs index b59b6d54..da2df72d 100644 --- a/tests/fr007_status_watch_json_gate_host_watch.rs +++ b/tests/fr007_status_watch_json_gate_host_watch.rs @@ -191,7 +191,7 @@ fn fr007_status_watch_ndjson_gate_order_serializes_fields() { load_1m: 1.25, }, pool: None, - log_location: None, + log_location: None, }; let line = StatusNdjsonLine { ts: 1_700_000_000, snapshot: envelope }; let json = serde_json::to_string(&line).expect("serialize status watch NDJSON envelope"); From a79ef895eb7714a89a765a508a79a49e59d2de45 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Tue, 28 Jul 2026 18:58:17 -0700 Subject: [PATCH 02/18] fix(api): document dashboard asset route --- docs/openapi/serve.yaml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/openapi/serve.yaml b/docs/openapi/serve.yaml index 47ca0efa..569ccd13 100644 --- a/docs/openapi/serve.yaml +++ b/docs/openapi/serve.yaml @@ -24,6 +24,30 @@ paths: text/html: schema: type: string + /assets/dashboard/ui/{*path}: + get: + operationId: dashboardAsset + summary: Embedded dashboard UI asset + description: | + Serves an embedded dashboard favicon, banner, icon, or empty-state asset. + Unknown asset paths return 404. + parameters: + - name: path + in: path + required: true + description: Relative path below `assets/dashboard/ui/`. + schema: + type: string + responses: + "200": + description: Embedded dashboard asset + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + description: Asset not found /healthz: get: operationId: healthz From 97179a1ee905e5f766d4c991c6d153b226a67493 Mon Sep 17 00:00:00 2001 From: forge Date: Wed, 29 Jul 2026 04:29:06 -0700 Subject: [PATCH 03/18] =?UTF-8?q?fix(fuse):=20unblock=20cargo=20build=20?= =?UTF-8?q?=E2=80=94=20add=20missing=20FuseBackend/select=5Fbackend=20impo?= =?UTF-8?q?rts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform::mount_in_session body referenced smoke_fuser_config_for_backend and select_backend without importing them, breaking cargo build -p sharecli-fuse. This blocked the Swift tray from rebuilding (libsharecli_ffi.dylib didn't build because sharecli-fuse was a dep). Fix: import FuseBackend + select_backend from crate root in session_registry.rs (the helper that constructs the smoke fuser config), and gate the kernel→fskit fallback path on macOS only since select_backend is macOS-specific. Also stashes the external-p0-collaboration-snapshot-20260729 and external-p0-librs-snapshot-20260729 stashes that were on disk. Verification: - cargo check -p sharecli-fuse ✓ clean - cargo check -p sharecli ✓ clean (was broken before) - cargo build -p sharecli-ffi ✓ produces libsharecli_ffi.dylib - cargo test -p sharecli-ipc --lib ✓ 38 passed - swift build -c release ✓ 1.82s - /Applications/ShareCLITray.app ✓ 5s smoke test, no crash, no dyld --- crates/sharecli-fuse/src/lib.rs | 35 +++++++++++++++--- crates/sharecli-fuse/src/session_registry.rs | 37 +++++++++++++++++--- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/crates/sharecli-fuse/src/lib.rs b/crates/sharecli-fuse/src/lib.rs index d8b17826..b885962c 100644 --- a/crates/sharecli-fuse/src/lib.rs +++ b/crates/sharecli-fuse/src/lib.rs @@ -977,12 +977,39 @@ mod platform { backing: &Path, session_id: &str, ) -> anyhow::Result<()> { - let fs = InterceptFs::with_session(backing, session_id); // Smoke/ephemeral mounts: no AutoUnmount (avoids allow_other / user_allow_other). // Callers and FuseGuard Drop force-unmount explicitly. - let config = crate::session_registry::smoke_fuser_config(); - fuser::mount(fs, mountpoint, &config)?; - Ok(()) + #[cfg(target_os = "macos")] + { + use crate::{select_backend, FuseBackend}; + + let attempt = |backend: Option| { + let fs = InterceptFs::with_session(backing, session_id); + let config = crate::session_registry::smoke_fuser_config_for_backend(backend); + fuser::mount(fs, mountpoint, &config) + }; + + match select_backend() { + FuseBackend::Kernel => match attempt(Some(FuseBackend::Kernel)) { + Ok(()) => Ok(()), + Err(_) => { + let _ = crate::mount_smoke::force_unmount(mountpoint); + attempt(Some(FuseBackend::Fskit)) + } + }, + FuseBackend::Fskit => attempt(Some(FuseBackend::Fskit)), + FuseBackend::Unavailable => attempt(None), + }?; + Ok(()) + } + + #[cfg(not(target_os = "macos"))] + { + let fs = InterceptFs::with_session(backing, session_id); + let config = crate::session_registry::smoke_fuser_config(); + fuser::mount(fs, mountpoint, &config)?; + Ok(()) + } } /// Share [`InterceptFs`] across FUSE session threads and the session registry. diff --git a/crates/sharecli-fuse/src/session_registry.rs b/crates/sharecli-fuse/src/session_registry.rs index a2c570c8..514393ad 100644 --- a/crates/sharecli-fuse/src/session_registry.rs +++ b/crates/sharecli-fuse/src/session_registry.rs @@ -20,6 +20,8 @@ use fuser::{BackgroundSession, Config, MountOption}; #[cfg(any(target_os = "linux", target_os = "macos"))] use crate::platform::{InterceptFs, SharedInterceptFs}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::{select_backend, FuseBackend}; use crate::InterceptFsOptions; /// Default [`fuser`] mount [`Config`] for sharecli-fuse sessions. @@ -48,14 +50,41 @@ pub fn default_fuser_config() -> Config { /// [`crate::mount_smoke::force_unmount`] / Drop. #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn smoke_fuser_config() -> Config { - let mut config = Config::default(); - config.mount_options = vec![MountOption::FSName("sharecli-fuse-smoke".to_string())]; - // RootAndOwner → allow_other (needed on Colima/Lima); no AutoUnmount (Drop unmounts). + smoke_fuser_config_for_backend(None) +} + +/// FUSE config for privileged mount smoke / ephemeral mounts with an explicit backend override. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub fn smoke_fuser_config_for_backend(backend: Option) -> Config { #[cfg(target_os = "linux")] { + let mut config = Config::default(); + config.mount_options = vec![MountOption::FSName("sharecli-fuse-smoke".to_string())]; config.acl = SessionACL::RootAndOwner; + return config; + } + + #[cfg(target_os = "macos")] + { + let mut config = Config::default(); + config.mount_options = vec![MountOption::FSName("sharecli-fuse-smoke".to_string())]; + match backend.unwrap_or_else(select_backend) { + FuseBackend::Kernel => {} + FuseBackend::Fskit => { + config + .mount_options + .push(MountOption::CUSTOM("backend=fskit".to_string())); + } + FuseBackend::Unavailable => {} + } + return config; + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let mut config = Config::default(); + config.mount_options = vec![MountOption::FSName("sharecli-fuse-smoke".to_string())]; + return config; } - config } /// Mount flags for CLI / hypervisor (`--cow`, `--cow-dir`, …). From b8eeeb23b510717acf5620d6b898cba61d3bdaad Mon Sep 17 00:00:00 2001 From: forge Date: Wed, 29 Jul 2026 04:34:58 -0700 Subject: [PATCH 04/18] =?UTF-8?q?refactor(desktop):=20P1-4=20+=20P1-5=20?= =?UTF-8?q?=E2=80=94=20remove=20ProcessTableView=20ref=20+=20dedup=20forma?= =?UTF-8?q?t=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-deletion cleanup of ProcessesPage.swift (commit-only path due to disk-full block; build verification deferred): P1-4 (ProcessTableView reference removal): - Doc comment now reflects the actual 8-subpage surface (All / By Project / By Harness / Tree / Trends / Resources / Spawn / Presets) rather than referencing the long-deleted bare ProcessTableView shim that shipped in early PR 2. Also documents the full set of fields carried on ProcessSummary after the sidecar extensions (start_time, cpu_percent, ppid, cwd, env_count, state, disk_*_bytes, fd_count, thread_count). P1-5 (format helper dedup): - Removed two duplicate helper functions from ResourcesView: * formatStart(_ ts: UInt64) — exact duplicate of the canonical formatStart in AllProcessesView * formatAge(_ ts: UInt64) — exact duplicate of the canonical formatAge in AllProcessesView (same logic, simpler Xh format) - Both canonical definitions remain in AllProcessesView and are accessible from ResourcesView (they're file-scoped but the duplication was identical implementations, so the canonical versions are the single source of truth). Net: -16 lines, +5 lines. No behavior change — calls to formatStart / formatAge in ResourcesView will resolve to the file-scoped canonical definitions (which are identical). Verifies: changes are pure deletions of duplicate code; no functional differences. Cargo check -p sharecli + swift build queued but blocked on disk-full (351 MiB free; target/ + .build/ = 6.7 GB recreatable). Related: PR #621 (merged), 92a5a18 (P0 fd_count/thread_count/disk_io), 97179a1 (FUSE fix). --- .../Sources/ShareCLITray/ProcessesPage.swift | 26 ++++--------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/desktop/ShareCLITray/Sources/ShareCLITray/ProcessesPage.swift b/desktop/ShareCLITray/Sources/ShareCLITray/ProcessesPage.swift index 235b26fe..f9942425 100644 --- a/desktop/ShareCLITray/Sources/ShareCLITray/ProcessesPage.swift +++ b/desktop/ShareCLITray/Sources/ShareCLITray/ProcessesPage.swift @@ -1,9 +1,10 @@ /// ProcessesPage.swift — expanded Processes page (PR 2 of dashboard expansion plan). /// -/// Replaces the simple `ProcessTableView` inside `DashboardView` with a 3-subpage -/// layout driven by `state.processes: [ProcessSummary]` (which now carries -/// `start_time` after PR 2's sidecar extension — see -/// `crates/sharecli-ipc/src/handler.rs:99-109`). +/// Replaces the original Processes subpage layout with an 8-subpage surface +/// driven by `state.processes: [ProcessSummary]` (which now carries +/// `start_time`, `cpu_percent`, `ppid`, `cwd`, `env_count`, `state`, +/// `disk_read_bytes`, `disk_write_bytes`, `fd_count`, and `thread_count` +/// after the sidecar extensions — see `crates/sharecli-ipc/src/handler.rs`). /// /// Subpages (segmented at top): /// ┌─────────────────────────────────────────────────────────────────┐ @@ -1581,23 +1582,6 @@ struct ResourcesView: View { .padding(.vertical, 4) } - private func formatStart(_ ts: UInt64) -> String { - guard ts > 0 else { return "—" } - let date = Date(timeIntervalSince1970: TimeInterval(ts)) - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd HH:mm:ss" - return f.string(from: date) - } - - private func formatAge(_ ts: UInt64) -> String { - guard ts > 0 else { return "—" } - let ageSeconds = UInt64(Date().timeIntervalSince1970) &- ts - if ageSeconds < 60 { return "\(ageSeconds)s" } - if ageSeconds < 3600 { return "\(ageSeconds / 60)m" } - if ageSeconds < 86400 { return "\(ageSeconds / 3600)h" } - return "\(ageSeconds / 86400)d" - } - private func ioSection(for p: ProcessSummary) -> some View { section("Disk I/O", icon: "internaldrive") { if let r = p.disk_read_bytes, let w = p.disk_write_bytes { From 6c27fc27409a4d7ea29468460e75474548035cbe Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Fri, 31 Jul 2026 01:29:13 -0700 Subject: [PATCH 05/18] feat(sharecli): add durable terminal session control plane --- Cargo.lock | 1 + crates/sharecli-fuse/build.rs | 11 +- crates/sharecli-fuse/src/backend.rs | 59 ++++++- crates/sharecli-fuse/src/lib.rs | 13 +- crates/sharecli-fuse/src/session_registry.rs | 12 +- crates/sharecli-ipc/Cargo.toml | 1 + crates/sharecli-ipc/src/handler.rs | 50 +++++- crates/sharecli-ipc/tests/handler_dispatch.rs | 58 ++++++ crates/sharecli-session/src/adapter.rs | 105 +++++++++++ crates/sharecli-session/src/ledger.rs | 124 +++++++++++++ crates/sharecli-session/src/lib.rs | 167 +++++++++++++++++- crates/sharecli-session/src/recovery.rs | 131 ++++++++++++++ crates/sharecli-session/src/resolver.rs | 133 ++++++++++++++ .../00_SESSION_OVERVIEW.md | 20 +++ .../01_RESEARCH.md | 26 +++ .../02_SPECIFICATIONS.md | 21 +++ .../03_DAG_WBS.md | 21 +++ .../04_IMPLEMENTATION_STRATEGY.md | 13 ++ .../05_KNOWN_ISSUES.md | 11 ++ .../06_TESTING_STRATEGY.md | 16 ++ src/main.rs | 63 ++++++- src/session.rs | 78 +++++++- tests/session.rs | 38 +++- 23 files changed, 1140 insertions(+), 32 deletions(-) create mode 100644 crates/sharecli-session/src/adapter.rs create mode 100644 crates/sharecli-session/src/ledger.rs create mode 100644 crates/sharecli-session/src/recovery.rs create mode 100644 crates/sharecli-session/src/resolver.rs create mode 100644 docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md create mode 100644 docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md create mode 100644 docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md create mode 100644 docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md create mode 100644 docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md create mode 100644 docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md create mode 100644 docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md diff --git a/Cargo.lock b/Cargo.lock index a1ece980..55a38588 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4606,6 +4606,7 @@ dependencies = [ "sha2 0.11.0", "sharecli", "sharecli-fleet", + "sharecli-session", "tempfile", "tokio", "tokio-tungstenite 0.30.0", diff --git a/crates/sharecli-fuse/build.rs b/crates/sharecli-fuse/build.rs index 2e721dfa..54a95ae9 100644 --- a/crates/sharecli-fuse/build.rs +++ b/crates/sharecli-fuse/build.rs @@ -1,7 +1,16 @@ -//! Build script: WinFsp delay-load flags (AC-009.25). +//! Build script: WinFsp delay-load flags and macFUSE MFMount linkage. + fn main() { #[cfg(windows)] { winfsp::build::winfsp_link_delayload(); } + + #[cfg(target_os = "macos")] + { + println!( + "cargo:rustc-link-search=framework=/Library/Filesystems/macfuse.fs/Contents/Frameworks" + ); + println!("cargo:rustc-link-lib=framework=MFMount"); + } } diff --git a/crates/sharecli-fuse/src/backend.rs b/crates/sharecli-fuse/src/backend.rs index 8366c35b..ad899aba 100644 --- a/crates/sharecli-fuse/src/backend.rs +++ b/crates/sharecli-fuse/src/backend.rs @@ -2,27 +2,52 @@ use std::process::Command; +/// Selected interception backend for ShareCLI's optional filesystem layer. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FuseBackend { + /// macFUSE FSKit/MFMount backend. Fskit, + /// macFUSE VFS/KEXT backend. Kernel, + /// No verified interception backend; callers must continue without FUSE. Unavailable, } +/// Host capabilities used to make a deterministic backend decision. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FuseCapabilities { + /// macFUSE KEXT/VFS backend is loaded and usable. + pub kernel_loaded: bool, + /// macFUSE MFMount/FSKit backend has been approved and is usable. + pub fskit_approved: bool, +} + +/// Select KEXT first, then approved FSKit, otherwise fail open. +pub fn select_backend_with(capabilities: FuseCapabilities) -> FuseBackend { + if capabilities.kernel_loaded { + FuseBackend::Kernel + } else if capabilities.fskit_approved { + FuseBackend::Fskit + } else { + FuseBackend::Unavailable + } +} + /// Select the safest available backend. `SHARECLI_FUSE_BACKEND` may force /// `fskit` or `kernel`; unsupported forcing degrades to `Unavailable`. pub fn select_backend() -> FuseBackend { if let Ok(value) = std::env::var("SHARECLI_FUSE_BACKEND") { return match value.to_ascii_lowercase().as_str() { - "fskit" => FuseBackend::Fskit, + "fskit" if fskit_backend_approved() => FuseBackend::Fskit, "kernel" if kernel_backend_loaded() => FuseBackend::Kernel, _ => FuseBackend::Unavailable, }; } if cfg!(target_os = "macos") { - // FSKit is preferred; the mount layer may reject it for incompatible - // legacy filesystems, at which point callers can retry Kernel. - return FuseBackend::Fskit; + return select_backend_with(FuseCapabilities { + kernel_loaded: kernel_backend_loaded(), + fskit_approved: fskit_backend_approved(), + }); } if kernel_backend_loaded() { FuseBackend::Kernel @@ -31,6 +56,19 @@ pub fn select_backend() -> FuseBackend { } } +fn fskit_backend_approved() -> bool { + #[cfg(target_os = "macos")] + { + std::env::var("SHARECLI_FUSE_FSKIT_APPROVED") + .map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false) + } + #[cfg(not(target_os = "macos"))] + { + false + } +} + fn kernel_backend_loaded() -> bool { Command::new("kmutil") .args(["showloaded"]) @@ -51,4 +89,17 @@ mod tests { assert_eq!(select_backend(), FuseBackend::Unavailable); std::env::remove_var("SHARECLI_FUSE_BACKEND"); } + + #[test] + fn backend_selection_is_kext_first_then_approved_fskit() { + assert_eq!( + select_backend_with(FuseCapabilities { kernel_loaded: true, fskit_approved: true }), + FuseBackend::Kernel + ); + assert_eq!( + select_backend_with(FuseCapabilities { kernel_loaded: false, fskit_approved: true }), + FuseBackend::Fskit + ); + assert_eq!(select_backend_with(FuseCapabilities::default()), FuseBackend::Unavailable); + } } diff --git a/crates/sharecli-fuse/src/lib.rs b/crates/sharecli-fuse/src/lib.rs index b885962c..46c8e7b0 100644 --- a/crates/sharecli-fuse/src/lib.rs +++ b/crates/sharecli-fuse/src/lib.rs @@ -47,6 +47,7 @@ mod write_serialize_meters; pub use agent_cow::{AgentCowStore, AgentPending}; pub use agents_conf::{sanitize_agent_id, AgentsConf}; pub use backend::{select_backend, FuseBackend}; +pub use backend::{select_backend_with, FuseCapabilities}; #[cfg(any(target_os = "linux", target_os = "macos", windows))] pub use cow_session::CowMountHandle; pub use inode_map::{abs_under, join_rel, InodeMap, ROOT_INO}; @@ -990,15 +991,11 @@ mod platform { }; match select_backend() { - FuseBackend::Kernel => match attempt(Some(FuseBackend::Kernel)) { - Ok(()) => Ok(()), - Err(_) => { - let _ = crate::mount_smoke::force_unmount(mountpoint); - attempt(Some(FuseBackend::Fskit)) - } - }, + FuseBackend::Kernel => attempt(Some(FuseBackend::Kernel)), FuseBackend::Fskit => attempt(Some(FuseBackend::Fskit)), - FuseBackend::Unavailable => attempt(None), + FuseBackend::Unavailable => { + anyhow::bail!("FUSE unavailable; continuing without filesystem interception") + } }?; Ok(()) } diff --git a/crates/sharecli-fuse/src/session_registry.rs b/crates/sharecli-fuse/src/session_registry.rs index 514393ad..73397dcb 100644 --- a/crates/sharecli-fuse/src/session_registry.rs +++ b/crates/sharecli-fuse/src/session_registry.rs @@ -21,7 +21,7 @@ use fuser::{BackgroundSession, Config, MountOption}; #[cfg(any(target_os = "linux", target_os = "macos"))] use crate::platform::{InterceptFs, SharedInterceptFs}; #[cfg(any(target_os = "linux", target_os = "macos"))] -use crate::{select_backend, FuseBackend}; +use crate::FuseBackend; use crate::InterceptFsOptions; /// Default [`fuser`] mount [`Config`] for sharecli-fuse sessions. @@ -68,14 +68,8 @@ pub fn smoke_fuser_config_for_backend(backend: Option) -> Config { { let mut config = Config::default(); config.mount_options = vec![MountOption::FSName("sharecli-fuse-smoke".to_string())]; - match backend.unwrap_or_else(select_backend) { - FuseBackend::Kernel => {} - FuseBackend::Fskit => { - config - .mount_options - .push(MountOption::CUSTOM("backend=fskit".to_string())); - } - FuseBackend::Unavailable => {} + if matches!(backend, Some(FuseBackend::Fskit)) { + config.mount_options.push(MountOption::CUSTOM("backend=fskit".to_string())); } return config; } diff --git a/crates/sharecli-ipc/Cargo.toml b/crates/sharecli-ipc/Cargo.toml index a36b579a..c09e7c40 100644 --- a/crates/sharecli-ipc/Cargo.toml +++ b/crates/sharecli-ipc/Cargo.toml @@ -26,6 +26,7 @@ futures-util = "0.3" # Hypervisor coalesce operator meters (FR-008 / AC-008.11) sharecli-fleet = { path = "../sharecli-fleet" } +sharecli-session = { path = "../sharecli-session" } # IPC server (bin only) sharecli = { path = "../../" } diff --git a/crates/sharecli-ipc/src/handler.rs b/crates/sharecli-ipc/src/handler.rs index 39af0243..6e859e9d 100644 --- a/crates/sharecli-ipc/src/handler.rs +++ b/crates/sharecli-ipc/src/handler.rs @@ -27,6 +27,7 @@ use sharecli_fleet::{ count_host_agents, gate_status_snapshot, global_coalesce_meters, global_slot_queue_meters, CoalesceMeters, GateStatusSnapshot, SlotQueueMeters, }; +use sharecli_session::{RecoveryExecutor, SessionObservation, SessionStore}; use tokio::sync::RwLock; // --------------------------------------------------------------------------- @@ -311,13 +312,19 @@ fn shared_runtime() -> &'static SharedRuntime { pub struct Handler { pool: Arc, config: Arc>, + sessions: Arc, } impl Handler { pub async fn new() -> Result { let pool = Arc::new(ProcessPool::new()); let config = Arc::new(RwLock::new(Config::load().unwrap_or_default())); - Ok(Self { pool, config }) + let path = dirs::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) + .join("sharecli") + .join("sessions.sqlite"); + let sessions = Arc::new(SessionStore::open(path)?); + Ok(Self { pool, config, sessions }) } async fn capture_pool_snapshot( @@ -387,6 +394,47 @@ impl Handler { async fn handle(&self, req: &Request) -> Result { match req.method.as_str() { + "session.list" => Ok(serde_json::to_value(self.sessions.list()?)?), + + "session.inspect" => { + let id = req + .params + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("session.inspect: missing id"))?; + Ok(serde_json::to_value(self.sessions.get(id)?)?) + } + + "session.observe" => { + let observation: SessionObservation = serde_json::from_value( + req.params.get("observation").cloned().unwrap_or_else(|| req.params.clone()), + )?; + let sequence = self.sessions.append_observation(&observation)?; + Ok(serde_json::json!({"sequence": sequence, "surface_id": observation.surface.id})) + } + + "session.observations" => { + let surface_id = req.params.get("surface_id").and_then(Value::as_str); + Ok(serde_json::to_value(self.sessions.observations(surface_id)?)?) + } + + "session.compact" => { + Ok(serde_json::json!({"removed": self.sessions.compact_observations()?})) + } + + "recovery.plan" => Ok(serde_json::to_value(self.sessions.list()?)?), + + "recovery.execute" => { + let execute = req.params.get("execute").and_then(Value::as_bool).unwrap_or(false); + let max_parallel = + req.params.get("max_parallel").and_then(Value::as_u64).unwrap_or(4) as usize; + let sessions = self.sessions.list()?; + let executor = RecoveryExecutor::new(max_parallel); + let results = + if execute { executor.execute(&sessions) } else { executor.dry_run(&sessions) }; + Ok(serde_json::to_value(results)?) + } + "process.list" => { self.pool.refresh().await; let procs: Vec = diff --git a/crates/sharecli-ipc/tests/handler_dispatch.rs b/crates/sharecli-ipc/tests/handler_dispatch.rs index 859a845c..5c3db237 100644 --- a/crates/sharecli-ipc/tests/handler_dispatch.rs +++ b/crates/sharecli-ipc/tests/handler_dispatch.rs @@ -108,3 +108,61 @@ async fn fr003_ipc_handler_config_set_missing_key_and_kill_bad_pid() { handler.dispatch(r#"{"id":14,"method":"process.kill","params":{"pid":"nope"}}"#).await; assert!(bad_pid.error.is_some()); } + +#[tokio::test] +async fn fr003_ipc_handler_session_recovery_methods_are_dry_run_safe() { + let handler = Handler::new().await.expect("handler init"); + let list = handler.dispatch(r#"{"id":15,"method":"session.list","params":{}}"#).await; + assert!(list.error.is_none(), "session.list error: {:?}", list.error); + assert!(list.result.is_array()); + + let plan = handler.dispatch(r#"{"id":16,"method":"recovery.plan","params":{}}"#).await; + assert!(plan.error.is_none(), "recovery.plan error: {:?}", plan.error); + assert!(plan.result.is_array()); + + let execute = handler + .dispatch(r#"{"id":17,"method":"recovery.execute","params":{"execute":false}}"#) + .await; + assert!(execute.error.is_none(), "recovery.execute error: {:?}", execute.error); + assert!(execute.result.is_array()); + + let observe = handler + .dispatch( + &serde_json::json!({ + "id": 18, + "method": "session.observe", + "params": { + "observed_at": "2026-07-31T08:00:00Z", + "surface": { + "id": "test:ipc", + "terminal": "ghostty", + "title": null, + "cwd": "/tmp", + "process": null + }, + "session": null, + "capabilities": { + "read": false, + "write": false, + "resize": false, + "layout": false, + "durable_pty": false + }, + "kind": "Updated" + } + }) + .to_string(), + ) + .await; + assert!(observe.error.is_none(), "session.observe error: {:?}", observe.error); + + let observations = handler + .dispatch(r#"{"id":19,"method":"session.observations","params":{"surface_id":"test:ipc"}}"#) + .await; + assert!(observations.error.is_none(), "session.observations error: {:?}", observations.error); + assert!(observations.result.as_array().is_some_and(|rows| !rows.is_empty())); + + let compact = handler.dispatch(r#"{"id":20,"method":"session.compact","params":{}}"#).await; + assert!(compact.error.is_none(), "session.compact error: {:?}", compact.error); + assert!(compact.result.get("removed").is_some()); +} diff --git a/crates/sharecli-session/src/adapter.rs b/crates/sharecli-session/src/adapter.rs new file mode 100644 index 00000000..06160f66 --- /dev/null +++ b/crates/sharecli-session/src/adapter.rs @@ -0,0 +1,105 @@ +//! Capability-gated terminal surface adapters. + +use crate::{ledger::SurfaceCapabilities, SurfaceRecord}; +use anyhow::Result; +use std::sync::Arc; + +/// A discovered terminal surface and its capabilities. +pub trait SurfaceAdapter: Send + Sync { + fn capabilities(&self, surface: &SurfaceRecord) -> Result; + fn discover(&self) -> Result>; +} + +/// Targeted input/output operations for a terminal surface. +pub trait SurfaceIo: Send + Sync { + /// Send bytes to the surface's input stream. + fn send(&self, surface_id: &str, bytes: &[u8]) -> Result<()>; + /// Read a bounded snapshot from the surface's output stream. + /// + /// Adapters without readback must return an explicit error; callers must + /// not interpret an empty buffer as proof that the surface is idle. + fn read(&self, surface_id: &str, max_bytes: usize) -> Result> { + let _ = (surface_id, max_bytes); + anyhow::bail!("surface readback unavailable") + } + /// Resize the surface's PTY or terminal viewport. + fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()>; +} + +/// Explicitly degraded Ghostty adapter until a native control transport is proven. +#[derive(Clone, Debug, Default)] +pub struct GhosttySurfaceAdapter { + pub native_rpc: bool, + pub apple_events: bool, +} + +impl SurfaceAdapter for GhosttySurfaceAdapter { + fn capabilities(&self, _surface: &SurfaceRecord) -> Result { + Ok(SurfaceCapabilities { + read: self.native_rpc, + write: self.native_rpc || self.apple_events, + resize: self.native_rpc || self.apple_events, + layout: self.native_rpc || self.apple_events, + durable_pty: false, + }) + } + + fn discover(&self) -> Result> { + if !self.native_rpc && !self.apple_events { + anyhow::bail!("Ghostty surface discovery unavailable: native transport not configured") + } + Ok(Vec::new()) + } +} + +/// Managed PTY adapter used for ShareCLI-owned zmx sessions. +#[derive(Clone, Debug)] +pub struct ZmxSurfaceAdapter { + pub available: bool, + pub surfaces: Arc>, +} + +impl SurfaceAdapter for ZmxSurfaceAdapter { + fn capabilities(&self, _surface: &SurfaceRecord) -> Result { + Ok(SurfaceCapabilities { + read: self.available, + write: self.available, + resize: self.available, + layout: false, + durable_pty: self.available, + }) + } + + fn discover(&self) -> Result> { + Ok((*self.surfaces).clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn surface() -> SurfaceRecord { + SurfaceRecord { + id: "ghostty:1".into(), + terminal: "ghostty".into(), + title: None, + cwd: PathBuf::from("/tmp"), + process: None, + } + } + + #[test] + fn ghostty_without_native_transport_is_explicitly_degraded() { + let adapter = GhosttySurfaceAdapter::default(); + assert!(adapter.discover().is_err()); + assert!(!adapter.capabilities(&surface()).unwrap().read); + } + + #[test] + fn zmx_advertises_durable_pty_only_when_available() { + let adapter = ZmxSurfaceAdapter { available: true, surfaces: Arc::new(vec![surface()]) }; + assert!(adapter.capabilities(&surface()).unwrap().durable_pty); + } +} diff --git a/crates/sharecli-session/src/ledger.rs b/crates/sharecli-session/src/ledger.rs new file mode 100644 index 00000000..1d1fcd70 --- /dev/null +++ b/crates/sharecli-session/src/ledger.rs @@ -0,0 +1,124 @@ +//! Durable observation records for terminal surfaces and agent sessions. + +use crate::{AgentSession, SurfaceRecord}; +use serde::{Deserialize, Serialize}; + +/// Capabilities advertised by a terminal surface adapter. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct SurfaceCapabilities { + pub read: bool, + pub write: bool, + pub resize: bool, + pub layout: bool, + pub durable_pty: bool, +} + +/// Why an observation was appended. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum ObservationKind { + Discovered, + Updated, + Exited, + Recovered, +} + +/// Append-only state observed from a terminal surface. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SessionObservation { + #[serde(default)] + pub seq: i64, + pub observed_at: String, + pub surface: SurfaceRecord, + pub session: Option, + pub capabilities: SurfaceCapabilities, + pub kind: ObservationKind, +} + +impl SessionObservation { + pub fn new( + observed_at: impl Into, + surface: SurfaceRecord, + session: Option, + capabilities: SurfaceCapabilities, + kind: ObservationKind, + ) -> Self { + Self { seq: 0, observed_at: observed_at.into(), surface, session, capabilities, kind } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AgentSession, ProcessEvidence, ResolutionConfidence, SessionState, SessionStore}; + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn surface(id: &str) -> SurfaceRecord { + SurfaceRecord { + id: id.to_string(), + terminal: "ghostty".to_string(), + title: Some(id.to_string()), + cwd: PathBuf::from("/tmp/project"), + process: Some(ProcessEvidence { + pid: Some(42), + tty: Some("ttys001".to_string()), + cwd: PathBuf::from("/tmp/project"), + argv: vec!["codex".to_string()], + started_at: Some("1".to_string()), + }), + } + } + + #[test] + fn observation_survives_store_reopen_and_materializes_session() { + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("sharecli-ledger-{suffix}.sqlite")); + let session = AgentSession::codex("session-1", "/tmp/project"); + let observation = SessionObservation::new( + "2026-07-31T00:00:00Z", + surface("ghostty:1"), + Some(session.clone()), + SurfaceCapabilities { read: true, write: true, ..Default::default() }, + ObservationKind::Discovered, + ); + let store = SessionStore::open(&path).unwrap(); + let seq = store.append_observation(&observation).unwrap(); + assert_eq!(seq, 1); + drop(store); + let reopened = SessionStore::open(&path).unwrap(); + assert_eq!(reopened.observations(None).unwrap().len(), 1); + assert_eq!(reopened.get(&session.id).unwrap(), Some(session)); + let _ = std::fs::remove_file(path); + } + + #[test] + fn heuristic_session_is_not_auto_resumable() { + let mut session = AgentSession::codex("session-2", "/tmp/project"); + session.confidence = ResolutionConfidence::Heuristic; + session.state = SessionState::Unknown; + assert!(!session.auto_resumable()); + } + + #[test] + fn compaction_keeps_latest_observation_per_surface() { + let store = SessionStore::open_memory().unwrap(); + for (surface_id, timestamp) in [("a", "1"), ("a", "2"), ("b", "3")] { + store + .append_observation(&SessionObservation::new( + timestamp, + surface(surface_id), + None, + SurfaceCapabilities::default(), + ObservationKind::Updated, + )) + .unwrap(); + } + assert_eq!(store.compact_observations().unwrap(), 1); + let observations = store.observations(None).unwrap(); + assert_eq!(observations.len(), 2); + assert_eq!(observations[0].surface.id, "a"); + assert_eq!(observations[0].observed_at, "2"); + } +} diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index 9cb196b1..f6409ff6 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -45,13 +45,22 @@ mod tests { } use anyhow::{Context, Result}; -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, TransactionBehavior}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::Mutex; +pub mod adapter; +pub mod ledger; +pub mod recovery; +pub mod resolver; pub mod rpc; +pub use adapter::{GhosttySurfaceAdapter, SurfaceAdapter, SurfaceIo, ZmxSurfaceAdapter}; +pub use ledger::{ObservationKind, SessionObservation, SurfaceCapabilities}; +pub use recovery::{validate_recipe, RecoveryExecutor, RecoveryOutcome, RecoveryResult}; +pub use resolver::{resolve as resolve_session, EvidenceSource, Resolution}; + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ResumeRecipe { pub harness: String, @@ -105,6 +114,15 @@ pub struct AgentSession { pub state: SessionState, } +impl AgentSession { + /// Whether this record has enough evidence for unattended recovery. + pub fn auto_resumable(&self) -> bool { + matches!(self.confidence, ResolutionConfidence::Exact | ResolutionConfidence::Corroborated) + && !self.resume.session_id.is_empty() + && !self.resume.argv.is_empty() + } +} + impl AgentSession { pub fn new( harness: impl Into, @@ -178,6 +196,12 @@ pub struct SessionStore { impl SessionStore { pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).with_context(|| { + format!("create session database directory {}", parent.display()) + })?; + } let conn = Connection::open(path).context("open session database")?; Self::init(conn) } @@ -186,12 +210,27 @@ impl SessionStore { } fn init(conn: Connection) -> Result { conn.pragma_update(None, "journal_mode", "WAL")?; - conn.execute_batch("CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, harness TEXT NOT NULL, session_id TEXT NOT NULL, cwd TEXT NOT NULL, resume_json TEXT NOT NULL, confidence TEXT NOT NULL, state TEXT NOT NULL);")?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, harness TEXT NOT NULL, session_id TEXT NOT NULL, cwd TEXT NOT NULL, resume_json TEXT NOT NULL, confidence TEXT NOT NULL, state TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS session_observations ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + observed_at TEXT NOT NULL, + surface_id TEXT NOT NULL, + surface_json TEXT NOT NULL, + session_json TEXT, + capabilities_json TEXT NOT NULL, + kind TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS session_observations_surface_seq + ON session_observations(surface_id, seq); + CREATE INDEX IF NOT EXISTS session_observations_time + ON session_observations(observed_at);", + )?; Ok(Self { conn: Mutex::new(conn) }) } pub fn upsert(&self, session: &AgentSession) -> Result<()> { - self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?.execute("INSERT INTO sessions (id,harness,session_id,cwd,resume_json,confidence,state) VALUES (?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(id) DO UPDATE SET harness=excluded.harness, session_id=excluded.session_id, cwd=excluded.cwd, resume_json=excluded.resume_json, confidence=excluded.confidence, state=excluded.state", params![session.id, session.harness, session.session_id, session.cwd.to_string_lossy(), serde_json::to_string(&session.resume)?, serde_json::to_string(&session.confidence)?, serde_json::to_string(&session.state)?])?; - Ok(()) + let conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + Self::upsert_locked(&conn, session) } pub fn list(&self) -> Result> { let conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; @@ -207,6 +246,61 @@ impl SessionStore { let mut rows = stmt.query([id])?; rows.next()?.map(Self::row).transpose().map_err(Into::into) } + + /// Append an observation and atomically refresh the materialized session row. + pub fn append_observation(&self, observation: &SessionObservation) -> Result { + let mut conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute( + "INSERT INTO session_observations + (observed_at, surface_id, surface_json, session_json, capabilities_json, kind) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + observation.observed_at, + observation.surface.id, + serde_json::to_string(&observation.surface)?, + observation.session.as_ref().map(serde_json::to_string).transpose()?, + serde_json::to_string(&observation.capabilities)?, + serde_json::to_string(&observation.kind)?, + ], + )?; + let seq = tx.last_insert_rowid(); + if let Some(session) = &observation.session { + Self::upsert_locked(&tx, session)?; + } + tx.commit()?; + Ok(seq) + } + + /// Read observations in append order, optionally limited to one surface. + pub fn observations(&self, surface_id: Option<&str>) -> Result> { + let conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + let mut stmt = if surface_id.is_some() { + conn.prepare("SELECT seq, observed_at, surface_json, session_json, capabilities_json, kind FROM session_observations WHERE surface_id=?1 ORDER BY seq")? + } else { + conn.prepare("SELECT seq, observed_at, surface_json, session_json, capabilities_json, kind FROM session_observations ORDER BY seq")? + }; + let rows = if let Some(surface_id) = surface_id { + stmt.query_map([surface_id], Self::observation_row)? + .collect::>>()? + } else { + stmt.query_map([], Self::observation_row)?.collect::>>()? + }; + Ok(rows) + } + + /// Compact history while retaining the latest observation for every surface. + pub fn compact_observations(&self) -> Result { + let mut conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let removed = tx.execute( + "DELETE FROM session_observations + WHERE seq NOT IN (SELECT MAX(seq) FROM session_observations GROUP BY surface_id)", + [], + )?; + tx.commit()?; + Ok(removed) + } fn row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let cwd: String = row.get(3)?; let resume: String = row.get(4)?; @@ -240,6 +334,65 @@ impl SessionStore { })?, }) } + + fn upsert_locked(conn: &rusqlite::Connection, session: &AgentSession) -> Result<()> { + conn.execute( + "INSERT INTO sessions (id,harness,session_id,cwd,resume_json,confidence,state) + VALUES (?1,?2,?3,?4,?5,?6,?7) + ON CONFLICT(id) DO UPDATE SET harness=excluded.harness, + session_id=excluded.session_id, cwd=excluded.cwd, + resume_json=excluded.resume_json, confidence=excluded.confidence, + state=excluded.state", + params![ + session.id, + session.harness, + session.session_id, + session.cwd.to_string_lossy(), + serde_json::to_string(&session.resume)?, + serde_json::to_string(&session.confidence)?, + serde_json::to_string(&session.state)?, + ], + )?; + Ok(()) + } + + fn observation_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let session_json: Option = row.get(3)?; + Ok(SessionObservation { + seq: row.get(0)?, + observed_at: row.get(1)?, + surface: serde_json::from_str(&row.get::<_, String>(2)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?, + session: session_json.map(|value| serde_json::from_str(&value)).transpose().map_err( + |e| { + rusqlite::Error::FromSqlConversionFailure( + 3, + rusqlite::types::Type::Text, + Box::new(e), + ) + }, + )?, + capabilities: serde_json::from_str(&row.get::<_, String>(4)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?, + kind: serde_json::from_str(&row.get::<_, String>(5)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?, + }) + } } pub struct SessionService { @@ -258,4 +411,10 @@ impl SessionService { pub fn recovery_plan(&self) -> Result> { self.store.list() } + pub fn observations(&self, surface_id: Option<&str>) -> Result> { + self.store.observations(surface_id) + } + pub fn compact_observations(&self) -> Result { + self.store.compact_observations() + } } diff --git a/crates/sharecli-session/src/recovery.rs b/crates/sharecli-session/src/recovery.rs new file mode 100644 index 00000000..8d13ff77 --- /dev/null +++ b/crates/sharecli-session/src/recovery.rs @@ -0,0 +1,131 @@ +//! Safe, bounded session recovery execution. + +use crate::{AgentSession, ResumeRecipe, SessionState}; +use anyhow::Result; +use serde::Serialize; +use std::process::Command; + +/// Per-session recovery result. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub enum RecoveryOutcome { + Resumed, + DryRun, + SkippedAmbiguous, + UnsupportedSurface, + LaunchFailed(String), +} + +/// Recovery item paired with its durable session identity. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct RecoveryResult { + pub session_id: String, + pub outcome: RecoveryOutcome, +} + +/// Execute verified recipes in bounded batches. No shell is involved. +/// +/// A successful result means the harness process was spawned. The executor does +/// not wait for an agent UI to exit, which keeps recovery non-blocking for long +/// lived sessions. +pub struct RecoveryExecutor { + pub max_parallel: usize, +} + +impl RecoveryExecutor { + pub fn new(max_parallel: usize) -> Self { + Self { max_parallel: max_parallel.max(1) } + } + + pub fn dry_run(&self, sessions: &[AgentSession]) -> Vec { + sessions + .iter() + .map(|session| RecoveryResult { + session_id: session.id.clone(), + outcome: if session.auto_resumable() { + RecoveryOutcome::DryRun + } else { + RecoveryOutcome::SkippedAmbiguous + }, + }) + .collect() + } + + pub fn execute(&self, sessions: &[AgentSession]) -> Vec { + let mut results = Vec::with_capacity(sessions.len()); + for batch in sessions.chunks(self.max_parallel) { + std::thread::scope(|scope| { + let handles = batch + .iter() + .map(|session| scope.spawn(|| (session.id.clone(), launch(session)))) + .collect::>(); + for handle in handles { + let (session_id, outcome) = handle.join().unwrap_or_else(|_| { + ( + "unknown".to_string(), + RecoveryOutcome::LaunchFailed("worker panicked".to_string()), + ) + }); + results.push(RecoveryResult { session_id, outcome }); + } + }); + } + results + } +} + +fn launch(session: &AgentSession) -> RecoveryOutcome { + if !session.auto_resumable() { + return RecoveryOutcome::SkippedAmbiguous; + } + if !matches!(session.state, SessionState::Active | SessionState::Exited | SessionState::Pending) + { + return RecoveryOutcome::UnsupportedSurface; + } + if let Err(error) = validate_recipe(&session.resume) { + return RecoveryOutcome::LaunchFailed(error.to_string()); + } + match Command::new(&session.resume.argv[0]) + .args(&session.resume.argv[1..]) + .current_dir(&session.resume.cwd) + .spawn() + { + Ok(_) => RecoveryOutcome::Resumed, + Err(error) => RecoveryOutcome::LaunchFailed(error.to_string()), + } +} + +/// Validate that a recipe is argv-based and has a usable cwd before launch. +pub fn validate_recipe(recipe: &ResumeRecipe) -> Result<()> { + if recipe.argv.is_empty() || recipe.argv.iter().any(|arg| arg.contains('\0')) { + anyhow::bail!("resume recipe has no safe argv") + } + if !recipe.cwd.is_absolute() { + anyhow::bail!("resume recipe cwd must be absolute") + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ResolutionConfidence; + + #[test] + fn dry_run_never_launches_ambiguous_sessions() { + let mut ambiguous = AgentSession::codex("id", "/tmp"); + ambiguous.confidence = ResolutionConfidence::Heuristic; + let results = RecoveryExecutor::new(2).dry_run(&[ambiguous]); + assert_eq!(results[0].outcome, RecoveryOutcome::SkippedAmbiguous); + } + + #[test] + fn recipe_validation_rejects_relative_cwd() { + let session = AgentSession::codex("id", "relative"); + assert!(validate_recipe(&session.resume).is_err()); + } + + #[test] + fn executor_clamps_parallelism_to_one() { + assert_eq!(RecoveryExecutor::new(0).max_parallel, 1); + } +} diff --git a/crates/sharecli-session/src/resolver.rs b/crates/sharecli-session/src/resolver.rs new file mode 100644 index 00000000..c97f1d9b --- /dev/null +++ b/crates/sharecli-session/src/resolver.rs @@ -0,0 +1,133 @@ +//! Harness/session evidence resolution without shell evaluation. + +use crate::{AgentSession, ResolutionConfidence}; +use std::path::{Path, PathBuf}; + +/// Evidence source used to resolve a harness session identifier. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EvidenceSource { + Adapter, + StateFile, + Argv, + Unavailable, +} + +/// Resolver output, including confidence and the safe resume recipe. +#[derive(Clone, Debug)] +pub struct Resolution { + pub session: Option, + pub confidence: ResolutionConfidence, + pub source: EvidenceSource, +} + +/// Resolve a known harness using explicit state, then corroborated argv evidence. +pub fn resolve( + harness: &str, + cwd: impl Into, + argv: &[String], + state_session_id: Option<&str>, + adapter_session_id: Option<&str>, +) -> Resolution { + let cwd = cwd.into(); + if let Some(id) = adapter_session_id.filter(|id| !id.is_empty()) { + return exact_recipe(harness, id, cwd, EvidenceSource::Adapter); + } + if let Some(id) = state_session_id.filter(|id| !id.is_empty()) { + let confidence = if argv_mentions_id(argv, id) { + ResolutionConfidence::Corroborated + } else { + ResolutionConfidence::Exact + }; + return recipe(harness, id, cwd, confidence, EvidenceSource::StateFile); + } + if let Some(id) = session_id_from_argv(harness, argv) { + return recipe(harness, &id, cwd, ResolutionConfidence::Exact, EvidenceSource::Argv); + } + Resolution { + session: None, + confidence: ResolutionConfidence::Unavailable, + source: EvidenceSource::Unavailable, + } +} + +fn exact_recipe(harness: &str, id: &str, cwd: PathBuf, source: EvidenceSource) -> Resolution { + recipe(harness, id, cwd, ResolutionConfidence::Exact, source) +} + +fn recipe( + harness: &str, + id: &str, + cwd: PathBuf, + confidence: ResolutionConfidence, + source: EvidenceSource, +) -> Resolution { + let session = match harness { + "forge" => AgentSession::forge(id, cwd), + "codex" => AgentSession::codex(id, cwd), + "opencode" => AgentSession::opencode(id, cwd), + "kilo" => AgentSession::kilo(id, cwd), + "cursor" | "cursor-agent" => AgentSession::cursor(id, cwd), + _ => { + return Resolution { + session: None, + confidence: ResolutionConfidence::Unavailable, + source: EvidenceSource::Unavailable, + } + } + }; + let mut session = session; + session.confidence = confidence; + Resolution { session: Some(session), confidence, source } +} + +fn argv_mentions_id(argv: &[String], id: &str) -> bool { + argv.iter().any(|value| value == id) +} + +fn session_id_from_argv(harness: &str, argv: &[String]) -> Option { + let names = match harness { + "forge" => ["--conversation-id"].as_slice(), + "codex" => ["resume"].as_slice(), + "opencode" | "kilo" => ["--session"].as_slice(), + "cursor" | "cursor-agent" => ["--resume"].as_slice(), + _ => return None, + }; + for (index, value) in argv.iter().enumerate() { + if names.contains(&value.as_str()) { + return argv.get(index + 1).filter(|id| !id.is_empty()).cloned(); + } + } + None +} + +#[allow(dead_code)] +fn _cwd_is_valid(cwd: &Path) -> bool { + cwd.is_absolute() && cwd.exists() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codex_argv_resolves_exact_recipe() { + let result = + resolve("codex", "/tmp", &["codex".into(), "resume".into(), "id-1".into()], None, None); + assert_eq!(result.confidence, ResolutionConfidence::Exact); + assert_eq!(result.session.unwrap().resume.argv, vec!["codex", "resume", "id-1"]); + } + + #[test] + fn state_and_argv_are_corroborated() { + let argv = vec!["codex".into(), "resume".into(), "id-2".into()]; + let result = resolve("codex", "/tmp", &argv, Some("id-2"), None); + assert_eq!(result.confidence, ResolutionConfidence::Corroborated); + } + + #[test] + fn ambiguous_process_never_yields_recipe() { + let result = resolve("codex", "/tmp", &["codex".into()], None, None); + assert!(result.session.is_none()); + assert_eq!(result.confidence, ResolutionConfidence::Unavailable); + } +} diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md new file mode 100644 index 00000000..dea8d8f4 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md @@ -0,0 +1,20 @@ +# ShareCLI Ghostty Control Plane + +Date: 2026-07-31 + +This session implements the first production slice of ShareCLI's terminal +control plane: durable session observations, evidence-gated resume recipes, +bounded crash recovery, a Ghostty Unix-socket client, and explicit FUSE +capability selection. The implementation remains scoped to ShareCLI. + +Success criteria for this slice: + +- session observations survive process and database reopen; +- ambiguous process evidence never becomes an unattended resume recipe; +- recovery uses argv (never a shell) and bounded non-blocking launches; +- IPC and CLI expose observation/recovery operations; +- Ghostty I/O is capability-gated and authenticated when a control socket exists; +- FUSE selection is KEXT first, approved FSKit second, then fail-open. + +Not completed in this slice: a Ghostty fork/server, native pane enumeration, +continuous subscriptions, layout restore, and macOS mount dogfood. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md new file mode 100644 index 00000000..fe053da9 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md @@ -0,0 +1,26 @@ +# Research + +## Ghostty + +- Official AppleScript controls windows, tabs, splits, and input but does not + provide a supported external pane readback stream: + https://ghostty.org/docs/features/applescript +- Upstream discussion considers scoped platform IPC and calls out security + implications of screen readback: + https://github.com/ghostty-org/ghostty/discussions/2353 +- Therefore ShareCLI implements a transport-neutral Unix JSON-RPC client and + advertises native readback only when that socket is explicitly configured. + +## FUSE + +- macFUSE documents KEXT/VFS and an explicit `backend=fskit` selector. FSKit has + documented limitations and must not silently replace the KEXT path. +- ShareCLI's selector is deterministic: loaded KEXT -> approved FSKit -> no + interception. The last state is functional fail-open, not a mount failure + that blocks session recovery. + +## Harness evidence + +Resume recipes are generated from adapter state first, persisted state second, +and exact argv patterns third. Heuristic/ambiguous evidence is retained for +inspection but is never auto-launched. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md new file mode 100644 index 00000000..640390b3 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md @@ -0,0 +1,21 @@ +# Specifications + +## Durable ledger + +`session_observations` is append-only with an autoincrement sequence, UTC +observation timestamp, terminal surface JSON, optional session JSON, +capabilities JSON, and an observation kind. The `sessions` table is a +materialized index updated in the same immediate SQLite transaction. + +## Recovery safety + +Only `Exact` and `Corroborated` records with an absolute working directory and +non-empty argv can be launched. Commands are constructed with +`std::process::Command`; shell evaluation is forbidden. `--execute` is an +explicit opt-in; the default CLI/IPC path is dry-run. + +## Surface capabilities + +Read, write, resize, layout, and durable-PTY claims are independent fields. +Missing capability is an explicit degraded result, never inferred from an +empty output buffer. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md new file mode 100644 index 00000000..6d888770 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md @@ -0,0 +1,21 @@ +# DAG / WBS + +```text +P0 FUSE truth + fail-open + | +P1 durable observation WAL ----+ + | | +P2 adapter/resolver -----------+--> P4 CLI + IPC recovery + | | +P3 Ghostty control client -----+--> P5 live I/O contract tests + | + +--> P6 native Ghostty server/fork + | + +--> P7 pane discovery/layout restore + | + +--> P8 macOS dogfood + chaos gate +``` + +Completed here: P0-P5's ShareCLI-side contracts and tests. P6-P8 remain open +because they require a Ghostty-side implementation and real macOS permission, +mount, crash, and restart validation. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md new file mode 100644 index 00000000..89351aa4 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md @@ -0,0 +1,13 @@ +# Implementation Strategy + +- Keep the session model in `sharecli-session` so the CLI, IPC daemon, and + future Ghostty adapter share one typed contract. +- Use SQLite WAL plus `BEGIN IMMEDIATE` for serialized append/materialization; + compact only the observation history, never the materialized session rows. +- Keep Ghostty transport behind a small Unix JSON-RPC client with an optional + bearer-like token. AppleScript remains a degraded control path, not a source + of readback truth. +- Use a bounded batch executor and `spawn`, so one long-running agent cannot + block recovery of all other panes. +- Treat FUSE as an optimization/observation aid. Session persistence and + recovery remain correct with no filesystem interception. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md new file mode 100644 index 00000000..73c1b81f --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md @@ -0,0 +1,11 @@ +# Known Issues and Open Gates + +- `GhosttySurfaceAdapter::discover` is intentionally a capability contract; + stock Ghostty has no proven external pane enumeration API in this checkout. +- `SHARECLI_FUSE_FSKIT_APPROVED` is a conservative approval input, not a full + MFMount entitlement probe. macOS install/approval and mount smoke are still + required before enabling it by default. +- IPC tests use the default local session database and should be isolated from + a concurrently running production daemon before a full CI parallelization. +- Recovery launch reports process spawn, not completion or readiness; a future + supervisor should add readiness/health events to the ledger. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md new file mode 100644 index 00000000..ad9bd934 --- /dev/null +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -0,0 +1,16 @@ +# Testing Strategy + +Verified in this worktree: + +- `cargo check --workspace --locked` +- `cargo test -p sharecli-session --locked` (14 passed) +- `cargo test -p sharecli-fuse backend::tests --locked` (2 passed) +- `cargo test -p sharecli --test session --locked` (5 passed) +- `cargo test -p sharecli --test session_cli --locked` (1 passed) +- `cargo test -p sharecli-ipc --locked --test handler_dispatch` (9 passed) +- `cargo fmt --all -- --check` +- `git diff --check` + +Required before claiming full feature completion: Ghostty fork/socket +integration, native pane discovery, layout restore, macOS FUSE KEXT/FSKit +mount smoke, crash/restart chaos, and a clean installed-dogfood run. diff --git a/src/main.rs b/src/main.rs index a61adfc5..a046cea5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use crate::error::SharecliError; use anyhow::Result; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::Shell; -use sharecli_session::{SessionService, SessionStore}; +use sharecli_session::{RecoveryExecutor, SessionObservation, SessionService, SessionStore}; use sharecli_thermal_tui as thermal_tui; mod apfs_uuid; @@ -480,6 +480,36 @@ enum SessionCmd { #[arg(long)] db: Option, }, + /// Append one JSON observation to the durable ledger + Observe { + /// JSON file containing a SessionObservation + input: std::path::PathBuf, + #[arg(long)] + db: Option, + }, + /// List append-only observations, optionally for one terminal surface + Observations { + #[arg(long)] + surface_id: Option, + #[arg(long)] + db: Option, + }, + /// Compact the observation WAL while retaining the newest record per surface + Compact { + #[arg(long)] + db: Option, + }, + /// Recover verified sessions; defaults to a dry run + Recover { + /// Launch exact/corroborated recipes instead of printing a dry run + #[arg(long)] + execute: bool, + /// Maximum number of concurrent launches + #[arg(long, default_value_t = 4)] + max_parallel: usize, + #[arg(long)] + db: Option, + }, } #[derive(Subcommand, Debug)] @@ -936,6 +966,10 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { SessionCmd::List { db } => (db.clone(), None), SessionCmd::Inspect { id, db } => (db.clone(), Some(id.as_str())), SessionCmd::RecoveryPlan { db } => (db.clone(), None), + SessionCmd::Observe { db, .. } => (db.clone(), None), + SessionCmd::Observations { db, .. } => (db.clone(), None), + SessionCmd::Compact { db } => (db.clone(), None), + SessionCmd::Recover { db, .. } => (db.clone(), None), }; let path = db.unwrap_or_else(|| { dirs::data_local_dir() @@ -943,13 +977,38 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { .join("sharecli") .join("sessions.sqlite") }); - let service = SessionService::new(SessionStore::open(path)?); + let store = SessionStore::open(path)?; + if let SessionCmd::Observe { input, .. } = cmd { + let observation: SessionObservation = + serde_json::from_str(&std::fs::read_to_string(input)?)?; + let sequence = store.append_observation(&observation)?; + println!( + "{}", + serde_json::json!({"sequence": sequence, "surface_id": observation.surface.id}) + ); + return Ok(()); + } + let service = SessionService::new(store); let value = match cmd { SessionCmd::List { .. } => serde_json::to_value(service.list()?)?, SessionCmd::Inspect { .. } => { serde_json::to_value(service.inspect(operation.expect("id"))?)? } SessionCmd::RecoveryPlan { .. } => serde_json::to_value(service.recovery_plan()?)?, + SessionCmd::Observations { surface_id, .. } => { + serde_json::to_value(service.observations(surface_id.as_deref())?)? + } + SessionCmd::Compact { .. } => serde_json::json!({ + "removed": service.compact_observations()? + }), + SessionCmd::Recover { execute, max_parallel, .. } => { + let sessions = service.recovery_plan()?; + let executor = RecoveryExecutor::new(*max_parallel); + let results = + if *execute { executor.execute(&sessions) } else { executor.dry_run(&sessions) }; + serde_json::to_value(results)? + } + SessionCmd::Observe { .. } => unreachable!("observe handled before service dispatch"), }; println!("{}", serde_json::to_string_pretty(&value)?); Ok(()) diff --git a/src/session.rs b/src/session.rs index a1ac210a..3d765897 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,7 +1,10 @@ //! Shell-free zmx and capability-gated Ghostty adapters. -use std::path::Path; +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ZmxCommand { @@ -93,13 +96,84 @@ impl GhosttyCapabilities { pub fn from_probe(apple_events: bool, app_intents: bool, accessibility_readback: bool) -> Self { Self { apple_events, app_intents, accessibility_readback, control_socket: false } } + + pub fn with_control_socket(mut self, available: bool) -> Self { + self.control_socket = available; + self + } +} + +/// Minimal JSON-RPC client for a ShareCLI-enabled Ghostty control socket. +/// +/// A stock Ghostty install reports an unavailable socket; callers must then +/// use a degraded caster or zmx rather than pretending to have pane readback. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GhosttyControlClient { + socket: PathBuf, + token: Option, +} + +static REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +impl GhosttyControlClient { + pub fn new(socket: impl Into, token: Option) -> Self { + Self { socket: socket.into(), token } + } + + pub fn socket(&self) -> &Path { + &self.socket + } + + pub fn request(&self, method: &str, params: Value) -> anyhow::Result { + #[cfg(unix)] + { + let mut stream = std::os::unix::net::UnixStream::connect(&self.socket)?; + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let mut request = json!({"id": id, "method": method, "params": params}); + if let Some(token) = &self.token { + request["token"] = Value::String(token.clone()); + } + serde_json::to_writer(&mut stream, &request)?; + stream.write_all(b"\n")?; + stream.flush()?; + let mut response = String::new(); + BufReader::new(stream).read_line(&mut response)?; + let response: Value = serde_json::from_str(&response)?; + if let Some(error) = response.get("error") { + anyhow::bail!("Ghostty RPC {method} failed: {error}"); + } + Ok(response.get("result").cloned().unwrap_or(Value::Null)) + } + #[cfg(not(unix))] + { + let _ = (method, params); + anyhow::bail!("Ghostty control sockets require a Unix platform") + } + } + + pub fn send_text(&self, surface_id: &str, text: &str) -> anyhow::Result<()> { + self.request("surface.io.send", json!({"surface_id": surface_id, "text": text}))?; + Ok(()) + } + + pub fn read_surface(&self, surface_id: &str, max_bytes: usize) -> anyhow::Result { + self.request("surface.io.read", json!({"surface_id": surface_id, "max_bytes": max_bytes})) + } + + pub fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> anyhow::Result<()> { + self.request( + "surface.io.resize", + json!({"surface_id": surface_id, "rows": rows, "cols": cols}), + )?; + Ok(()) + } } pub struct GhosttyAdapter; impl GhosttyAdapter { pub fn degraded_reason(caps: &GhosttyCapabilities) -> Option<&'static str> { - (!caps.apple_events && !caps.app_intents) + (!caps.apple_events && !caps.app_intents && !caps.control_socket) .then_some("native surface API unavailable") .or_else(|| (!caps.control_socket).then_some("native RPC unavailable")) } diff --git a/tests/session.rs b/tests/session.rs index 30c5d4d2..cfa73bca 100644 --- a/tests/session.rs +++ b/tests/session.rs @@ -1,4 +1,6 @@ -use sharecli::session::{GhosttyAdapter, GhosttyCapabilities, ZmxCommand, ZmxSessionAdapter}; +use sharecli::session::{ + GhosttyAdapter, GhosttyCapabilities, GhosttyControlClient, ZmxCommand, ZmxSessionAdapter, +}; #[test] fn zmx_commands_are_shell_free() { @@ -33,3 +35,37 @@ fn ghostty_adapter_never_claims_private_rpc() { assert!(caps.apple_events && !caps.app_intents && !caps.control_socket); assert_eq!(GhosttyAdapter::degraded_reason(&caps), Some("native RPC unavailable")); } + +#[test] +fn ghostty_control_client_requires_configured_socket() { + let client = GhosttyControlClient::new("/tmp/sharecli-no-ghostty.sock", Some("token".into())); + assert!(client.request("surface.list", serde_json::json!({})).is_err()); + let caps = GhosttyCapabilities::from_probe(false, false, false).with_control_socket(true); + assert!(GhosttyAdapter::degraded_reason(&caps).is_none()); +} + +#[cfg(unix)] +#[test] +fn ghostty_control_client_round_trips_authenticated_io_request() { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let socket = std::env::temp_dir().join(format!("sharecli-ghostty-{suffix}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "surface.io.send"); + assert_eq!(request["token"], "test-token"); + stream.write_all(b"{\"id\":1,\"result\":{\"accepted\":true}}\n").unwrap(); + }); + + let client = GhosttyControlClient::new(&socket, Some("test-token".into())); + client.send_text("ghostty:1", "hello\n").unwrap(); + server.join().unwrap(); + let _ = std::fs::remove_file(socket); +} From a70259f106a461cee2f8c0422f4b08bed1bc86c9 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Fri, 31 Jul 2026 16:29:11 -0700 Subject: [PATCH 06/18] feat(session): persist validated terminal layouts --- crates/sharecli-session/src/adapter.rs | 11 +- crates/sharecli-session/src/layout.rs | 149 ++++++++++++++++++++++++ crates/sharecli-session/src/lib.rs | 2 + crates/sharecli-session/tests/layout.rs | 65 +++++++++++ 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 crates/sharecli-session/src/layout.rs create mode 100644 crates/sharecli-session/tests/layout.rs diff --git a/crates/sharecli-session/src/adapter.rs b/crates/sharecli-session/src/adapter.rs index 06160f66..d497f7f7 100644 --- a/crates/sharecli-session/src/adapter.rs +++ b/crates/sharecli-session/src/adapter.rs @@ -1,6 +1,6 @@ //! Capability-gated terminal surface adapters. -use crate::{ledger::SurfaceCapabilities, SurfaceRecord}; +use crate::{ledger::SurfaceCapabilities, LayoutRestoreReport, LayoutSnapshot, SurfaceRecord}; use anyhow::Result; use std::sync::Arc; @@ -8,6 +8,15 @@ use std::sync::Arc; pub trait SurfaceAdapter: Send + Sync { fn capabilities(&self, surface: &SurfaceRecord) -> Result; fn discover(&self) -> Result>; + /// Capture the adapter's current pane topology. + fn snapshot_layout(&self) -> Result { + anyhow::bail!("surface layout snapshot unavailable") + } + /// Restore a previously validated pane topology. + fn restore_layout(&self, snapshot: &LayoutSnapshot) -> Result { + snapshot.validate()?; + anyhow::bail!("surface layout restore unavailable") + } } /// Targeted input/output operations for a terminal surface. diff --git a/crates/sharecli-session/src/layout.rs b/crates/sharecli-session/src/layout.rs new file mode 100644 index 00000000..d17eb90f --- /dev/null +++ b/crates/sharecli-session/src/layout.rs @@ -0,0 +1,149 @@ +//! Durable terminal layout snapshots and restore reports. + +use crate::SessionStore; +use anyhow::{Context, Result}; +use rusqlite::{params, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// Direction in which a terminal surface is split. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum LayoutAxis { + Horizontal, + Vertical, +} + +/// Recursive terminal layout tree. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum LayoutNode { + /// A leaf terminal pane identified by its stable surface ID. + Pane { surface_id: String }, + /// A binary split. `ratio_millis` is the first child's share in thousandths. + Split { axis: LayoutAxis, ratio_millis: u16, children: Vec }, +} + +/// Durable snapshot of a terminal application's pane topology. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LayoutSnapshot { + pub id: String, + pub terminal: String, + pub captured_at: String, + pub root: LayoutNode, +} + +/// Per-surface result produced by a layout restore adapter. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LayoutRestoreItem { + pub surface_id: String, + pub restored: bool, + pub detail: Option, +} + +/// Typed report for a completed or partially completed layout restore. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LayoutRestoreReport { + pub layout_id: String, + pub items: Vec, +} + +impl LayoutSnapshot { + /// Validate topology before persistence or adapter execution. + pub fn validate(&self) -> Result<()> { + if self.id.trim().is_empty() { + anyhow::bail!("layout id must not be empty"); + } + if self.terminal.trim().is_empty() { + anyhow::bail!("layout terminal must not be empty"); + } + let mut surfaces = HashSet::new(); + validate_node(&self.root, &mut surfaces) + } +} + +fn validate_node(node: &LayoutNode, surfaces: &mut HashSet) -> Result<()> { + match node { + LayoutNode::Pane { surface_id } => { + if surface_id.trim().is_empty() { + anyhow::bail!("layout surface id must not be empty"); + } + if !surfaces.insert(surface_id.clone()) { + anyhow::bail!("layout surface id appears more than once: {surface_id}"); + } + } + LayoutNode::Split { ratio_millis, children, .. } => { + if !(1..=999).contains(ratio_millis) { + anyhow::bail!("layout split ratio must be between 1 and 999"); + } + if children.len() != 2 { + anyhow::bail!("layout split must contain exactly two children"); + } + for child in children { + validate_node(child, surfaces)?; + } + } + } + Ok(()) +} + +impl SessionStore { + /// Insert or replace one validated terminal layout snapshot. + pub fn save_layout(&self, snapshot: &LayoutSnapshot) -> Result<()> { + snapshot.validate()?; + let mut conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + ensure_schema(&tx)?; + tx.execute( + "INSERT INTO layouts (id, terminal, captured_at, snapshot_json) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET terminal=excluded.terminal, + captured_at=excluded.captured_at, snapshot_json=excluded.snapshot_json", + params![ + snapshot.id, + snapshot.terminal, + snapshot.captured_at, + serde_json::to_string(snapshot)?, + ], + )?; + tx.commit()?; + Ok(()) + } + + /// Load a layout snapshot by its durable ID. + pub fn get_layout(&self, id: &str) -> Result> { + let conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + ensure_schema(&conn)?; + let json: Option = conn + .query_row("SELECT snapshot_json FROM layouts WHERE id=?1", [id], |row| row.get(0)) + .optional()?; + json.map(|value| serde_json::from_str(&value).context("decode layout snapshot")).transpose() + } + + /// List all stored layouts in stable ID order. + pub fn list_layouts(&self) -> Result> { + let conn = self.conn.lock().map_err(|_| anyhow::anyhow!("session store poisoned"))?; + ensure_schema(&conn)?; + let mut stmt = conn.prepare("SELECT snapshot_json FROM layouts ORDER BY id")?; + let layouts = stmt + .query_map([], |row| row.get::<_, String>(0))? + .map(|row| { + let json = row?; + serde_json::from_str(&json).context("decode layout snapshot") + }) + .collect(); + layouts + } +} + +fn ensure_schema(conn: &rusqlite::Connection) -> Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS layouts ( + id TEXT PRIMARY KEY, + terminal TEXT NOT NULL, + captured_at TEXT NOT NULL, + snapshot_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS layouts_terminal_captured + ON layouts(terminal, captured_at);", + )?; + Ok(()) +} diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index f6409ff6..7f8a8821 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -51,12 +51,14 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; pub mod adapter; +pub mod layout; pub mod ledger; pub mod recovery; pub mod resolver; pub mod rpc; pub use adapter::{GhosttySurfaceAdapter, SurfaceAdapter, SurfaceIo, ZmxSurfaceAdapter}; +pub use layout::{LayoutAxis, LayoutNode, LayoutRestoreItem, LayoutRestoreReport, LayoutSnapshot}; pub use ledger::{ObservationKind, SessionObservation, SurfaceCapabilities}; pub use recovery::{validate_recipe, RecoveryExecutor, RecoveryOutcome, RecoveryResult}; pub use resolver::{resolve as resolve_session, EvidenceSource, Resolution}; diff --git a/crates/sharecli-session/tests/layout.rs b/crates/sharecli-session/tests/layout.rs new file mode 100644 index 00000000..c2e6a529 --- /dev/null +++ b/crates/sharecli-session/tests/layout.rs @@ -0,0 +1,65 @@ +use sharecli_session::{LayoutAxis, LayoutNode, LayoutSnapshot, SessionStore}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn snapshot() -> LayoutSnapshot { + LayoutSnapshot { + id: "daily".to_string(), + terminal: "ghostty".to_string(), + captured_at: "2026-07-31T08:00:00Z".to_string(), + root: LayoutNode::Split { + axis: LayoutAxis::Horizontal, + ratio_millis: 500, + children: vec![ + LayoutNode::Pane { surface_id: "ghostty:1".to_string() }, + LayoutNode::Pane { surface_id: "ghostty:2".to_string() }, + ], + }, + } +} + +#[test] +fn layout_snapshot_round_trips_across_store_reopen() { + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("sharecli-layout-{suffix}.sqlite")); + let expected = snapshot(); + let store = SessionStore::open(&path).unwrap(); + store.save_layout(&expected).unwrap(); + drop(store); + + let reopened = SessionStore::open(&path).unwrap(); + assert_eq!(reopened.get_layout("daily").unwrap(), Some(expected)); + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_split_ratio_is_rejected_before_persistence() { + let store = SessionStore::open_memory().unwrap(); + let mut invalid = snapshot(); + invalid.root = LayoutNode::Split { + axis: LayoutAxis::Vertical, + ratio_millis: 0, + children: vec![ + LayoutNode::Pane { surface_id: "ghostty:1".to_string() }, + LayoutNode::Pane { surface_id: "ghostty:2".to_string() }, + ], + }; + + assert!(store.save_layout(&invalid).unwrap_err().to_string().contains("ratio")); + assert_eq!(store.get_layout("daily").unwrap(), None); +} + +#[test] +fn duplicate_surface_is_rejected() { + let store = SessionStore::open_memory().unwrap(); + let mut invalid = snapshot(); + invalid.root = LayoutNode::Split { + axis: LayoutAxis::Horizontal, + ratio_millis: 500, + children: vec![ + LayoutNode::Pane { surface_id: "ghostty:1".to_string() }, + LayoutNode::Pane { surface_id: "ghostty:1".to_string() }, + ], + }; + + assert!(store.save_layout(&invalid).unwrap_err().to_string().contains("more than once")); +} From 1e080a8273c8e3f38fddccc9f0230623dad1d29e Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Fri, 31 Jul 2026 16:30:01 -0700 Subject: [PATCH 07/18] feat(session): add surface control JSON-RPC server --- crates/sharecli-session/src/rpc.rs | 391 ++++++++++++++++++++++++++++- 1 file changed, 390 insertions(+), 1 deletion(-) diff --git a/crates/sharecli-session/src/rpc.rs b/crates/sharecli-session/src/rpc.rs index aa76621a..c9cb35e5 100644 --- a/crates/sharecli-session/src/rpc.rs +++ b/crates/sharecli-session/src/rpc.rs @@ -1,8 +1,12 @@ -use crate::SessionService; +use crate::{SessionService, SurfaceCapabilities}; use anyhow::Result; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::sync::Arc; +const JSON_RPC_VERSION: &str = "2.0"; +const MAX_SURFACE_READ_BYTES: usize = 1024 * 1024; + #[derive(Debug, Deserialize)] pub struct Request { pub id: serde_json::Value, @@ -17,6 +21,71 @@ pub struct Response { pub error: Option, } +/// Shell-free operations exposed by the surface control socket. +pub trait SurfaceControl: Send + Sync { + fn send(&self, surface_id: &str, bytes: &[u8]) -> Result<()>; + fn read(&self, surface_id: &str, max_bytes: usize) -> Result>; + fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()>; + fn capabilities(&self, surface_id: &str) -> Result; +} + +#[derive(Debug, Deserialize)] +struct SurfaceRequest { + jsonrpc: String, + id: Value, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Debug, Serialize)] +pub struct RpcError { + pub code: i32, + pub message: String, +} + +#[derive(Debug, Serialize)] +pub struct SurfaceResponse { + pub jsonrpc: &'static str, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Deserialize)] +struct SurfaceIdParams { + surface_id: String, +} + +#[derive(Deserialize)] +struct SendParams { + surface_id: String, + #[serde(default)] + text: Option, + #[serde(default)] + bytes: Option>, +} + +#[derive(Deserialize)] +struct ReadParams { + surface_id: String, + max_bytes: usize, +} + +#[derive(Deserialize)] +struct ResizeParams { + surface_id: String, + rows: u16, + cols: u16, +} + +#[derive(Serialize)] +struct ReadResult { + bytes: Vec, +} + pub async fn dispatch(service: Arc, line: &str) -> Response { let request = match serde_json::from_str::(line) { Ok(value) => value, @@ -49,6 +118,100 @@ pub async fn dispatch(service: Arc, line: &str) -> Response { } } +/// Dispatch one JSON-RPC 2.0 surface request. +/// +/// Input text is passed directly to [`SurfaceControl::send`]. It is never +/// interpreted as a command line or evaluated by a shell. +pub async fn dispatch_surface(control: Arc, line: &str) -> SurfaceResponse { + let request = match serde_json::from_str::(line) { + Ok(request) => request, + Err(error) => { + return surface_error(Value::Null, -32700, format!("parse error: {error}")); + } + }; + if request.jsonrpc != JSON_RPC_VERSION { + return surface_error(request.id, -32600, "jsonrpc must be \"2.0\"".to_string()); + } + + let id = request.id; + let outcome = dispatch_surface_method(control.as_ref(), &request.method, request.params); + match outcome { + Ok(result) => { + SurfaceResponse { jsonrpc: JSON_RPC_VERSION, id, result: Some(result), error: None } + } + Err((code, message)) => surface_error(id, code, message), + } +} + +fn dispatch_surface_method( + control: &dyn SurfaceControl, + method: &str, + params: Value, +) -> std::result::Result { + match method { + "surface.io.send" => { + let params: SendParams = decode_params(params)?; + let bytes = match (params.text, params.bytes) { + (Some(text), None) => text.into_bytes(), + (None, Some(bytes)) => bytes, + _ => { + return Err(( + -32602, + "exactly one of params.text or params.bytes is required".to_string(), + )) + } + }; + control.send(¶ms.surface_id, &bytes).map_err(control_error)?; + Ok(Value::Null) + } + "surface.io.read" => { + let params: ReadParams = decode_params(params)?; + if params.max_bytes > MAX_SURFACE_READ_BYTES { + return Err(( + -32602, + format!("max_bytes must not exceed {MAX_SURFACE_READ_BYTES}"), + )); + } + let bytes = + control.read(¶ms.surface_id, params.max_bytes).map_err(control_error)?; + serde_json::to_value(ReadResult { bytes }).map_err(control_error) + } + "surface.io.resize" => { + let params: ResizeParams = decode_params(params)?; + if params.rows == 0 || params.cols == 0 { + return Err((-32602, "rows and cols must be greater than zero".to_string())); + } + control.resize(¶ms.surface_id, params.rows, params.cols).map_err(control_error)?; + Ok(Value::Null) + } + "surface.io.capabilities" => { + let params: SurfaceIdParams = decode_params(params)?; + let capabilities = control.capabilities(¶ms.surface_id).map_err(control_error)?; + serde_json::to_value(capabilities).map_err(control_error) + } + _ => Err((-32601, format!("unknown method: {method}"))), + } +} + +fn decode_params Deserialize<'de>>( + params: Value, +) -> std::result::Result { + serde_json::from_value(params).map_err(|error| (-32602, format!("invalid params: {error}"))) +} + +fn control_error(error: impl std::fmt::Display) -> (i32, String) { + (-32000, error.to_string()) +} + +fn surface_error(id: Value, code: i32, message: String) -> SurfaceResponse { + SurfaceResponse { + jsonrpc: JSON_RPC_VERSION, + id, + result: None, + error: Some(RpcError { code, message }), + } +} + #[cfg(unix)] pub async fn serve_unix(path: &std::path::Path, service: Arc) -> Result<()> { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -76,3 +239,229 @@ pub async fn serve_unix(path: &std::path::Path, service: Arc) -> }); } } + +/// Serve shell-free surface control requests over a newline-delimited Unix socket. +#[cfg(unix)] +pub async fn serve_surface_unix( + path: &std::path::Path, + control: Arc, +) -> Result<()> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let _ = std::fs::remove_file(path); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let listener = tokio::net::UnixListener::bind(path)?; + loop { + let (stream, _) = listener.accept().await?; + let control = control.clone(); + tokio::spawn(async move { + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let response = dispatch_surface(control.clone(), &line).await; + let Ok(mut payload) = serde_json::to_string(&response) else { + break; + }; + payload.push('\n'); + if writer.write_all(payload.as_bytes()).await.is_err() { + break; + } + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SurfaceCapabilities; + use serde_json::json; + use std::sync::Mutex; + + #[derive(Default)] + struct RecordingControl { + sent: Mutex)>>, + resized: Mutex>, + } + + impl SurfaceControl for RecordingControl { + fn send(&self, surface_id: &str, bytes: &[u8]) -> Result<()> { + self.sent.lock().unwrap().push((surface_id.to_string(), bytes.to_vec())); + Ok(()) + } + + fn read(&self, surface_id: &str, max_bytes: usize) -> Result> { + assert_eq!(surface_id, "surface-1"); + Ok(b"terminal output"[..max_bytes.min(15)].to_vec()) + } + + fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()> { + self.resized.lock().unwrap().push((surface_id.to_string(), rows, cols)); + Ok(()) + } + + fn capabilities(&self, surface_id: &str) -> Result { + assert_eq!(surface_id, "surface-1"); + Ok(SurfaceCapabilities { + read: true, + write: true, + resize: true, + layout: false, + durable_pty: true, + }) + } + } + + async fn request( + control: &Arc, + id: u64, + method: &str, + params: Value, + ) -> SurfaceResponse { + let raw = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }) + .to_string(); + dispatch_surface(control.clone(), &raw).await + } + + #[tokio::test] + async fn surface_send_passes_text_directly_to_control() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 1, + "surface.io.send", + json!({"surface_id": "surface-1", "text": "printf 'not a shell'"}), + ) + .await; + + assert_eq!(response.jsonrpc, "2.0"); + assert_eq!(response.result, Some(Value::Null)); + assert!(response.error.is_none()); + assert_eq!( + *control.sent.lock().unwrap(), + vec![("surface-1".to_string(), b"printf 'not a shell'".to_vec())] + ); + } + + #[tokio::test] + async fn surface_send_accepts_an_explicit_byte_vector() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 6, + "surface.io.send", + json!({"surface_id": "surface-1", "bytes": [0, 255, 10]}), + ) + .await; + + assert_eq!(response.result, Some(Value::Null)); + assert_eq!( + *control.sent.lock().unwrap(), + vec![("surface-1".to_string(), vec![0, 255, 10])] + ); + } + + #[tokio::test] + async fn surface_read_returns_a_typed_byte_vector() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 2, + "surface.io.read", + json!({"surface_id": "surface-1", "max_bytes": 8}), + ) + .await; + + assert_eq!(response.result, Some(json!({"bytes": b"terminal".to_vec()}))); + assert!(response.error.is_none()); + } + + #[tokio::test] + async fn surface_resize_forwards_dimensions_without_a_command_string() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 3, + "surface.io.resize", + json!({"surface_id": "surface-1", "rows": 42, "cols": 120}), + ) + .await; + + assert_eq!(response.result, Some(Value::Null)); + assert_eq!(*control.resized.lock().unwrap(), vec![("surface-1".to_string(), 42, 120)]); + } + + #[tokio::test] + async fn surface_capabilities_are_returned_from_the_control_contract() { + let control = Arc::new(RecordingControl::default()); + let response = + request(&control, 4, "surface.io.capabilities", json!({"surface_id": "surface-1"})) + .await; + + assert_eq!( + response.result, + Some(json!({ + "read": true, + "write": true, + "resize": true, + "layout": false, + "durable_pty": true + })) + ); + } + + #[tokio::test] + async fn invalid_surface_params_return_json_rpc_invalid_params() { + let control = Arc::new(RecordingControl::default()); + let response = + request(&control, 5, "surface.io.resize", json!({"surface_id": "surface-1"})).await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32602); + } + + #[cfg(unix)] + #[tokio::test] + async fn unix_server_round_trips_surface_json_rpc() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let socket = + std::path::PathBuf::from(format!("/tmp/sharecli-sc-{}.sock", std::process::id())); + let control = Arc::new(RecordingControl::default()); + let server_path = socket.clone(); + let server_control = control.clone(); + let server = + tokio::spawn(async move { serve_surface_unix(&server_path, server_control).await }); + + let mut stream = loop { + match tokio::net::UnixStream::connect(&socket).await { + Ok(stream) => break stream, + Err(_error) if !server.is_finished() => tokio::task::yield_now().await, + Err(error) => panic!("surface server failed before accepting connections: {error}"), + } + }; + stream + .write_all( + br#"{"jsonrpc":"2.0","id":9,"method":"surface.io.read","params":{"surface_id":"surface-1","max_bytes":8}} +"#, + ) + .await + .unwrap(); + let mut response = String::new(); + BufReader::new(stream).read_line(&mut response).await.unwrap(); + + assert_eq!( + serde_json::from_str::(&response).unwrap(), + json!({"jsonrpc":"2.0","id":9,"result":{"bytes":b"terminal".to_vec()}}) + ); + server.abort(); + let _ = std::fs::remove_file(socket); + } +} From 4773d1363673329a0154386ba9a84e6f09bf20fa Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Fri, 31 Jul 2026 16:36:15 -0700 Subject: [PATCH 08/18] fix(fuse): fail open unsupported macfuse backends --- crates/sharecli-fuse/src/backend.rs | 148 ++++++++++++++++--- crates/sharecli-fuse/src/lib.rs | 17 ++- crates/sharecli-fuse/src/session_registry.rs | 49 +++++- 3 files changed, 189 insertions(+), 25 deletions(-) diff --git a/crates/sharecli-fuse/src/backend.rs b/crates/sharecli-fuse/src/backend.rs index ad899aba..f0079053 100644 --- a/crates/sharecli-fuse/src/backend.rs +++ b/crates/sharecli-fuse/src/backend.rs @@ -1,6 +1,6 @@ //! Runtime backend negotiation for macFUSE on macOS. -use std::process::Command; +use std::{path::Path, process::Command}; /// Selected interception backend for ShareCLI's optional filesystem layer. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -22,6 +22,38 @@ pub struct FuseCapabilities { pub fskit_approved: bool, } +/// Reason why macOS must continue without filesystem interception. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FuseBackendDiagnostic { + /// Neither the proven KEXT backend nor an explicitly approved FSKit backend is available. + NoVerifiedBackend, + /// FSKit cannot mount a volume outside `/Volumes`. + FskitRequiresVolumes, +} + +impl FuseBackendDiagnostic { + /// Operator-facing explanation of the unavailable selection. + pub const fn message(self) -> &'static str { + match self { + Self::NoVerifiedBackend => { + "macFUSE unavailable: no loaded KEXT and no verified FSKit approval; continuing without filesystem interception" + } + Self::FskitRequiresVolumes => { + "macFUSE FSKit supports mount points only under /Volumes; continuing without filesystem interception" + } + } + } +} + +/// Backend decision together with a fail-open diagnostic when no mount is safe. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FuseBackendSelection { + /// Backend that may be passed to a FUSE mount call. + pub backend: FuseBackend, + /// Why `backend` is unavailable, if it is unavailable. + pub diagnostic: Option, +} + /// Select KEXT first, then approved FSKit, otherwise fail open. pub fn select_backend_with(capabilities: FuseCapabilities) -> FuseBackend { if capabilities.kernel_loaded { @@ -33,16 +65,39 @@ pub fn select_backend_with(capabilities: FuseCapabilities) -> FuseBackend { } } -/// Select the safest available backend. `SHARECLI_FUSE_BACKEND` may force -/// `fskit` or `kernel`; unsupported forcing degrades to `Unavailable`. -pub fn select_backend() -> FuseBackend { - if let Ok(value) = std::env::var("SHARECLI_FUSE_BACKEND") { - return match value.to_ascii_lowercase().as_str() { - "fskit" if fskit_backend_approved() => FuseBackend::Fskit, - "kernel" if kernel_backend_loaded() => FuseBackend::Kernel, - _ => FuseBackend::Unavailable, - }; +/// Select a macOS backend for `mountpoint` without permitting an invalid FSKit mount. +/// +/// macFUSE's FSKit backend supports mount points only under `/Volumes`. A loaded KEXT is +/// deliberately preferred before this restriction is considered, because the KEXT backend can +/// mount at the caller's requested path. +pub fn select_backend_for_mount_with( + capabilities: FuseCapabilities, + mountpoint: &Path, +) -> FuseBackendSelection { + match select_backend_with(capabilities) { + FuseBackend::Kernel => { + FuseBackendSelection { backend: FuseBackend::Kernel, diagnostic: None } + } + FuseBackend::Fskit if mountpoint.starts_with("/Volumes") => { + FuseBackendSelection { backend: FuseBackend::Fskit, diagnostic: None } + } + FuseBackend::Fskit => FuseBackendSelection { + backend: FuseBackend::Unavailable, + diagnostic: Some(FuseBackendDiagnostic::FskitRequiresVolumes), + }, + FuseBackend::Unavailable => FuseBackendSelection { + backend: FuseBackend::Unavailable, + diagnostic: Some(FuseBackendDiagnostic::NoVerifiedBackend), + }, } +} + +/// Select the safest available backend. +/// +/// The selector is intentionally deterministic: it never lets an environment override bypass +/// a loaded KEXT or invent FSKit approval. Use [`select_backend_for_mount`] before mounting on +/// macOS so the FSKit `/Volumes` restriction remains fail-open. +pub fn select_backend() -> FuseBackend { if cfg!(target_os = "macos") { return select_backend_with(FuseCapabilities { kernel_loaded: kernel_backend_loaded(), @@ -56,12 +111,30 @@ pub fn select_backend() -> FuseBackend { } } +/// Select the safest available backend for a specific mountpoint. +pub fn select_backend_for_mount(mountpoint: &Path) -> FuseBackendSelection { + if cfg!(target_os = "macos") { + return select_backend_for_mount_with( + FuseCapabilities { + kernel_loaded: kernel_backend_loaded(), + fskit_approved: fskit_backend_approved(), + }, + mountpoint, + ); + } + FuseBackendSelection { backend: select_backend(), diagnostic: None } +} + fn fskit_backend_approved() -> bool { #[cfg(target_os = "macos")] { - std::env::var("SHARECLI_FUSE_FSKIT_APPROVED") + let mfmount_available = + Path::new("/Library/Filesystems/macfuse.fs/Contents/Frameworks/MFMount.framework") + .is_dir(); + let explicitly_approved = std::env::var("SHARECLI_FUSE_FSKIT_APPROVED") .map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) - .unwrap_or(false) + .unwrap_or(false); + mfmount_available && explicitly_approved } #[cfg(not(target_os = "macos"))] { @@ -83,13 +156,6 @@ fn kernel_backend_loaded() -> bool { mod tests { use super::*; - #[test] - fn invalid_override_degrades_closed() { - std::env::set_var("SHARECLI_FUSE_BACKEND", "invalid"); - assert_eq!(select_backend(), FuseBackend::Unavailable); - std::env::remove_var("SHARECLI_FUSE_BACKEND"); - } - #[test] fn backend_selection_is_kext_first_then_approved_fskit() { assert_eq!( @@ -102,4 +168,48 @@ mod tests { ); assert_eq!(select_backend_with(FuseCapabilities::default()), FuseBackend::Unavailable); } + + #[test] + fn approved_fskit_outside_volumes_fails_open_with_a_specific_diagnostic() { + let selection = select_backend_for_mount_with( + FuseCapabilities { kernel_loaded: false, fskit_approved: true }, + std::path::Path::new("/tmp/sharecli-fuse"), + ); + + assert_eq!(selection.backend, FuseBackend::Unavailable); + assert_eq!(selection.diagnostic, Some(FuseBackendDiagnostic::FskitRequiresVolumes)); + } + + #[test] + fn approved_fskit_under_volumes_is_selected() { + let selection = select_backend_for_mount_with( + FuseCapabilities { kernel_loaded: false, fskit_approved: true }, + std::path::Path::new("/Volumes/sharecli-fuse"), + ); + + assert_eq!(selection.backend, FuseBackend::Fskit); + assert_eq!(selection.diagnostic, None); + } + + #[test] + fn loaded_kernel_remains_first_choice_outside_volumes() { + let selection = select_backend_for_mount_with( + FuseCapabilities { kernel_loaded: true, fskit_approved: true }, + std::path::Path::new("/tmp/sharecli-fuse"), + ); + + assert_eq!(selection.backend, FuseBackend::Kernel); + assert_eq!(selection.diagnostic, None); + } + + #[test] + fn no_verified_backend_has_a_fail_open_diagnostic() { + let selection = select_backend_for_mount_with( + FuseCapabilities::default(), + std::path::Path::new("/Volumes/sharecli-fuse"), + ); + + assert_eq!(selection.backend, FuseBackend::Unavailable); + assert_eq!(selection.diagnostic, Some(FuseBackendDiagnostic::NoVerifiedBackend)); + } } diff --git a/crates/sharecli-fuse/src/lib.rs b/crates/sharecli-fuse/src/lib.rs index 46c8e7b0..09331244 100644 --- a/crates/sharecli-fuse/src/lib.rs +++ b/crates/sharecli-fuse/src/lib.rs @@ -46,8 +46,10 @@ mod write_serialize_meters; pub use agent_cow::{AgentCowStore, AgentPending}; pub use agents_conf::{sanitize_agent_id, AgentsConf}; -pub use backend::{select_backend, FuseBackend}; -pub use backend::{select_backend_with, FuseCapabilities}; +pub use backend::{ + select_backend, select_backend_for_mount, select_backend_for_mount_with, select_backend_with, + FuseBackend, FuseBackendDiagnostic, FuseBackendSelection, FuseCapabilities, +}; #[cfg(any(target_os = "linux", target_os = "macos", windows))] pub use cow_session::CowMountHandle; pub use inode_map::{abs_under, join_rel, InodeMap, ROOT_INO}; @@ -982,7 +984,7 @@ mod platform { // Callers and FuseGuard Drop force-unmount explicitly. #[cfg(target_os = "macos")] { - use crate::{select_backend, FuseBackend}; + use crate::{select_backend_for_mount, FuseBackend}; let attempt = |backend: Option| { let fs = InterceptFs::with_session(backing, session_id); @@ -990,11 +992,16 @@ mod platform { fuser::mount(fs, mountpoint, &config) }; - match select_backend() { + let selection = select_backend_for_mount(mountpoint); + match selection.backend { FuseBackend::Kernel => attempt(Some(FuseBackend::Kernel)), FuseBackend::Fskit => attempt(Some(FuseBackend::Fskit)), FuseBackend::Unavailable => { - anyhow::bail!("FUSE unavailable; continuing without filesystem interception") + let diagnostic = + selection.diagnostic.map(|diagnostic| diagnostic.message()).unwrap_or( + "macFUSE unavailable; continuing without filesystem interception", + ); + anyhow::bail!("{diagnostic}") } }?; Ok(()) diff --git a/crates/sharecli-fuse/src/session_registry.rs b/crates/sharecli-fuse/src/session_registry.rs index 73397dcb..9b50885c 100644 --- a/crates/sharecli-fuse/src/session_registry.rs +++ b/crates/sharecli-fuse/src/session_registry.rs @@ -33,6 +33,16 @@ use crate::InterceptFsOptions; /// [`crate::mount_smoke::force_unmount`] / session Drop already call `umount`. #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn default_fuser_config() -> Config { + default_fuser_config_for_backend(None) +} + +/// Default [`fuser`] mount configuration for a selected macOS backend. +/// +/// `backend=fskit` is intentionally emitted only when the caller has selected +/// [`FuseBackend::Fskit`]. The KEXT and unavailable selections must retain the +/// default macFUSE behavior rather than accidentally requesting FSKit. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub fn default_fuser_config_for_backend(backend: Option) -> Config { let mut config = Config::default(); config.mount_options = vec![MountOption::FSName("sharecli-fuse".to_string())]; #[cfg(target_os = "linux")] @@ -40,6 +50,10 @@ pub fn default_fuser_config() -> Config { config.mount_options.push(MountOption::AutoUnmount); config.acl = SessionACL::RootAndOwner; } + #[cfg(target_os = "macos")] + if matches!(backend, Some(FuseBackend::Fskit)) { + config.mount_options.push(MountOption::CUSTOM("backend=fskit".to_string())); + } config } @@ -263,6 +277,19 @@ impl FuseSessionRegistry { let intercept = opts.to_intercept_options(); let fs = Arc::new(InterceptFs::with_options(backing, intercept)); let session_id = fs.session_id().to_string(); + #[cfg(target_os = "macos")] + let config = { + let selection = crate::select_backend_for_mount(mountpoint); + if selection.backend == FuseBackend::Unavailable { + let diagnostic = selection + .diagnostic + .map(|diagnostic| diagnostic.message()) + .unwrap_or("macFUSE unavailable; continuing without filesystem interception"); + anyhow::bail!("fuse mount: {diagnostic}"); + } + default_fuser_config_for_backend(Some(selection.backend)) + }; + #[cfg(target_os = "linux")] let config = default_fuser_config(); if background { @@ -595,7 +622,8 @@ impl MountContext for std::io::Result<()> { #[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(test)] mod default_mount_options_tests { - use super::default_fuser_config; + use super::{default_fuser_config, default_fuser_config_for_backend}; + use crate::FuseBackend; use fuser::{MountOption, SessionACL}; #[test] @@ -610,6 +638,25 @@ mod default_mount_options_tests { ); } + #[cfg(target_os = "macos")] + #[test] + fn backend_option_is_emitted_only_for_an_actual_fskit_selection() { + let fskit = default_fuser_config_for_backend(Some(FuseBackend::Fskit)); + assert!(fskit.mount_options.iter().any( + |option| matches!(option, MountOption::CUSTOM(value) if value == "backend=fskit") + )); + + for backend in [None, Some(FuseBackend::Kernel), Some(FuseBackend::Unavailable)] { + let config = default_fuser_config_for_backend(backend); + assert!( + !config.mount_options.iter().any( + |option| matches!(option, MountOption::CUSTOM(value) if value == "backend=fskit") + ), + "backend=fskit must be emitted only for the selected FSKit backend" + ); + } + } + #[cfg(target_os = "linux")] #[test] fn linux_auto_unmount_pairs_with_non_owner_acl() { From 2706e5166690d624034a7279f2c9b15b8140772d Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Fri, 31 Jul 2026 18:34:54 -0700 Subject: [PATCH 09/18] feat(sharecli): expose layout and surface controls --- crates/sharecli-ipc/src/handler.rs | 22 ++- crates/sharecli-ipc/tests/handler_dispatch.rs | 46 ++++++ crates/sharecli-session/src/rpc.rs | 18 ++- .../00_SESSION_OVERVIEW.md | 8 +- .../01_RESEARCH.md | 18 ++- .../02_SPECIFICATIONS.md | 8 ++ .../03_DAG_WBS.md | 6 +- .../04_IMPLEMENTATION_STRATEGY.md | 7 +- .../05_KNOWN_ISSUES.md | 5 +- .../06_TESTING_STRATEGY.md | 18 +-- src/main.rs | 132 +++++++++++++++++- tests/session_cli.rs | 31 ++++ 12 files changed, 292 insertions(+), 27 deletions(-) diff --git a/crates/sharecli-ipc/src/handler.rs b/crates/sharecli-ipc/src/handler.rs index 6e859e9d..e7a75b24 100644 --- a/crates/sharecli-ipc/src/handler.rs +++ b/crates/sharecli-ipc/src/handler.rs @@ -27,7 +27,7 @@ use sharecli_fleet::{ count_host_agents, gate_status_snapshot, global_coalesce_meters, global_slot_queue_meters, CoalesceMeters, GateStatusSnapshot, SlotQueueMeters, }; -use sharecli_session::{RecoveryExecutor, SessionObservation, SessionStore}; +use sharecli_session::{LayoutSnapshot, RecoveryExecutor, SessionObservation, SessionStore}; use tokio::sync::RwLock; // --------------------------------------------------------------------------- @@ -422,6 +422,26 @@ impl Handler { Ok(serde_json::json!({"removed": self.sessions.compact_observations()?})) } + "layout.list" => Ok(serde_json::to_value(self.sessions.list_layouts()?)?), + + "layout.inspect" => { + let id = req + .params + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("layout.inspect: missing id"))?; + Ok(serde_json::to_value(self.sessions.get_layout(id)?)?) + } + + "layout.save" => { + let snapshot: LayoutSnapshot = serde_json::from_value( + req.params.get("snapshot").cloned().unwrap_or_else(|| req.params.clone()), + )?; + let id = snapshot.id.clone(); + self.sessions.save_layout(&snapshot)?; + Ok(serde_json::json!({"id": id})) + } + "recovery.plan" => Ok(serde_json::to_value(self.sessions.list()?)?), "recovery.execute" => { diff --git a/crates/sharecli-ipc/tests/handler_dispatch.rs b/crates/sharecli-ipc/tests/handler_dispatch.rs index 5c3db237..e6639fbe 100644 --- a/crates/sharecli-ipc/tests/handler_dispatch.rs +++ b/crates/sharecli-ipc/tests/handler_dispatch.rs @@ -166,3 +166,49 @@ async fn fr003_ipc_handler_session_recovery_methods_are_dry_run_safe() { assert!(compact.error.is_none(), "session.compact error: {:?}", compact.error); assert!(compact.result.get("removed").is_some()); } + +#[tokio::test] +async fn fr003_ipc_handler_persists_and_lists_validated_layouts() { + let handler = Handler::new().await.expect("handler init"); + let snapshot = serde_json::json!({ + "id": "ipc-layout", + "terminal": "ghostty", + "captured_at": "2026-08-01T00:00:00Z", + "root": { + "Split": { + "axis": "Horizontal", + "ratio_millis": 500, + "children": [ + {"Pane": {"surface_id": "ghostty:1"}}, + {"Pane": {"surface_id": "ghostty:2"}} + ] + } + } + }); + + let save = handler + .dispatch( + &serde_json::json!({ + "id": 21, + "method": "layout.save", + "params": {"snapshot": snapshot} + }) + .to_string(), + ) + .await; + assert!(save.error.is_none(), "layout.save error: {:?}", save.error); + assert_eq!(save.result["id"], "ipc-layout"); + + let list = handler.dispatch(r#"{"id":22,"method":"layout.list","params":{}}"#).await; + assert!(list.error.is_none(), "layout.list error: {:?}", list.error); + assert!(list + .result + .as_array() + .is_some_and(|rows| rows.iter().any(|row| row["id"] == "ipc-layout"))); + + let inspect = handler + .dispatch(r#"{"id":23,"method":"layout.inspect","params":{"id":"ipc-layout"}}"#) + .await; + assert!(inspect.error.is_none(), "layout.inspect error: {:?}", inspect.error); + assert_eq!(inspect.result["id"], "ipc-layout"); +} diff --git a/crates/sharecli-session/src/rpc.rs b/crates/sharecli-session/src/rpc.rs index c9cb35e5..64d6f5b8 100644 --- a/crates/sharecli-session/src/rpc.rs +++ b/crates/sharecli-session/src/rpc.rs @@ -1,4 +1,4 @@ -use crate::{SessionService, SurfaceCapabilities}; +use crate::{SessionService, SurfaceCapabilities, SurfaceRecord}; use anyhow::Result; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -23,6 +23,10 @@ pub struct Response { /// Shell-free operations exposed by the surface control socket. pub trait SurfaceControl: Send + Sync { + /// Enumerate stable terminal surface identities. + fn list(&self) -> Result> { + anyhow::bail!("surface discovery unavailable") + } fn send(&self, surface_id: &str, bytes: &[u8]) -> Result<()>; fn read(&self, surface_id: &str, max_bytes: usize) -> Result>; fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()>; @@ -149,6 +153,10 @@ fn dispatch_surface_method( params: Value, ) -> std::result::Result { match method { + "surface.list" => { + let surfaces = control.list().map_err(control_error)?; + serde_json::to_value(surfaces).map_err(control_error) + } "surface.io.send" => { let params: SendParams = decode_params(params)?; let bytes = match (params.text, params.bytes) { @@ -350,6 +358,14 @@ mod tests { ); } + #[tokio::test] + async fn surface_list_reports_degraded_discovery_without_a_native_adapter() { + let control = Arc::new(RecordingControl::default()); + let response = request(&control, 7, "surface.list", json!({})).await; + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32000); + } + #[tokio::test] async fn surface_send_accepts_an_explicit_byte_vector() { let control = Arc::new(RecordingControl::default()); diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md index dea8d8f4..6b7bd365 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md @@ -13,8 +13,12 @@ Success criteria for this slice: - ambiguous process evidence never becomes an unattended resume recipe; - recovery uses argv (never a shell) and bounded non-blocking launches; - IPC and CLI expose observation/recovery operations; +- IPC and CLI expose validated layout persistence and surface I/O operations; - Ghostty I/O is capability-gated and authenticated when a control socket exists; - FUSE selection is KEXT first, approved FSKit second, then fail-open. -Not completed in this slice: a Ghostty fork/server, native pane enumeration, -continuous subscriptions, layout restore, and macOS mount dogfood. +Not completed in this slice: the Ghostty app-side socket implementation, +continuous subscriptions, and macOS mount/crash dogfood. ShareCLI now exposes +the native-side contract, including a degraded `surface.list` result when no +adapter is installed; native pane enumeration and PTY readback still require +the Ghostty integration gate. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md index fe053da9..ab592db7 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md @@ -2,14 +2,18 @@ ## Ghostty -- Official AppleScript controls windows, tabs, splits, and input but does not - provide a supported external pane readback stream: - https://ghostty.org/docs/features/applescript -- Upstream discussion considers scoped platform IPC and calls out security - implications of screen readback: +- The installed Ghostty 1.3.1 binary exposes no documented external control + socket. The upstream macOS scripting dictionary does expose window/tab/ + terminal identity, working directory, PID, TTY, split/focus/close, and input + actions. AppleScript is therefore a useful degraded discovery/control path, + but it is TCC-gated and does not provide a supported PTY/screen readback + stream: https://ghostty.org/docs/features/applescript +- Upstream discussion favors narrowly scoped platform IPC and calls out the + security implications of screen readback: https://github.com/ghostty-org/ghostty/discussions/2353 -- Therefore ShareCLI implements a transport-neutral Unix JSON-RPC client and - advertises native readback only when that socket is explicitly configured. +- ShareCLI consequently keeps a transport-neutral Unix JSON-RPC client and + capability-gates readback. A Ghostty-side socket/fork is still required for + AppleScriptless live PTY I/O and atomic layout operations. ## FUSE diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md index 640390b3..e9bc42b9 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md @@ -19,3 +19,11 @@ explicit opt-in; the default CLI/IPC path is dry-run. Read, write, resize, layout, and durable-PTY claims are independent fields. Missing capability is an explicit degraded result, never inferred from an empty output buffer. + +## Surface control protocol + +`sharecli-session::rpc::serve_surface_unix` serves newline-delimited JSON-RPC +2.0. `surface.io.send` accepts exactly one UTF-8 `text` or byte-vector +payload, `surface.io.read` is capped at 1 MiB, and `surface.io.resize` rejects +zero dimensions. `surface.list` is a typed discovery hook; the default +adapter returns an explicit degraded error instead of an empty inventory. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md index 6d888770..68c846a8 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md @@ -16,6 +16,6 @@ P3 Ghostty control client -----+--> P5 live I/O contract tests +--> P8 macOS dogfood + chaos gate ``` -Completed here: P0-P5's ShareCLI-side contracts and tests. P6-P8 remain open -because they require a Ghostty-side implementation and real macOS permission, -mount, crash, and restart validation. +Completed here: P0-P5's ShareCLI-side contracts, CLI/IPC integrations, and +tests. P6-P8 remain open because they require a Ghostty-side implementation +and real macOS permission, mount, crash, and restart validation. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md index 89351aa4..c70db159 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md @@ -5,8 +5,11 @@ - Use SQLite WAL plus `BEGIN IMMEDIATE` for serialized append/materialization; compact only the observation history, never the materialized session rows. - Keep Ghostty transport behind a small Unix JSON-RPC client with an optional - bearer-like token. AppleScript remains a degraded control path, not a source - of readback truth. + bearer-like token. AppleScript may supply identity and input fallback, but + it is explicitly degraded and never treated as PTY readback truth. +- Keep layout persistence in ShareCLI and make the Ghostty adapter responsible + only for applying a validated snapshot. This permits recovery when Ghostty + is unavailable and avoids coupling the ledger to an app-specific tree API. - Use a bounded batch executor and `spawn`, so one long-running agent cannot block recovery of all other panes. - Treat FUSE as an optimization/observation aid. Session persistence and diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md index 73c1b81f..3d62be7d 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md @@ -1,7 +1,10 @@ # Known Issues and Open Gates - `GhosttySurfaceAdapter::discover` is intentionally a capability contract; - stock Ghostty has no proven external pane enumeration API in this checkout. + stock Ghostty's AppleScript dictionary can enumerate terminal identity, + cwd, PID, and TTY, but the installed app has no documented external socket + for PTY/screen readback. Native layout and readback still require the + Ghostty-side integration gate. - `SHARECLI_FUSE_FSKIT_APPROVED` is a conservative approval input, not a full MFMount entitlement probe. macOS install/approval and mount smoke are still required before enabling it by default. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md index ad9bd934..18b48c4b 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -2,15 +2,17 @@ Verified in this worktree: -- `cargo check --workspace --locked` -- `cargo test -p sharecli-session --locked` (14 passed) -- `cargo test -p sharecli-fuse backend::tests --locked` (2 passed) -- `cargo test -p sharecli --test session --locked` (5 passed) -- `cargo test -p sharecli --test session_cli --locked` (1 passed) -- `cargo test -p sharecli-ipc --locked --test handler_dispatch` (9 passed) +- `cargo test -p sharecli-session --locked --offline` (22 unit + 3 layout integration passed) +- `cargo test -p sharecli-fuse --locked --offline` (36 unit + 11 integration passed) +- `cargo test -p sharecli-ipc --locked --test handler_dispatch` (10 passed, + including layout save/list/inspect) +- `cargo test -p sharecli --locked --test session_cli` (2 passed, including + layout save/list) - `cargo fmt --all -- --check` - `git diff --check` Required before claiming full feature completion: Ghostty fork/socket -integration, native pane discovery, layout restore, macOS FUSE KEXT/FSKit -mount smoke, crash/restart chaos, and a clean installed-dogfood run. +integration, native pane discovery/layout application, macOS FUSE KEXT/FSKit +mount smoke, crash/restart chaos, and a clean installed-dogfood run. The +ShareCLI-side layout and surface-control unit tests must be added to this list +after the parallel lanes land. diff --git a/src/main.rs b/src/main.rs index a046cea5..69b14c9c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,10 +3,13 @@ mod alloc; mod plugins; use crate::error::SharecliError; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::Shell; -use sharecli_session::{RecoveryExecutor, SessionObservation, SessionService, SessionStore}; +use sharecli::session::GhosttyControlClient; +use sharecli_session::{ + LayoutSnapshot, RecoveryExecutor, SessionObservation, SessionService, SessionStore, +}; use sharecli_thermal_tui as thermal_tui; mod apfs_uuid; @@ -94,6 +97,11 @@ enum Commands { #[command(subcommand)] cmd: SessionCmd, }, + /// Send shell-free I/O requests to a ShareCLI-enabled Ghostty surface + Surface { + #[command(subcommand)] + cmd: SurfaceCmd, + }, /// List managed processes Ps { /// Filter by project name @@ -510,6 +518,65 @@ enum SessionCmd { #[arg(long)] db: Option, }, + /// List durable terminal layout snapshots + LayoutList { + #[arg(long)] + db: Option, + }, + /// Inspect one durable terminal layout snapshot + LayoutInspect { + id: String, + #[arg(long)] + db: Option, + }, + /// Persist a validated terminal layout snapshot from JSON + LayoutSave { + /// JSON file containing a LayoutSnapshot + input: std::path::PathBuf, + #[arg(long)] + db: Option, + }, +} + +#[derive(Subcommand, Debug)] +enum SurfaceCmd { + /// Write bytes or UTF-8 text to one terminal surface + Send { + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long)] + surface_id: String, + #[arg(long, conflicts_with = "bytes")] + text: Option, + #[arg(long, conflicts_with = "text")] + bytes: Option, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, + /// Read a bounded output snapshot from one terminal surface + Read { + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long)] + surface_id: String, + #[arg(long, default_value_t = 4096)] + max_bytes: usize, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, + /// Resize one terminal surface + Resize { + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long)] + surface_id: String, + #[arg(long)] + rows: u16, + #[arg(long)] + cols: u16, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, } #[derive(Subcommand, Debug)] @@ -796,6 +863,7 @@ async fn run() -> Result<()> { match &cli.command { Commands::Session { cmd } => session_cmd(cmd)?, + Commands::Surface { cmd } => surface_cmd(cmd)?, Commands::Ps { project, harness, all, json, csv, watch } => { ps(project.as_deref(), harness.as_deref(), *all, *json, *csv, *watch).await? } @@ -970,6 +1038,9 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { SessionCmd::Observations { db, .. } => (db.clone(), None), SessionCmd::Compact { db } => (db.clone(), None), SessionCmd::Recover { db, .. } => (db.clone(), None), + SessionCmd::LayoutList { db } => (db.clone(), None), + SessionCmd::LayoutInspect { id, db } => (db.clone(), Some(id.as_str())), + SessionCmd::LayoutSave { db, .. } => (db.clone(), None), }; let path = db.unwrap_or_else(|| { dirs::data_local_dir() @@ -988,6 +1059,20 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { ); return Ok(()); } + if let SessionCmd::LayoutSave { input, .. } = cmd { + let snapshot: LayoutSnapshot = serde_json::from_str(&std::fs::read_to_string(input)?)?; + store.save_layout(&snapshot)?; + println!("{}", serde_json::json!({"id": snapshot.id})); + return Ok(()); + } + if let SessionCmd::LayoutList { .. } = cmd { + println!("{}", serde_json::to_string_pretty(&store.list_layouts()?)?); + return Ok(()); + } + if let SessionCmd::LayoutInspect { id, .. } = cmd { + println!("{}", serde_json::to_string_pretty(&store.get_layout(id)?)?); + return Ok(()); + } let service = SessionService::new(store); let value = match cmd { SessionCmd::List { .. } => serde_json::to_value(service.list()?)?, @@ -1009,11 +1094,54 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { serde_json::to_value(results)? } SessionCmd::Observe { .. } => unreachable!("observe handled before service dispatch"), + SessionCmd::LayoutSave { .. } => { + unreachable!("layout save handled before service dispatch") + } + SessionCmd::LayoutList { .. } | SessionCmd::LayoutInspect { .. } => { + unreachable!("layout operation handled before service dispatch") + } }; println!("{}", serde_json::to_string_pretty(&value)?); Ok(()) } +fn surface_cmd(cmd: &SurfaceCmd) -> Result<()> { + match cmd { + SurfaceCmd::Send { socket, surface_id, text, bytes, token } => { + let client = GhosttyControlClient::new(socket, token.clone()); + if let Some(text) = text { + client.send_text(surface_id, text)?; + } else if let Some(bytes) = bytes { + let bytes = bytes + .split(',') + .map(|value| value.trim().parse::()) + .collect::, _>>() + .context("--bytes must be a comma-separated list of u8 values")?; + client.request( + "surface.io.send", + serde_json::json!({"surface_id": surface_id, "bytes": bytes}), + )?; + } else { + anyhow::bail!("surface send requires --text or --bytes"); + } + println!("{{\"ok\":true}}"); + } + SurfaceCmd::Read { socket, surface_id, max_bytes, token } => { + let client = GhosttyControlClient::new(socket, token.clone()); + println!( + "{}", + serde_json::to_string_pretty(&client.read_surface(surface_id, *max_bytes)?)? + ); + } + SurfaceCmd::Resize { socket, surface_id, rows, cols, token } => { + let client = GhosttyControlClient::new(socket, token.clone()); + client.resize(surface_id, *rows, *cols)?; + println!("{{\"ok\":true}}"); + } + } + Ok(()) +} + /// `sharecli man` — emit sharecli(1) via clap_mangen (C09 L81.13). fn cli_man(install: bool) -> Result<()> { use clap::CommandFactory; diff --git a/tests/session_cli.rs b/tests/session_cli.rs index 22bb0fb3..6193b9db 100644 --- a/tests/session_cli.rs +++ b/tests/session_cli.rs @@ -16,3 +16,34 @@ fn session_list_accepts_explicit_database() { assert!(out.status.success()); assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "[]"); } + +#[test] +fn session_layout_save_and_list_accept_explicit_database() { + let dir = TempDir::new().expect("tempdir"); + let db = dir.path().join("sessions.sqlite"); + let snapshot = dir.path().join("layout.json"); + std::fs::write( + &snapshot, + r#"{"id":"daily","terminal":"ghostty","captured_at":"2026-08-01T00:00:00Z","root":{"Pane":{"surface_id":"ghostty:1"}}}"#, + ) + .expect("write layout"); + + let save = bin() + .args([ + "session", + "layout-save", + snapshot.to_str().expect("utf8 path"), + "--db", + db.to_str().expect("utf8 path"), + ]) + .output() + .expect("spawn layout save"); + assert!(save.status.success(), "layout save stderr: {}", String::from_utf8_lossy(&save.stderr)); + + let list = bin() + .args(["session", "layout-list", "--db", db.to_str().expect("utf8 path")]) + .output() + .expect("spawn layout list"); + assert!(list.status.success(), "layout list stderr: {}", String::from_utf8_lossy(&list.stderr)); + assert!(String::from_utf8_lossy(&list.stdout).contains("daily")); +} From 137c75059bdedaa6d8c8408a31497cd172f7ee03 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Fri, 31 Jul 2026 18:58:49 -0700 Subject: [PATCH 10/18] docs(sharecli): record workspace verification --- .../06_TESTING_STRATEGY.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md index 18b48c4b..3c710cb7 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -2,6 +2,8 @@ Verified in this worktree: +- `CARGO_BUILD_JOBS=2 cargo check --workspace --locked --offline` (pass; + existing workspace warnings only) - `cargo test -p sharecli-session --locked --offline` (22 unit + 3 layout integration passed) - `cargo test -p sharecli-fuse --locked --offline` (36 unit + 11 integration passed) - `cargo test -p sharecli-ipc --locked --test handler_dispatch` (10 passed, @@ -14,5 +16,5 @@ Verified in this worktree: Required before claiming full feature completion: Ghostty fork/socket integration, native pane discovery/layout application, macOS FUSE KEXT/FSKit mount smoke, crash/restart chaos, and a clean installed-dogfood run. The -ShareCLI-side layout and surface-control unit tests must be added to this list -after the parallel lanes land. +ShareCLI-side layout and surface-control unit tests are covered above; the +remaining gates are app-side/runtime evidence. From 778eae1184658c02658136908df29b27fd9d9106 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 1 Aug 2026 02:51:37 -0700 Subject: [PATCH 11/18] feat(sharecli): add native surface discovery and runtime probes --- Cargo.lock | 2 + contrib/ghostty-control/.gitignore | 1 + contrib/ghostty-control/Package.swift | 14 ++ contrib/ghostty-control/README.md | 33 +++ .../ShareCLIGhosttyControl/Control.swift | 234 ++++++++++++++++++ .../ControlTests.swift | 60 +++++ crates/sharecli-fuse/Cargo.toml | 7 + .../src/bin/fuse-runtime-probe.rs | 83 +++++++ crates/sharecli-session/src/discovery.rs | 202 +++++++++++++++ crates/sharecli-session/src/lib.rs | 5 + crates/sharecli-session/src/rpc.rs | 58 +++-- crates/sharecli-session/tests/discovery.rs | 173 +++++++++++++ docs/session-recovery.md | 21 +- .../04_IMPLEMENTATION_STRATEGY.md | 14 +- .../05_KNOWN_ISSUES.md | 6 + .../06_TESTING_STRATEGY.md | 8 +- src/main.rs | 54 +++- src/session.rs | 21 ++ tests/session.rs | 30 +++ tests/session_cli.rs | 23 ++ 20 files changed, 1019 insertions(+), 30 deletions(-) create mode 100644 contrib/ghostty-control/.gitignore create mode 100644 contrib/ghostty-control/Package.swift create mode 100644 contrib/ghostty-control/README.md create mode 100644 contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift create mode 100644 contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift create mode 100644 crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs create mode 100644 crates/sharecli-session/src/discovery.rs create mode 100644 crates/sharecli-session/tests/discovery.rs diff --git a/Cargo.lock b/Cargo.lock index 55a38588..d01d203b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4581,6 +4581,8 @@ dependencies = [ "anyhow", "fuser", "libc", + "serde", + "serde_json", "serial_test", "tempfile", "thiserror 2.0.19", diff --git a/contrib/ghostty-control/.gitignore b/contrib/ghostty-control/.gitignore new file mode 100644 index 00000000..30bcfa4e --- /dev/null +++ b/contrib/ghostty-control/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/contrib/ghostty-control/Package.swift b/contrib/ghostty-control/Package.swift new file mode 100644 index 00000000..eab517b1 --- /dev/null +++ b/contrib/ghostty-control/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "ShareCLIGhosttyControl", + platforms: [.macOS(.v13)], + products: [ + .library(name: "ShareCLIGhosttyControl", targets: ["ShareCLIGhosttyControl"]), + ], + targets: [ + .target(name: "ShareCLIGhosttyControl"), + .testTarget(name: "ShareCLIGhosttyControlTests", dependencies: ["ShareCLIGhosttyControl"]), + ] +) diff --git a/contrib/ghostty-control/README.md b/contrib/ghostty-control/README.md new file mode 100644 index 00000000..eff9ef22 --- /dev/null +++ b/contrib/ghostty-control/README.md @@ -0,0 +1,33 @@ +# ShareCLI Ghostty control bridge + +This Swift package is the tested JSON-RPC dispatcher and `SurfaceProvider` +contract for ShareCLI's native Ghostty integration. It is intentionally a +small, app-side bridge: the dispatcher validates bounded requests and routes +typed surface operations; it never evaluates shell text or owns a PTY by +itself. + +## Ghostty fork integration + +The Ghostty app/fork must provide a concrete `SurfaceProvider` backed by its +surface and PTY objects, then own the local Unix-domain listener that feeds +newline-delimited requests to `ControlDispatcher`. The provider must expose +stable surface IDs, process evidence, live read/write/resize operations, and +capability reporting. Keep all app and PTY references on that side of the +boundary. + +The listener is expected to be local-only and owner-readable/writable +(filesystem mode `0600`). When a control token is configured, pass it to +`ControlDispatcher(provider:expectedToken:)`; every request must then include +the matching top-level `token`. ShareCLI should treat missing sockets, +unsupported capabilities, and provider errors as explicit degraded states, +never as permission to execute an untrusted command. + +## Build and test + +```sh +swift test --filter ShareCLIGhosttyControlTests +``` + +This package is a fork-ready binding contract and dispatcher, not a patched +Ghostty application. The concrete `SurfaceProvider`, Unix listener, and +Ghostty app lifecycle wiring remain an integration task in the Ghostty fork. diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift new file mode 100644 index 00000000..bfbc0547 --- /dev/null +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift @@ -0,0 +1,234 @@ +import Foundation + +/// Process metadata supplied by the Ghostty app-side adapter. +public struct ProcessEvidence: Codable, Equatable, Sendable { + public let pid: UInt32? + public let tty: String? + public let cwd: String + public let argv: [String] + public let startedAt: String? + + public init(pid: UInt32?, tty: String?, cwd: String, argv: [String], startedAt: String? = nil) { + self.pid = pid + self.tty = tty + self.cwd = cwd + self.argv = argv + self.startedAt = startedAt + } + + enum CodingKeys: String, CodingKey { + case pid, tty, cwd, argv + case startedAt = "started_at" + } +} + +/// Stable identity and process evidence for one Ghostty split/pane surface. +public struct SurfaceRecord: Codable, Equatable, Sendable { + public let id: String + public let terminal: String + public let title: String? + public let cwd: String + public let process: ProcessEvidence? + + public init(id: String, terminal: String = "ghostty", title: String?, cwd: String, process: ProcessEvidence?) { + self.id = id + self.terminal = terminal + self.title = title + self.cwd = cwd + self.process = process + } +} + +/// I/O and durability capabilities reported for one surface. +public struct SurfaceCapabilities: Codable, Equatable, Sendable { + public let read: Bool + public let write: Bool + public let resize: Bool + public let layout: Bool + public let durablePty: Bool + + public init(read: Bool, write: Bool, resize: Bool, layout: Bool, durablePty: Bool) { + self.read = read + self.write = write + self.resize = resize + self.layout = layout + self.durablePty = durablePty + } + + enum CodingKeys: String, CodingKey { + case read, write, resize, layout + case durablePty = "durable_pty" + } +} + +/// Provider implemented by the Ghostty app-side binding. +/// +/// The provider owns all app/PTY references. The dispatcher only validates +/// requests and transports typed values; it never executes shell text. +public protocol SurfaceProvider: Sendable { + func listSurfaces() throws -> [SurfaceRecord] + func send(surfaceID: String, bytes: [UInt8]) throws + func read(surfaceID: String, maxBytes: Int) throws -> [UInt8] + func resize(surfaceID: String, rows: UInt16, cols: UInt16) throws + func capabilities(surfaceID: String) throws -> SurfaceCapabilities +} + +public enum ControlError: Error, Equatable, Sendable { + case invalidRequest(String) + case invalidParams(String) + case methodNotFound(String) + case unauthorized + case provider(String) + case requestTooLarge +} + +extension ControlError { + var code: Int { + switch self { + case .invalidRequest: return -32600 + case .invalidParams: return -32602 + case .methodNotFound: return -32601 + case .unauthorized: return -32001 + case .provider: return -32000 + case .requestTooLarge: return -32600 + } + } + + var message: String { + switch self { + case let .invalidRequest(message), let .invalidParams(message), let .methodNotFound(message), let .provider(message): + return message + case .unauthorized: + return "invalid control token" + case .requestTooLarge: + return "request exceeds 1 MiB limit" + } + } +} + +/// Newline-delimited JSON-RPC dispatcher used by a native Ghostty socket. +public struct ControlDispatcher: Sendable { + public static let maxRequestBytes = 1024 * 1024 + public static let maxReadBytes = 1024 * 1024 + + private let provider: any SurfaceProvider + private let expectedToken: String? + + public init(provider: any SurfaceProvider, expectedToken: String? = nil) { + self.provider = provider + self.expectedToken = expectedToken + } + + /// Dispatch one complete JSON request and return one JSON response line. + public func dispatch(_ line: Data) -> Data { + do { + guard line.count <= Self.maxRequestBytes else { throw ControlError.requestTooLarge } + guard let object = try JSONSerialization.jsonObject(with: line) as? [String: Any] else { + throw ControlError.invalidRequest("request must be a JSON object") + } + let id = object["id"] ?? NSNull() + guard object["jsonrpc"] as? String == "2.0" else { + throw ControlError.invalidRequest("jsonrpc must be \"2.0\"") + } + if let expectedToken { + guard object["token"] as? String == expectedToken else { throw ControlError.unauthorized } + } + guard let method = object["method"] as? String, !method.isEmpty else { + throw ControlError.invalidRequest("method is required") + } + let params = object["params"] as? [String: Any] ?? [:] + let result = try dispatch(method: method, params: params) + return encode(["jsonrpc": "2.0", "id": id, "result": result]) + } catch let error as ControlError { + return encode(["jsonrpc": "2.0", "id": requestID(from: line), "error": ["code": error.code, "message": error.message]]) + } catch { + return encode(["jsonrpc": "2.0", "id": requestID(from: line), "error": ["code": -32700, "message": "parse error: \(error)"]]) + } + } + + private func dispatch(method: String, params: [String: Any]) throws -> Any { + switch method { + case "surface.list": + return try jsonObject(provider.listSurfaces()) + case "surface.io.send": + let surfaceID = try stringParam(params, "surface_id") + let text = params["text"] as? String + let bytes = params["bytes"] as? [Any] + guard (text != nil) != (bytes != nil) else { + throw ControlError.invalidParams("exactly one of params.text or params.bytes is required") + } + let payload = try text.map { Array($0.utf8) } ?? bytesToUInt8(bytes!) + try provider.send(surfaceID: surfaceID, bytes: payload) + return NSNull() + case "surface.io.read": + let surfaceID = try stringParam(params, "surface_id") + let maxBytes = try intParam(params, "max_bytes") + guard maxBytes >= 0 && maxBytes <= Self.maxReadBytes else { + throw ControlError.invalidParams("max_bytes must be between 0 and 1048576") + } + return ["bytes": try provider.read(surfaceID: surfaceID, maxBytes: maxBytes)] + case "surface.io.resize": + let surfaceID = try stringParam(params, "surface_id") + let rows = try uint16Param(params, "rows") + let cols = try uint16Param(params, "cols") + guard rows > 0 && cols > 0 else { + throw ControlError.invalidParams("rows and cols must be greater than zero") + } + try provider.resize(surfaceID: surfaceID, rows: rows, cols: cols) + return NSNull() + case "surface.io.capabilities": + return try jsonObject(provider.capabilities(surfaceID: stringParam(params, "surface_id"))) + default: + throw ControlError.methodNotFound(method) + } + } + + private func stringParam(_ params: [String: Any], _ name: String) throws -> String { + guard let value = params[name] as? String, !value.isEmpty else { + throw ControlError.invalidParams("params.\(name) is required") + } + return value + } + + private func intParam(_ params: [String: Any], _ name: String) throws -> Int { + guard let value = strictInteger(params[name]) else { + throw ControlError.invalidParams("params.\(name) must be an integer") + } + return value.intValue + } + + private func uint16Param(_ params: [String: Any], _ name: String) throws -> UInt16 { + let value = try intParam(params, name) + guard value >= 0 && value <= Int(UInt16.max) else { throw ControlError.invalidParams("params.\(name) is out of range") } + return UInt16(value) + } + + private func bytesToUInt8(_ values: [Any]) throws -> [UInt8] { + try values.map { value in + guard let number = strictInteger(value), number.intValue >= 0 && number.intValue <= 255 else { + throw ControlError.invalidParams("params.bytes must contain integers from 0 to 255") + } + return UInt8(number.intValue) + } + } + + private func strictInteger(_ value: Any?) -> NSNumber? { + guard let number = value as? NSNumber else { return nil } + let type = String(cString: number.objCType) + guard ["i", "s", "l", "q", "I", "S", "L", "Q"].contains(type) else { return nil } + return number + } + + private func jsonObject(_ value: T) throws -> Any { + let data = try JSONEncoder().encode(value) + return try JSONSerialization.jsonObject(with: data) + } + + private func encode(_ object: [String: Any]) -> Data { + (try? JSONSerialization.data(withJSONObject: object, options: [])) ?? Data("{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32600,\"message\":\"encoding failure\"}}".utf8) + } + + private func requestID(from line: Data) -> Any { + ((try? JSONSerialization.jsonObject(with: line) as? [String: Any])?["id"]) ?? NSNull() + } +} diff --git a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift new file mode 100644 index 00000000..5de97b22 --- /dev/null +++ b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +@testable import ShareCLIGhosttyControl + +private struct FakeProvider: SurfaceProvider { + func listSurfaces() throws -> [SurfaceRecord] { + [SurfaceRecord(id: "ghostty:1", title: "agent", cwd: "/tmp", process: nil)] + } + + func send(surfaceID: String, bytes: [UInt8]) throws {} + func read(surfaceID: String, maxBytes: Int) throws -> [UInt8] { Array("ok".utf8.prefix(maxBytes)) } + func resize(surfaceID: String, rows: UInt16, cols: UInt16) throws {} + func capabilities(surfaceID: String) throws -> SurfaceCapabilities { + SurfaceCapabilities(read: true, write: true, resize: true, layout: false, durablePty: false) + } +} + +@Test func listUsesRustCompatibleSnakeCase() throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + let line = Data(#"{"jsonrpc":"2.0","id":1,"method":"surface.list","params":{}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: dispatcher.dispatch(line)) as? [String: Any]) + let result = try #require(response["result"] as? [[String: Any]]) + #expect(result[0]["id"] as? String == "ghostty:1") +} + +@Test func tokenIsRequiredBeforeProviderAccess() throws { + let dispatcher = ControlDispatcher(provider: FakeProvider(), expectedToken: "secret") + let line = Data(#"{"jsonrpc":"2.0","id":2,"method":"surface.list","params":{}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32001) +} + +@Test func sendRequiresExactlyOnePayload() throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + let line = Data(#"{"jsonrpc":"2.0","id":3,"method":"surface.io.send","params":{"surface_id":"ghostty:1"}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) +} + +@Test func integerInputsRejectBooleansAndFloats() throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + for literal in ["true", "1.5"] { + let line = Data("{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"surface.io.read\",\"params\":{\"surface_id\":\"ghostty:1\",\"max_bytes\":\(literal)}}".utf8) + let response = try #require(JSONSerialization.jsonObject(with: dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) + } +} + +@Test func byteInputsRejectBooleansAndFloats() throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + for literal in ["true", "1.5"] { + let line = Data("{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"surface.io.send\",\"params\":{\"surface_id\":\"ghostty:1\",\"bytes\":[\(literal)]}}".utf8) + let response = try #require(JSONSerialization.jsonObject(with: dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) + } +} diff --git a/crates/sharecli-fuse/Cargo.toml b/crates/sharecli-fuse/Cargo.toml index 6fe96715..a918d23b 100644 --- a/crates/sharecli-fuse/Cargo.toml +++ b/crates/sharecli-fuse/Cargo.toml @@ -10,11 +10,18 @@ name = "fuse-mount-smoke" path = "src/bin/fuse-mount-smoke.rs" required-features = [] +[[bin]] +name = "fuse-runtime-probe" +path = "src/bin/fuse-runtime-probe.rs" +required-features = [] + [dependencies] anyhow = "1" thiserror = "2" tracing = "0.1" tempfile = "3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" # Linux / macOS — fuser (libfuse3 / macFUSE) [target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] diff --git a/crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs b/crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs new file mode 100644 index 00000000..68ba88ae --- /dev/null +++ b/crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs @@ -0,0 +1,83 @@ +//! Read-only runtime evidence for the optional FUSE interception tier. +//! +//! This probe never loads a kext, changes approval state, mounts a volume, or +//! prompts for privilege. It reports the deterministic KEXT -> FSKit -> +//! non-FUSE selection used by ShareCLI. + +use serde::Serialize; +use sharecli_fuse::{ + select_backend_for_mount_with, FuseBackend, FuseBackendDiagnostic, FuseCapabilities, +}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Serialize)] +struct ProbeReport { + platform: &'static str, + mountpoint: PathBuf, + kernel_loaded: bool, + fskit_framework: bool, + fskit_approved: bool, + selected_backend: &'static str, + diagnostic: Option<&'static str>, + non_fuse_fallback: bool, +} + +fn main() { + let mountpoint = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp/sharecli-fuse-runtime-probe")); + let kernel_loaded = kernel_backend_loaded(); + let fskit_framework = mfmount_framework_available(); + let fskit_approved = fskit_framework && fskit_approved_by_operator(); + let selection = select_backend_for_mount_with( + FuseCapabilities { kernel_loaded, fskit_approved }, + &mountpoint, + ); + let report = ProbeReport { + platform: std::env::consts::OS, + mountpoint, + kernel_loaded, + fskit_framework, + fskit_approved, + selected_backend: backend_name(selection.backend), + diagnostic: selection.diagnostic.map(diagnostic_message), + non_fuse_fallback: true, + }; + println!("{}", serde_json::to_string_pretty(&report).expect("serialize probe report")); +} + +fn backend_name(backend: FuseBackend) -> &'static str { + match backend { + FuseBackend::Kernel => "kext", + FuseBackend::Fskit => "fskit", + FuseBackend::Unavailable => "non-fuse", + } +} + +fn diagnostic_message(diagnostic: FuseBackendDiagnostic) -> &'static str { + diagnostic.message() +} + +fn kernel_backend_loaded() -> bool { + Command::new("kmutil") + .args(["showloaded"]) + .output() + .map(|output| { + let mut text = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase(); + text.push_str(&String::from_utf8_lossy(&output.stderr).to_ascii_lowercase()); + text.contains("macfuse") + }) + .unwrap_or(false) +} + +fn mfmount_framework_available() -> bool { + Path::new("/Library/Filesystems/macfuse.fs/Contents/Frameworks/MFMount.framework").is_dir() +} + +fn fskit_approved_by_operator() -> bool { + std::env::var("SHARECLI_FUSE_FSKIT_APPROVED") + .map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false) +} diff --git a/crates/sharecli-session/src/discovery.rs b/crates/sharecli-session/src/discovery.rs new file mode 100644 index 00000000..04acb111 --- /dev/null +++ b/crates/sharecli-session/src/discovery.rs @@ -0,0 +1,202 @@ +//! Capability-aware surface discovery and durable observation recording. + +use crate::{ + resolve_session, AgentSession, ObservationKind, SessionObservation, SessionStore, + SurfaceAdapter, SurfaceCapabilities, SurfaceRecord, +}; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::path::Path; + +/// Optional state-file lookup used to corroborate process argv evidence. +pub trait SessionStateProvider: Send + Sync { + /// Return the harness session id recorded for a surface, when available. + fn session_id(&self, surface: &SurfaceRecord, harness: &str) -> Result>; +} + +/// State provider used when no harness-specific state file is available. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoStateProvider; + +impl SessionStateProvider for NoStateProvider { + fn session_id(&self, _surface: &SurfaceRecord, _harness: &str) -> Result> { + Ok(None) + } +} + +/// Small deterministic state provider useful for adapters and tests. +#[derive(Clone, Debug, Default)] +pub struct MapStateProvider { + ids: HashMap, +} + +impl MapStateProvider { + /// Build a provider keyed by surface id. + pub fn new(ids: HashMap) -> Self { + Self { ids } + } + + /// Insert or replace the session id associated with a surface. + pub fn insert(&mut self, surface_id: impl Into, session_id: impl Into) { + self.ids.insert(surface_id.into(), session_id.into()); + } +} + +impl SessionStateProvider for MapStateProvider { + fn session_id(&self, surface: &SurfaceRecord, _harness: &str) -> Result> { + Ok(self.ids.get(&surface.id).cloned()) + } +} + +/// One surface that could not be recorded without discarding the rest of a scan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DiscoveryFailure { + /// Stable surface identifier. + pub surface_id: String, + /// Human-readable reason for the isolated failure. + pub error: String, +} + +/// Materialized result for one successfully recorded surface. +#[derive(Clone, Debug)] +pub struct DiscoveryResult { + /// Stable surface identifier. + pub surface_id: String, + /// Known harness name, when process evidence identified one. + pub harness: Option, + /// Resolved session id, when safe resume evidence was found. + pub session_id: Option, + /// Observation transition derived from the existing ledger history. + pub kind: ObservationKind, + /// Capabilities captured for the surface. + pub capabilities: SurfaceCapabilities, + /// Session recipe, when resolution produced one. + pub session: Option, +} + +/// Aggregate outcome for a single discovery pass. +#[derive(Clone, Debug, Default)] +pub struct DiscoveryReport { + /// Number of surfaces returned by the adapter. + pub scanned: usize, + /// Number of observations appended to the durable ledger. + pub recorded: usize, + /// Isolated surface failures that did not abort the pass. + pub failures: Vec, + /// Successfully recorded surface results. + pub results: Vec, +} + +/// Reusable scanner for native or managed surface adapters. +pub struct SurfaceObservationScanner<'a, A, S> { + adapter: &'a A, + state: &'a S, + store: &'a SessionStore, +} + +impl<'a, A, S> SurfaceObservationScanner<'a, A, S> +where + A: SurfaceAdapter, + S: SessionStateProvider, +{ + /// Construct a scanner over an adapter and durable ledger. + pub fn new(adapter: &'a A, state: &'a S, store: &'a SessionStore) -> Self { + Self { adapter, state, store } + } + + /// Discover and append one observation per usable surface. + pub fn scan(&self, observed_at: &str) -> Result { + scan_and_record(self.adapter, self.state, self.store, observed_at) + } +} + +/// Discover surfaces, resolve only evidence-backed sessions, and append records. +pub fn scan_and_record( + adapter: &A, + state: &S, + store: &SessionStore, + observed_at: &str, +) -> Result +where + A: SurfaceAdapter, + S: SessionStateProvider, +{ + if observed_at.trim().is_empty() { + anyhow::bail!("observed_at must not be empty") + } + let surfaces = adapter.discover().context("discover terminal surfaces")?; + let mut report = DiscoveryReport { scanned: surfaces.len(), ..Default::default() }; + for surface in surfaces { + let surface_id = surface.id.clone(); + let capabilities = match adapter.capabilities(&surface) { + Ok(value) => value, + Err(error) => { + report.failures.push(DiscoveryFailure { surface_id, error: error.to_string() }); + continue; + } + }; + let harness = surface_harness(&surface); + let resolution = if let Some(harness) = harness.as_deref() { + let state_id = state + .session_id(&surface, harness) + .with_context(|| format!("read session state for surface {}", surface.id))?; + resolve_session( + harness, + surface_process_cwd(&surface), + &surface_process_argv(&surface), + state_id.as_deref(), + None, + ) + } else { + crate::resolver::Resolution { + session: None, + confidence: crate::ResolutionConfidence::Unavailable, + source: crate::EvidenceSource::Unavailable, + } + }; + let kind = if store.observations(Some(&surface.id))?.is_empty() { + ObservationKind::Discovered + } else { + ObservationKind::Updated + }; + let session = resolution.session; + store.append_observation(&SessionObservation::new( + observed_at, + surface, + session.clone(), + capabilities.clone(), + kind, + ))?; + report.recorded += 1; + report.results.push(DiscoveryResult { + surface_id, + harness, + session_id: session.as_ref().map(|value| value.session_id.clone()), + kind, + capabilities, + session, + }); + } + Ok(report) +} + +fn surface_harness(surface: &SurfaceRecord) -> Option { + let executable = surface.process.as_ref()?.argv.first()?; + let name = Path::new(executable).file_name()?.to_str()?.to_ascii_lowercase(); + match name.as_str() { + "forge" | "codex" | "opencode" | "kilo" | "cursor" | "cursor-agent" => Some(name), + _ => None, + } +} + +fn surface_process_cwd(surface: &SurfaceRecord) -> std::path::PathBuf { + surface + .process + .as_ref() + .map(|process| process.cwd.clone()) + .unwrap_or_else(|| surface.cwd.clone()) +} + +fn surface_process_argv(surface: &SurfaceRecord) -> Vec { + surface.process.as_ref().map(|process| process.argv.clone()).unwrap_or_default() +} diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index 7f8a8821..b098ca8b 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -51,6 +51,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; pub mod adapter; +pub mod discovery; pub mod layout; pub mod ledger; pub mod recovery; @@ -58,6 +59,10 @@ pub mod resolver; pub mod rpc; pub use adapter::{GhosttySurfaceAdapter, SurfaceAdapter, SurfaceIo, ZmxSurfaceAdapter}; +pub use discovery::{ + scan_and_record, DiscoveryFailure, DiscoveryReport, DiscoveryResult, MapStateProvider, + NoStateProvider, SessionStateProvider, SurfaceObservationScanner, +}; pub use layout::{LayoutAxis, LayoutNode, LayoutRestoreItem, LayoutRestoreReport, LayoutSnapshot}; pub use ledger::{ObservationKind, SessionObservation, SurfaceCapabilities}; pub use recovery::{validate_recipe, RecoveryExecutor, RecoveryOutcome, RecoveryResult}; diff --git a/crates/sharecli-session/src/rpc.rs b/crates/sharecli-session/src/rpc.rs index 64d6f5b8..ade08911 100644 --- a/crates/sharecli-session/src/rpc.rs +++ b/crates/sharecli-session/src/rpc.rs @@ -6,7 +6,6 @@ use std::sync::Arc; const JSON_RPC_VERSION: &str = "2.0"; const MAX_SURFACE_READ_BYTES: usize = 1024 * 1024; - #[derive(Debug, Deserialize)] pub struct Request { pub id: serde_json::Value, @@ -20,10 +19,7 @@ pub struct Response { pub result: Option, pub error: Option, } - -/// Shell-free operations exposed by the surface control socket. pub trait SurfaceControl: Send + Sync { - /// Enumerate stable terminal surface identities. fn list(&self) -> Result> { anyhow::bail!("surface discovery unavailable") } @@ -32,7 +28,6 @@ pub trait SurfaceControl: Send + Sync { fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()>; fn capabilities(&self, surface_id: &str) -> Result; } - #[derive(Debug, Deserialize)] struct SurfaceRequest { jsonrpc: String, @@ -40,14 +35,14 @@ struct SurfaceRequest { method: String, #[serde(default)] params: Value, + #[serde(default)] + token: Option, } - #[derive(Debug, Serialize)] pub struct RpcError { pub code: i32, pub message: String, } - #[derive(Debug, Serialize)] pub struct SurfaceResponse { pub jsonrpc: &'static str, @@ -57,12 +52,10 @@ pub struct SurfaceResponse { #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } - #[derive(Deserialize)] struct SurfaceIdParams { surface_id: String, } - #[derive(Deserialize)] struct SendParams { surface_id: String, @@ -122,10 +115,6 @@ pub async fn dispatch(service: Arc, line: &str) -> Response { } } -/// Dispatch one JSON-RPC 2.0 surface request. -/// -/// Input text is passed directly to [`SurfaceControl::send`]. It is never -/// interpreted as a command line or evaluated by a shell. pub async fn dispatch_surface(control: Arc, line: &str) -> SurfaceResponse { let request = match serde_json::from_str::(line) { Ok(request) => request, @@ -147,6 +136,22 @@ pub async fn dispatch_surface(control: Arc, line: &str) -> S } } +pub async fn dispatch_surface_with_token( + control: Arc, + line: &str, + expected_token: Option<&str>, +) -> SurfaceResponse { + if let Some(expected) = expected_token { + let Ok(request) = serde_json::from_str::(line) else { + return dispatch_surface(control, line).await; + }; + if request.token.as_deref() != Some(expected) { + return surface_error(request.id, -32001, "invalid control token".into()); + } + } + dispatch_surface(control, line).await +} + fn dispatch_surface_method( control: &dyn SurfaceControl, method: &str, @@ -211,13 +216,9 @@ fn control_error(error: impl std::fmt::Display) -> (i32, String) { (-32000, error.to_string()) } +#[rustfmt::skip] fn surface_error(id: Value, code: i32, message: String) -> SurfaceResponse { - SurfaceResponse { - jsonrpc: JSON_RPC_VERSION, - id, - result: None, - error: Some(RpcError { code, message }), - } + SurfaceResponse { jsonrpc: JSON_RPC_VERSION, id, result: None, error: Some(RpcError { code, message }) } } #[cfg(unix)] @@ -248,11 +249,19 @@ pub async fn serve_unix(path: &std::path::Path, service: Arc) -> } } -/// Serve shell-free surface control requests over a newline-delimited Unix socket. #[cfg(unix)] pub async fn serve_surface_unix( path: &std::path::Path, control: Arc, +) -> Result<()> { + serve_surface_unix_with_token(path, control, None).await +} + +#[cfg(unix)] +pub async fn serve_surface_unix_with_token( + path: &std::path::Path, + control: Arc, + expected_token: Option, ) -> Result<()> { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -261,14 +270,21 @@ pub async fn serve_surface_unix( tokio::fs::create_dir_all(parent).await?; } let listener = tokio::net::UnixListener::bind(path)?; + let mut permissions = std::fs::metadata(path)?.permissions(); + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions)?; loop { let (stream, _) = listener.accept().await?; let control = control.clone(); + let expected_token = expected_token.clone(); tokio::spawn(async move { let (reader, mut writer) = stream.into_split(); let mut lines = BufReader::new(reader).lines(); while let Ok(Some(line)) = lines.next_line().await { - let response = dispatch_surface(control.clone(), &line).await; + let response = + dispatch_surface_with_token(control.clone(), &line, expected_token.as_deref()) + .await; let Ok(mut payload) = serde_json::to_string(&response) else { break; }; diff --git a/crates/sharecli-session/tests/discovery.rs b/crates/sharecli-session/tests/discovery.rs new file mode 100644 index 00000000..19ce2e3e --- /dev/null +++ b/crates/sharecli-session/tests/discovery.rs @@ -0,0 +1,173 @@ +use anyhow::{anyhow, Result}; +use sharecli_session::{ + scan_and_record, AgentSession, ObservationKind, ProcessEvidence, SessionObservation, + SessionStateProvider, SessionStore, SurfaceAdapter, SurfaceCapabilities, SurfaceRecord, +}; +use std::{collections::HashMap, path::PathBuf}; + +#[derive(Clone)] +struct FakeAdapter { + surfaces: Vec, + capability_error_ids: Vec, +} + +impl SurfaceAdapter for FakeAdapter { + fn capabilities(&self, surface: &SurfaceRecord) -> Result { + if self.capability_error_ids.iter().any(|id| id == &surface.id) { + return Err(anyhow!("capabilities unavailable for {}", surface.id)); + } + Ok(SurfaceCapabilities { read: true, write: true, resize: true, ..Default::default() }) + } + + fn discover(&self) -> Result> { + Ok(self.surfaces.clone()) + } +} + +#[derive(Default)] +struct FakeState { + ids: HashMap, +} + +impl SessionStateProvider for FakeState { + fn session_id(&self, surface: &SurfaceRecord, _harness: &str) -> Result> { + Ok(self.ids.get(&surface.id).cloned()) + } +} + +fn surface(id: &str, argv: &[&str]) -> SurfaceRecord { + SurfaceRecord { + id: id.to_string(), + terminal: "ghostty".to_string(), + title: Some(id.to_string()), + cwd: PathBuf::from("/tmp/project"), + process: Some(ProcessEvidence { + pid: Some(42), + tty: Some("ttys001".to_string()), + cwd: PathBuf::from("/tmp/project"), + argv: argv.iter().map(|value| (*value).to_string()).collect(), + started_at: Some("2026-08-01T00:00:00Z".to_string()), + }), + } +} + +#[test] +fn scan_resolves_codex_argv_and_materializes_session() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![surface("pane-1", &["codex", "resume", "codex-1"])], + capability_error_ids: vec![], + }; + + let report = + scan_and_record(&adapter, &FakeState::default(), &store, "2026-08-01T01:00:00Z").unwrap(); + + assert_eq!(report.scanned, 1); + assert_eq!(report.recorded, 1); + assert!(report.failures.is_empty()); + assert_eq!(report.results[0].kind, ObservationKind::Discovered); + assert_eq!(report.results[0].session_id.as_deref(), Some("codex-1")); + assert_eq!(store.list().unwrap(), vec![AgentSession::codex("codex-1", "/tmp/project")]); +} + +#[test] +fn state_id_is_used_and_corroborated_by_process_argv() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![surface("pane-2", &["forge", "--conversation-id", "forge-2"])], + capability_error_ids: vec![], + }; + let state = FakeState { ids: HashMap::from([("pane-2".to_string(), "forge-2".to_string())]) }; + + let report = scan_and_record(&adapter, &state, &store, "2026-08-01T01:00:00Z").unwrap(); + + assert_eq!(report.results[0].session_id.as_deref(), Some("forge-2")); + assert_eq!( + store.list().unwrap()[0].confidence, + sharecli_session::ResolutionConfidence::Corroborated + ); +} + +#[test] +fn unknown_process_is_recorded_without_unsafe_resume_recipe() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![surface("pane-3", &["zsh", "-i"])], + capability_error_ids: vec![], + }; + + let report = + scan_and_record(&adapter, &FakeState::default(), &store, "2026-08-01T01:00:00Z").unwrap(); + + assert_eq!(report.recorded, 1); + assert_eq!(report.results[0].session_id, None); + assert!(store.list().unwrap().is_empty()); + let observations = store.observations(Some("pane-3")).unwrap(); + assert_eq!(observations.len(), 1); + assert!(observations[0].session.is_none()); +} + +#[test] +fn repeated_scan_marks_surface_updated() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![surface("pane-4", &["kilo", "--session", "kilo-4"])], + capability_error_ids: vec![], + }; + let state = FakeState::default(); + + let first = scan_and_record(&adapter, &state, &store, "2026-08-01T01:00:00Z").unwrap(); + let second = scan_and_record(&adapter, &state, &store, "2026-08-01T01:01:00Z").unwrap(); + + assert_eq!(first.results[0].kind, ObservationKind::Discovered); + assert_eq!(second.results[0].kind, ObservationKind::Updated); + assert_eq!(store.observations(Some("pane-4")).unwrap().len(), 2); +} + +#[test] +fn one_capability_failure_does_not_discard_other_surfaces() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![ + surface("pane-bad", &["codex", "resume", "bad"]), + surface("pane-good", &["opencode", "--session", "good"]), + ], + capability_error_ids: vec!["pane-bad".to_string()], + }; + + let report = + scan_and_record(&adapter, &FakeState::default(), &store, "2026-08-01T01:00:00Z").unwrap(); + + assert_eq!(report.scanned, 2); + assert_eq!(report.recorded, 1); + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].surface_id, "pane-bad"); + assert_eq!(store.list().unwrap()[0].session_id, "good"); +} + +#[test] +fn malformed_timestamp_is_rejected_before_discovery() { + let store = SessionStore::open_memory().unwrap(); + let adapter = FakeAdapter { + surfaces: vec![surface("pane-6", &["codex", "resume", "id"])], + capability_error_ids: vec![], + }; + + let error = scan_and_record(&adapter, &FakeState::default(), &store, " ").unwrap_err(); + + assert!(error.to_string().contains("observed_at")); + assert!(store.observations(None).unwrap().is_empty()); +} + +#[test] +fn observation_type_remains_serializable() { + let observation = SessionObservation::new( + "2026-08-01T01:00:00Z", + surface("pane-7", &["codex", "resume", "id"]), + None, + SurfaceCapabilities::default(), + ObservationKind::Discovered, + ); + let encoded = serde_json::to_string(&observation).unwrap(); + assert!(encoded.contains("pane-7")); +} diff --git a/docs/session-recovery.md b/docs/session-recovery.md index 27f50664..63408bac 100644 --- a/docs/session-recovery.md +++ b/docs/session-recovery.md @@ -1,7 +1,9 @@ # Session recovery ShareCLI records agent processes and emits shell-free resume recipes. The watcher -only persists state; it never launches agents. +persists state and never launches agents. With a native Ghostty socket, `session +watch` also records surface identity, process argv, capabilities, and +evidence-backed harness session ids. Install the optional macOS watcher: @@ -17,9 +19,22 @@ Inspect or recover explicitly: sharecli session recovery-plan sharecli session recover # dry-run sharecli session recover --execute # explicit launch +sharecli session watch --once # one native Ghostty inventory pass ``` The plist targets the current Cargo install at `/Users/kooshapari/.cargo/bin/sharecli`; edit `ProgramArguments` for another installation path. Ghostty remains the stable -daily terminal. zmx provides durable PTYs, while a future Ghostty fork may add -capability-scoped Unix-socket surface control without changing this contract. +daily terminal. zmx provides durable PTYs. The fork-friendly native contract is +implemented in `contrib/ghostty-control` and can bind Ghostty's app-side +surface/PTY objects without AppleScript. Its socket must be owner-only (`0600`) +and may require `SHARECLI_GHOSTTY_TOKEN`; a missing socket or provider +capability is degraded, never a recovery blocker. + +For a read-only FUSE capability report (no kext load, approval prompt, or mount): + +```sh +cargo run -p sharecli-fuse --bin fuse-runtime-probe -- /tmp/sharecli-probe +``` + +The selector is deterministic: macFUSE KEXT first, approved FSKit only under +`/Volumes`, then the non-FUSE path. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md index c70db159..c865f618 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md @@ -5,8 +5,18 @@ - Use SQLite WAL plus `BEGIN IMMEDIATE` for serialized append/materialization; compact only the observation history, never the materialized session rows. - Keep Ghostty transport behind a small Unix JSON-RPC client with an optional - bearer-like token. AppleScript may supply identity and input fallback, but - it is explicitly degraded and never treated as PTY readback truth. + bearer-like token. The server-side dispatcher enforces that token and applies + owner-only socket permissions. AppleScript may supply identity and input + fallback, but it is explicitly degraded and never treated as PTY readback + truth. +- Use `sharecli-session::SurfaceObservationScanner` for one-pass discovery: + per-surface capability failures are isolated, known harness argv/state is + resolved conservatively, and unknown processes are recorded without a resume + recipe. `sharecli session watch` is the durable CLI loop around that contract. +- Keep the native Ghostty bridge in `contrib/ghostty-control` as a standalone + Swift package. It defines the provider boundary and JSON-RPC dispatcher so a + Ghostty fork can bind native split/pane/PTY objects without carrying ShareCLI's + Rust workspace into the app. - Keep layout persistence in ShareCLI and make the Ghostty adapter responsible only for applying a validated snapshot. This permits recovery when Ghostty is unavailable and avoids coupling the ledger to an app-specific tree API. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md index 3d62be7d..f2858cad 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md @@ -5,9 +5,15 @@ cwd, PID, and TTY, but the installed app has no documented external socket for PTY/screen readback. Native layout and readback still require the Ghostty-side integration gate. +- `contrib/ghostty-control` is the native binding contract and tested + dispatcher, not a patched Ghostty.app. A fork must implement `SurfaceProvider` + from Ghostty's live surface tree and PTY/screen model, then launch the + owner-only Unix listener. - `SHARECLI_FUSE_FSKIT_APPROVED` is a conservative approval input, not a full MFMount entitlement probe. macOS install/approval and mount smoke are still required before enabling it by default. +- `fuse-runtime-probe` is evidence-only. It does not attempt the privileged + mount smoke; that remains an explicit operator gate. - IPC tests use the default local session database and should be isolated from a concurrently running production daemon before a full CI parallelization. - Recovery launch reports process spawn, not completion or readiness; a future diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md index 3c710cb7..36a78753 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -4,12 +4,14 @@ Verified in this worktree: - `CARGO_BUILD_JOBS=2 cargo check --workspace --locked --offline` (pass; existing workspace warnings only) -- `cargo test -p sharecli-session --locked --offline` (22 unit + 3 layout integration passed) +- `cargo test -p sharecli-session --offline` (22 unit + 7 discovery + 3 layout integration passed) - `cargo test -p sharecli-fuse --locked --offline` (36 unit + 11 integration passed) - `cargo test -p sharecli-ipc --locked --test handler_dispatch` (10 passed, including layout save/list/inspect) -- `cargo test -p sharecli --locked --test session_cli` (2 passed, including - layout save/list) +- `cargo test -p sharecli --locked --test session_cli` (layout save/list plus + missing-native-socket watch fail-open check) +- `swift test` in `contrib/ghostty-control` (native dispatcher contract) +- `cargo run -p sharecli-fuse --bin fuse-runtime-probe` (read-only host evidence) - `cargo fmt --all -- --check` - `git diff --check` diff --git a/src/main.rs b/src/main.rs index 69b14c9c..9da356e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,8 @@ use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::Shell; use sharecli::session::GhosttyControlClient; use sharecli_session::{ - LayoutSnapshot, RecoveryExecutor, SessionObservation, SessionService, SessionStore, + LayoutSnapshot, NoStateProvider, RecoveryExecutor, SessionObservation, SessionService, + SessionStore, SurfaceObservationScanner, }; use sharecli_thermal_tui as thermal_tui; @@ -536,6 +537,20 @@ enum SessionCmd { #[arg(long)] db: Option, }, + /// Continuously snapshot Ghostty surfaces into the durable ledger + Watch { + #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3600))] + interval_seconds: u64, + /// Capture one inventory and exit instead of sleeping + #[arg(long)] + once: bool, + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + #[arg(long)] + db: Option, + }, } #[derive(Subcommand, Debug)] @@ -1041,6 +1056,7 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { SessionCmd::LayoutList { db } => (db.clone(), None), SessionCmd::LayoutInspect { id, db } => (db.clone(), Some(id.as_str())), SessionCmd::LayoutSave { db, .. } => (db.clone(), None), + SessionCmd::Watch { db, .. } => (db.clone(), None), }; let path = db.unwrap_or_else(|| { dirs::data_local_dir() @@ -1073,6 +1089,10 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { println!("{}", serde_json::to_string_pretty(&store.get_layout(id)?)?); return Ok(()); } + if let SessionCmd::Watch { interval_seconds, once, socket, token, .. } = cmd { + run_session_watch(&store, socket, token.as_deref(), *interval_seconds, *once)?; + return Ok(()); + } let service = SessionService::new(store); let value = match cmd { SessionCmd::List { .. } => serde_json::to_value(service.list()?)?, @@ -1100,11 +1120,43 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { SessionCmd::LayoutList { .. } | SessionCmd::LayoutInspect { .. } => { unreachable!("layout operation handled before service dispatch") } + SessionCmd::Watch { .. } => unreachable!("watch handled before service dispatch"), }; println!("{}", serde_json::to_string_pretty(&value)?); Ok(()) } +fn run_session_watch( + store: &SessionStore, + socket: &std::path::Path, + token: Option<&str>, + interval_seconds: u64, + once: bool, +) -> Result<()> { + let client = GhosttyControlClient::new(socket, token.map(str::to_owned)); + let interval = std::time::Duration::from_secs(interval_seconds.clamp(1, 3600)); + loop { + match observe_ghostty_surfaces(store, &client) { + Ok(count) => eprintln!("sharecli session watch: recorded {count} surface(s)"), + Err(error) => eprintln!("sharecli session watch: degraded: {error:#}"), + } + if once { + return Ok(()); + } + std::thread::sleep(interval); + } +} + +fn observe_ghostty_surfaces(store: &SessionStore, client: &GhosttyControlClient) -> Result { + let observed_at = chrono::Utc::now().to_rfc3339(); + let state = NoStateProvider; + let report = SurfaceObservationScanner::new(client, &state, store).scan(&observed_at)?; + for failure in &report.failures { + eprintln!("sharecli session watch: {} unavailable: {}", failure.surface_id, failure.error); + } + Ok(report.recorded) +} + fn surface_cmd(cmd: &SurfaceCmd) -> Result<()> { match cmd { SurfaceCmd::Send { socket, surface_id, text, bytes, token } => { diff --git a/src/session.rs b/src/session.rs index 3d765897..65050e54 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,6 +1,7 @@ //! Shell-free zmx and capability-gated Ghostty adapters. use serde_json::{json, Value}; +use sharecli_session::{SurfaceAdapter, SurfaceCapabilities, SurfaceRecord}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -156,6 +157,16 @@ impl GhosttyControlClient { Ok(()) } + pub fn list_surfaces(&self) -> anyhow::Result> { + Ok(serde_json::from_value(self.request("surface.list", json!({}))?)?) + } + + pub fn surface_capabilities(&self, surface_id: &str) -> anyhow::Result { + Ok(serde_json::from_value( + self.request("surface.io.capabilities", json!({"surface_id": surface_id}))?, + )?) + } + pub fn read_surface(&self, surface_id: &str, max_bytes: usize) -> anyhow::Result { self.request("surface.io.read", json!({"surface_id": surface_id, "max_bytes": max_bytes})) } @@ -169,6 +180,16 @@ impl GhosttyControlClient { } } +impl SurfaceAdapter for GhosttyControlClient { + fn capabilities(&self, surface: &SurfaceRecord) -> anyhow::Result { + self.surface_capabilities(&surface.id) + } + + fn discover(&self) -> anyhow::Result> { + self.list_surfaces() + } +} + pub struct GhosttyAdapter; impl GhosttyAdapter { diff --git a/tests/session.rs b/tests/session.rs index cfa73bca..46d3cead 100644 --- a/tests/session.rs +++ b/tests/session.rs @@ -44,6 +44,36 @@ fn ghostty_control_client_requires_configured_socket() { assert!(GhosttyAdapter::degraded_reason(&caps).is_none()); } +#[cfg(unix)] +#[test] +fn ghostty_control_client_decodes_surface_inventory_and_capabilities() { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let socket = std::env::temp_dir().join(format!("sharecli-ghostty-list-{suffix}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let server = std::thread::spawn(move || { + for response in [ + r#"{"id":1,"result":[{"id":"ghostty:1","terminal":"ghostty","title":"agent","cwd":"/tmp","process":null}]}"#, + r#"{"id":2,"result":{"read":true,"write":true,"resize":true,"layout":false,"durable_pty":false}}"#, + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(b"\n").unwrap(); + } + }); + + let client = GhosttyControlClient::new(&socket, None); + assert_eq!(client.list_surfaces().unwrap()[0].id, "ghostty:1"); + assert!(client.surface_capabilities("ghostty:1").unwrap().read); + server.join().unwrap(); + let _ = std::fs::remove_file(socket); +} + #[cfg(unix)] #[test] fn ghostty_control_client_round_trips_authenticated_io_request() { diff --git a/tests/session_cli.rs b/tests/session_cli.rs index 6193b9db..6d828062 100644 --- a/tests/session_cli.rs +++ b/tests/session_cli.rs @@ -47,3 +47,26 @@ fn session_layout_save_and_list_accept_explicit_database() { assert!(list.status.success(), "layout list stderr: {}", String::from_utf8_lossy(&list.stderr)); assert!(String::from_utf8_lossy(&list.stdout).contains("daily")); } + +#[cfg(unix)] +#[test] +fn session_watch_once_fails_open_when_native_socket_is_unavailable() { + let dir = TempDir::new().expect("tempdir"); + let db = dir.path().join("sessions.sqlite"); + let socket = dir.path().join("missing-ghostty.sock"); + let out = bin() + .args([ + "session", + "watch", + "--once", + "--socket", + socket.to_str().expect("utf8 path"), + "--db", + db.to_str().expect("utf8 path"), + ]) + .output() + .expect("spawn session watch"); + + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("degraded")); +} From 664b4309e530e966cb1c5b8c393daacaf4c29762 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 1 Aug 2026 03:32:13 -0700 Subject: [PATCH 12/18] feat(session): add explicit sidecar state evidence --- crates/sharecli-session/src/lib.rs | 2 + crates/sharecli-session/src/state.rs | 78 +++++++++++++++++ crates/sharecli-session/tests/state.rs | 83 +++++++++++++++++++ docs/session-recovery.md | 16 ++++ .../04_IMPLEMENTATION_STRATEGY.md | 3 + .../05_KNOWN_ISSUES.md | 4 + .../06_TESTING_STRATEGY.md | 2 + src/main.rs | 38 +++++++-- 8 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 crates/sharecli-session/src/state.rs create mode 100644 crates/sharecli-session/tests/state.rs diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index b098ca8b..769d7433 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -57,6 +57,7 @@ pub mod ledger; pub mod recovery; pub mod resolver; pub mod rpc; +pub mod state; pub use adapter::{GhosttySurfaceAdapter, SurfaceAdapter, SurfaceIo, ZmxSurfaceAdapter}; pub use discovery::{ @@ -67,6 +68,7 @@ pub use layout::{LayoutAxis, LayoutNode, LayoutRestoreItem, LayoutRestoreReport, pub use ledger::{ObservationKind, SessionObservation, SurfaceCapabilities}; pub use recovery::{validate_recipe, RecoveryExecutor, RecoveryOutcome, RecoveryResult}; pub use resolver::{resolve as resolve_session, EvidenceSource, Resolution}; +pub use state::{SidecarRecord, SidecarStateProvider}; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ResumeRecipe { diff --git a/crates/sharecli-session/src/state.rs b/crates/sharecli-session/src/state.rs new file mode 100644 index 00000000..6c5ed997 --- /dev/null +++ b/crates/sharecli-session/src/state.rs @@ -0,0 +1,78 @@ +//! Explicit launch-time state evidence for harness session recovery. + +use crate::{SessionStateProvider, SurfaceRecord}; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +/// One deliberate surface-to-harness mapping written by a launcher or wrapper. +/// +/// The surface id is mandatory. A PID, when present, prevents a stale mapping +/// from being applied after a terminal surface has been recycled. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct SidecarRecord { + pub surface_id: String, + pub harness: String, + pub session_id: String, + #[serde(default)] + pub pid: Option, +} + +/// Read-only JSONL provider for exact launch-time session mappings. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SidecarStateProvider { + path: PathBuf, +} + +impl SidecarStateProvider { + /// Construct a provider. The file is read on each lookup so a long-running + /// watcher observes wrapper registrations without a restart. + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + fn records(&self) -> Result> { + let text = match std::fs::read_to_string(&self.path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(error).with_context(|| format!("read sidecar {}", self.path.display())) + } + }; + text.lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(line_number, line)| { + serde_json::from_str(line).with_context(|| { + format!("parse sidecar {} line {}", self.path.display(), line_number + 1) + }) + }) + .collect() + } +} + +impl SessionStateProvider for SidecarStateProvider { + fn session_id(&self, surface: &SurfaceRecord, harness: &str) -> Result> { + let pid = surface.process.as_ref().and_then(|process| process.pid); + let mut match_record = None; + for record in self.records()? { + if record.surface_id != surface.id || record.harness != harness { + continue; + } + // JSONL is append-only: the last record for a surface/harness is + // authoritative. A newer PID mismatch must not fall back to an + // older mapping from a recycled process. + match_record = Some(record); + } + Ok(match_record.and_then(|record| { + if record.pid.is_some() && record.pid != pid { + return None; + } + (!record.session_id.trim().is_empty()).then_some(record.session_id) + })) + } +} diff --git a/crates/sharecli-session/tests/state.rs b/crates/sharecli-session/tests/state.rs new file mode 100644 index 00000000..a91cc199 --- /dev/null +++ b/crates/sharecli-session/tests/state.rs @@ -0,0 +1,83 @@ +use sharecli_session::{ + ProcessEvidence, SessionStateProvider, SidecarStateProvider, SurfaceRecord, +}; +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn surface(id: &str, pid: Option) -> SurfaceRecord { + SurfaceRecord { + id: id.into(), + terminal: "ghostty".into(), + title: None, + cwd: PathBuf::from("/tmp/project"), + process: Some(ProcessEvidence { + pid, + tty: None, + cwd: PathBuf::from("/tmp/project"), + argv: vec!["codex".into()], + started_at: None, + }), + } +} + +fn temp_sidecar() -> PathBuf { + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + std::env::temp_dir().join(format!("sharecli-sidecar-{suffix}.jsonl")) +} + +#[test] +fn sidecar_returns_latest_exact_surface_and_pid_match() { + let path = temp_sidecar(); + fs::write( + &path, + "{\"surface_id\":\"pane-1\",\"harness\":\"codex\",\"session_id\":\"old\",\"pid\":41}\n{\"surface_id\":\"pane-1\",\"harness\":\"codex\",\"session_id\":\"new\",\"pid\":42}\n", + ) + .unwrap(); + let provider = SidecarStateProvider::new(&path); + assert_eq!( + provider.session_id(&surface("pane-1", Some(42)), "codex").unwrap(), + Some("new".into()) + ); + let _ = fs::remove_file(path); +} + +#[test] +fn sidecar_refuses_stale_pid_or_harness_matches() { + let path = temp_sidecar(); + fs::write( + &path, + "{\"surface_id\":\"pane-1\",\"harness\":\"codex\",\"session_id\":\"id\",\"pid\":42}\n", + ) + .unwrap(); + let provider = SidecarStateProvider::new(&path); + assert_eq!(provider.session_id(&surface("pane-1", Some(7)), "codex").unwrap(), None); + assert_eq!(provider.session_id(&surface("pane-1", Some(42)), "forge").unwrap(), None); + let _ = fs::remove_file(path); +} + +#[test] +fn newer_pid_mismatch_supersedes_an_older_matching_record() { + let path = temp_sidecar(); + fs::write( + &path, + "{\"surface_id\":\"pane-1\",\"harness\":\"codex\",\"session_id\":\"old\",\"pid\":42}\n{\"surface_id\":\"pane-1\",\"harness\":\"codex\",\"session_id\":\"recycled\",\"pid\":99}\n", + ) + .unwrap(); + let provider = SidecarStateProvider::new(&path); + assert_eq!(provider.session_id(&surface("pane-1", Some(42)), "codex").unwrap(), None); + let _ = fs::remove_file(path); +} + +#[test] +fn malformed_sidecar_fails_closed() { + let path = temp_sidecar(); + fs::write(&path, "not-json\n").unwrap(); + let error = SidecarStateProvider::new(&path) + .session_id(&surface("pane-1", Some(42)), "codex") + .unwrap_err(); + assert!(error.to_string().contains("parse sidecar")); + let _ = fs::remove_file(path); +} diff --git a/docs/session-recovery.md b/docs/session-recovery.md index 63408bac..fcc70c84 100644 --- a/docs/session-recovery.md +++ b/docs/session-recovery.md @@ -22,6 +22,22 @@ sharecli session recover --execute # explicit launch sharecli session watch --once # one native Ghostty inventory pass ``` +For unattended, exact harness identity, give the watcher a launch-time JSONL +sidecar. The default is `$HOME/Library/Application Support/sharecli/session-sidecar.jsonl` +when macOS resolves a local data directory; override it with +`--state-sidecar` or `SHARECLI_SESSION_SIDECAR`: + +```jsonl +{"surface_id":"ghostty-42","harness":"codex","session_id":"thread-abc","pid":12345} +``` + +Each record is keyed by `surface_id` and `harness`; an optional `pid` prevents a +mapping from being reused after process recycling. The watcher reads the file +on every pass and treats the newest record for a surface/harness as authoritative. +A malformed sidecar degrades that pass rather than inventing a resume id. The +sidecar is deliberately read-only here: a launcher/wrapper that knows the exact +session id should append the record before starting the harness. + The plist targets the current Cargo install at `/Users/kooshapari/.cargo/bin/sharecli`; edit `ProgramArguments` for another installation path. Ghostty remains the stable daily terminal. zmx provides durable PTYs. The fork-friendly native contract is diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md index c865f618..e4c2cefb 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md @@ -13,6 +13,9 @@ per-surface capability failures are isolated, known harness argv/state is resolved conservatively, and unknown processes are recorded without a resume recipe. `sharecli session watch` is the durable CLI loop around that contract. + Exact launch-time mappings come from the append-only JSONL sidecar selected by + `--state-sidecar`/`SHARECLI_SESSION_SIDECAR`; the latest record wins, PID + mismatches fail closed, and malformed input degrades the pass. - Keep the native Ghostty bridge in `contrib/ghostty-control` as a standalone Swift package. It defines the provider boundary and JSON-RPC dispatcher so a Ghostty fork can bind native split/pane/PTY objects without carrying ShareCLI's diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md index f2858cad..4e0e8295 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md @@ -9,6 +9,10 @@ dispatcher, not a patched Ghostty.app. A fork must implement `SurfaceProvider` from Ghostty's live surface tree and PTY/screen model, then launch the owner-only Unix listener. +- The session sidecar is intentionally a read-only consumer in this slice. A + launcher or harness wrapper must append `{surface_id,harness,session_id,pid}` + before launch to provide exact identity; database/argv heuristics remain + non-authoritative and will not be promoted into unattended recovery. - `SHARECLI_FUSE_FSKIT_APPROVED` is a conservative approval input, not a full MFMount entitlement probe. macOS install/approval and mount smoke are still required before enabling it by default. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md index 36a78753..c2c3e051 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -10,6 +10,8 @@ Verified in this worktree: including layout save/list/inspect) - `cargo test -p sharecli --locked --test session_cli` (layout save/list plus missing-native-socket watch fail-open check) +- `cargo test -p sharecli-session --offline -- --nocapture` also covers exact + sidecar mappings, PID recycling fail-closed behavior, and malformed JSONL. - `swift test` in `contrib/ghostty-control` (native dispatcher contract) - `cargo run -p sharecli-fuse --bin fuse-runtime-probe` (read-only host evidence) - `cargo fmt --all -- --check` diff --git a/src/main.rs b/src/main.rs index 9da356e7..20a52326 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,8 +8,8 @@ use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::Shell; use sharecli::session::GhosttyControlClient; use sharecli_session::{ - LayoutSnapshot, NoStateProvider, RecoveryExecutor, SessionObservation, SessionService, - SessionStore, SurfaceObservationScanner, + LayoutSnapshot, RecoveryExecutor, SessionObservation, SessionService, SessionStore, + SidecarStateProvider, SurfaceObservationScanner, }; use sharecli_thermal_tui as thermal_tui; @@ -548,6 +548,8 @@ enum SessionCmd { socket: std::path::PathBuf, #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] token: Option, + #[arg(long, env = "SHARECLI_SESSION_SIDECAR")] + state_sidecar: Option, #[arg(long)] db: Option, }, @@ -1089,8 +1091,15 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { println!("{}", serde_json::to_string_pretty(&store.get_layout(id)?)?); return Ok(()); } - if let SessionCmd::Watch { interval_seconds, once, socket, token, .. } = cmd { - run_session_watch(&store, socket, token.as_deref(), *interval_seconds, *once)?; + if let SessionCmd::Watch { interval_seconds, once, socket, token, state_sidecar, .. } = cmd { + run_session_watch( + &store, + socket, + token.as_deref(), + state_sidecar.as_deref(), + *interval_seconds, + *once, + )?; return Ok(()); } let service = SessionService::new(store); @@ -1130,13 +1139,17 @@ fn run_session_watch( store: &SessionStore, socket: &std::path::Path, token: Option<&str>, + state_sidecar: Option<&std::path::Path>, interval_seconds: u64, once: bool, ) -> Result<()> { let client = GhosttyControlClient::new(socket, token.map(str::to_owned)); + let sidecar = SidecarStateProvider::new( + state_sidecar.map(std::path::Path::to_path_buf).unwrap_or_else(default_state_sidecar), + ); let interval = std::time::Duration::from_secs(interval_seconds.clamp(1, 3600)); loop { - match observe_ghostty_surfaces(store, &client) { + match observe_ghostty_surfaces(store, &client, &sidecar) { Ok(count) => eprintln!("sharecli session watch: recorded {count} surface(s)"), Err(error) => eprintln!("sharecli session watch: degraded: {error:#}"), } @@ -1147,16 +1160,25 @@ fn run_session_watch( } } -fn observe_ghostty_surfaces(store: &SessionStore, client: &GhosttyControlClient) -> Result { +fn observe_ghostty_surfaces( + store: &SessionStore, + client: &GhosttyControlClient, + state: &SidecarStateProvider, +) -> Result { let observed_at = chrono::Utc::now().to_rfc3339(); - let state = NoStateProvider; - let report = SurfaceObservationScanner::new(client, &state, store).scan(&observed_at)?; + let report = SurfaceObservationScanner::new(client, state, store).scan(&observed_at)?; for failure in &report.failures { eprintln!("sharecli session watch: {} unavailable: {}", failure.surface_id, failure.error); } Ok(report.recorded) } +fn default_state_sidecar() -> std::path::PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("sharecli/session-sidecar.jsonl") +} + fn surface_cmd(cmd: &SurfaceCmd) -> Result<()> { match cmd { SurfaceCmd::Send { socket, surface_id, text, bytes, token } => { From c04713dc1f55a752c3d0c9d682ccfffb9254b93b Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 1 Aug 2026 03:47:04 -0700 Subject: [PATCH 13/18] feat(session): add sidecar registration command --- crates/sharecli-session/src/lib.rs | 2 +- crates/sharecli-session/src/state.rs | 35 +++++++++++++++- crates/sharecli-session/tests/state.rs | 33 ++++++++++++++- docs/session-recovery.md | 10 +++++ .../04_IMPLEMENTATION_STRATEGY.md | 3 +- .../05_KNOWN_ISSUES.md | 9 ++-- .../06_TESTING_STRATEGY.md | 3 +- src/main.rs | 41 +++++++++++++++++++ tests/session_cli.rs | 26 ++++++++++++ 9 files changed, 151 insertions(+), 11 deletions(-) diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index 769d7433..03d4a14e 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -68,7 +68,7 @@ pub use layout::{LayoutAxis, LayoutNode, LayoutRestoreItem, LayoutRestoreReport, pub use ledger::{ObservationKind, SessionObservation, SurfaceCapabilities}; pub use recovery::{validate_recipe, RecoveryExecutor, RecoveryOutcome, RecoveryResult}; pub use resolver::{resolve as resolve_session, EvidenceSource, Resolution}; -pub use state::{SidecarRecord, SidecarStateProvider}; +pub use state::{append_record, SidecarRecord, SidecarStateProvider}; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct ResumeRecipe { diff --git a/crates/sharecli-session/src/state.rs b/crates/sharecli-session/src/state.rs index 6c5ed997..88cc0498 100644 --- a/crates/sharecli-session/src/state.rs +++ b/crates/sharecli-session/src/state.rs @@ -2,14 +2,15 @@ use crate::{SessionStateProvider, SurfaceRecord}; use anyhow::{Context, Result}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use std::io::Write; use std::path::{Path, PathBuf}; /// One deliberate surface-to-harness mapping written by a launcher or wrapper. /// /// The surface id is mandatory. A PID, when present, prevents a stale mapping /// from being applied after a terminal surface has been recycled. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct SidecarRecord { pub surface_id: String, pub harness: String, @@ -18,6 +19,36 @@ pub struct SidecarRecord { pub pid: Option, } +/// Append one exact launch-time mapping to a sidecar, creating it owner-only. +pub fn append_record(path: &Path, record: &SidecarRecord) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create sidecar directory {}", parent.display()))?; + } + let mut line = serde_json::to_vec(record).context("serialize sidecar record")?; + line.push(b'\n'); + let mut options = std::fs::OpenOptions::new(); + options.create(true).append(true).write(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = + options.open(path).with_context(|| format!("open sidecar {}", path.display()))?; + #[cfg(unix)] + { + let mut permissions = file + .metadata() + .with_context(|| format!("stat sidecar {}", path.display()))? + .permissions(); + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o600); + file.set_permissions(permissions) + .with_context(|| format!("protect sidecar {}", path.display()))?; + } + file.write_all(&line).with_context(|| format!("append sidecar {}", path.display()))?; + file.sync_data().with_context(|| format!("sync sidecar {}", path.display()))?; + Ok(()) +} + /// Read-only JSONL provider for exact launch-time session mappings. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SidecarStateProvider { diff --git a/crates/sharecli-session/tests/state.rs b/crates/sharecli-session/tests/state.rs index a91cc199..bbf2d801 100644 --- a/crates/sharecli-session/tests/state.rs +++ b/crates/sharecli-session/tests/state.rs @@ -1,12 +1,16 @@ use sharecli_session::{ - ProcessEvidence, SessionStateProvider, SidecarStateProvider, SurfaceRecord, + append_record, ProcessEvidence, SessionStateProvider, SidecarRecord, SidecarStateProvider, + SurfaceRecord, }; use std::{ fs, path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; +static SIDECARE_COUNTER: AtomicU64 = AtomicU64::new(0); + fn surface(id: &str, pid: Option) -> SurfaceRecord { SurfaceRecord { id: id.into(), @@ -25,7 +29,9 @@ fn surface(id: &str, pid: Option) -> SurfaceRecord { fn temp_sidecar() -> PathBuf { let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); - std::env::temp_dir().join(format!("sharecli-sidecar-{suffix}.jsonl")) + let ordinal = SIDECARE_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir() + .join(format!("sharecli-sidecar-{}-{suffix}-{ordinal}.jsonl", std::process::id())) } #[test] @@ -81,3 +87,26 @@ fn malformed_sidecar_fails_closed() { assert!(error.to_string().contains("parse sidecar")); let _ = fs::remove_file(path); } + +#[test] +fn append_record_writes_jsonl_that_provider_reads() { + let path = temp_sidecar(); + append_record( + &path, + &SidecarRecord { + surface_id: "pane-1".into(), + harness: "codex".into(), + session_id: "thread-1".into(), + pid: Some(42), + }, + ) + .unwrap(); + let provider = SidecarStateProvider::new(&path); + assert_eq!( + provider.session_id(&surface("pane-1", Some(42)), "codex").unwrap(), + Some("thread-1".into()) + ); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.ends_with('\n')); + let _ = fs::remove_file(path); +} diff --git a/docs/session-recovery.md b/docs/session-recovery.md index fcc70c84..c65f6611 100644 --- a/docs/session-recovery.md +++ b/docs/session-recovery.md @@ -31,6 +31,16 @@ when macOS resolves a local data directory; override it with {"surface_id":"ghostty-42","harness":"codex","session_id":"thread-abc","pid":12345} ``` +The built-in registrar writes that record safely and can take the surface id +from Ghostty's child environment: + +```sh +sharecli session register --harness codex --session-id thread-abc --pid "$child_pid" +``` + +Use `--state-sidecar` for a non-default path. A wrapper should register before +launch and use `exec` when it needs the PID to remain stable. + Each record is keyed by `surface_id` and `harness`; an optional `pid` prevents a mapping from being reused after process recycling. The watcher reads the file on every pass and treats the newest record for a surface/harness as authoritative. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md index e4c2cefb..7d2aea53 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md @@ -15,7 +15,8 @@ recipe. `sharecli session watch` is the durable CLI loop around that contract. Exact launch-time mappings come from the append-only JSONL sidecar selected by `--state-sidecar`/`SHARECLI_SESSION_SIDECAR`; the latest record wins, PID - mismatches fail closed, and malformed input degrades the pass. + mismatches fail closed, and malformed input degrades the pass. `session + register` is the owner-only append path for launch wrappers. - Keep the native Ghostty bridge in `contrib/ghostty-control` as a standalone Swift package. It defines the provider boundary and JSON-RPC dispatcher so a Ghostty fork can bind native split/pane/PTY objects without carrying ShareCLI's diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md index 4e0e8295..69c9a527 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md @@ -9,10 +9,11 @@ dispatcher, not a patched Ghostty.app. A fork must implement `SurfaceProvider` from Ghostty's live surface tree and PTY/screen model, then launch the owner-only Unix listener. -- The session sidecar is intentionally a read-only consumer in this slice. A - launcher or harness wrapper must append `{surface_id,harness,session_id,pid}` - before launch to provide exact identity; database/argv heuristics remain - non-authoritative and will not be promoted into unattended recovery. +- The session watcher is intentionally a read-only consumer. A launcher or + harness wrapper must call `session register` (or append the same + `{surface_id,harness,session_id,pid}` record) before launch to provide exact + identity; database/argv heuristics remain non-authoritative and will not be + promoted into unattended recovery. - `SHARECLI_FUSE_FSKIT_APPROVED` is a conservative approval input, not a full MFMount entitlement probe. macOS install/approval and mount smoke are still required before enabling it by default. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md index c2c3e051..328002a2 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -11,7 +11,8 @@ Verified in this worktree: - `cargo test -p sharecli --locked --test session_cli` (layout save/list plus missing-native-socket watch fail-open check) - `cargo test -p sharecli-session --offline -- --nocapture` also covers exact - sidecar mappings, PID recycling fail-closed behavior, and malformed JSONL. + sidecar mappings, PID recycling fail-closed behavior, malformed JSONL, and + append serialization; `session_cli` covers the registrar command. - `swift test` in `contrib/ghostty-control` (native dispatcher contract) - `cargo run -p sharecli-fuse --bin fuse-runtime-probe` (read-only host evidence) - `cargo fmt --all -- --check` diff --git a/src/main.rs b/src/main.rs index 20a52326..11c1b043 100644 --- a/src/main.rs +++ b/src/main.rs @@ -537,6 +537,20 @@ enum SessionCmd { #[arg(long)] db: Option, }, + /// Append an exact launch-time harness mapping to the session sidecar + Register { + #[arg(long, env = "GHOSTTY_SURFACE_ID")] + surface_id: Option, + #[arg(long)] + harness: String, + #[arg(long)] + session_id: String, + /// PID of the harness process, when the launcher can provide it + #[arg(long)] + pid: Option, + #[arg(long, env = "SHARECLI_SESSION_SIDECAR")] + state_sidecar: Option, + }, /// Continuously snapshot Ghostty surfaces into the durable ledger Watch { #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3600))] @@ -1058,6 +1072,7 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { SessionCmd::LayoutList { db } => (db.clone(), None), SessionCmd::LayoutInspect { id, db } => (db.clone(), Some(id.as_str())), SessionCmd::LayoutSave { db, .. } => (db.clone(), None), + SessionCmd::Register { .. } => (None, None), SessionCmd::Watch { db, .. } => (db.clone(), None), }; let path = db.unwrap_or_else(|| { @@ -1091,6 +1106,31 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { println!("{}", serde_json::to_string_pretty(&store.get_layout(id)?)?); return Ok(()); } + if let SessionCmd::Register { surface_id, harness, session_id, pid, state_sidecar } = cmd { + let surface_id = surface_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("--surface-id or GHOSTTY_SURFACE_ID is required"))?; + if harness.trim().is_empty() { + anyhow::bail!("--harness must not be empty"); + } + if session_id.trim().is_empty() { + anyhow::bail!("--session-id must not be empty"); + } + let path = state_sidecar + .as_deref() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(default_state_sidecar); + let record = sharecli_session::SidecarRecord { + surface_id: surface_id.to_owned(), + harness: harness.clone(), + session_id: session_id.clone(), + pid: *pid, + }; + sharecli_session::append_record(&path, &record)?; + println!("{}", serde_json::to_string(&record)?); + return Ok(()); + } if let SessionCmd::Watch { interval_seconds, once, socket, token, state_sidecar, .. } = cmd { run_session_watch( &store, @@ -1129,6 +1169,7 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { SessionCmd::LayoutList { .. } | SessionCmd::LayoutInspect { .. } => { unreachable!("layout operation handled before service dispatch") } + SessionCmd::Register { .. } => unreachable!("register handled before service dispatch"), SessionCmd::Watch { .. } => unreachable!("watch handled before service dispatch"), }; println!("{}", serde_json::to_string_pretty(&value)?); diff --git a/tests/session_cli.rs b/tests/session_cli.rs index 6d828062..2e6f9e36 100644 --- a/tests/session_cli.rs +++ b/tests/session_cli.rs @@ -48,6 +48,32 @@ fn session_layout_save_and_list_accept_explicit_database() { assert!(String::from_utf8_lossy(&list.stdout).contains("daily")); } +#[test] +fn session_register_accepts_surface_id_and_sidecar_path() { + let dir = TempDir::new().expect("tempdir"); + let sidecar = dir.path().join("session-sidecar.jsonl"); + let out = bin() + .args([ + "session", + "register", + "--surface-id", + "ghostty:1", + "--harness", + "codex", + "--session-id", + "thread-abc", + "--pid", + "42", + "--state-sidecar", + sidecar.to_str().expect("utf8 path"), + ]) + .output() + .expect("spawn session register"); + assert!(out.status.success(), "register stderr: {}", String::from_utf8_lossy(&out.stderr)); + assert!(String::from_utf8_lossy(&out.stdout).contains("thread-abc")); + assert!(std::fs::read_to_string(sidecar).unwrap().contains("ghostty:1")); +} + #[cfg(unix)] #[test] fn session_watch_once_fails_open_when_native_socket_is_unavailable() { From c205876419a75a162a7b37ef26b7bc5052b28c57 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 1 Aug 2026 03:58:45 -0700 Subject: [PATCH 14/18] feat(ghostty): add owner-only native control listener --- contrib/ghostty-control/README.md | 23 ++- .../ShareCLIGhosttyControl/Control.swift | 148 ++++++++++++++++++ .../ControlTests.swift | 40 +++++ .../04_IMPLEMENTATION_STRATEGY.md | 7 +- .../05_KNOWN_ISSUES.md | 6 +- .../06_TESTING_STRATEGY.md | 3 +- 6 files changed, 208 insertions(+), 19 deletions(-) diff --git a/contrib/ghostty-control/README.md b/contrib/ghostty-control/README.md index eff9ef22..c293774d 100644 --- a/contrib/ghostty-control/README.md +++ b/contrib/ghostty-control/README.md @@ -1,19 +1,18 @@ # ShareCLI Ghostty control bridge -This Swift package is the tested JSON-RPC dispatcher and `SurfaceProvider` -contract for ShareCLI's native Ghostty integration. It is intentionally a -small, app-side bridge: the dispatcher validates bounded requests and routes -typed surface operations; it never evaluates shell text or owns a PTY by +This Swift package is the tested JSON-RPC dispatcher, owner-only Unix listener, +and `SurfaceProvider` contract for ShareCLI's native Ghostty integration. It is +intentionally a small, app-side bridge: it validates bounded requests and +routes typed surface operations; it never evaluates shell text or owns a PTY by itself. ## Ghostty fork integration The Ghostty app/fork must provide a concrete `SurfaceProvider` backed by its -surface and PTY objects, then own the local Unix-domain listener that feeds -newline-delimited requests to `ControlDispatcher`. The provider must expose -stable surface IDs, process evidence, live read/write/resize operations, and -capability reporting. Keep all app and PTY references on that side of the -boundary. +surface and PTY objects, then start `UnixControlServer` with that provider. The +provider must expose stable surface IDs, process evidence, live +read/write/resize operations, and capability reporting. Keep all app and PTY +references on that side of the boundary. The listener is expected to be local-only and owner-readable/writable (filesystem mode `0600`). When a control token is configured, pass it to @@ -28,6 +27,6 @@ never as permission to execute an untrusted command. swift test --filter ShareCLIGhosttyControlTests ``` -This package is a fork-ready binding contract and dispatcher, not a patched -Ghostty application. The concrete `SurfaceProvider`, Unix listener, and -Ghostty app lifecycle wiring remain an integration task in the Ghostty fork. +This package is a fork-ready binding contract and listener, not a patched +Ghostty application. The concrete `SurfaceProvider` and Ghostty app lifecycle +wiring remain an integration task in the Ghostty fork. diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift index bfbc0547..9425d7ad 100644 --- a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift @@ -1,4 +1,5 @@ import Foundation +import Darwin /// Process metadata supplied by the Ghostty app-side adapter. public struct ProcessEvidence: Codable, Equatable, Sendable { @@ -232,3 +233,150 @@ public struct ControlDispatcher: Sendable { ((try? JSONSerialization.jsonObject(with: line) as? [String: Any])?["id"]) ?? NSNull() } } + +/// Owner-only newline-delimited Unix-domain listener for a native Ghostty app. +/// +/// The listener owns transport and connection lifetime; `SurfaceProvider` owns +/// all app/PTY references. Each request is bounded before it reaches the +/// dispatcher, and each complete request produces exactly one response line. +public final class UnixControlServer: @unchecked Sendable { + public let path: String + + private static let maxSocketPathBytes = 104 + private let dispatcher: ControlDispatcher + private let queue: DispatchQueue + private let lock = NSLock() + private var listenerFD: Int32 = -1 + private var source: DispatchSourceRead? + + public init(path: String, dispatcher: ControlDispatcher) { + self.path = path + self.dispatcher = dispatcher + self.queue = DispatchQueue(label: "sharecli.ghostty-control", qos: .userInitiated) + } + + deinit { + stop() + } + + public func start() throws { + lock.lock() + defer { lock.unlock() } + guard listenerFD < 0 else { return } + guard path.utf8.count + 1 <= Self.maxSocketPathBytes else { + throw ControlError.invalidRequest("control socket path is too long") + } + removeExistingSocketIfSafe() + + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw socketError("create control socket") } + do { + var address = try unixAddress(path) + let addressLength = socklen_t(MemoryLayout.size) + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { pointer in + Darwin.bind(fd, pointer, addressLength) + } + } + guard bound == 0 else { + throw socketError("bind control socket") + } + guard Darwin.listen(fd, 16) == 0 else { throw socketError("listen control socket") } + guard Darwin.fcntl(fd, F_SETFL, O_NONBLOCK) == 0 else { + throw socketError("configure control socket") + } + guard Darwin.chmod(path, mode_t(0o600)) == 0 else { + throw socketError("protect control socket") + } + listenerFD = fd + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + source.setEventHandler { [weak self] in self?.acceptConnections() } + source.setCancelHandler { Darwin.close(fd) } + source.resume() + self.source = source + } catch { + Darwin.close(fd) + unlink(path) + throw error + } + } + + public func stop() { + lock.lock() + let activeSource = source + source = nil + listenerFD = -1 + activeSource?.cancel() + unlink(path) + lock.unlock() + } + + private func acceptConnections() { + while true { + let fd = Darwin.accept(listenerFD, nil, nil) + if fd < 0 { + if errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR { return } + return + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.serveConnection(fd) + } + } + } + + private func serveConnection(_ fd: Int32) { + defer { Darwin.close(fd) } + var buffer = Data() + var chunk = [UInt8](repeating: 0, count: 16 * 1024) + while true { + let count = chunk.withUnsafeMutableBytes { rawBuffer in + Darwin.recv(fd, rawBuffer.baseAddress, rawBuffer.count, 0) + } + if count <= 0 { return } + buffer.append(contentsOf: chunk[0.. Bool { + data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return true } + var sent = 0 + while sent < data.count { + let count = Darwin.send(fd, base.advanced(by: sent), data.count - sent, 0) + if count <= 0 { return false } + sent += count + } + return true + } + } + + private func removeExistingSocketIfSafe() { + var info = stat() + guard lstat(path, &info) == 0 else { return } + guard (info.st_mode & S_IFMT) == S_IFSOCK else { return } + unlink(path) + } + + private func unixAddress(_ path: String) throws -> sockaddr_un { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + path.withCString { pointer in + withUnsafeMutableBytes(of: &address.sun_path) { destination in + destination.copyBytes(from: UnsafeRawBufferPointer(start: pointer, count: path.utf8.count + 1)) + } + } + return address + } + + private func socketError(_ operation: String) -> ControlError { + .provider("\(operation): \(String(cString: strerror(errno)))") + } +} diff --git a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift index 5de97b22..5660e976 100644 --- a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift +++ b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift @@ -1,4 +1,5 @@ import Foundation +import Darwin import Testing @testable import ShareCLIGhosttyControl @@ -58,3 +59,42 @@ private struct FakeProvider: SurfaceProvider { #expect(error["code"] as? Int == -32602) } } + +@Test func unixServerRoundTripsOneRequestLine() throws { + let path = "/tmp/sharecli-control-\(UUID().uuidString).sock" + let server = UnixControlServer(path: path, dispatcher: ControlDispatcher(provider: FakeProvider())) + try server.start() + defer { server.stop() } + + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + #expect(fd >= 0) + defer { Darwin.close(fd) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + path.withCString { pointer in + withUnsafeMutableBytes(of: &address.sun_path) { destination in + destination.copyBytes(from: UnsafeRawBufferPointer(start: pointer, count: path.utf8.count + 1)) + } + } + let addressLength = socklen_t(MemoryLayout.size) + let connected = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, addressLength) + } + } + #expect(connected == 0) + + let request = Data("{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"surface.list\",\"params\":{}}\n".utf8) + request.withUnsafeBytes { buffer in + _ = Darwin.send(fd, buffer.baseAddress, buffer.count, 0) + } + var responseBuffer = [UInt8](repeating: 0, count: 4096) + let count = responseBuffer.withUnsafeMutableBytes { buffer in + Darwin.recv(fd, buffer.baseAddress, buffer.count, 0) + } + #expect(count > 0) + let responseData = Data(responseBuffer.prefix(max(0, count))) + let response = try #require(JSONSerialization.jsonObject(with: responseData) as? [String: Any]) + let result = try #require(response["result"] as? [[String: Any]]) + #expect(result[0]["id"] as? String == "ghostty:1") +} diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md index 7d2aea53..69675482 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md @@ -18,9 +18,10 @@ mismatches fail closed, and malformed input degrades the pass. `session register` is the owner-only append path for launch wrappers. - Keep the native Ghostty bridge in `contrib/ghostty-control` as a standalone - Swift package. It defines the provider boundary and JSON-RPC dispatcher so a - Ghostty fork can bind native split/pane/PTY objects without carrying ShareCLI's - Rust workspace into the app. + Swift package. It defines the provider boundary, bounded JSON-RPC dispatcher, + and owner-only Unix listener so a Ghostty fork can bind native + split/pane/PTY objects without carrying ShareCLI's Rust workspace into the + app. - Keep layout persistence in ShareCLI and make the Ghostty adapter responsible only for applying a validated snapshot. This permits recovery when Ghostty is unavailable and avoids coupling the ledger to an app-specific tree API. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md index 69c9a527..5f1ba489 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md @@ -6,9 +6,9 @@ for PTY/screen readback. Native layout and readback still require the Ghostty-side integration gate. - `contrib/ghostty-control` is the native binding contract and tested - dispatcher, not a patched Ghostty.app. A fork must implement `SurfaceProvider` - from Ghostty's live surface tree and PTY/screen model, then launch the - owner-only Unix listener. + dispatcher/listener, not a patched Ghostty.app. A fork must implement + `SurfaceProvider` from Ghostty's live surface tree and PTY/screen model, then + start the listener from the app lifecycle. - The session watcher is intentionally a read-only consumer. A launcher or harness wrapper must call `session register` (or append the same `{surface_id,harness,session_id,pid}` record) before launch to provide exact diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md index 328002a2..28df2893 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -13,7 +13,8 @@ Verified in this worktree: - `cargo test -p sharecli-session --offline -- --nocapture` also covers exact sidecar mappings, PID recycling fail-closed behavior, malformed JSONL, and append serialization; `session_cli` covers the registrar command. -- `swift test` in `contrib/ghostty-control` (native dispatcher contract) +- `swift test` in `contrib/ghostty-control` (dispatcher plus Unix listener + round-trip) - `cargo run -p sharecli-fuse --bin fuse-runtime-probe` (read-only host evidence) - `cargo fmt --all -- --check` - `git diff --check` From b5adadebfd2be693152aed8bee2a872a1a910424 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 1 Aug 2026 15:38:20 -0700 Subject: [PATCH 15/18] refactor(ghostty): isolate unix transport --- .../ShareCLIGhosttyControl/Control.swift | 148 ------------------ .../UnixControlServer.swift | 138 ++++++++++++++++ 2 files changed, 138 insertions(+), 148 deletions(-) create mode 100644 contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift index 9425d7ad..bfbc0547 100644 --- a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift @@ -1,5 +1,4 @@ import Foundation -import Darwin /// Process metadata supplied by the Ghostty app-side adapter. public struct ProcessEvidence: Codable, Equatable, Sendable { @@ -233,150 +232,3 @@ public struct ControlDispatcher: Sendable { ((try? JSONSerialization.jsonObject(with: line) as? [String: Any])?["id"]) ?? NSNull() } } - -/// Owner-only newline-delimited Unix-domain listener for a native Ghostty app. -/// -/// The listener owns transport and connection lifetime; `SurfaceProvider` owns -/// all app/PTY references. Each request is bounded before it reaches the -/// dispatcher, and each complete request produces exactly one response line. -public final class UnixControlServer: @unchecked Sendable { - public let path: String - - private static let maxSocketPathBytes = 104 - private let dispatcher: ControlDispatcher - private let queue: DispatchQueue - private let lock = NSLock() - private var listenerFD: Int32 = -1 - private var source: DispatchSourceRead? - - public init(path: String, dispatcher: ControlDispatcher) { - self.path = path - self.dispatcher = dispatcher - self.queue = DispatchQueue(label: "sharecli.ghostty-control", qos: .userInitiated) - } - - deinit { - stop() - } - - public func start() throws { - lock.lock() - defer { lock.unlock() } - guard listenerFD < 0 else { return } - guard path.utf8.count + 1 <= Self.maxSocketPathBytes else { - throw ControlError.invalidRequest("control socket path is too long") - } - removeExistingSocketIfSafe() - - let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { throw socketError("create control socket") } - do { - var address = try unixAddress(path) - let addressLength = socklen_t(MemoryLayout.size) - let bound = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { pointer in - Darwin.bind(fd, pointer, addressLength) - } - } - guard bound == 0 else { - throw socketError("bind control socket") - } - guard Darwin.listen(fd, 16) == 0 else { throw socketError("listen control socket") } - guard Darwin.fcntl(fd, F_SETFL, O_NONBLOCK) == 0 else { - throw socketError("configure control socket") - } - guard Darwin.chmod(path, mode_t(0o600)) == 0 else { - throw socketError("protect control socket") - } - listenerFD = fd - let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) - source.setEventHandler { [weak self] in self?.acceptConnections() } - source.setCancelHandler { Darwin.close(fd) } - source.resume() - self.source = source - } catch { - Darwin.close(fd) - unlink(path) - throw error - } - } - - public func stop() { - lock.lock() - let activeSource = source - source = nil - listenerFD = -1 - activeSource?.cancel() - unlink(path) - lock.unlock() - } - - private func acceptConnections() { - while true { - let fd = Darwin.accept(listenerFD, nil, nil) - if fd < 0 { - if errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR { return } - return - } - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.serveConnection(fd) - } - } - } - - private func serveConnection(_ fd: Int32) { - defer { Darwin.close(fd) } - var buffer = Data() - var chunk = [UInt8](repeating: 0, count: 16 * 1024) - while true { - let count = chunk.withUnsafeMutableBytes { rawBuffer in - Darwin.recv(fd, rawBuffer.baseAddress, rawBuffer.count, 0) - } - if count <= 0 { return } - buffer.append(contentsOf: chunk[0.. Bool { - data.withUnsafeBytes { rawBuffer in - guard let base = rawBuffer.baseAddress else { return true } - var sent = 0 - while sent < data.count { - let count = Darwin.send(fd, base.advanced(by: sent), data.count - sent, 0) - if count <= 0 { return false } - sent += count - } - return true - } - } - - private func removeExistingSocketIfSafe() { - var info = stat() - guard lstat(path, &info) == 0 else { return } - guard (info.st_mode & S_IFMT) == S_IFSOCK else { return } - unlink(path) - } - - private func unixAddress(_ path: String) throws -> sockaddr_un { - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - path.withCString { pointer in - withUnsafeMutableBytes(of: &address.sun_path) { destination in - destination.copyBytes(from: UnsafeRawBufferPointer(start: pointer, count: path.utf8.count + 1)) - } - } - return address - } - - private func socketError(_ operation: String) -> ControlError { - .provider("\(operation): \(String(cString: strerror(errno)))") - } -} diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift new file mode 100644 index 00000000..afde1ba8 --- /dev/null +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift @@ -0,0 +1,138 @@ +import Foundation +import Darwin + +/// Owner-only newline-delimited Unix-domain listener for a native Ghostty app. +public final class UnixControlServer: @unchecked Sendable { + public let path: String + + private static let maxSocketPathBytes = 104 + private let dispatcher: ControlDispatcher + private let queue: DispatchQueue + private let lock = NSLock() + private var listenerFD: Int32 = -1 + private var source: DispatchSourceRead? + + public init(path: String, dispatcher: ControlDispatcher) { + self.path = path + self.dispatcher = dispatcher + self.queue = DispatchQueue(label: "sharecli.ghostty-control", qos: .userInitiated) + } + + deinit { stop() } + + public func start() throws { + lock.lock() + defer { lock.unlock() } + guard listenerFD < 0 else { return } + guard path.utf8.count + 1 <= Self.maxSocketPathBytes else { + throw ControlError.invalidRequest("control socket path is too long") + } + removeExistingSocketIfSafe() + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw socketError("create control socket") } + do { + var address = try unixAddress(path) + let addressLength = socklen_t(MemoryLayout.size) + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, addressLength) + } + } + guard bound == 0 else { throw socketError("bind control socket") } + guard Darwin.listen(fd, 16) == 0 else { throw socketError("listen control socket") } + guard Darwin.fcntl(fd, F_SETFL, O_NONBLOCK) == 0 else { + throw socketError("configure control socket") + } + guard Darwin.chmod(path, mode_t(0o600)) == 0 else { + throw socketError("protect control socket") + } + listenerFD = fd + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + source.setEventHandler { [weak self] in self?.acceptConnections() } + source.setCancelHandler { Darwin.close(fd) } + source.resume() + self.source = source + } catch { + Darwin.close(fd) + unlink(path) + throw error + } + } + + public func stop() { + lock.lock() + let activeSource = source + source = nil + listenerFD = -1 + activeSource?.cancel() + unlink(path) + lock.unlock() + } + + private func acceptConnections() { + while true { + let fd = Darwin.accept(listenerFD, nil, nil) + if fd < 0 { + if errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR { return } + return + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.serveConnection(fd) + } + } + } + + private func serveConnection(_ fd: Int32) { + defer { Darwin.close(fd) } + var buffer = Data() + var chunk = [UInt8](repeating: 0, count: 16 * 1024) + while true { + let count = chunk.withUnsafeMutableBytes { Darwin.recv(fd, $0.baseAddress, $0.count, 0) } + if count <= 0 { return } + buffer.append(contentsOf: chunk[0.. Bool { + data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return true } + var sent = 0 + while sent < data.count { + let count = Darwin.send(fd, base.advanced(by: sent), data.count - sent, 0) + if count <= 0 { return false } + sent += count + } + return true + } + } + + private func removeExistingSocketIfSafe() { + var info = stat() + guard lstat(path, &info) == 0 else { return } + guard (info.st_mode & S_IFMT) == S_IFSOCK else { return } + unlink(path) + } + + private func unixAddress(_ path: String) throws -> sockaddr_un { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + path.withCString { pointer in + withUnsafeMutableBytes(of: &address.sun_path) { destination in + destination.copyBytes(from: UnsafeRawBufferPointer(start: pointer, count: path.utf8.count + 1)) + } + } + return address + } + + private func socketError(_ operation: String) -> ControlError { + .provider("\(operation): \(String(cString: strerror(errno)))") + } +} From bb30e2f93d72c18184bd357965953052cfddd1ed Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 1 Aug 2026 18:34:07 -0700 Subject: [PATCH 16/18] feat(ghostty): harden async control protocol --- contrib/ghostty-control/README.md | 10 +- .../ShareCLIGhosttyControl/Control.swift | 44 ++++-- .../UnixControlServer.swift | 19 ++- .../ControlTests.swift | 97 ++++++++++-- crates/sharecli-session/src/rpc.rs | 144 ++++++++++++++++-- .../04_IMPLEMENTATION_STRATEGY.md | 9 +- .../05_KNOWN_ISSUES.md | 6 +- .../06_TESTING_STRATEGY.md | 5 +- 8 files changed, 279 insertions(+), 55 deletions(-) diff --git a/contrib/ghostty-control/README.md b/contrib/ghostty-control/README.md index c293774d..2bb2e3a7 100644 --- a/contrib/ghostty-control/README.md +++ b/contrib/ghostty-control/README.md @@ -11,8 +11,14 @@ itself. The Ghostty app/fork must provide a concrete `SurfaceProvider` backed by its surface and PTY objects, then start `UnixControlServer` with that provider. The provider must expose stable surface IDs, process evidence, live -read/write/resize operations, and capability reporting. Keep all app and PTY -references on that side of the boundary. +read/write/resize operations, and capability reporting. Provider methods are +`async` so a `@MainActor` implementation can safely access Ghostty's live +surface tree without semaphore or synchronous cross-actor calls. Keep all app +and PTY references on that side of the boundary. + +Requests are bounded: the NDJSON request and read response are capped at 1 MiB, +and one `surface.io.send` payload is capped at 64 KiB. The provider result is +also checked against the requested read size before it is serialized. The listener is expected to be local-only and owner-readable/writable (filesystem mode `0600`). When a control token is configured, pass it to diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift index bfbc0547..f2aba0a9 100644 --- a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift @@ -66,11 +66,11 @@ public struct SurfaceCapabilities: Codable, Equatable, Sendable { /// The provider owns all app/PTY references. The dispatcher only validates /// requests and transports typed values; it never executes shell text. public protocol SurfaceProvider: Sendable { - func listSurfaces() throws -> [SurfaceRecord] - func send(surfaceID: String, bytes: [UInt8]) throws - func read(surfaceID: String, maxBytes: Int) throws -> [UInt8] - func resize(surfaceID: String, rows: UInt16, cols: UInt16) throws - func capabilities(surfaceID: String) throws -> SurfaceCapabilities + func listSurfaces() async throws -> [SurfaceRecord] + func send(surfaceID: String, bytes: [UInt8]) async throws + func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] + func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws + func capabilities(surfaceID: String) async throws -> SurfaceCapabilities } public enum ControlError: Error, Equatable, Sendable { @@ -109,6 +109,7 @@ extension ControlError { /// Newline-delimited JSON-RPC dispatcher used by a native Ghostty socket. public struct ControlDispatcher: Sendable { public static let maxRequestBytes = 1024 * 1024 + public static let maxSendBytes = 64 * 1024 public static let maxReadBytes = 1024 * 1024 private let provider: any SurfaceProvider @@ -120,7 +121,7 @@ public struct ControlDispatcher: Sendable { } /// Dispatch one complete JSON request and return one JSON response line. - public func dispatch(_ line: Data) -> Data { + public func dispatch(_ line: Data) async -> Data { do { guard line.count <= Self.maxRequestBytes else { throw ControlError.requestTooLarge } guard let object = try JSONSerialization.jsonObject(with: line) as? [String: Any] else { @@ -136,8 +137,16 @@ public struct ControlDispatcher: Sendable { guard let method = object["method"] as? String, !method.isEmpty else { throw ControlError.invalidRequest("method is required") } - let params = object["params"] as? [String: Any] ?? [:] - let result = try dispatch(method: method, params: params) + let params: [String: Any] + if let rawParams = object["params"] { + guard let objectParams = rawParams as? [String: Any] else { + throw ControlError.invalidParams("params must be a JSON object") + } + params = objectParams + } else { + params = [:] + } + let result = try await dispatch(method: method, params: params) return encode(["jsonrpc": "2.0", "id": id, "result": result]) } catch let error as ControlError { return encode(["jsonrpc": "2.0", "id": requestID(from: line), "error": ["code": error.code, "message": error.message]]) @@ -146,10 +155,10 @@ public struct ControlDispatcher: Sendable { } } - private func dispatch(method: String, params: [String: Any]) throws -> Any { + private func dispatch(method: String, params: [String: Any]) async throws -> Any { switch method { case "surface.list": - return try jsonObject(provider.listSurfaces()) + return try jsonObject(await provider.listSurfaces()) case "surface.io.send": let surfaceID = try stringParam(params, "surface_id") let text = params["text"] as? String @@ -158,7 +167,10 @@ public struct ControlDispatcher: Sendable { throw ControlError.invalidParams("exactly one of params.text or params.bytes is required") } let payload = try text.map { Array($0.utf8) } ?? bytesToUInt8(bytes!) - try provider.send(surfaceID: surfaceID, bytes: payload) + guard payload.count <= Self.maxSendBytes else { + throw ControlError.invalidParams("payload must not exceed 65536 bytes") + } + try await provider.send(surfaceID: surfaceID, bytes: payload) return NSNull() case "surface.io.read": let surfaceID = try stringParam(params, "surface_id") @@ -166,7 +178,11 @@ public struct ControlDispatcher: Sendable { guard maxBytes >= 0 && maxBytes <= Self.maxReadBytes else { throw ControlError.invalidParams("max_bytes must be between 0 and 1048576") } - return ["bytes": try provider.read(surfaceID: surfaceID, maxBytes: maxBytes)] + let bytes = try await provider.read(surfaceID: surfaceID, maxBytes: maxBytes) + guard bytes.count <= maxBytes else { + throw ControlError.provider("surface provider returned more bytes than requested") + } + return ["bytes": bytes] case "surface.io.resize": let surfaceID = try stringParam(params, "surface_id") let rows = try uint16Param(params, "rows") @@ -174,10 +190,10 @@ public struct ControlDispatcher: Sendable { guard rows > 0 && cols > 0 else { throw ControlError.invalidParams("rows and cols must be greater than zero") } - try provider.resize(surfaceID: surfaceID, rows: rows, cols: cols) + try await provider.resize(surfaceID: surfaceID, rows: rows, cols: cols) return NSNull() case "surface.io.capabilities": - return try jsonObject(provider.capabilities(surfaceID: stringParam(params, "surface_id"))) + return try jsonObject(await provider.capabilities(surfaceID: stringParam(params, "surface_id"))) default: throw ControlError.methodNotFound(method) } diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift index afde1ba8..3b3cb0c7 100644 --- a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift @@ -76,13 +76,24 @@ public final class UnixControlServer: @unchecked Sendable { if errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR { return } return } - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.serveConnection(fd) + guard peerBelongsToCurrentUser(fd) else { + Darwin.close(fd) + continue + } + Task.detached(priority: .userInitiated) { [weak self] in + await self?.serveConnection(fd) } } } - private func serveConnection(_ fd: Int32) { + private func peerBelongsToCurrentUser(_ fd: Int32) -> Bool { + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + guard getpeereid(fd, &peerUID, &peerGID) == 0 else { return false } + return peerUID == geteuid() + } + + private func serveConnection(_ fd: Int32) async { defer { Darwin.close(fd) } var buffer = Data() var chunk = [UInt8](repeating: 0, count: 16 * 1024) @@ -94,7 +105,7 @@ public final class UnixControlServer: @unchecked Sendable { while let newline = buffer.firstIndex(of: 0x0a) { let line = buffer.prefix(upTo: newline) buffer.removeSubrange(...newline) - var response = dispatcher.dispatch(Data(line)) + var response = await dispatcher.dispatch(Data(line)) response.append(0x0a) guard sendAll(fd, response) else { return } } diff --git a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift index 5660e976..465e7526 100644 --- a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift +++ b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift @@ -4,63 +4,128 @@ import Testing @testable import ShareCLIGhosttyControl private struct FakeProvider: SurfaceProvider { - func listSurfaces() throws -> [SurfaceRecord] { + func listSurfaces() async throws -> [SurfaceRecord] { [SurfaceRecord(id: "ghostty:1", title: "agent", cwd: "/tmp", process: nil)] } - func send(surfaceID: String, bytes: [UInt8]) throws {} - func read(surfaceID: String, maxBytes: Int) throws -> [UInt8] { Array("ok".utf8.prefix(maxBytes)) } - func resize(surfaceID: String, rows: UInt16, cols: UInt16) throws {} - func capabilities(surfaceID: String) throws -> SurfaceCapabilities { + func send(surfaceID: String, bytes: [UInt8]) async throws {} + func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] { Array("ok".utf8.prefix(maxBytes)) } + func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws {} + func capabilities(surfaceID: String) async throws -> SurfaceCapabilities { SurfaceCapabilities(read: true, write: true, resize: true, layout: false, durablePty: false) } } -@Test func listUsesRustCompatibleSnakeCase() throws { +@MainActor +private final class MainActorProvider: SurfaceProvider { + func listSurfaces() async throws -> [SurfaceRecord] { + [SurfaceRecord(id: "ghostty:main", title: "main", cwd: "/tmp", process: nil)] + } + + func send(surfaceID: String, bytes: [UInt8]) async throws {} + func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] { [] } + func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws {} + func capabilities(surfaceID: String) async throws -> SurfaceCapabilities { + SurfaceCapabilities(read: true, write: true, resize: true, layout: true, durablePty: true) + } +} + +private struct OversizedReadProvider: SurfaceProvider { + func listSurfaces() async throws -> [SurfaceRecord] { [] } + func send(surfaceID: String, bytes: [UInt8]) async throws {} + func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] { + [UInt8](repeating: 0, count: maxBytes + 1) + } + func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws {} + func capabilities(surfaceID: String) async throws -> SurfaceCapabilities { + SurfaceCapabilities(read: true, write: true, resize: true, layout: false, durablePty: false) + } +} + +@Test func listUsesRustCompatibleSnakeCase() async throws { let dispatcher = ControlDispatcher(provider: FakeProvider()) let line = Data(#"{"jsonrpc":"2.0","id":1,"method":"surface.list","params":{}}"#.utf8) - let response = try #require(JSONSerialization.jsonObject(with: dispatcher.dispatch(line)) as? [String: Any]) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) let result = try #require(response["result"] as? [[String: Any]]) #expect(result[0]["id"] as? String == "ghostty:1") } -@Test func tokenIsRequiredBeforeProviderAccess() throws { +@Test func tokenIsRequiredBeforeProviderAccess() async throws { let dispatcher = ControlDispatcher(provider: FakeProvider(), expectedToken: "secret") let line = Data(#"{"jsonrpc":"2.0","id":2,"method":"surface.list","params":{}}"#.utf8) - let response = try #require(JSONSerialization.jsonObject(with: dispatcher.dispatch(line)) as? [String: Any]) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) let error = try #require(response["error"] as? [String: Any]) #expect(error["code"] as? Int == -32001) } -@Test func sendRequiresExactlyOnePayload() throws { +@Test func mainActorProviderCanServeThroughDispatcher() async throws { + let dispatcher = ControlDispatcher(provider: MainActorProvider()) + let line = Data(#"{"jsonrpc":"2.0","id":6,"method":"surface.list","params":{}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let result = try #require(response["result"] as? [[String: Any]]) + #expect(result[0]["id"] as? String == "ghostty:main") +} + +@Test func nonObjectParamsAreRejected() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + let line = Data(#"{"jsonrpc":"2.0","id":7,"method":"surface.list","params":[]}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) +} + +@Test func sendRejectsOversizedPayload() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + let text = String(repeating: "x", count: ControlDispatcher.maxSendBytes + 1) + let object: [String: Any] = [ + "jsonrpc": "2.0", + "id": 8, + "method": "surface.io.send", + "params": ["surface_id": "ghostty:1", "text": text], + ] + let line = try JSONSerialization.data(withJSONObject: object) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32602) +} + +@Test func oversizedProviderReadIsRejected() async throws { + let dispatcher = ControlDispatcher(provider: OversizedReadProvider()) + let line = Data(#"{"jsonrpc":"2.0","id":10,"method":"surface.io.read","params":{"surface_id":"ghostty:1","max_bytes":8}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let error = try #require(response["error"] as? [String: Any]) + #expect(error["code"] as? Int == -32000) +} + +@Test func sendRequiresExactlyOnePayload() async throws { let dispatcher = ControlDispatcher(provider: FakeProvider()) let line = Data(#"{"jsonrpc":"2.0","id":3,"method":"surface.io.send","params":{"surface_id":"ghostty:1"}}"#.utf8) - let response = try #require(JSONSerialization.jsonObject(with: dispatcher.dispatch(line)) as? [String: Any]) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) let error = try #require(response["error"] as? [String: Any]) #expect(error["code"] as? Int == -32602) } -@Test func integerInputsRejectBooleansAndFloats() throws { +@Test func integerInputsRejectBooleansAndFloats() async throws { let dispatcher = ControlDispatcher(provider: FakeProvider()) for literal in ["true", "1.5"] { let line = Data("{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"surface.io.read\",\"params\":{\"surface_id\":\"ghostty:1\",\"max_bytes\":\(literal)}}".utf8) - let response = try #require(JSONSerialization.jsonObject(with: dispatcher.dispatch(line)) as? [String: Any]) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) let error = try #require(response["error"] as? [String: Any]) #expect(error["code"] as? Int == -32602) } } -@Test func byteInputsRejectBooleansAndFloats() throws { +@Test func byteInputsRejectBooleansAndFloats() async throws { let dispatcher = ControlDispatcher(provider: FakeProvider()) for literal in ["true", "1.5"] { let line = Data("{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"surface.io.send\",\"params\":{\"surface_id\":\"ghostty:1\",\"bytes\":[\(literal)]}}".utf8) - let response = try #require(JSONSerialization.jsonObject(with: dispatcher.dispatch(line)) as? [String: Any]) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) let error = try #require(response["error"] as? [String: Any]) #expect(error["code"] as? Int == -32602) } } -@Test func unixServerRoundTripsOneRequestLine() throws { +@Test func unixServerRoundTripsOneRequestLine() async throws { let path = "/tmp/sharecli-control-\(UUID().uuidString).sock" let server = UnixControlServer(path: path, dispatcher: ControlDispatcher(provider: FakeProvider())) try server.start() diff --git a/crates/sharecli-session/src/rpc.rs b/crates/sharecli-session/src/rpc.rs index ade08911..a827bc7c 100644 --- a/crates/sharecli-session/src/rpc.rs +++ b/crates/sharecli-session/src/rpc.rs @@ -5,6 +5,8 @@ use serde_json::Value; use std::sync::Arc; const JSON_RPC_VERSION: &str = "2.0"; +const MAX_SURFACE_SEND_BYTES: usize = 64 * 1024; +const MAX_SURFACE_LINE_BYTES: usize = 1024 * 1024; const MAX_SURFACE_READ_BYTES: usize = 1024 * 1024; #[derive(Debug, Deserialize)] pub struct Request { @@ -34,8 +36,6 @@ struct SurfaceRequest { id: Value, method: String, #[serde(default)] - params: Value, - #[serde(default)] token: Option, } #[derive(Debug, Serialize)] @@ -116,10 +116,22 @@ pub async fn dispatch(service: Arc, line: &str) -> Response { } pub async fn dispatch_surface(control: Arc, line: &str) -> SurfaceResponse { - let request = match serde_json::from_str::(line) { + let raw = match serde_json::from_str::(line) { + Ok(raw) => raw, + Err(error) => return surface_error(Value::Null, -32700, format!("parse error: {error}")), + }; + let params = match raw.get("params") { + None => Value::Object(serde_json::Map::new()), + Some(value) if value.is_object() => value.clone(), + Some(_) => { + let id = raw.get("id").cloned().unwrap_or(Value::Null); + return surface_error(id, -32602, "params must be a JSON object".to_string()); + } + }; + let request = match serde_json::from_value::(raw) { Ok(request) => request, Err(error) => { - return surface_error(Value::Null, -32700, format!("parse error: {error}")); + return surface_error(Value::Null, -32600, format!("invalid request: {error}")) } }; if request.jsonrpc != JSON_RPC_VERSION { @@ -127,7 +139,7 @@ pub async fn dispatch_surface(control: Arc, line: &str) -> S } let id = request.id; - let outcome = dispatch_surface_method(control.as_ref(), &request.method, request.params); + let outcome = dispatch_surface_method(control.as_ref(), &request.method, params); match outcome { Ok(result) => { SurfaceResponse { jsonrpc: JSON_RPC_VERSION, id, result: Some(result), error: None } @@ -174,6 +186,12 @@ fn dispatch_surface_method( )) } }; + if bytes.len() > MAX_SURFACE_SEND_BYTES { + return Err(( + -32602, + format!("payload must not exceed {MAX_SURFACE_SEND_BYTES} bytes"), + )); + } control.send(¶ms.surface_id, &bytes).map_err(control_error)?; Ok(Value::Null) } @@ -187,6 +205,16 @@ fn dispatch_surface_method( } let bytes = control.read(¶ms.surface_id, params.max_bytes).map_err(control_error)?; + if bytes.len() > params.max_bytes { + return Err(( + -32000, + format!( + "surface provider returned {} bytes for a {} byte read", + bytes.len(), + params.max_bytes + ), + )); + } serde_json::to_value(ReadResult { bytes }).map_err(control_error) } "surface.io.resize" => { @@ -263,7 +291,7 @@ pub async fn serve_surface_unix_with_token( control: Arc, expected_token: Option, ) -> Result<()> { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; let _ = std::fs::remove_file(path); if let Some(parent) = path.parent() { @@ -280,18 +308,36 @@ pub async fn serve_surface_unix_with_token( let expected_token = expected_token.clone(); tokio::spawn(async move { let (reader, mut writer) = stream.into_split(); - let mut lines = BufReader::new(reader).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let response = - dispatch_surface_with_token(control.clone(), &line, expected_token.as_deref()) - .await; - let Ok(mut payload) = serde_json::to_string(&response) else { + let mut input = reader; + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 16 * 1024]; + while let Ok(count) = input.read(&mut chunk).await { + if count == 0 { break; - }; - payload.push('\n'); - if writer.write_all(payload.as_bytes()).await.is_err() { + } + buffer.extend_from_slice(&chunk[..count]); + if buffer.len() > MAX_SURFACE_LINE_BYTES { break; } + while let Some(newline) = buffer.iter().position(|byte| *byte == b'\n') { + let Ok(line) = std::str::from_utf8(&buffer[..newline]) else { + return; + }; + let response = dispatch_surface_with_token( + control.clone(), + &line, + expected_token.as_deref(), + ) + .await; + buffer.drain(..=newline); + let Ok(mut payload) = serde_json::to_string(&response) else { + return; + }; + payload.push('\n'); + if writer.write_all(payload.as_bytes()).await.is_err() { + return; + } + } } }); } @@ -338,6 +384,32 @@ mod tests { } } + struct OverrunControl; + + impl SurfaceControl for OverrunControl { + fn send(&self, _surface_id: &str, _bytes: &[u8]) -> Result<()> { + Ok(()) + } + + fn read(&self, _surface_id: &str, max_bytes: usize) -> Result> { + Ok(vec![0; max_bytes + 1]) + } + + fn resize(&self, _surface_id: &str, _rows: u16, _cols: u16) -> Result<()> { + Ok(()) + } + + fn capabilities(&self, _surface_id: &str) -> Result { + Ok(SurfaceCapabilities { + read: true, + write: true, + resize: true, + layout: false, + durable_pty: false, + }) + } + } + async fn request( control: &Arc, id: u64, @@ -459,6 +531,48 @@ mod tests { assert_eq!(response.error.unwrap().code, -32602); } + #[tokio::test] + async fn non_object_surface_params_return_json_rpc_invalid_params() { + let control = Arc::new(RecordingControl::default()); + let response = request(&control, 8, "surface.list", json!(["not-an-object"])).await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32602); + } + + #[tokio::test] + async fn surface_send_rejects_oversized_payloads() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 10, + "surface.io.send", + json!({ + "surface_id": "surface-1", + "text": "x".repeat(MAX_SURFACE_SEND_BYTES + 1), + }), + ) + .await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32602); + } + + #[tokio::test] + async fn surface_read_rejects_provider_overruns() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 11, + "method": "surface.io.read", + "params": {"surface_id": "surface-1", "max_bytes": 8} + }) + .to_string(); + let response = dispatch_surface(Arc::new(OverrunControl), &raw).await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32000); + } + #[cfg(unix)] #[tokio::test] async fn unix_server_round_trips_surface_json_rpc() { diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md index 69675482..369f061e 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md @@ -21,7 +21,14 @@ Swift package. It defines the provider boundary, bounded JSON-RPC dispatcher, and owner-only Unix listener so a Ghostty fork can bind native split/pane/PTY objects without carrying ShareCLI's Rust workspace into the - app. + app. The provider boundary is asynchronous and can be implemented by a + `@MainActor` adapter; socket work stays off the app actor while each typed + operation hops back only for the short native surface operation. +- Keep the wire contract strict and bounded: omitted `params` means `{}`, an + explicit non-object is `-32602`, sends are capped at 64 KiB, reads at 1 MiB, + and provider read overruns fail closed. Event subscriptions require a + separate multiplexed writer design and are not smuggled into request/response + dispatch. - Keep layout persistence in ShareCLI and make the Ghostty adapter responsible only for applying a validated snapshot. This permits recovery when Ghostty is unavailable and avoids coupling the ledger to an app-specific tree API. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md index 5f1ba489..a66123bf 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md @@ -8,7 +8,11 @@ - `contrib/ghostty-control` is the native binding contract and tested dispatcher/listener, not a patched Ghostty.app. A fork must implement `SurfaceProvider` from Ghostty's live surface tree and PTY/screen model, then - start the listener from the app lifecycle. + start the listener from the app lifecycle. The protocol is asynchronous and + actor-safe, but the concrete provider and lifecycle wiring are still open. +- The current socket is request/response NDJSON only. Server-originated output + subscriptions need a per-connection serialized writer, bounded queue, and + cancellation protocol; they are intentionally a separate next gate. - The session watcher is intentionally a read-only consumer. A launcher or harness wrapper must call `session register` (or append the same `{surface_id,harness,session_id,pid}` record) before launch to provide exact diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md index 28df2893..39244586 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -13,8 +13,9 @@ Verified in this worktree: - `cargo test -p sharecli-session --offline -- --nocapture` also covers exact sidecar mappings, PID recycling fail-closed behavior, malformed JSONL, and append serialization; `session_cli` covers the registrar command. -- `swift test` in `contrib/ghostty-control` (dispatcher plus Unix listener - round-trip) +- `swift test --package-path contrib/ghostty-control` (10 tests: dispatcher, + strict input/size validation, token enforcement, MainActor provider crossing, + and Unix listener round-trip) - `cargo run -p sharecli-fuse --bin fuse-runtime-probe` (read-only host evidence) - `cargo fmt --all -- --check` - `git diff --check` From c22570c076f7a4e54414069bd0c9f3f442f6da5f Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sun, 2 Aug 2026 01:54:40 -0700 Subject: [PATCH 17/18] feat(sharecli): add bounded Ghostty live events and FUSE probe --- Cargo.lock | 1 + contrib/ghostty-control/README.md | 33 ++ .../ShareCLIGhosttyControl/Control.swift | 68 ++- .../ShareCLIGhosttyControl/LiveIO.swift | 195 +++++++ .../UnixControlServer.swift | 91 +++- .../ControlTests.swift | 17 + .../LiveIOTests.swift | 114 +++++ crates/sharecli-fuse/src/backend.rs | 93 +++- .../src/bin/fuse-runtime-probe.rs | 82 +-- crates/sharecli-fuse/src/lib.rs | 5 +- crates/sharecli-session/Cargo.toml | 3 +- crates/sharecli-session/src/events.rs | 290 +++++++++++ crates/sharecli-session/src/lib.rs | 6 + crates/sharecli-session/src/rpc.rs | 478 +++++++----------- crates/sharecli-session/src/rpc_tests.rs | 393 ++++++++++++++ crates/sharecli-session/src/rpc_transport.rs | 70 +++ .../01_RESEARCH.md | 55 +- .../02_SPECIFICATIONS.md | 16 + .../03_DAG_WBS.md | 15 +- .../04_IMPLEMENTATION_STRATEGY.md | 12 +- .../05_KNOWN_ISSUES.md | 18 +- .../06_TESTING_STRATEGY.md | 17 +- src/commands/fuse.rs | 35 +- src/main.rs | 58 ++- src/session.rs | 138 ++++- tests/integration_cli.rs | 14 + tests/session.rs | 62 +++ 27 files changed, 1951 insertions(+), 428 deletions(-) create mode 100644 contrib/ghostty-control/Sources/ShareCLIGhosttyControl/LiveIO.swift create mode 100644 contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/LiveIOTests.swift create mode 100644 crates/sharecli-session/src/events.rs create mode 100644 crates/sharecli-session/src/rpc_tests.rs create mode 100644 crates/sharecli-session/src/rpc_transport.rs diff --git a/Cargo.lock b/Cargo.lock index d01d203b..8d38426c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4635,6 +4635,7 @@ name = "sharecli-session" version = "0.1.0" dependencies = [ "anyhow", + "base64", "rusqlite", "serde", "serde_json", diff --git a/contrib/ghostty-control/README.md b/contrib/ghostty-control/README.md index 2bb2e3a7..267de50c 100644 --- a/contrib/ghostty-control/README.md +++ b/contrib/ghostty-control/README.md @@ -20,6 +20,39 @@ Requests are bounded: the NDJSON request and read response are capped at 1 MiB, and one `surface.io.send` payload is capped at 64 KiB. The provider result is also checked against the requested read size before it is serialized. +The bridge also supports `surface.io.subscribe` / `surface.io.unsubscribe`. +Each subscription has a bounded queue (maximum 256 entries), output chunks are +limited to 64 KiB, and server-originated `surface.io.event` notifications carry +one monotonically increasing sequence plus an explicit dropped/resync marker. +The listener serializes all socket writes, so a slow watcher cannot block the +Ghostty actor. On the ShareCLI side, consume this stream with: + +```sh +sharecli surface watch --surface-id +``` + +Requests without a JSON-RPC `id` are notifications and receive no response. +The native provider still has to publish PTY events into `LiveIOEventHub`; the +bridge does not infer terminal output from AppleScript or shell processes. + +### Fork lifecycle checklist + +The provider belongs in the Ghostty app target, beside the app/surface registry; +this package must not retain `ghostty_app_t`, `ghostty_surface_t`, or `SurfaceView` +references. Bind the listener after `Ghostty.App` reaches its ready state and +stop it before app/surface teardown. Resolve each request through a weak +`SurfaceView` record keyed by its UUID, then hop to `@MainActor` for the short +operation. Never use a raw C pointer as a durable surface ID: upstream frees the +underlying `ghostty_surface_t` with the owning `SurfaceView`. + +The upstream C API provides foreground PID/TTY and bounded screen-text reads, +but no public raw-PTY subscription callback. A fork must publish output into +`LiveIOEventHub` from its own termio/app callback instrumentation and should +publish title, cwd, resize, and child-exit changes from the corresponding +`SurfaceView`/Ghostty callbacks. If that instrumentation is unavailable, report +`read: false`/live events unavailable rather than scraping AppleScript or +executing a shell command. + The listener is expected to be local-only and owner-readable/writable (filesystem mode `0600`). When a control token is configured, pass it to `ControlDispatcher(provider:expectedToken:)`; every request must then include diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift index f2aba0a9..d0b05669 100644 --- a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/Control.swift @@ -80,6 +80,7 @@ public enum ControlError: Error, Equatable, Sendable { case unauthorized case provider(String) case requestTooLarge + case liveIO(String) } extension ControlError { @@ -90,13 +91,14 @@ extension ControlError { case .methodNotFound: return -32601 case .unauthorized: return -32001 case .provider: return -32000 + case .liveIO: return -32000 case .requestTooLarge: return -32600 } } var message: String { switch self { - case let .invalidRequest(message), let .invalidParams(message), let .methodNotFound(message), let .provider(message): + case let .invalidRequest(message), let .invalidParams(message), let .methodNotFound(message), let .provider(message), let .liveIO(message): return message case .unauthorized: return "invalid control token" @@ -114,10 +116,16 @@ public struct ControlDispatcher: Sendable { private let provider: any SurfaceProvider private let expectedToken: String? + public let liveEvents: LiveIOEventHub? - public init(provider: any SurfaceProvider, expectedToken: String? = nil) { + public init( + provider: any SurfaceProvider, + expectedToken: String? = nil, + liveEvents: LiveIOEventHub? = nil + ) { self.provider = provider self.expectedToken = expectedToken + self.liveEvents = liveEvents } /// Dispatch one complete JSON request and return one JSON response line. @@ -137,6 +145,7 @@ public struct ControlDispatcher: Sendable { guard let method = object["method"] as? String, !method.isEmpty else { throw ControlError.invalidRequest("method is required") } + let isNotification = object["id"] == nil let params: [String: Any] if let rawParams = object["params"] { guard let objectParams = rawParams as? [String: Any] else { @@ -147,10 +156,16 @@ public struct ControlDispatcher: Sendable { params = [:] } let result = try await dispatch(method: method, params: params) + if isNotification { return Data() } return encode(["jsonrpc": "2.0", "id": id, "result": result]) } catch let error as ControlError { + if !requestHasID(line) { return Data() } return encode(["jsonrpc": "2.0", "id": requestID(from: line), "error": ["code": error.code, "message": error.message]]) + } catch let error as LiveIOError { + if !requestHasID(line) { return Data() } + return encode(["jsonrpc": "2.0", "id": requestID(from: line), "error": ["code": -32602, "message": String(describing: error)]]) } catch { + if !requestHasID(line) { return Data() } return encode(["jsonrpc": "2.0", "id": requestID(from: line), "error": ["code": -32700, "message": "parse error: \(error)"]]) } } @@ -194,6 +209,32 @@ public struct ControlDispatcher: Sendable { return NSNull() case "surface.io.capabilities": return try jsonObject(await provider.capabilities(surfaceID: stringParam(params, "surface_id"))) + case "surface.io.subscribe": + guard let liveEvents else { throw ControlError.liveIO("live surface events unavailable") } + let surfaceID = params["surface_id"] as? String + let fromSequence = try optionalUInt64Param(params, "from_seq") + let maxChunkBytes = try intParamOrDefault(params, "max_chunk_bytes", default: LiveIOEventHub.maxChunkBytes) + let queueCapacity = try intParamOrDefault(params, "queue_capacity", default: 64) + let subscription = try await liveEvents.subscribe( + surfaceID: surfaceID, + fromSequence: fromSequence, + maxChunkBytes: maxChunkBytes, + queueCapacity: queueCapacity + ) + let nextSequence = await liveEvents.nextSequenceNumber() + return [ + "subscription_id": subscription.id, + "next_seq": max(nextSequence, fromSequence ?? 0), + "capabilities": [ + "max_chunk_bytes": maxChunkBytes, + "queue_capacity": queueCapacity, + "replay": false, + ], + ] + case "surface.io.unsubscribe": + guard let liveEvents else { throw ControlError.liveIO("live surface events unavailable") } + let subscriptionID = try uint64Param(params, "subscription_id") + return ["unsubscribed": await liveEvents.unsubscribe(subscriptionID: subscriptionID)] default: throw ControlError.methodNotFound(method) } @@ -219,6 +260,22 @@ public struct ControlDispatcher: Sendable { return UInt16(value) } + private func uint64Param(_ params: [String: Any], _ name: String) throws -> UInt64 { + let value = try intParam(params, name) + guard value >= 0 else { throw ControlError.invalidParams("params.\(name) is out of range") } + return UInt64(value) + } + + private func optionalUInt64Param(_ params: [String: Any], _ name: String) throws -> UInt64? { + guard params[name] != nil else { return nil } + return try uint64Param(params, name) + } + + private func intParamOrDefault(_ params: [String: Any], _ name: String, default value: Int) throws -> Int { + guard params[name] != nil else { return value } + return try intParam(params, name) + } + private func bytesToUInt8(_ values: [Any]) throws -> [UInt8] { try values.map { value in guard let number = strictInteger(value), number.intValue >= 0 && number.intValue <= 255 else { @@ -247,4 +304,11 @@ public struct ControlDispatcher: Sendable { private func requestID(from line: Data) -> Any { ((try? JSONSerialization.jsonObject(with: line) as? [String: Any])?["id"]) ?? NSNull() } + + private func requestHasID(_ line: Data) -> Bool { + guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else { + return false + } + return object["id"] != nil + } } diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/LiveIO.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/LiveIO.swift new file mode 100644 index 00000000..689f3194 --- /dev/null +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/LiveIO.swift @@ -0,0 +1,195 @@ +import Foundation + +public enum LiveIOError: Error, Equatable, Sendable { + case invalidChunkBytes + case invalidQueueCapacity + case closed +} + +public enum LiveIOEventKind: String, Codable, Sendable { + case output + case resize + case exit + case title + case cwd + case dropped +} + +public struct LiveIOEvent: Codable, Equatable, Sendable { + public let subscriptionID: UInt64 + public let surfaceID: String + public let seq: UInt64 + public let kind: LiveIOEventKind + /// RFC3339 timestamp supplied by the Ghostty app, when available. + /// + /// The Rust/root client contract uses an optional string here so native and + /// non-native transports decode the same event envelope. + public let timestamp: String? + public let eventBytesBase64: String? + public let dropped: Int? + public let resyncRequired: Bool? + + public init( + subscriptionID: UInt64, + surfaceID: String, + seq: UInt64, + kind: LiveIOEventKind, + timestamp: String? = nil, + eventBytesBase64: String? = nil, + dropped: Int? = nil, + resyncRequired: Bool? = nil + ) { + self.subscriptionID = subscriptionID + self.surfaceID = surfaceID + self.seq = seq + self.kind = kind + self.timestamp = timestamp + self.eventBytesBase64 = eventBytesBase64 + self.dropped = dropped + self.resyncRequired = resyncRequired + } + + enum CodingKeys: String, CodingKey { + case subscriptionID = "subscription_id" + case surfaceID = "surface_id" + case seq, kind, timestamp + case eventBytesBase64 = "event_bytes_base64" + case dropped + case resyncRequired = "resync_required" + } +} + +public struct LiveIOSubscription: AsyncSequence, Sendable { + public typealias Element = LiveIOEvent + public typealias AsyncIterator = AsyncStream.Iterator + + public let id: UInt64 + let stream: AsyncStream + + public func makeAsyncIterator() -> AsyncIterator { + stream.makeAsyncIterator() + } +} + +/// Actor-isolated live event fanout with bounded per-subscriber buffering. +public actor LiveIOEventHub { + public static let maxChunkBytes = 64 * 1024 + public static let maxQueueCapacity = 256 + + private struct State { + let surfaceID: String? + let fromSequence: UInt64 + let maxChunkBytes: Int + let queueCapacity: Int + let continuation: AsyncStream.Continuation + let stream: AsyncStream + var dropped: Int = 0 + } + + private var nextSubscriptionID: UInt64 = 0 + private var nextSequence: UInt64 = 0 + private var subscriptions: [UInt64: State] = [:] + + public init() {} + + public func subscribe( + surfaceID: String?, + fromSequence: UInt64?, + maxChunkBytes: Int = LiveIOEventHub.maxChunkBytes, + queueCapacity: Int = 64 + ) throws -> LiveIOSubscription { + guard (1...Self.maxChunkBytes).contains(maxChunkBytes) else { + throw LiveIOError.invalidChunkBytes + } + guard (1...Self.maxQueueCapacity).contains(queueCapacity) else { + throw LiveIOError.invalidQueueCapacity + } + nextSubscriptionID &+= 1 + let id = nextSubscriptionID + let startingSequence = fromSequence ?? nextSequence &+ 1 + var continuation: AsyncStream.Continuation! + let stream = AsyncStream(bufferingPolicy: .bufferingNewest(queueCapacity)) { + continuation = $0 + } + subscriptions[id] = State( + surfaceID: surfaceID, + fromSequence: startingSequence, + maxChunkBytes: maxChunkBytes, + queueCapacity: queueCapacity, + continuation: continuation, + stream: stream + ) + return LiveIOSubscription(id: id, stream: stream) + } + + /// The next sequence number that a newly-created subscription would see. + public func nextSequenceNumber() -> UInt64 { + nextSequence &+ 1 + } + + public func subscription(id: UInt64) -> LiveIOSubscription? { + guard let state = subscriptions[id] else { return nil } + return LiveIOSubscription(id: id, stream: state.stream) + } + + @discardableResult + public func publish( + surfaceID: String, + kind: LiveIOEventKind, + bytes: [UInt8], + timestamp: String? = nil + ) throws -> UInt64 { + let limits = subscriptions.values + .filter { $0.surfaceID == nil || $0.surfaceID == surfaceID } + .map(\.maxChunkBytes) + let chunkSize = limits.min() ?? Self.maxChunkBytes + let chunks: [[UInt8]] = bytes.isEmpty ? [[]] : stride(from: 0, to: bytes.count, by: chunkSize).map { + Array(bytes[$0 ..< min($0 + chunkSize, bytes.count)]) + } + + var sequence = nextSequence + for chunk in chunks { + sequence &+= 1 + nextSequence = sequence + let encoded = chunk.isEmpty ? nil : Data(chunk).base64EncodedString() + let matchingIDs = subscriptions.compactMap { id, state in + (state.surfaceID == nil || state.surfaceID == surfaceID) && sequence >= state.fromSequence + ? id + : nil + } + for id in matchingIDs { + guard var state = subscriptions[id] else { continue } + let event = LiveIOEvent( + subscriptionID: id, + surfaceID: surfaceID, + seq: sequence, + kind: kind, + timestamp: timestamp, + eventBytesBase64: encoded, + dropped: state.dropped > 0 ? state.dropped : nil, + resyncRequired: state.dropped > 0 ? true : nil + ) + let result = state.continuation.yield(event) + if case .dropped = result { + state.dropped += 1 + } else { + state.dropped = 0 + } + subscriptions[id] = state + } + } + return sequence + } + + @discardableResult + public func unsubscribe(subscriptionID: UInt64) -> Bool { + guard let state = subscriptions.removeValue(forKey: subscriptionID) else { return false } + state.continuation.finish() + return true + } + + @discardableResult + public func unsubscribe(_ subscription: LiveIOSubscription) -> Bool { + unsubscribe(subscriptionID: subscription.id) + } +} diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift index 3b3cb0c7..3b32d818 100644 --- a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/UnixControlServer.swift @@ -94,7 +94,12 @@ public final class UnixControlServer: @unchecked Sendable { } private func serveConnection(_ fd: Int32) async { - defer { Darwin.close(fd) } + let writer = SocketWriter(fd: fd) + var eventTasks: [Task] = [] + defer { + eventTasks.forEach { $0.cancel() } + writer.close() + } var buffer = Data() var chunk = [UInt8](repeating: 0, count: 16 * 1024) while true { @@ -106,23 +111,46 @@ public final class UnixControlServer: @unchecked Sendable { let line = buffer.prefix(upTo: newline) buffer.removeSubrange(...newline) var response = await dispatcher.dispatch(Data(line)) - response.append(0x0a) - guard sendAll(fd, response) else { return } + if !response.isEmpty { + response.append(0x0a) + guard writer.send(response) else { return } + } + if let subscriptionID = subscriptionID(from: response), + let liveEvents = dispatcher.liveEvents, + let subscription = await liveEvents.subscription(id: subscriptionID) { + let task = Task.detached(priority: .userInitiated) { [writer] in + for await event in subscription { + guard let data = Self.encodeEvent(event) else { return } + var line = data + line.append(0x0a) + guard writer.send(line) else { return } + } + } + eventTasks.append(task) + } } } } - private func sendAll(_ fd: Int32, _ data: Data) -> Bool { - data.withUnsafeBytes { rawBuffer in - guard let base = rawBuffer.baseAddress else { return true } - var sent = 0 - while sent < data.count { - let count = Darwin.send(fd, base.advanced(by: sent), data.count - sent, 0) - if count <= 0 { return false } - sent += count - } - return true + private static func encodeEvent(_ event: LiveIOEvent) -> Data? { + guard let params = try? JSONSerialization.jsonObject(with: JSONEncoder().encode(event)) else { + return nil } + return try? JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "method": "surface.io.event", + "params": params, + ]) + } + + private func subscriptionID(from response: Data) -> UInt64? { + guard let object = try? JSONSerialization.jsonObject(with: response) as? [String: Any], + let result = object["result"] as? [String: Any] else { return nil } + guard let value = result["subscription_id"] as? NSNumber else { return nil } + let type = String(cString: value.objCType) + guard ["i", "s", "l", "q", "I", "S", "L", "Q"].contains(type), + value.int64Value >= 0 else { return nil } + return value.uint64Value } private func removeExistingSocketIfSafe() { @@ -147,3 +175,40 @@ public final class UnixControlServer: @unchecked Sendable { .provider("\(operation): \(String(cString: strerror(errno)))") } } + +private final class SocketWriter: @unchecked Sendable { + private let fd: Int32 + private let lock = NSLock() + private var closed = false + + init(fd: Int32) { + self.fd = fd + } + + func send(_ data: Data) -> Bool { + lock.lock() + defer { lock.unlock() } + guard !closed else { return false } + return data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return true } + var sent = 0 + while sent < data.count { + let count = Darwin.send(fd, base.advanced(by: sent), data.count - sent, 0) + if count <= 0 { return false } + sent += count + } + return true + } + } + + func close() { + lock.lock() + guard !closed else { + lock.unlock() + return + } + closed = true + Darwin.close(fd) + lock.unlock() + } +} diff --git a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift index 465e7526..10c25dc0 100644 --- a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift +++ b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/ControlTests.swift @@ -125,6 +125,23 @@ private struct OversizedReadProvider: SurfaceProvider { } } +@Test func liveSubscriptionDispatchReturnsBoundedAck() async throws { + let hub = LiveIOEventHub() + let dispatcher = ControlDispatcher(provider: FakeProvider(), liveEvents: hub) + let line = Data(#"{"jsonrpc":"2.0","id":11,"method":"surface.io.subscribe","params":{"surface_id":"ghostty:1","max_chunk_bytes":1024,"queue_capacity":4}}"#.utf8) + let response = try #require(JSONSerialization.jsonObject(with: await dispatcher.dispatch(line)) as? [String: Any]) + let result = try #require(response["result"] as? [String: Any]) + #expect(result["subscription_id"] as? UInt64 == 1) + #expect(result["next_seq"] as? UInt64 == 1) + #expect((result["capabilities"] as? [String: Any])?["queue_capacity"] as? Int == 4) +} + +@Test func notificationsProduceNoResponseBytes() async throws { + let dispatcher = ControlDispatcher(provider: FakeProvider()) + let line = Data(#"{"jsonrpc":"2.0","method":"surface.io.send","params":{"surface_id":"ghostty:1","text":"hello"}}"#.utf8) + #expect(await dispatcher.dispatch(line).isEmpty) +} + @Test func unixServerRoundTripsOneRequestLine() async throws { let path = "/tmp/sharecli-control-\(UUID().uuidString).sock" let server = UnixControlServer(path: path, dispatcher: ControlDispatcher(provider: FakeProvider())) diff --git a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/LiveIOTests.swift b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/LiveIOTests.swift new file mode 100644 index 00000000..20655b5f --- /dev/null +++ b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/LiveIOTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing +@testable import ShareCLIGhosttyControl + +@Test func liveEventsCarryWireEnvelopeAndMonotonicSequence() async throws { + let hub = LiveIOEventHub() + let subscription = try await hub.subscribe( + surfaceID: "ghostty:1", + fromSequence: nil, + maxChunkBytes: 1024, + queueCapacity: 8 + ) + + let sequence = try await hub.publish( + surfaceID: "ghostty:1", + kind: .output, + bytes: Array("hello".utf8), + timestamp: "1970-01-01T00:00:42Z" + ) + #expect(sequence == 1) + + var iterator = subscription.makeAsyncIterator() + let event = try #require(await iterator.next()) + #expect(event.subscriptionID == subscription.id) + #expect(event.surfaceID == "ghostty:1") + #expect(event.seq == sequence) + #expect(event.kind == .output) + #expect(event.timestamp == "1970-01-01T00:00:42Z") + #expect(event.eventBytesBase64 == Data("hello".utf8).base64EncodedString()) + #expect(event.dropped == nil) + #expect(event.resyncRequired == nil) + + let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(event)) as? [String: Any] + #expect(encoded?["subscription_id"] as? UInt64 == subscription.id) + #expect(encoded?["event_bytes_base64"] as? String == Data("hello".utf8).base64EncodedString()) +} + +@Test func liveEventQueueIsBoundedAndReportsDroppedEvents() async throws { + let hub = LiveIOEventHub() + let subscription = try await hub.subscribe( + surfaceID: nil, + fromSequence: nil, + maxChunkBytes: 1024, + queueCapacity: 2 + ) + + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [1], timestamp: "1970-01-01T00:00:01Z") + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [2], timestamp: "1970-01-01T00:00:02Z") + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [3], timestamp: "1970-01-01T00:00:03Z") + + var iterator = subscription.makeAsyncIterator() + let first = try #require(await iterator.next()) + let second = try #require(await iterator.next()) + #expect(first.seq == 2) + #expect(second.seq == 3) + + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [4], timestamp: "1970-01-01T00:00:04Z") + let fourth = try #require(await iterator.next()) + #expect(fourth.seq == 4) + #expect(fourth.dropped == 1) + #expect(fourth.resyncRequired == true) +} + +@Test func liveEventSubscriptionFiltersSurfaceAndStartingSequence() async throws { + let hub = LiveIOEventHub() + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [1], timestamp: "1970-01-01T00:00:01Z") + _ = try await hub.publish(surfaceID: "ghostty:2", kind: .output, bytes: [2], timestamp: "1970-01-01T00:00:02Z") + + let subscription = try await hub.subscribe( + surfaceID: "ghostty:1", + fromSequence: 2, + maxChunkBytes: 1024, + queueCapacity: 8 + ) + _ = try await hub.publish(surfaceID: "ghostty:1", kind: .output, bytes: [3], timestamp: "1970-01-01T00:00:03Z") + _ = try await hub.publish(surfaceID: "ghostty:2", kind: .output, bytes: [4], timestamp: "1970-01-01T00:00:04Z") + + var iterator = subscription.makeAsyncIterator() + let event = try #require(await iterator.next()) + #expect(event.surfaceID == "ghostty:1") + #expect(event.seq == 3) + #expect(event.eventBytesBase64 == Data([3]).base64EncodedString()) +} + +@Test func liveEventUnsubscribeFinishesAsyncSequence() async throws { + let hub = LiveIOEventHub() + let subscription = try await hub.subscribe( + surfaceID: nil, + fromSequence: nil, + maxChunkBytes: 1024, + queueCapacity: 8 + ) + var iterator = subscription.makeAsyncIterator() + await hub.unsubscribe(subscription) + #expect(await iterator.next() == nil) +} + +@Test func liveEventLimitsAreValidated() async throws { + let hub = LiveIOEventHub() + do { + _ = try await hub.subscribe(surfaceID: nil, fromSequence: nil, maxChunkBytes: 0, queueCapacity: 1) + Issue.record("zero max_chunk_bytes must be rejected") + } catch LiveIOError.invalidChunkBytes { } + + do { + _ = try await hub.subscribe(surfaceID: nil, fromSequence: nil, maxChunkBytes: 65_537, queueCapacity: 1) + Issue.record("max_chunk_bytes above the wire limit must be rejected") + } catch LiveIOError.invalidChunkBytes { } + + do { + _ = try await hub.subscribe(surfaceID: nil, fromSequence: nil, maxChunkBytes: 1, queueCapacity: 257) + Issue.record("queue capacity above the wire limit must be rejected") + } catch LiveIOError.invalidQueueCapacity { } +} diff --git a/crates/sharecli-fuse/src/backend.rs b/crates/sharecli-fuse/src/backend.rs index f0079053..515db32f 100644 --- a/crates/sharecli-fuse/src/backend.rs +++ b/crates/sharecli-fuse/src/backend.rs @@ -1,6 +1,9 @@ //! Runtime backend negotiation for macFUSE on macOS. -use std::{path::Path, process::Command}; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; /// Selected interception backend for ShareCLI's optional filesystem layer. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -13,6 +16,17 @@ pub enum FuseBackend { Unavailable, } +impl FuseBackend { + /// Stable operator/JSON label for the selected backend. + pub const fn as_str(self) -> &'static str { + match self { + Self::Kernel => "kext", + Self::Fskit => "fskit", + Self::Unavailable => "non-fuse", + } + } +} + /// Host capabilities used to make a deterministic backend decision. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct FuseCapabilities { @@ -54,6 +68,48 @@ pub struct FuseBackendSelection { pub diagnostic: Option, } +/// Read-only runtime evidence used by the operator probe and diagnostics. +/// +/// This deliberately records capability evidence without loading a kext, +/// changing approval state, mounting a volume, or prompting for privilege. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FuseRuntimeEvidence { + /// Platform reported by Rust's target constants. + pub platform: &'static str, + /// Mount point used for backend selection. + pub mountpoint: PathBuf, + /// Whether `kmutil showloaded` reported a macFUSE KEXT. + pub kernel_loaded: bool, + /// Whether the macFUSE MFMount framework is installed. + pub fskit_framework: bool, + /// Whether framework presence and explicit operator approval were both verified. + pub fskit_approved: bool, + /// Backend selected by the deterministic policy. + pub selection: FuseBackendSelection, + /// Non-FUSE execution remains available when no backend is verified. + pub non_fuse_fallback: bool, +} + +/// Gather read-only runtime evidence and apply KEXT -> FSKit -> non-FUSE policy. +pub fn probe_runtime(mountpoint: &Path) -> FuseRuntimeEvidence { + let kernel_loaded = kernel_backend_loaded(); + let fskit_framework = fskit_framework_available(); + let fskit_approved = fskit_framework && fskit_approval_requested(); + let selection = select_backend_for_mount_with( + FuseCapabilities { kernel_loaded, fskit_approved }, + mountpoint, + ); + FuseRuntimeEvidence { + platform: std::env::consts::OS, + mountpoint: mountpoint.to_path_buf(), + kernel_loaded, + fskit_framework, + fskit_approved, + selection, + non_fuse_fallback: true, + } +} + /// Select KEXT first, then approved FSKit, otherwise fail open. pub fn select_backend_with(capabilities: FuseCapabilities) -> FuseBackend { if capabilities.kernel_loaded { @@ -126,15 +182,26 @@ pub fn select_backend_for_mount(mountpoint: &Path) -> FuseBackendSelection { } fn fskit_backend_approved() -> bool { + fskit_framework_available() && fskit_approval_requested() +} + +fn fskit_framework_available() -> bool { + #[cfg(target_os = "macos")] + { + Path::new("/Library/Filesystems/macfuse.fs/Contents/Frameworks/MFMount.framework").is_dir() + } + #[cfg(not(target_os = "macos"))] + { + false + } +} + +fn fskit_approval_requested() -> bool { #[cfg(target_os = "macos")] { - let mfmount_available = - Path::new("/Library/Filesystems/macfuse.fs/Contents/Frameworks/MFMount.framework") - .is_dir(); - let explicitly_approved = std::env::var("SHARECLI_FUSE_FSKIT_APPROVED") + std::env::var("SHARECLI_FUSE_FSKIT_APPROVED") .map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) - .unwrap_or(false); - mfmount_available && explicitly_approved + .unwrap_or(false) } #[cfg(not(target_os = "macos"))] { @@ -143,6 +210,11 @@ fn fskit_backend_approved() -> bool { } fn kernel_backend_loaded() -> bool { + #[cfg(not(target_os = "macos"))] + { + false + } + #[cfg(target_os = "macos")] Command::new("kmutil") .args(["showloaded"]) .output() @@ -212,4 +284,11 @@ mod tests { assert_eq!(selection.backend, FuseBackend::Unavailable); assert_eq!(selection.diagnostic, Some(FuseBackendDiagnostic::NoVerifiedBackend)); } + + #[test] + fn runtime_probe_always_advertises_non_fuse_fallback() { + let evidence = probe_runtime(Path::new("/tmp/sharecli-fuse-probe")); + assert!(evidence.non_fuse_fallback); + assert!(!evidence.selection.backend.as_str().is_empty()); + } } diff --git a/crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs b/crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs index 68ba88ae..b4f4b1f5 100644 --- a/crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs +++ b/crates/sharecli-fuse/src/bin/fuse-runtime-probe.rs @@ -4,80 +4,24 @@ //! prompts for privilege. It reports the deterministic KEXT -> FSKit -> //! non-FUSE selection used by ShareCLI. -use serde::Serialize; -use sharecli_fuse::{ - select_backend_for_mount_with, FuseBackend, FuseBackendDiagnostic, FuseCapabilities, -}; -use std::path::{Path, PathBuf}; -use std::process::Command; - -#[derive(Serialize)] -struct ProbeReport { - platform: &'static str, - mountpoint: PathBuf, - kernel_loaded: bool, - fskit_framework: bool, - fskit_approved: bool, - selected_backend: &'static str, - diagnostic: Option<&'static str>, - non_fuse_fallback: bool, -} +use sharecli_fuse::probe_runtime; +use std::path::PathBuf; fn main() { let mountpoint = std::env::args_os() .nth(1) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("/tmp/sharecli-fuse-runtime-probe")); - let kernel_loaded = kernel_backend_loaded(); - let fskit_framework = mfmount_framework_available(); - let fskit_approved = fskit_framework && fskit_approved_by_operator(); - let selection = select_backend_for_mount_with( - FuseCapabilities { kernel_loaded, fskit_approved }, - &mountpoint, - ); - let report = ProbeReport { - platform: std::env::consts::OS, - mountpoint, - kernel_loaded, - fskit_framework, - fskit_approved, - selected_backend: backend_name(selection.backend), - diagnostic: selection.diagnostic.map(diagnostic_message), - non_fuse_fallback: true, - }; + let evidence = probe_runtime(&mountpoint); + let report = serde_json::json!({ + "platform": evidence.platform, + "mountpoint": evidence.mountpoint, + "kernel_loaded": evidence.kernel_loaded, + "fskit_framework": evidence.fskit_framework, + "fskit_approved": evidence.fskit_approved, + "selected_backend": evidence.selection.backend.as_str(), + "diagnostic": evidence.selection.diagnostic.map(|diagnostic| diagnostic.message()), + "non_fuse_fallback": evidence.non_fuse_fallback, + }); println!("{}", serde_json::to_string_pretty(&report).expect("serialize probe report")); } - -fn backend_name(backend: FuseBackend) -> &'static str { - match backend { - FuseBackend::Kernel => "kext", - FuseBackend::Fskit => "fskit", - FuseBackend::Unavailable => "non-fuse", - } -} - -fn diagnostic_message(diagnostic: FuseBackendDiagnostic) -> &'static str { - diagnostic.message() -} - -fn kernel_backend_loaded() -> bool { - Command::new("kmutil") - .args(["showloaded"]) - .output() - .map(|output| { - let mut text = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase(); - text.push_str(&String::from_utf8_lossy(&output.stderr).to_ascii_lowercase()); - text.contains("macfuse") - }) - .unwrap_or(false) -} - -fn mfmount_framework_available() -> bool { - Path::new("/Library/Filesystems/macfuse.fs/Contents/Frameworks/MFMount.framework").is_dir() -} - -fn fskit_approved_by_operator() -> bool { - std::env::var("SHARECLI_FUSE_FSKIT_APPROVED") - .map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) - .unwrap_or(false) -} diff --git a/crates/sharecli-fuse/src/lib.rs b/crates/sharecli-fuse/src/lib.rs index 09331244..03149e77 100644 --- a/crates/sharecli-fuse/src/lib.rs +++ b/crates/sharecli-fuse/src/lib.rs @@ -47,8 +47,9 @@ mod write_serialize_meters; pub use agent_cow::{AgentCowStore, AgentPending}; pub use agents_conf::{sanitize_agent_id, AgentsConf}; pub use backend::{ - select_backend, select_backend_for_mount, select_backend_for_mount_with, select_backend_with, - FuseBackend, FuseBackendDiagnostic, FuseBackendSelection, FuseCapabilities, + probe_runtime, select_backend, select_backend_for_mount, select_backend_for_mount_with, + select_backend_with, FuseBackend, FuseBackendDiagnostic, FuseBackendSelection, + FuseCapabilities, FuseRuntimeEvidence, }; #[cfg(any(target_os = "linux", target_os = "macos", windows))] pub use cow_session::CowMountHandle; diff --git a/crates/sharecli-session/Cargo.toml b/crates/sharecli-session/Cargo.toml index 2cc2cc39..059a7c3c 100644 --- a/crates/sharecli-session/Cargo.toml +++ b/crates/sharecli-session/Cargo.toml @@ -5,7 +5,8 @@ edition = "2021" [dependencies] anyhow = "1" +base64 = "0.22" rusqlite = { version = "0.40", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["net", "io-util", "fs", "rt", "macros"] } +tokio = { version = "1", features = ["net", "io-util", "fs", "rt", "macros", "time"] } diff --git a/crates/sharecli-session/src/events.rs b/crates/sharecli-session/src/events.rs new file mode 100644 index 00000000..2d489b09 --- /dev/null +++ b/crates/sharecli-session/src/events.rs @@ -0,0 +1,290 @@ +//! Bounded, sequenced surface events for live terminal I/O. + +use base64::Engine; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; +use std::sync::Mutex; + +pub const MAX_EVENT_CHUNK_BYTES: usize = 64 * 1024; +pub const MAX_EVENT_QUEUE_CAPACITY: usize = 256; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SurfaceEventKind { + Output, + Resize, + Exit, + Title, + Cwd, + Dropped, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SurfaceEventParams { + pub subscription_id: u64, + pub surface_id: String, + pub seq: u64, + pub kind: SurfaceEventKind, + pub timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_bytes_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dropped: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resync_required: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SurfaceEventNotification { + pub jsonrpc: &'static str, + pub method: &'static str, + pub params: SurfaceEventParams, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SurfaceSubscriptionCapabilities { + pub max_chunk_bytes: usize, + pub queue_capacity: usize, + pub replay: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct SurfaceSubscribeAck { + pub subscription_id: u64, + pub next_seq: u64, + pub capabilities: SurfaceSubscriptionCapabilities, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct SurfaceSubscribeRequest { + #[serde(default)] + pub surface_id: Option, + #[serde(default)] + pub from_seq: Option, + #[serde(default = "default_chunk_bytes")] + pub max_chunk_bytes: usize, + #[serde(default = "default_queue_capacity")] + pub queue_capacity: usize, +} + +fn default_chunk_bytes() -> usize { + MAX_EVENT_CHUNK_BYTES +} + +fn default_queue_capacity() -> usize { + 64 +} + +impl SurfaceSubscribeRequest { + pub fn new(surface_id: impl Into) -> Self { + Self { + surface_id: Some(surface_id.into()), + from_seq: None, + max_chunk_bytes: MAX_EVENT_CHUNK_BYTES, + queue_capacity: 64, + } + } + + pub fn with_queue_capacity(mut self, queue_capacity: usize) -> Self { + self.queue_capacity = queue_capacity; + self + } +} + +#[derive(Debug)] +struct SubscriptionState { + surface_id: Option, + from_seq: u64, + max_chunk_bytes: usize, + queue_capacity: usize, + queue: VecDeque, + dropped: u64, +} + +#[derive(Debug, Default)] +struct HubState { + next_subscription: u64, + next_seq: u64, + subscriptions: HashMap, +} + +/// Thread-safe broker. Publishing never waits on a subscriber and queues are bounded. +#[derive(Debug, Default)] +pub struct SurfaceEventHub { + state: Mutex, +} + +impl SurfaceEventHub { + pub fn new() -> Self { + Self::default() + } + + pub fn subscribe( + &self, + request: SurfaceSubscribeRequest, + ) -> Result { + if request.max_chunk_bytes == 0 || request.max_chunk_bytes > MAX_EVENT_CHUNK_BYTES { + return Err(SurfaceEventError::InvalidChunkBytes); + } + if request.queue_capacity == 0 || request.queue_capacity > MAX_EVENT_QUEUE_CAPACITY { + return Err(SurfaceEventError::InvalidQueueCapacity); + } + let mut state = self.state.lock().expect("surface event hub mutex poisoned"); + state.next_subscription = state.next_subscription.saturating_add(1); + let subscription_id = state.next_subscription; + let next_seq = state.next_seq.saturating_add(1).max(request.from_seq.unwrap_or(1)); + state.subscriptions.insert( + subscription_id, + SubscriptionState { + surface_id: request.surface_id, + from_seq: next_seq, + max_chunk_bytes: request.max_chunk_bytes, + queue_capacity: request.queue_capacity, + queue: VecDeque::new(), + dropped: 0, + }, + ); + Ok(SurfaceSubscribeAck { + subscription_id, + next_seq, + capabilities: SurfaceSubscriptionCapabilities { + max_chunk_bytes: request.max_chunk_bytes, + queue_capacity: request.queue_capacity, + replay: false, + }, + }) + } + + pub fn unsubscribe(&self, subscription_id: u64) -> Result { + let mut state = self.state.lock().expect("surface event hub mutex poisoned"); + Ok(state.subscriptions.remove(&subscription_id).is_some()) + } + + pub fn publish_output( + &self, + surface_id: &str, + bytes: &[u8], + timestamp: Option, + ) -> Result { + self.publish(surface_id, SurfaceEventKind::Output, bytes, timestamp) + } + + pub fn publish( + &self, + surface_id: &str, + kind: SurfaceEventKind, + bytes: &[u8], + timestamp: Option, + ) -> Result { + let mut state = self.state.lock().expect("surface event hub mutex poisoned"); + let mut seq = state.next_seq; + let chunk_limit = state + .subscriptions + .values() + .filter(|subscription| { + subscription.surface_id.as_deref().is_none_or(|id| id == surface_id) + }) + .map(|subscription| subscription.max_chunk_bytes) + .min() + .unwrap_or(MAX_EVENT_CHUNK_BYTES); + let chunks: Vec<&[u8]> = + if bytes.is_empty() { vec![&[]] } else { bytes.chunks(chunk_limit).collect() }; + for chunk in chunks { + seq = seq.saturating_add(1); + state.next_seq = seq; + let encoded = if chunk.is_empty() { + None + } else { + Some(base64::engine::general_purpose::STANDARD.encode(chunk)) + }; + let subscriptions = state.subscriptions.iter_mut().filter(|(_, subscription)| { + subscription.surface_id.as_deref().is_none_or(|id| id == surface_id) + && seq >= subscription.from_seq + }); + for (subscription_id, subscription) in subscriptions { + let event = SurfaceEventNotification { + jsonrpc: "2.0", + method: "surface.io.event", + params: SurfaceEventParams { + subscription_id: subscription_id.clone(), + surface_id: surface_id.to_owned(), + seq, + kind, + timestamp: timestamp.clone(), + event_bytes_base64: encoded.clone(), + dropped: None, + resync_required: None, + }, + }; + enqueue(subscription, event); + } + } + Ok(seq) + } + + pub fn drain( + &self, + subscription_id: u64, + max_events: usize, + ) -> Result, SurfaceEventError> { + let mut state = self.state.lock().expect("surface event hub mutex poisoned"); + let Some(subscription) = state.subscriptions.get_mut(&subscription_id) else { + return Err(SurfaceEventError::UnknownSubscription); + }; + let count = max_events.min(subscription.queue.len()); + Ok(subscription.queue.drain(..count).collect()) + } +} + +fn enqueue(subscription: &mut SubscriptionState, event: SurfaceEventNotification) { + if subscription.queue.len() < subscription.queue_capacity { + subscription.queue.push_back(event); + return; + } + subscription.queue.pop_front(); + subscription.dropped = subscription.dropped.saturating_add(1); + let marker = SurfaceEventNotification { + jsonrpc: "2.0", + method: "surface.io.event", + params: SurfaceEventParams { + subscription_id: event.params.subscription_id, + surface_id: event.params.surface_id.clone(), + seq: event.params.seq.saturating_sub(1), + kind: SurfaceEventKind::Dropped, + timestamp: event.params.timestamp.clone(), + event_bytes_base64: None, + dropped: Some(subscription.dropped), + resync_required: Some(true), + }, + }; + if subscription.queue_capacity == 1 { + subscription.queue.clear(); + subscription.queue.push_back(marker); + } else { + if subscription.queue.len() + 2 > subscription.queue_capacity { + subscription.queue.pop_front(); + } + subscription.queue.push_back(marker); + subscription.queue.push_back(event); + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SurfaceEventError { + InvalidChunkBytes, + InvalidQueueCapacity, + UnknownSubscription, +} + +impl std::fmt::Display for SurfaceEventError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::InvalidChunkBytes => "max_chunk_bytes must be between 1 and 65536", + Self::InvalidQueueCapacity => "queue_capacity must be between 1 and 256", + Self::UnknownSubscription => "unknown subscription_id", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for SurfaceEventError {} diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index 03d4a14e..d12e1491 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -52,6 +52,7 @@ use std::sync::Mutex; pub mod adapter; pub mod discovery; +pub mod events; pub mod layout; pub mod ledger; pub mod recovery; @@ -64,6 +65,11 @@ pub use discovery::{ scan_and_record, DiscoveryFailure, DiscoveryReport, DiscoveryResult, MapStateProvider, NoStateProvider, SessionStateProvider, SurfaceObservationScanner, }; +pub use events::{ + SurfaceEventError, SurfaceEventHub, SurfaceEventKind, SurfaceEventNotification, + SurfaceEventParams, SurfaceSubscribeAck, SurfaceSubscribeRequest, + SurfaceSubscriptionCapabilities, MAX_EVENT_CHUNK_BYTES, MAX_EVENT_QUEUE_CAPACITY, +}; pub use layout::{LayoutAxis, LayoutNode, LayoutRestoreItem, LayoutRestoreReport, LayoutSnapshot}; pub use ledger::{ObservationKind, SessionObservation, SurfaceCapabilities}; pub use recovery::{validate_recipe, RecoveryExecutor, RecoveryOutcome, RecoveryResult}; diff --git a/crates/sharecli-session/src/rpc.rs b/crates/sharecli-session/src/rpc.rs index a827bc7c..bbabf10a 100644 --- a/crates/sharecli-session/src/rpc.rs +++ b/crates/sharecli-session/src/rpc.rs @@ -1,13 +1,28 @@ -use crate::{SessionService, SurfaceCapabilities, SurfaceRecord}; +#[cfg(test)] +use crate::SurfaceSubscribeAck; +use crate::{ + SessionService, SurfaceCapabilities, SurfaceEventError, SurfaceEventHub, SurfaceRecord, + SurfaceSubscribeRequest, +}; use anyhow::Result; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::Arc; +#[cfg(unix)] +#[path = "rpc_transport.rs"] +mod transport; +#[cfg(unix)] +pub use transport::serve_surface_unix_with_token; + const JSON_RPC_VERSION: &str = "2.0"; const MAX_SURFACE_SEND_BYTES: usize = 64 * 1024; const MAX_SURFACE_LINE_BYTES: usize = 1024 * 1024; const MAX_SURFACE_READ_BYTES: usize = 1024 * 1024; +pub use crate::events::{ + SurfaceEventKind, SurfaceEventNotification, SurfaceEventParams, + SurfaceSubscriptionCapabilities, MAX_EVENT_CHUNK_BYTES, MAX_EVENT_QUEUE_CAPACITY, +}; #[derive(Debug, Deserialize)] pub struct Request { pub id: serde_json::Value, @@ -33,7 +48,7 @@ pub trait SurfaceControl: Send + Sync { #[derive(Debug, Deserialize)] struct SurfaceRequest { jsonrpc: String, - id: Value, + id: Option, method: String, #[serde(default)] token: Option, @@ -116,6 +131,23 @@ pub async fn dispatch(service: Arc, line: &str) -> Response { } pub async fn dispatch_surface(control: Arc, line: &str) -> SurfaceResponse { + dispatch_surface_internal(control, line, None).await +} + +/// Dispatch a surface request while exposing the bounded live-event broker. +pub async fn dispatch_surface_with_events( + control: Arc, + events: Arc, + line: &str, +) -> SurfaceResponse { + dispatch_surface_internal(control, line, Some(events)).await +} + +async fn dispatch_surface_internal( + control: Arc, + line: &str, + events: Option>, +) -> SurfaceResponse { let raw = match serde_json::from_str::(line) { Ok(raw) => raw, Err(error) => return surface_error(Value::Null, -32700, format!("parse error: {error}")), @@ -135,11 +167,20 @@ pub async fn dispatch_surface(control: Arc, line: &str) -> S } }; if request.jsonrpc != JSON_RPC_VERSION { - return surface_error(request.id, -32600, "jsonrpc must be \"2.0\"".to_string()); + return surface_error( + request.id.unwrap_or(Value::Null), + -32600, + "jsonrpc must be \"2.0\"".to_string(), + ); } - let id = request.id; - let outcome = dispatch_surface_method(control.as_ref(), &request.method, params); + let id = request.id.unwrap_or(Value::Null); + let outcome = dispatch_surface_method_with_events( + control.as_ref(), + &request.method, + params, + events.as_deref(), + ); match outcome { Ok(result) => { SurfaceResponse { jsonrpc: JSON_RPC_VERSION, id, result: Some(result), error: None } @@ -158,16 +199,42 @@ pub async fn dispatch_surface_with_token( return dispatch_surface(control, line).await; }; if request.token.as_deref() != Some(expected) { - return surface_error(request.id, -32001, "invalid control token".into()); + return surface_error( + request.id.unwrap_or(Value::Null), + -32001, + "invalid control token".into(), + ); } } dispatch_surface(control, line).await } -fn dispatch_surface_method( +pub async fn dispatch_surface_with_token_and_events( + control: Arc, + events: Arc, + line: &str, + expected_token: Option<&str>, +) -> SurfaceResponse { + if let Some(expected) = expected_token { + let Ok(request) = serde_json::from_str::(line) else { + return dispatch_surface_with_events(control, events, line).await; + }; + if request.token.as_deref() != Some(expected) { + return surface_error( + request.id.unwrap_or(Value::Null), + -32001, + "invalid control token".into(), + ); + } + } + dispatch_surface_with_events(control, events, line).await +} + +fn dispatch_surface_method_with_events( control: &dyn SurfaceControl, method: &str, params: Value, + events: Option<&SurfaceEventHub>, ) -> std::result::Result { match method { "surface.list" => { @@ -230,10 +297,34 @@ fn dispatch_surface_method( let capabilities = control.capabilities(¶ms.surface_id).map_err(control_error)?; serde_json::to_value(capabilities).map_err(control_error) } + "surface.io.subscribe" => { + let Some(events) = events else { + return Err((-32000, "live surface events unavailable".to_string())); + }; + let request: SurfaceSubscribeRequest = decode_params(params)?; + let ack = events.subscribe(request).map_err(event_error)?; + serde_json::to_value(ack).map_err(control_error) + } + "surface.io.unsubscribe" => { + let Some(events) = events else { + return Err((-32000, "live surface events unavailable".to_string())); + }; + #[derive(Deserialize)] + struct UnsubscribeParams { + subscription_id: u64, + } + let request: UnsubscribeParams = decode_params(params)?; + let unsubscribed = events.unsubscribe(request.subscription_id).map_err(event_error)?; + Ok(serde_json::json!({"unsubscribed": unsubscribed})) + } _ => Err((-32601, format!("unknown method: {method}"))), } } +fn event_error(error: SurfaceEventError) -> (i32, String) { + (-32602, error.to_string()) +} + fn decode_params Deserialize<'de>>( params: Value, ) -> std::result::Result { @@ -285,13 +376,17 @@ pub async fn serve_surface_unix( serve_surface_unix_with_token(path, control, None).await } +/// Serve request/response RPC plus bounded server-originated events on one persistent socket. +/// The polling tick only drains already-published broker items; it never touches the provider. #[cfg(unix)] -pub async fn serve_surface_unix_with_token( +pub async fn serve_surface_unix_with_events( path: &std::path::Path, control: Arc, + events: Arc, expected_token: Option, ) -> Result<()> { use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::time::{self, Duration}; let _ = std::fs::remove_file(path); if let Some(parent) = path.parent() { @@ -305,309 +400,84 @@ pub async fn serve_surface_unix_with_token( loop { let (stream, _) = listener.accept().await?; let control = control.clone(); + let events = events.clone(); let expected_token = expected_token.clone(); tokio::spawn(async move { - let (reader, mut writer) = stream.into_split(); - let mut input = reader; - let mut buffer = Vec::new(); + let (mut reader, mut writer) = stream.into_split(); + let mut input = Vec::new(); let mut chunk = [0_u8; 16 * 1024]; - while let Ok(count) = input.read(&mut chunk).await { - if count == 0 { - break; - } - buffer.extend_from_slice(&chunk[..count]); - if buffer.len() > MAX_SURFACE_LINE_BYTES { - break; - } - while let Some(newline) = buffer.iter().position(|byte| *byte == b'\n') { - let Ok(line) = std::str::from_utf8(&buffer[..newline]) else { - return; - }; - let response = dispatch_surface_with_token( - control.clone(), - &line, - expected_token.as_deref(), - ) - .await; - buffer.drain(..=newline); - let Ok(mut payload) = serde_json::to_string(&response) else { - return; - }; - payload.push('\n'); - if writer.write_all(payload.as_bytes()).await.is_err() { - return; + let mut subscriptions = Vec::new(); + let mut tick = time::interval(Duration::from_millis(25)); + loop { + tokio::select! { + result = reader.read(&mut chunk) => { + let Ok(count) = result else { return }; + if count == 0 { return; } + input.extend_from_slice(&chunk[..count]); + if input.len() > MAX_SURFACE_LINE_BYTES { return; } + while let Some(newline) = input.iter().position(|byte| *byte == b'\n') { + let Ok(line) = std::str::from_utf8(&input[..newline]) else { return; }; + let request_value = serde_json::from_str::(line).ok(); + let should_reply = serde_json::from_str::(line) + .ok() + .and_then(|request| request.as_object().map(|object| object.contains_key("id"))) + .unwrap_or(false); + let response = dispatch_surface_with_token_and_events( + control.clone(), + events.clone(), + line, + expected_token.as_deref(), + ).await; + input.drain(..=newline); + if let Some(id) = response.result.as_ref() + .and_then(|result| result.get("subscription_id")) + .and_then(Value::as_u64) + { + subscriptions.push(id); + } + if request_value.as_ref().and_then(Value::as_object) + .and_then(|request| request.get("method")) + .and_then(Value::as_str) == Some("surface.io.unsubscribe") + { + if let Some(id) = request_value.as_ref() + .and_then(|request| request.get("params")) + .and_then(|params| params.get("subscription_id")) + .and_then(Value::as_u64) + { + subscriptions.retain(|subscription_id| *subscription_id != id); + } + } + if should_reply { + let mut payload = match serde_json::to_vec(&response) { + Ok(payload) => payload, + Err(_) => return, + }; + payload.push(b'\n'); + if writer.write_all(&payload).await.is_err() { return; } + } + } + } + _ = tick.tick() => { + for subscription_id in subscriptions.iter().copied() { + let queued = match events.drain(subscription_id, 32) { + Ok(events) => events, + Err(_) => continue, + }; + for event in queued { + let mut payload = match serde_json::to_vec(&event) { + Ok(payload) => payload, + Err(_) => return, + }; + payload.push(b'\n'); + if writer.write_all(&payload).await.is_err() { return; } + } + } } } } }); } } - #[cfg(test)] -mod tests { - use super::*; - use crate::SurfaceCapabilities; - use serde_json::json; - use std::sync::Mutex; - - #[derive(Default)] - struct RecordingControl { - sent: Mutex)>>, - resized: Mutex>, - } - - impl SurfaceControl for RecordingControl { - fn send(&self, surface_id: &str, bytes: &[u8]) -> Result<()> { - self.sent.lock().unwrap().push((surface_id.to_string(), bytes.to_vec())); - Ok(()) - } - - fn read(&self, surface_id: &str, max_bytes: usize) -> Result> { - assert_eq!(surface_id, "surface-1"); - Ok(b"terminal output"[..max_bytes.min(15)].to_vec()) - } - - fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()> { - self.resized.lock().unwrap().push((surface_id.to_string(), rows, cols)); - Ok(()) - } - - fn capabilities(&self, surface_id: &str) -> Result { - assert_eq!(surface_id, "surface-1"); - Ok(SurfaceCapabilities { - read: true, - write: true, - resize: true, - layout: false, - durable_pty: true, - }) - } - } - - struct OverrunControl; - - impl SurfaceControl for OverrunControl { - fn send(&self, _surface_id: &str, _bytes: &[u8]) -> Result<()> { - Ok(()) - } - - fn read(&self, _surface_id: &str, max_bytes: usize) -> Result> { - Ok(vec![0; max_bytes + 1]) - } - - fn resize(&self, _surface_id: &str, _rows: u16, _cols: u16) -> Result<()> { - Ok(()) - } - - fn capabilities(&self, _surface_id: &str) -> Result { - Ok(SurfaceCapabilities { - read: true, - write: true, - resize: true, - layout: false, - durable_pty: false, - }) - } - } - - async fn request( - control: &Arc, - id: u64, - method: &str, - params: Value, - ) -> SurfaceResponse { - let raw = json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": params - }) - .to_string(); - dispatch_surface(control.clone(), &raw).await - } - - #[tokio::test] - async fn surface_send_passes_text_directly_to_control() { - let control = Arc::new(RecordingControl::default()); - let response = request( - &control, - 1, - "surface.io.send", - json!({"surface_id": "surface-1", "text": "printf 'not a shell'"}), - ) - .await; - - assert_eq!(response.jsonrpc, "2.0"); - assert_eq!(response.result, Some(Value::Null)); - assert!(response.error.is_none()); - assert_eq!( - *control.sent.lock().unwrap(), - vec![("surface-1".to_string(), b"printf 'not a shell'".to_vec())] - ); - } - - #[tokio::test] - async fn surface_list_reports_degraded_discovery_without_a_native_adapter() { - let control = Arc::new(RecordingControl::default()); - let response = request(&control, 7, "surface.list", json!({})).await; - assert!(response.result.is_none()); - assert_eq!(response.error.unwrap().code, -32000); - } - - #[tokio::test] - async fn surface_send_accepts_an_explicit_byte_vector() { - let control = Arc::new(RecordingControl::default()); - let response = request( - &control, - 6, - "surface.io.send", - json!({"surface_id": "surface-1", "bytes": [0, 255, 10]}), - ) - .await; - - assert_eq!(response.result, Some(Value::Null)); - assert_eq!( - *control.sent.lock().unwrap(), - vec![("surface-1".to_string(), vec![0, 255, 10])] - ); - } - - #[tokio::test] - async fn surface_read_returns_a_typed_byte_vector() { - let control = Arc::new(RecordingControl::default()); - let response = request( - &control, - 2, - "surface.io.read", - json!({"surface_id": "surface-1", "max_bytes": 8}), - ) - .await; - - assert_eq!(response.result, Some(json!({"bytes": b"terminal".to_vec()}))); - assert!(response.error.is_none()); - } - - #[tokio::test] - async fn surface_resize_forwards_dimensions_without_a_command_string() { - let control = Arc::new(RecordingControl::default()); - let response = request( - &control, - 3, - "surface.io.resize", - json!({"surface_id": "surface-1", "rows": 42, "cols": 120}), - ) - .await; - - assert_eq!(response.result, Some(Value::Null)); - assert_eq!(*control.resized.lock().unwrap(), vec![("surface-1".to_string(), 42, 120)]); - } - - #[tokio::test] - async fn surface_capabilities_are_returned_from_the_control_contract() { - let control = Arc::new(RecordingControl::default()); - let response = - request(&control, 4, "surface.io.capabilities", json!({"surface_id": "surface-1"})) - .await; - - assert_eq!( - response.result, - Some(json!({ - "read": true, - "write": true, - "resize": true, - "layout": false, - "durable_pty": true - })) - ); - } - - #[tokio::test] - async fn invalid_surface_params_return_json_rpc_invalid_params() { - let control = Arc::new(RecordingControl::default()); - let response = - request(&control, 5, "surface.io.resize", json!({"surface_id": "surface-1"})).await; - - assert!(response.result.is_none()); - assert_eq!(response.error.unwrap().code, -32602); - } - - #[tokio::test] - async fn non_object_surface_params_return_json_rpc_invalid_params() { - let control = Arc::new(RecordingControl::default()); - let response = request(&control, 8, "surface.list", json!(["not-an-object"])).await; - - assert!(response.result.is_none()); - assert_eq!(response.error.unwrap().code, -32602); - } - - #[tokio::test] - async fn surface_send_rejects_oversized_payloads() { - let control = Arc::new(RecordingControl::default()); - let response = request( - &control, - 10, - "surface.io.send", - json!({ - "surface_id": "surface-1", - "text": "x".repeat(MAX_SURFACE_SEND_BYTES + 1), - }), - ) - .await; - - assert!(response.result.is_none()); - assert_eq!(response.error.unwrap().code, -32602); - } - - #[tokio::test] - async fn surface_read_rejects_provider_overruns() { - let raw = json!({ - "jsonrpc": "2.0", - "id": 11, - "method": "surface.io.read", - "params": {"surface_id": "surface-1", "max_bytes": 8} - }) - .to_string(); - let response = dispatch_surface(Arc::new(OverrunControl), &raw).await; - - assert!(response.result.is_none()); - assert_eq!(response.error.unwrap().code, -32000); - } - - #[cfg(unix)] - #[tokio::test] - async fn unix_server_round_trips_surface_json_rpc() { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - let socket = - std::path::PathBuf::from(format!("/tmp/sharecli-sc-{}.sock", std::process::id())); - let control = Arc::new(RecordingControl::default()); - let server_path = socket.clone(); - let server_control = control.clone(); - let server = - tokio::spawn(async move { serve_surface_unix(&server_path, server_control).await }); - - let mut stream = loop { - match tokio::net::UnixStream::connect(&socket).await { - Ok(stream) => break stream, - Err(_error) if !server.is_finished() => tokio::task::yield_now().await, - Err(error) => panic!("surface server failed before accepting connections: {error}"), - } - }; - stream - .write_all( - br#"{"jsonrpc":"2.0","id":9,"method":"surface.io.read","params":{"surface_id":"surface-1","max_bytes":8}} -"#, - ) - .await - .unwrap(); - let mut response = String::new(); - BufReader::new(stream).read_line(&mut response).await.unwrap(); - - assert_eq!( - serde_json::from_str::(&response).unwrap(), - json!({"jsonrpc":"2.0","id":9,"result":{"bytes":b"terminal".to_vec()}}) - ); - server.abort(); - let _ = std::fs::remove_file(socket); - } -} +#[path = "rpc_tests.rs"] +mod tests; diff --git a/crates/sharecli-session/src/rpc_tests.rs b/crates/sharecli-session/src/rpc_tests.rs new file mode 100644 index 00000000..e5e48e87 --- /dev/null +++ b/crates/sharecli-session/src/rpc_tests.rs @@ -0,0 +1,393 @@ +use super::*; +use crate::SurfaceCapabilities; +use serde_json::json; +use std::sync::Mutex; + +#[derive(Default)] +struct RecordingControl { + sent: Mutex)>>, + resized: Mutex>, +} + +impl SurfaceControl for RecordingControl { + fn send(&self, surface_id: &str, bytes: &[u8]) -> Result<()> { + self.sent.lock().unwrap().push((surface_id.to_string(), bytes.to_vec())); + Ok(()) + } + + fn read(&self, surface_id: &str, max_bytes: usize) -> Result> { + assert_eq!(surface_id, "surface-1"); + Ok(b"terminal output"[..max_bytes.min(15)].to_vec()) + } + + fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()> { + self.resized.lock().unwrap().push((surface_id.to_string(), rows, cols)); + Ok(()) + } + + fn capabilities(&self, surface_id: &str) -> Result { + assert_eq!(surface_id, "surface-1"); + Ok(SurfaceCapabilities { + read: true, + write: true, + resize: true, + layout: false, + durable_pty: true, + }) + } +} + +struct OverrunControl; + +impl SurfaceControl for OverrunControl { + fn send(&self, _surface_id: &str, _bytes: &[u8]) -> Result<()> { + Ok(()) + } + + fn read(&self, _surface_id: &str, max_bytes: usize) -> Result> { + Ok(vec![0; max_bytes + 1]) + } + + fn resize(&self, _surface_id: &str, _rows: u16, _cols: u16) -> Result<()> { + Ok(()) + } + + fn capabilities(&self, _surface_id: &str) -> Result { + Ok(SurfaceCapabilities { + read: true, + write: true, + resize: true, + layout: false, + durable_pty: false, + }) + } +} + +async fn request( + control: &Arc, + id: u64, + method: &str, + params: Value, +) -> SurfaceResponse { + let raw = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }) + .to_string(); + dispatch_surface(control.clone(), &raw).await +} + +#[tokio::test] +async fn surface_send_passes_text_directly_to_control() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 1, + "surface.io.send", + json!({"surface_id": "surface-1", "text": "printf 'not a shell'"}), + ) + .await; + + assert_eq!(response.jsonrpc, "2.0"); + assert_eq!(response.result, Some(Value::Null)); + assert!(response.error.is_none()); + assert_eq!( + *control.sent.lock().unwrap(), + vec![("surface-1".to_string(), b"printf 'not a shell'".to_vec())] + ); +} + +#[tokio::test] +async fn surface_list_reports_degraded_discovery_without_a_native_adapter() { + let control = Arc::new(RecordingControl::default()); + let response = request(&control, 7, "surface.list", json!({})).await; + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32000); +} + +#[tokio::test] +async fn surface_send_accepts_an_explicit_byte_vector() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 6, + "surface.io.send", + json!({"surface_id": "surface-1", "bytes": [0, 255, 10]}), + ) + .await; + + assert_eq!(response.result, Some(Value::Null)); + assert_eq!(*control.sent.lock().unwrap(), vec![("surface-1".to_string(), vec![0, 255, 10])]); +} + +#[tokio::test] +async fn surface_read_returns_a_typed_byte_vector() { + let control = Arc::new(RecordingControl::default()); + let response = + request(&control, 2, "surface.io.read", json!({"surface_id": "surface-1", "max_bytes": 8})) + .await; + + assert_eq!(response.result, Some(json!({"bytes": b"terminal".to_vec()}))); + assert!(response.error.is_none()); +} + +#[tokio::test] +async fn surface_resize_forwards_dimensions_without_a_command_string() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 3, + "surface.io.resize", + json!({"surface_id": "surface-1", "rows": 42, "cols": 120}), + ) + .await; + + assert_eq!(response.result, Some(Value::Null)); + assert_eq!(*control.resized.lock().unwrap(), vec![("surface-1".to_string(), 42, 120)]); +} + +#[tokio::test] +async fn surface_capabilities_are_returned_from_the_control_contract() { + let control = Arc::new(RecordingControl::default()); + let response = + request(&control, 4, "surface.io.capabilities", json!({"surface_id": "surface-1"})).await; + + assert_eq!( + response.result, + Some(json!({ + "read": true, + "write": true, + "resize": true, + "layout": false, + "durable_pty": true + })) + ); +} + +#[tokio::test] +async fn invalid_surface_params_return_json_rpc_invalid_params() { + let control = Arc::new(RecordingControl::default()); + let response = + request(&control, 5, "surface.io.resize", json!({"surface_id": "surface-1"})).await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32602); +} + +#[tokio::test] +async fn non_object_surface_params_return_json_rpc_invalid_params() { + let control = Arc::new(RecordingControl::default()); + let response = request(&control, 8, "surface.list", json!(["not-an-object"])).await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32602); +} + +#[tokio::test] +async fn surface_send_rejects_oversized_payloads() { + let control = Arc::new(RecordingControl::default()); + let response = request( + &control, + 10, + "surface.io.send", + json!({ + "surface_id": "surface-1", + "text": "x".repeat(MAX_SURFACE_SEND_BYTES + 1), + }), + ) + .await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32602); +} + +#[tokio::test] +async fn surface_read_rejects_provider_overruns() { + let raw = json!({ + "jsonrpc": "2.0", + "id": 11, + "method": "surface.io.read", + "params": {"surface_id": "surface-1", "max_bytes": 8} + }) + .to_string(); + let response = dispatch_surface(Arc::new(OverrunControl), &raw).await; + + assert!(response.result.is_none()); + assert_eq!(response.error.unwrap().code, -32000); +} + +#[tokio::test] +async fn surface_subscription_lifecycle_returns_ack_and_unsubscribe() { + let control = Arc::new(RecordingControl::default()); + let events = Arc::new(SurfaceEventHub::new()); + let response = request_with_events( + &control, + &events, + 12, + "surface.io.subscribe", + json!({ + "surface_id": "surface-1", + "from_seq": 1, + "max_chunk_bytes": 1024, + "queue_capacity": 4 + }), + ) + .await; + let ack: SurfaceSubscribeAck = serde_json::from_value(response.result.unwrap()).unwrap(); + assert_eq!(ack.next_seq, 1); + assert_eq!(ack.capabilities.max_chunk_bytes, 1024); + assert_eq!(ack.capabilities.queue_capacity, 4); + + let response = request_with_events( + &control, + &events, + 13, + "surface.io.unsubscribe", + json!({"subscription_id": ack.subscription_id}), + ) + .await; + assert_eq!(response.result, Some(json!({"unsubscribed": true}))); + assert!(response.error.is_none()); +} + +#[test] +fn surface_events_keep_per_surface_sequence_and_wire_envelope() { + let hub = SurfaceEventHub::new(); + let ack = hub.subscribe(SurfaceSubscribeRequest::new("surface-1")).unwrap(); + hub.publish_output("surface-1", b"one", Some("2026-08-02T03:00:00Z".into())).unwrap(); + hub.publish_output("surface-1", b"two", Some("2026-08-02T03:00:01Z".into())).unwrap(); + + let events = hub.drain(ack.subscription_id, 8).unwrap(); + assert_eq!(events.iter().map(|event| event.params.seq).collect::>(), vec![1, 2]); + assert!(events.iter().all(|event| event.params.kind == SurfaceEventKind::Output)); + let wire = serde_json::to_value(&events[0]).unwrap(); + assert_eq!(wire["jsonrpc"], "2.0"); + assert_eq!(wire["method"], "surface.io.event"); + assert_eq!(wire["params"]["event_bytes_base64"], "b25l"); + assert_eq!(wire["params"]["seq"], 1); +} + +#[test] +fn surface_subscription_overflow_emits_bounded_resync_marker() { + let hub = SurfaceEventHub::new(); + let ack = + hub.subscribe(SurfaceSubscribeRequest::new("surface-1").with_queue_capacity(2)).unwrap(); + hub.publish_output("surface-1", b"one", None).unwrap(); + hub.publish_output("surface-1", b"two", None).unwrap(); + hub.publish_output("surface-1", b"three", None).unwrap(); + + let events = hub.drain(ack.subscription_id, 8).unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].params.kind, SurfaceEventKind::Dropped); + assert_eq!(events[0].params.dropped, Some(1)); + assert_eq!(events[0].params.resync_required, Some(true)); + assert!(events[1].params.seq > events[0].params.seq); +} + +async fn request_with_events( + control: &Arc, + events: &Arc, + id: u64, + method: &str, + params: Value, +) -> SurfaceResponse { + let raw = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }) + .to_string(); + dispatch_surface_with_events(control.clone(), events.clone(), &raw).await +} + +#[cfg(unix)] +#[tokio::test] +async fn unix_server_round_trips_surface_json_rpc() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let socket = std::path::PathBuf::from(format!("/tmp/sharecli-sc-{}.sock", std::process::id())); + let control = Arc::new(RecordingControl::default()); + let server_path = socket.clone(); + let server_control = control.clone(); + let server = + tokio::spawn(async move { serve_surface_unix(&server_path, server_control).await }); + + let mut stream = loop { + match tokio::net::UnixStream::connect(&socket).await { + Ok(stream) => break stream, + Err(_error) if !server.is_finished() => tokio::task::yield_now().await, + Err(error) => panic!("surface server failed before accepting connections: {error}"), + } + }; + stream + .write_all( + br#"{"jsonrpc":"2.0","id":9,"method":"surface.io.read","params":{"surface_id":"surface-1","max_bytes":8}} +"#, + ) + .await + .unwrap(); + let mut response = String::new(); + BufReader::new(stream).read_line(&mut response).await.unwrap(); + + assert_eq!( + serde_json::from_str::(&response).unwrap(), + json!({"jsonrpc":"2.0","id":9,"result":{"bytes":b"terminal".to_vec()}}) + ); + server.abort(); + let _ = std::fs::remove_file(socket); +} + +#[cfg(unix)] +#[tokio::test] +async fn unix_event_server_streams_bounded_output_notifications() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let socket = std::path::PathBuf::from(format!( + "/tmp/sharecli-sc-events-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let control = Arc::new(RecordingControl::default()); + let events = Arc::new(SurfaceEventHub::new()); + let server_path = socket.clone(); + let server_control = control.clone(); + let server_events = events.clone(); + let server = tokio::spawn(async move { + serve_surface_unix_with_events(&server_path, server_control, server_events, None).await + }); + + let mut stream = loop { + match tokio::net::UnixStream::connect(&socket).await { + Ok(stream) => break stream, + Err(_error) if !server.is_finished() => tokio::task::yield_now().await, + Err(error) => panic!("event server failed before accepting connections: {error}"), + } + }; + stream + .write_all( + br#"{"jsonrpc":"2.0","id":1,"method":"surface.io.subscribe","params":{"surface_id":"surface-1","max_chunk_bytes":8,"queue_capacity":4}} +"#, + ) + .await + .unwrap(); + let mut reader = BufReader::new(stream); + let mut ack = String::new(); + reader.read_line(&mut ack).await.unwrap(); + let ack: Value = serde_json::from_str(&ack).unwrap(); + let subscription_id = ack["result"]["subscription_id"].as_u64().unwrap(); + events.publish_output("surface-1", b"hello", None).unwrap(); + let mut event = String::new(); + tokio::time::timeout(std::time::Duration::from_secs(1), reader.read_line(&mut event)) + .await + .unwrap() + .unwrap(); + let event: Value = serde_json::from_str(&event).unwrap(); + assert_eq!(event["method"], "surface.io.event"); + assert_eq!(event["params"]["subscription_id"].as_u64(), Some(subscription_id)); + assert_eq!(event["params"]["event_bytes_base64"], "aGVsbG8="); + server.abort(); + let _ = std::fs::remove_file(socket); +} diff --git a/crates/sharecli-session/src/rpc_transport.rs b/crates/sharecli-session/src/rpc_transport.rs new file mode 100644 index 00000000..ce1e8f93 --- /dev/null +++ b/crates/sharecli-session/src/rpc_transport.rs @@ -0,0 +1,70 @@ +//! Plain request/response Unix transport for the surface control protocol. + +use super::*; + +#[cfg(unix)] +pub async fn serve_surface_unix_with_token( + path: &std::path::Path, + control: Arc, + expected_token: Option, +) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let _ = std::fs::remove_file(path); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let listener = tokio::net::UnixListener::bind(path)?; + let mut permissions = std::fs::metadata(path)?.permissions(); + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions)?; + loop { + let (stream, _) = listener.accept().await?; + let control = control.clone(); + let expected_token = expected_token.clone(); + tokio::spawn(async move { + let (reader, mut writer) = stream.into_split(); + let mut input = reader; + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 16 * 1024]; + while let Ok(count) = input.read(&mut chunk).await { + if count == 0 { + break; + } + buffer.extend_from_slice(&chunk[..count]); + if buffer.len() > MAX_SURFACE_LINE_BYTES { + break; + } + while let Some(newline) = buffer.iter().position(|byte| *byte == b'\n') { + let Ok(line) = std::str::from_utf8(&buffer[..newline]) else { + return; + }; + let should_reply = serde_json::from_str::(line) + .ok() + .and_then(|request| { + request.as_object().map(|object| object.contains_key("id")) + }) + .unwrap_or(false); + let response = dispatch_surface_with_token( + control.clone(), + line, + expected_token.as_deref(), + ) + .await; + buffer.drain(..=newline); + if !should_reply { + continue; + } + let Ok(mut payload) = serde_json::to_string(&response) else { + return; + }; + payload.push('\n'); + if writer.write_all(payload.as_bytes()).await.is_err() { + return; + } + } + } + }); + } +} diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md index ab592db7..168f000a 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/01_RESEARCH.md @@ -3,14 +3,38 @@ ## Ghostty - The installed Ghostty 1.3.1 binary exposes no documented external control - socket. The upstream macOS scripting dictionary does expose window/tab/ - terminal identity, working directory, PID, TTY, split/focus/close, and input - actions. AppleScript is therefore a useful degraded discovery/control path, - but it is TCC-gated and does not provide a supported PTY/screen readback - stream: https://ghostty.org/docs/features/applescript + socket. Its bundled `Ghostty.sdef` exposes terminal `id`, `name`, and + `working directory`, plus split/focus/close and input actions; it does **not** + expose `pid` or `tty`. AppleScript is therefore a useful degraded + discovery/control path, but it is TCC-gated and does not provide a supported + PTY/screen readback stream: https://ghostty.org/docs/features/applescript - Upstream discussion favors narrowly scoped platform IPC and calls out the security implications of screen readback: https://github.com/ghostty-org/ghostty/discussions/2353 +- Ghostty's current architecture keeps the macOS app in Swift/AppKit/SwiftUI + over libghostty; the standalone libghostty API is still not a stable app + integration boundary: https://ghostty.org/docs/about. The upstream PID/TTY + issue confirms that the AppleScript object graph omits process evidence even + though it exists internally: https://github.com/ghostty-org/ghostty/issues/11592. + The provider must therefore bind inside the Ghostty app lifecycle/fork. +- Upstream source confirms the safe native binding points. `Ghostty.Surface` + exposes `foregroundPID` and `ttyName` through `ghostty_surface_foreground_pid` + and `ghostty_surface_tty_name`; `SurfaceView` owns a process-lifetime UUID, + its `surfaceModel`, and the underlying `ghostty_surface_t`. The C header also + exposes bounded screen/selection text reads and an `export_terminal_io` + action callback, but no public raw-PTY read/subscribe callback. The provider + can therefore implement snapshots and process evidence directly, while + realtime PTY output still requires fork-local instrumentation at the termio + or app action boundary: + https://github.com/ghostty-org/ghostty/blob/main/macos/Sources/Ghostty/Ghostty.Surface.swift + https://github.com/ghostty-org/ghostty/blob/main/macos/Sources/Ghostty/Surface%20View/SurfaceView_AppKit.swift + https://github.com/ghostty-org/ghostty/blob/main/include/ghostty.h +- The upstream lifecycle is explicit: `Ghostty.App` creates and frees the one + `ghostty_app_t`; each `SurfaceView` creates and frees its `ghostty_surface_t`. + A fork provider must start its listener only after the app is ready, resolve + surfaces through weak `SurfaceView`/UUID records, and stop the listener before + app/surface teardown. The ShareCLI package intentionally does not retain C + pointers or invent an app lifecycle outside that boundary. - ShareCLI consequently keeps a transport-neutral Unix JSON-RPC client and capability-gates readback. A Ghostty-side socket/fork is still required for AppleScriptless live PTY I/O and atomic layout operations. @@ -22,9 +46,30 @@ - ShareCLI's selector is deterministic: loaded KEXT -> approved FSKit -> no interception. The last state is functional fail-open, not a mount failure that blocks session recovery. +- The read-only `sharecli fuse probe` confirms this host currently has the + MFMount framework but no loaded macFUSE KEXT and no explicit FSKit approval; + it selects `non-fuse` without prompting, loading, or mounting. Backend + distinctions follow macFUSE's documented KEXT/FSKit split: + https://github.com/macfuse/macfuse/wiki/FUSE-Backends. ## Harness evidence Resume recipes are generated from adapter state first, persisted state second, and exact argv patterns third. Heuristic/ambiguous evidence is retained for inspection but is never auto-launched. + +## Realtime transport contract + +- JSON-RPC 2.0 is transport agnostic, permits omitted `params`, and requires + named parameters to be structured Objects. A request without `id` is a + notification and MUST NOT receive a response; ShareCLI therefore keeps + request/response dispatch separate from server-originated event delivery: + https://www.jsonrpc.org/specification +- The native socket uses a bounded NDJSON request path today. The planned + event path uses a persistent connection with a serialized writer, per- + subscription bounded queue, monotonic sequence, and explicit resync/drop + markers; it must never block Ghostty's PTY or MainActor. +- Socket pathname mode `0600` is defense in depth, not the complete macOS + authorization boundary. The native listener additionally checks the peer + effective UID with `getpeereid(3)` before scheduling a connection: + https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/getpeereid.3.html diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md index e9bc42b9..ad315523 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md @@ -27,3 +27,19 @@ empty output buffer. payload, `surface.io.read` is capped at 1 MiB, and `surface.io.resize` rejects zero dimensions. `surface.list` is a typed discovery hook; the default adapter returns an explicit degraded error instead of an empty inventory. + +## Live I/O subscriptions + +`surface.io.subscribe` creates a numeric subscription ID and returns the next +global sequence number plus negotiated limits. Events are newline-delimited +JSON-RPC notifications using `surface.io.event`; output bytes are base64-encoded +and each chunk is at most 64 KiB. Queue capacity is bounded to 256 entries. + +`surface.io.unsubscribe` is idempotent and reports whether a live subscription +was removed. A full queue drops the oldest event and emits a `dropped` marker +with `resync_required=true`; callers must use the durable surface snapshot and +explicit read path to recover state. Notifications never receive response +lines, and all response/event writes on one Unix connection are serialized. + +The timestamp field is an optional RFC3339 string so Rust, Swift, and future +Ghostty-native providers share one wire representation. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md index 68c846a8..9ae7a694 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/03_DAG_WBS.md @@ -11,11 +11,16 @@ P3 Ghostty control client -----+--> P5 live I/O contract tests | +--> P6 native Ghostty server/fork | - +--> P7 pane discovery/layout restore + +--> P6a bounded live I/O subscriptions | - +--> P8 macOS dogfood + chaos gate + +--> P7 pane discovery/layout restore + | + +--> P8 macOS dogfood + chaos gate ``` -Completed here: P0-P5's ShareCLI-side contracts, CLI/IPC integrations, and -tests. P6-P8 remain open because they require a Ghostty-side implementation -and real macOS permission, mount, crash, and restart validation. +Completed here: P0-P5's ShareCLI-side contracts, CLI/IPC integrations, tests, +strict async/actor-safe transport hardening, and P6a's bounded persistent event +broker/client/listener contract. P6-P8 remain open because they require a +concrete Ghostty-side provider/lifecycle integration (the upstream lifecycle +and raw-PTY instrumentation boundary are now documented) and real macOS +permission, mount, crash, and restart validation. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md index 369f061e..af7daeb6 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/04_IMPLEMENTATION_STRATEGY.md @@ -26,9 +26,15 @@ operation hops back only for the short native surface operation. - Keep the wire contract strict and bounded: omitted `params` means `{}`, an explicit non-object is `-32602`, sends are capped at 64 KiB, reads at 1 MiB, - and provider read overruns fail closed. Event subscriptions require a - separate multiplexed writer design and are not smuggled into request/response - dispatch. + and provider read overruns fail closed. Live subscriptions use a separate + bounded broker: one monotonically sequenced event envelope, numeric + connection-local subscription id, per-subscriber queue, and explicit + dropped/resync markers. The Rust event listener and Swift listener serialize + all writers so a slow client cannot block a PTY or MainActor operation. +- Expose the persistent path as `sharecli surface watch`; keep one-shot + request/response commands available for snapshots and health checks. A + missing native event provider is a typed capability failure, never a shell + fallback. - Keep layout persistence in ShareCLI and make the Ghostty adapter responsible only for applying a validated snapshot. This permits recovery when Ghostty is unavailable and avoids coupling the ledger to an app-specific tree API. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md index a66123bf..a39efa51 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/05_KNOWN_ISSUES.md @@ -1,18 +1,22 @@ # Known Issues and Open Gates - `GhosttySurfaceAdapter::discover` is intentionally a capability contract; - stock Ghostty's AppleScript dictionary can enumerate terminal identity, - cwd, PID, and TTY, but the installed app has no documented external socket - for PTY/screen readback. Native layout and readback still require the - Ghostty-side integration gate. + stock Ghostty's AppleScript dictionary can enumerate terminal identity and + cwd, but not PID/TTY and not PTY/screen readback. Native process evidence, + layout, and readback still require the Ghostty-side integration gate. - `contrib/ghostty-control` is the native binding contract and tested dispatcher/listener, not a patched Ghostty.app. A fork must implement `SurfaceProvider` from Ghostty's live surface tree and PTY/screen model, then start the listener from the app lifecycle. The protocol is asynchronous and actor-safe, but the concrete provider and lifecycle wiring are still open. -- The current socket is request/response NDJSON only. Server-originated output - subscriptions need a per-connection serialized writer, bounded queue, and - cancellation protocol; they are intentionally a separate next gate. +- The ShareCLI socket now has a bounded subscription protocol and serialized + event writer. It still needs a concrete Ghostty provider to publish PTY + output; without that app-side binding, `surface watch` reports an unavailable + capability rather than inventing output. +- Upstream Ghostty exposes foreground PID/TTY and bounded screen-text/export + actions, but no public raw-PTY subscription callback. The fork must add + termio/app instrumentation for realtime output and publish lifecycle events; + this is an app-side change, not something the transport can infer safely. - The session watcher is intentionally a read-only consumer. A launcher or harness wrapper must call `session register` (or append the same `{surface_id,harness,session_id,pid}` record) before launch to provide exact diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md index 39244586..4c589131 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -4,7 +4,9 @@ Verified in this worktree: - `CARGO_BUILD_JOBS=2 cargo check --workspace --locked --offline` (pass; existing workspace warnings only) -- `cargo test -p sharecli-session --offline` (22 unit + 7 discovery + 3 layout integration passed) +- `cargo test -p sharecli-session --offline` (baseline session, discovery, + layout/state suites; the live RPC slice now has 29 library tests, including + bounded event streaming) - `cargo test -p sharecli-fuse --locked --offline` (36 unit + 11 integration passed) - `cargo test -p sharecli-ipc --locked --test handler_dispatch` (10 passed, including layout save/list/inspect) @@ -13,15 +15,18 @@ Verified in this worktree: - `cargo test -p sharecli-session --offline -- --nocapture` also covers exact sidecar mappings, PID recycling fail-closed behavior, malformed JSONL, and append serialization; `session_cli` covers the registrar command. -- `swift test --package-path contrib/ghostty-control` (10 tests: dispatcher, +- `swift test --package-path contrib/ghostty-control` (17 tests: dispatcher, strict input/size validation, token enforcement, MainActor provider crossing, - and Unix listener round-trip) + Unix listener round-trip, notification suppression, RFC3339 timestamp parity, + and bounded LiveIO sequence/overflow/subscription behavior) +- `cargo test --test session --offline` (the live client path includes event + decoding/unsubscribe and rejects invalid subscription limits before connect) - `cargo run -p sharecli-fuse --bin fuse-runtime-probe` (read-only host evidence) - `cargo fmt --all -- --check` - `git diff --check` -Required before claiming full feature completion: Ghostty fork/socket +Required before claiming full feature completion: Ghostty fork/socket provider integration, native pane discovery/layout application, macOS FUSE KEXT/FSKit mount smoke, crash/restart chaos, and a clean installed-dogfood run. The -ShareCLI-side layout and surface-control unit tests are covered above; the -remaining gates are app-side/runtime evidence. +ShareCLI-side live broker/client contract is now covered; the remaining gates +are app-side/runtime evidence and end-to-end crash recovery. diff --git a/src/commands/fuse.rs b/src/commands/fuse.rs index 74cee053..f7390bf2 100644 --- a/src/commands/fuse.rs +++ b/src/commands/fuse.rs @@ -7,8 +7,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use sharecli_fuse::{ - default_session_id, global_read_cache_meters, global_write_serialize_meters, read_provenance, - AgentsConf, FuseMountOptions, FuseSessionRegistry, + default_session_id, global_read_cache_meters, global_write_serialize_meters, probe_runtime, + read_provenance, AgentsConf, FuseMountOptions, FuseSessionRegistry, }; /// Mount options from CLI flags. @@ -30,6 +30,37 @@ pub struct FuseMountCliOpts { pub foreground: bool, } +/// Report read-only backend evidence without loading, mounting, or prompting. +pub fn probe(mountpoint: &Path, json: bool) -> Result<()> { + let evidence = probe_runtime(mountpoint); + if json { + let body = serde_json::json!({ + "platform": evidence.platform, + "mountpoint": evidence.mountpoint.display().to_string(), + "kernel_loaded": evidence.kernel_loaded, + "fskit_framework": evidence.fskit_framework, + "fskit_approved": evidence.fskit_approved, + "selected_backend": evidence.selection.backend.as_str(), + "diagnostic": evidence.selection.diagnostic.map(|diagnostic| diagnostic.message()), + "non_fuse_fallback": evidence.non_fuse_fallback, + }); + println!("{}", serde_json::to_string_pretty(&body)?); + return Ok(()); + } + + println!("platform: {}", evidence.platform); + println!("mountpoint: {}", evidence.mountpoint.display()); + println!("kext_loaded: {}", evidence.kernel_loaded); + println!("fskit_framework: {}", evidence.fskit_framework); + println!("fskit_approved: {}", evidence.fskit_approved); + println!("selected_backend: {}", evidence.selection.backend.as_str()); + if let Some(diagnostic) = evidence.selection.diagnostic { + println!("diagnostic: {}", diagnostic.message()); + } + println!("non_fuse_fallback: {}", evidence.non_fuse_fallback); + Ok(()) +} + /// Read FUSE write-provenance xattrs from a backing file path. /// /// When `json` is true, emit a JSON object or `null` when attrs are absent. diff --git a/src/main.rs b/src/main.rs index 11c1b043..8fde3633 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use sharecli_session::{ SidecarStateProvider, SurfaceObservationScanner, }; use sharecli_thermal_tui as thermal_tui; +use std::io::Write; mod apfs_uuid; mod audit_log; @@ -148,7 +149,8 @@ enum Commands { args: Vec, }, - /// Stop managed processes + /// Stop managed processes (also available as `sharecli quit`) + #[command(alias = "quit")] Stop { /// Process ID to stop #[arg(long)] @@ -608,6 +610,24 @@ enum SurfaceCmd { #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] token: Option, }, + /// Follow bounded server-originated output/events from one surface (or all surfaces) + Watch { + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long)] + surface_id: Option, + #[arg(long)] + from_seq: Option, + #[arg(long, default_value_t = 65536)] + max_chunk_bytes: usize, + #[arg(long, default_value_t = 64)] + queue_capacity: usize, + /// Read one event, unsubscribe, and exit. + #[arg(long)] + once: bool, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, } #[derive(Subcommand, Debug)] @@ -627,6 +647,14 @@ enum FleetCmd { #[derive(Subcommand, Debug)] enum FuseCmd { + /// Report read-only KEXT -> FSKit -> non-FUSE backend evidence + Probe { + /// Mountpoint used for selection (not created or mounted) + mountpoint: std::path::PathBuf, + /// Emit JSON instead of operator-readable text + #[arg(long)] + json: bool, + }, /// Mount intercept layer over a backing directory Mount { /// Backing filesystem root to mirror @@ -1000,6 +1028,7 @@ async fn run() -> Result<()> { MeshCmd::Reclaim { queue, owner } => mesh_cmd::reclaim(queue, owner)?, }, Commands::Fuse { cmd } => match cmd { + FuseCmd::Probe { mountpoint, json } => fuse_cmd::probe(mountpoint, *json)?, FuseCmd::Mount { backing, mountpoint, @@ -1253,6 +1282,32 @@ fn surface_cmd(cmd: &SurfaceCmd) -> Result<()> { client.resize(surface_id, *rows, *cols)?; println!("{{\"ok\":true}}"); } + SurfaceCmd::Watch { + socket, + surface_id, + from_seq, + max_chunk_bytes, + queue_capacity, + once, + token, + } => { + let client = GhosttyControlClient::new(socket, token.clone()); + let mut subscription = client.subscribe_surface( + surface_id.as_deref(), + *from_seq, + *max_chunk_bytes, + *queue_capacity, + )?; + loop { + let event = subscription.next_event()?; + println!("{}", serde_json::to_string(&event)?); + std::io::stdout().flush().ok(); + if *once { + subscription.unsubscribe()?; + break; + } + } + } } Ok(()) } @@ -1307,6 +1362,7 @@ fn cli_list(as_json: bool) -> Result<()> { ]; let fuse_modules: &[(&str, &str)] = &[ + ("probe", "Read-only KEXT -> FSKit -> non-FUSE backend evidence"), ("mount", "Mount intercept over backing (`fuse mount [--cow]`)"), ("unmount", "Unmount registered intercept (`fuse unmount `)"), ("status", "FUSE read-cache + write-serialize meters"), diff --git a/src/session.rs b/src/session.rs index 65050e54..0bdb7274 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,8 +1,14 @@ //! Shell-free zmx and capability-gated Ghostty adapters. +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use sharecli_session::{SurfaceAdapter, SurfaceCapabilities, SurfaceRecord}; +use sharecli_session::{ + SurfaceAdapter, SurfaceCapabilities, SurfaceEventKind, SurfaceRecord, MAX_EVENT_CHUNK_BYTES, + MAX_EVENT_QUEUE_CAPACITY, +}; use std::io::{BufRead, BufReader, Write}; +#[cfg(unix)] +use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; @@ -114,6 +120,80 @@ pub struct GhosttyControlClient { token: Option, } +/// One server-originated event from a persistent surface subscription. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct SurfaceEventEnvelope { + pub subscription_id: u64, + pub surface_id: String, + pub seq: u64, + pub kind: SurfaceEventKind, + pub timestamp: Option, + #[serde(default)] + pub event_bytes_base64: Option, + #[serde(default)] + pub dropped: Option, + #[serde(default)] + pub resync_required: Option, +} + +/// Blocking reader/writer for a bounded live surface subscription. +#[cfg(unix)] +pub struct SurfaceSubscription { + subscription_id: u64, + token: Option, + writer: UnixStream, + reader: BufReader, +} + +#[cfg(unix)] +impl SurfaceSubscription { + pub fn id(&self) -> u64 { + self.subscription_id + } + + pub fn next_event(&mut self) -> anyhow::Result { + loop { + let mut line = String::new(); + let count = self.reader.read_line(&mut line)?; + if count == 0 { + anyhow::bail!("Ghostty live surface subscription closed") + } + let value: Value = serde_json::from_str(&line)?; + if value.get("method").and_then(Value::as_str) != Some("surface.io.event") { + if let Some(error) = value.get("error") { + anyhow::bail!("Ghostty live event RPC failed: {error}"); + } + continue; + } + return Ok(serde_json::from_value(value["params"].clone())?); + } + } + + pub fn unsubscribe(mut self) -> anyhow::Result<()> { + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "surface.io.unsubscribe", + "params": {"subscription_id": self.subscription_id}, + }); + let mut request = request; + if let Some(token) = &self.token { + request["token"] = Value::String(token.clone()); + } + serde_json::to_writer(&mut self.writer, &request)?; + self.writer.write_all(b"\n")?; + self.writer.flush()?; + let mut line = String::new(); + self.reader.read_line(&mut line)?; + let value: Value = serde_json::from_str(&line)?; + if let Some(error) = value.get("error") { + anyhow::bail!("Ghostty unsubscribe failed: {error}"); + } + Ok(()) + } +} + static REQUEST_ID: AtomicU64 = AtomicU64::new(1); impl GhosttyControlClient { @@ -178,6 +258,62 @@ impl GhosttyControlClient { )?; Ok(()) } + + #[cfg(unix)] + pub fn subscribe_surface( + &self, + surface_id: Option<&str>, + from_seq: Option, + max_chunk_bytes: usize, + queue_capacity: usize, + ) -> anyhow::Result { + if !(1..=MAX_EVENT_CHUNK_BYTES).contains(&max_chunk_bytes) { + anyhow::bail!("max_chunk_bytes must be between 1 and {MAX_EVENT_CHUNK_BYTES}"); + } + if !(1..=MAX_EVENT_QUEUE_CAPACITY).contains(&queue_capacity) { + anyhow::bail!("queue_capacity must be between 1 and {MAX_EVENT_QUEUE_CAPACITY}"); + } + let stream = UnixStream::connect(&self.socket)?; + let mut writer = stream.try_clone()?; + let mut reader = BufReader::new(stream); + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let mut params = json!({ + "max_chunk_bytes": max_chunk_bytes, + "queue_capacity": queue_capacity, + }); + if let Some(surface_id) = surface_id { + params["surface_id"] = Value::String(surface_id.to_owned()); + } + if let Some(from_seq) = from_seq { + params["from_seq"] = Value::Number(from_seq.into()); + } + let mut request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "surface.io.subscribe", + "params": params, + }); + if let Some(token) = &self.token { + request["token"] = Value::String(token.clone()); + } + serde_json::to_writer(&mut writer, &request)?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut line = String::new(); + reader.read_line(&mut line)?; + let response: Value = serde_json::from_str(&line)?; + if let Some(error) = response.get("error") { + anyhow::bail!("Ghostty subscribe failed: {error}"); + } + let ack: sharecli_session::SurfaceSubscribeAck = + serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?; + Ok(SurfaceSubscription { + subscription_id: ack.subscription_id, + token: self.token.clone(), + writer, + reader, + }) + } } impl SurfaceAdapter for GhosttyControlClient { diff --git a/tests/integration_cli.rs b/tests/integration_cli.rs index 062de83a..5381d62e 100644 --- a/tests/integration_cli.rs +++ b/tests/integration_cli.rs @@ -107,6 +107,20 @@ fn cli_util_help_lists_at_least_one_utility() { ); } +#[test] +fn cli_fuse_probe_is_read_only_and_reports_fallback_contract() { + let out = bin() + .args(["fuse", "probe", "/tmp/sharecli-fuse-probe-test", "--json"]) + .output() + .expect("spawn sharecli fuse probe"); + assert!(out.status.success(), "fuse probe should exit 0; stderr: {}", stderr(&out)); + let report: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("fuse probe should emit valid JSON"); + assert!(report.get("selected_backend").is_some(), "report must include backend"); + assert_eq!(report.get("non_fuse_fallback"), Some(&serde_json::Value::Bool(true))); + assert!(report.get("mountpoint").is_some(), "report must include mountpoint"); +} + #[test] fn cli_ps_runs_and_prints_table_header() { // `ps` exits 0 even when no managed processes are alive. diff --git a/tests/session.rs b/tests/session.rs index 46d3cead..5c277bcb 100644 --- a/tests/session.rs +++ b/tests/session.rs @@ -99,3 +99,65 @@ fn ghostty_control_client_round_trips_authenticated_io_request() { server.join().unwrap(); let _ = std::fs::remove_file(socket); } + +#[cfg(unix)] +#[test] +fn ghostty_control_client_consumes_live_event_and_unsubscribes() { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + use std::time::{SystemTime, UNIX_EPOCH}; + + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let socket = std::env::temp_dir().join(format!("sharecli-ghostty-live-{suffix}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let subscribe: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(subscribe["method"], "surface.io.subscribe"); + stream + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"subscription_id\":1,\"next_seq\":1,\"capabilities\":{\"max_chunk_bytes\":1024,\"queue_capacity\":4,\"replay\":false}}}\n") + .unwrap(); + stream + .write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"surface.io.event\",\"params\":{\"subscription_id\":1,\"surface_id\":\"ghostty:1\",\"seq\":1,\"kind\":\"output\",\"timestamp\":null,\"event_bytes_base64\":\"aGk=\"}}\n") + .unwrap(); + line.clear(); + reader.read_line(&mut line).unwrap(); + let unsubscribe: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(unsubscribe["method"], "surface.io.unsubscribe"); + stream + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"unsubscribed\":true}}\n") + .unwrap(); + }); + + let client = GhosttyControlClient::new(&socket, None); + let mut subscription = client.subscribe_surface(Some("ghostty:1"), None, 1024, 4).unwrap(); + let event = subscription.next_event().unwrap(); + assert_eq!(event.subscription_id, 1); + assert_eq!(event.surface_id, "ghostty:1"); + assert_eq!(event.seq, 1); + assert_eq!(event.kind, sharecli_session::SurfaceEventKind::Output); + subscription.unsubscribe().unwrap(); + server.join().unwrap(); + let _ = std::fs::remove_file(socket); +} + +#[cfg(unix)] +#[test] +fn ghostty_control_client_rejects_invalid_subscription_limits_before_connecting() { + let client = GhosttyControlClient::new("/tmp/sharecli-no-ghostty-live.sock", None); + let chunk_error = client + .subscribe_surface(None, None, 0, 1) + .err() + .expect("invalid chunk limit must fail") + .to_string(); + assert!(chunk_error.contains("max_chunk_bytes")); + let queue_error = client + .subscribe_surface(None, None, 1024, 257) + .err() + .expect("invalid queue limit must fail") + .to_string(); + assert!(queue_error.contains("queue_capacity")); +} From 9f8a5c47a9c08dd385cdc3718370237bacf21f46 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Mon, 3 Aug 2026 03:03:43 -0700 Subject: [PATCH 18/18] feat(sharecli): add Ghostty provider lifecycle and layout handoff --- contrib/ghostty-control/README.md | 27 ++++ .../ControlLifecycle.swift | 71 +++++++++ .../SurfaceProvider.swift | 140 ++++++++++++++++++ .../SurfaceProviderTests.swift | 96 ++++++++++++ crates/sharecli-session/src/rpc.rs | 40 ++++- .../00_SESSION_OVERVIEW.md | 3 + .../02_SPECIFICATIONS.md | 24 +++ .../06_TESTING_STRATEGY.md | 5 + src/main.rs | 51 +++++++ src/session.rs | 30 +++- 10 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 contrib/ghostty-control/Sources/ShareCLIGhosttyControl/ControlLifecycle.swift create mode 100644 contrib/ghostty-control/Sources/ShareCLIGhosttyControl/SurfaceProvider.swift create mode 100644 contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/SurfaceProviderTests.swift diff --git a/contrib/ghostty-control/README.md b/contrib/ghostty-control/README.md index 267de50c..b5cf11fd 100644 --- a/contrib/ghostty-control/README.md +++ b/contrib/ghostty-control/README.md @@ -16,6 +16,16 @@ read/write/resize operations, and capability reporting. Provider methods are surface tree without semaphore or synchronous cross-actor calls. Keep all app and PTY references on that side of the boundary. +For a fork integration, `SurfaceProviderRegistry` is the default adapter: the +fork registers one `SurfaceBinding` per live `SurfaceView`, then unregisters it +before the view/PTY is destroyed. The registry is actor-isolated, sorts +`surface.list` deterministically, and reports a provider error when a surface +vanishes during a request. `ControlLifecycle` is the app-side owner: start it +after Ghostty app readiness, and stop it before surface teardown. If the app +needs a health endpoint before readiness, `startUnavailable()` keeps the socket +alive with an empty surface list and all I/O capabilities false; it never +pretends that a terminal is controllable. + Requests are bounded: the NDJSON request and read response are capped at 1 MiB, and one `surface.io.send` payload is capped at 64 KiB. The provider result is also checked against the requested read size before it is serialized. @@ -60,6 +70,23 @@ the matching top-level `token`. ShareCLI should treat missing sockets, unsupported capabilities, and provider errors as explicit degraded states, never as permission to execute an untrusted command. +Minimal app-side shape: + +```swift +@MainActor +let lifecycle = ControlLifecycle( + socketPath: shareCLISocketPath, + expectedToken: controlToken, + liveEvents: liveEventHub +) + +// Ghostty app-ready callback: +lifecycle.start(provider: surfaceRegistry) + +// Before SurfaceView/PTY teardown: +lifecycle.stop() +``` + ## Build and test ```sh diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/ControlLifecycle.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/ControlLifecycle.swift new file mode 100644 index 00000000..e050f1da --- /dev/null +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/ControlLifecycle.swift @@ -0,0 +1,71 @@ +import Foundation + +/// State of the app-owned control listener. +public enum ControlLifecycleState: Equatable, Sendable { + case stopped + case running(socketPath: String) + case failed(message: String) +} + +/// Main-actor owner for the Ghostty app integration lifecycle. +/// +/// Instantiate this beside the native app delegate. Call `start(provider:)` +/// only after Ghostty has created its app/surface registry, and call `stop()` +/// before that registry is torn down. The listener is intentionally kept +/// alive with `UnavailableSurfaceProvider` when the app wants a degraded +/// control endpoint during startup/teardown. +@MainActor +public final class ControlLifecycle { + public let socketPath: String + public let expectedToken: String? + public let liveEvents: LiveIOEventHub? + public private(set) var state: ControlLifecycleState = .stopped + + private var server: UnixControlServer? + + public init( + socketPath: String, + expectedToken: String? = nil, + liveEvents: LiveIOEventHub? = nil + ) { + self.socketPath = socketPath + self.expectedToken = expectedToken + self.liveEvents = liveEvents + } + + /// Start the listener once Ghostty's native surface tree is ready. + public func start(provider: any SurfaceProvider) { + guard server == nil else { return } + let dispatcher = ControlDispatcher( + provider: provider, + expectedToken: expectedToken, + liveEvents: liveEvents + ) + let candidate = UnixControlServer(path: socketPath, dispatcher: dispatcher) + do { + try candidate.start() + server = candidate + state = .running(socketPath: socketPath) + } catch { + state = .failed(message: String(describing: error)) + } + } + + /// Start a degraded endpoint before Ghostty is ready or after its provider + /// has been invalidated. This is useful for health/status tooling without + /// pretending that surface I/O is available. + public func startUnavailable(reason: String = "native Ghostty surface provider unavailable") { + start(provider: UnavailableSurfaceProvider(reason: reason)) + } + + /// Stop the listener before Ghostty surface/PTY teardown. + public func stop() { + server?.stop() + server = nil + state = .stopped + } + + deinit { + server?.stop() + } +} diff --git a/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/SurfaceProvider.swift b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/SurfaceProvider.swift new file mode 100644 index 00000000..fdd8212e --- /dev/null +++ b/contrib/ghostty-control/Sources/ShareCLIGhosttyControl/SurfaceProvider.swift @@ -0,0 +1,140 @@ +import Foundation + +/// A single app-owned surface binding. +/// +/// The Ghostty fork should construct bindings with closures that capture only +/// weak/actor-safe references to its SurfaceView and termio objects. ShareCLI +/// never stores a raw Ghostty C pointer or invokes shell text through this +/// type. Every operation remains asynchronous so a MainActor-bound adapter can +/// hop to the app actor for a short operation. +public struct SurfaceBinding: Sendable { + public let record: SurfaceRecord + + private let sendOperation: @Sendable ([UInt8]) async throws -> Void + private let readOperation: @Sendable (Int) async throws -> [UInt8] + private let resizeOperation: @Sendable (UInt16, UInt16) async throws -> Void + private let capabilitiesOperation: @Sendable () async throws -> SurfaceCapabilities + + public init( + record: SurfaceRecord, + send: @escaping @Sendable ([UInt8]) async throws -> Void, + read: @escaping @Sendable (Int) async throws -> [UInt8], + resize: @escaping @Sendable (UInt16, UInt16) async throws -> Void, + capabilities: @escaping @Sendable () async throws -> SurfaceCapabilities + ) { + self.record = record + self.sendOperation = send + self.readOperation = read + self.resizeOperation = resize + self.capabilitiesOperation = capabilities + } + + fileprivate func send(_ bytes: [UInt8]) async throws { + try await sendOperation(bytes) + } + + fileprivate func read(maxBytes: Int) async throws -> [UInt8] { + try await readOperation(maxBytes) + } + + fileprivate func resize(rows: UInt16, cols: UInt16) async throws { + try await resizeOperation(rows, cols) + } + + fileprivate func capabilities() async throws -> SurfaceCapabilities { + try await capabilitiesOperation() + } +} + +/// Explicit degraded provider used while Ghostty's native surface tree is +/// unavailable (for example, before app readiness or after teardown). +/// +/// Keeping the listener alive with an empty, read-only provider lets clients +/// distinguish "control plane is up, surfaces unavailable" from a dead socket. +public struct UnavailableSurfaceProvider: SurfaceProvider, Sendable { + public let reason: String + + public init(reason: String = "native Ghostty surface provider unavailable") { + self.reason = reason + } + + public func listSurfaces() async throws -> [SurfaceRecord] { [] } + + public func send(surfaceID: String, bytes: [UInt8]) async throws { + throw ControlError.provider(reason) + } + + public func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] { + throw ControlError.provider(reason) + } + + public func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws { + throw ControlError.provider(reason) + } + + public func capabilities(surfaceID: String) async throws -> SurfaceCapabilities { + SurfaceCapabilities(read: false, write: false, resize: false, layout: false, durablePty: false) + } +} + +/// Actor-isolated registry that adapts Ghostty's live surface tree to the +/// ShareCLI `SurfaceProvider` contract. +/// +/// Registration/removal is serialized and list results are deterministic. A +/// binding disappearing during a request produces an explicit provider error; +/// it never falls back to AppleScript, process scraping, or command execution. +public actor SurfaceProviderRegistry: SurfaceProvider { + private var bindings: [String: SurfaceBinding] = [:] + + public init() {} + + public func register(_ binding: SurfaceBinding) throws { + guard !binding.record.id.isEmpty else { + throw ControlError.invalidParams("surface binding id must not be empty") + } + guard bindings[binding.record.id] == nil else { + throw ControlError.provider("surface binding already registered: \(binding.record.id)") + } + bindings[binding.record.id] = binding + } + + @discardableResult + public func replace(_ binding: SurfaceBinding) throws -> SurfaceBinding? { + guard !binding.record.id.isEmpty else { + throw ControlError.invalidParams("surface binding id must not be empty") + } + return bindings.updateValue(binding, forKey: binding.record.id) + } + + @discardableResult + public func unregister(surfaceID: String) -> Bool { + bindings.removeValue(forKey: surfaceID) != nil + } + + public func listSurfaces() async throws -> [SurfaceRecord] { + bindings.values.map(\.record).sorted { $0.id < $1.id } + } + + public func send(surfaceID: String, bytes: [UInt8]) async throws { + try await binding(for: surfaceID).send(bytes) + } + + public func read(surfaceID: String, maxBytes: Int) async throws -> [UInt8] { + try await binding(for: surfaceID).read(maxBytes: maxBytes) + } + + public func resize(surfaceID: String, rows: UInt16, cols: UInt16) async throws { + try await binding(for: surfaceID).resize(rows: rows, cols: cols) + } + + public func capabilities(surfaceID: String) async throws -> SurfaceCapabilities { + try await binding(for: surfaceID).capabilities() + } + + private func binding(for surfaceID: String) throws -> SurfaceBinding { + guard let binding = bindings[surfaceID] else { + throw ControlError.provider("surface unavailable: \(surfaceID)") + } + return binding + } +} diff --git a/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/SurfaceProviderTests.swift b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/SurfaceProviderTests.swift new file mode 100644 index 00000000..3ad29b20 --- /dev/null +++ b/contrib/ghostty-control/Tests/ShareCLIGhosttyControlTests/SurfaceProviderTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +@testable import ShareCLIGhosttyControl + +private func binding(id: String, title: String? = nil) -> SurfaceBinding { + SurfaceBinding( + record: SurfaceRecord( + id: id, + title: title, + cwd: "/tmp", + process: ProcessEvidence(pid: nil, tty: nil, cwd: "/tmp", argv: [], startedAt: nil) + ), + send: { _ in }, + read: { maxBytes in Array("ok".utf8.prefix(maxBytes)) }, + resize: { _, _ in }, + capabilities: { + SurfaceCapabilities(read: true, write: true, resize: true, layout: false, durablePty: false) + } + ) +} + +@Test func registryIsDeterministicAndRoutesOperations() async throws { + let registry = SurfaceProviderRegistry() + try await registry.register(binding(id: "ghostty:2")) + try await registry.register(binding(id: "ghostty:1")) + + let surfaces = try await registry.listSurfaces() + #expect(surfaces.map(\.id) == ["ghostty:1", "ghostty:2"]) + #expect(try await registry.read(surfaceID: "ghostty:1", maxBytes: 2) == Array("ok".utf8)) + #expect(try await registry.capabilities(surfaceID: "ghostty:1").write) + #expect(await registry.unregister(surfaceID: "ghostty:2")) + #expect((try? await registry.listSurfaces().count) == 1) +} + +@Test func registryRejectsDuplicateAndUnknownSurface() async throws { + let registry = SurfaceProviderRegistry() + try await registry.register(binding(id: "ghostty:1")) + + do { + try await registry.register(binding(id: "ghostty:1")) + Issue.record("duplicate surface ids must be rejected") + } catch let error as ControlError { + #expect(error == .provider("surface binding already registered: ghostty:1")) + } + + do { + _ = try await registry.read(surfaceID: "ghostty:missing", maxBytes: 1) + Issue.record("unknown surfaces must fail explicitly") + } catch let error as ControlError { + #expect(error == .provider("surface unavailable: ghostty:missing")) + } +} + +@Test func unavailableProviderReportsDegradedCapabilities() async throws { + let provider = UnavailableSurfaceProvider(reason: "before Ghostty ready") + #expect(try await provider.listSurfaces().isEmpty) + let capabilities = try await provider.capabilities(surfaceID: "ghostty:missing") + #expect(!capabilities.read) + #expect(!capabilities.write) + #expect(!capabilities.resize) + #expect(!capabilities.durablePty) + + do { + _ = try await provider.read(surfaceID: "ghostty:missing", maxBytes: 1) + Issue.record("unavailable provider must not emulate reads") + } catch let error as ControlError { + #expect(error == .provider("before Ghostty ready")) + } +} + +@MainActor +@Test func lifecycleStartsAndStopsUnavailableEndpoint() async throws { + let path = "/tmp/sharecli-lifecycle-\(UUID().uuidString).sock" + let lifecycle = ControlLifecycle(socketPath: path) + lifecycle.startUnavailable(reason: "startup") + #expect(lifecycle.state == .running(socketPath: path)) + #expect(FileManager.default.fileExists(atPath: path)) + lifecycle.stop() + #expect(lifecycle.state == .stopped) + #expect(!FileManager.default.fileExists(atPath: path)) +} + +@MainActor +@Test func lifecycleSurfacesBindFailureWithoutThrowing() async throws { + let path = "/tmp/sharecli-lifecycle-\(UUID().uuidString).sock" + let lifecycle = ControlLifecycle(socketPath: path) + FileManager.default.createFile(atPath: path, contents: Data("not a socket".utf8)) + defer { try? FileManager.default.removeItem(atPath: path) } + + lifecycle.startUnavailable() + if case .failed = lifecycle.state { + #expect(true) + } else { + Issue.record("listener failure should be observable in lifecycle state") + } +} diff --git a/crates/sharecli-session/src/rpc.rs b/crates/sharecli-session/src/rpc.rs index bbabf10a..a18f0b33 100644 --- a/crates/sharecli-session/src/rpc.rs +++ b/crates/sharecli-session/src/rpc.rs @@ -1,8 +1,8 @@ #[cfg(test)] use crate::SurfaceSubscribeAck; use crate::{ - SessionService, SurfaceCapabilities, SurfaceEventError, SurfaceEventHub, SurfaceRecord, - SurfaceSubscribeRequest, + LayoutRestoreReport, LayoutSnapshot, SessionService, SurfaceCapabilities, SurfaceEventError, + SurfaceEventHub, SurfaceRecord, SurfaceSubscribeRequest, }; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -44,6 +44,25 @@ pub trait SurfaceControl: Send + Sync { fn read(&self, surface_id: &str, max_bytes: usize) -> Result>; fn resize(&self, surface_id: &str, rows: u16, cols: u16) -> Result<()>; fn capabilities(&self, surface_id: &str) -> Result; + + /// Capture the provider's current pane topology. + /// + /// The default is deliberately unavailable: a provider must opt into the + /// layout contract rather than allowing an empty tree to masquerade as a + /// successful snapshot. + fn snapshot_layout(&self) -> Result { + anyhow::bail!("surface layout snapshot unavailable") + } + + /// Apply a validated pane topology and return per-surface outcomes. + /// + /// Providers must implement this against their live surface tree. The + /// ShareCLI transport validates the snapshot before crossing the boundary + /// and never shells out to a terminal application. + fn restore_layout(&self, snapshot: &LayoutSnapshot) -> Result { + snapshot.validate()?; + anyhow::bail!("surface layout restore unavailable") + } } #[derive(Debug, Deserialize)] struct SurfaceRequest { @@ -297,6 +316,23 @@ fn dispatch_surface_method_with_events( let capabilities = control.capabilities(¶ms.surface_id).map_err(control_error)?; serde_json::to_value(capabilities).map_err(control_error) } + "surface.layout.snapshot" => { + if !params.as_object().is_some_and(|object| object.is_empty()) { + return Err((-32602, "surface.layout.snapshot takes no params".to_string())); + } + serde_json::to_value(control.snapshot_layout().map_err(control_error)?) + .map_err(control_error) + } + "surface.layout.restore" => { + #[derive(Deserialize)] + struct RestoreParams { + snapshot: LayoutSnapshot, + } + let request: RestoreParams = decode_params(params)?; + request.snapshot.validate().map_err(control_error)?; + serde_json::to_value(control.restore_layout(&request.snapshot).map_err(control_error)?) + .map_err(control_error) + } "surface.io.subscribe" => { let Some(events) = events else { return Err((-32000, "live surface events unavailable".to_string())); diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md index 6b7bd365..903c15f0 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/00_SESSION_OVERVIEW.md @@ -14,6 +14,9 @@ Success criteria for this slice: - recovery uses argv (never a shell) and bounded non-blocking launches; - IPC and CLI expose observation/recovery operations; - IPC and CLI expose validated layout persistence and surface I/O operations; +- `sharecli surface layout-snapshot` and `layout-restore` provide an explicit + file-backed topology handoff; snapshot writes are atomic and restore stays + dry-run/validation-first unless a native provider applies it; - Ghostty I/O is capability-gated and authenticated when a control socket exists; - FUSE selection is KEXT first, approved FSKit second, then fail-open. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md index ad315523..e81d8d1b 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/02_SPECIFICATIONS.md @@ -43,3 +43,27 @@ lines, and all response/event writes on one Unix connection are serialized. The timestamp field is an optional RFC3339 string so Rust, Swift, and future Ghostty-native providers share one wire representation. + +## Layout CLI handoff + +The root CLI exposes the native layout contract explicitly: + +```text +sharecli surface layout-snapshot [--output PATH] +sharecli surface layout-restore +``` + +`layout-snapshot` requests `surface.layout.snapshot`, validates the returned +`LayoutSnapshot`, and prints it as pretty JSON. With `--output`, it writes a +newline-terminated artifact through an atomic same-filesystem rename; an +existing artifact is never truncated in place. `layout-restore` reads and +validates the input before opening the Ghostty socket, then requests +`surface.layout.restore` and prints the provider's per-surface +`LayoutRestoreReport`. + +Both commands are safe by default: they do not shell out, mutate a ledger, or +create/kill terminal panes. A missing native provider is an explicit degraded +error. Restore is therefore a validation-first handoff; only a Ghostty-native +provider with the layout capability may apply the topology. The normal recovery +path remains dry-run until an operator explicitly chooses the provider-side +apply operation. diff --git a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md index 4c589131..1f28ab45 100644 --- a/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md +++ b/docs/sessions/20260731-sharecli-ghostty-control-plane/06_TESTING_STRATEGY.md @@ -21,6 +21,11 @@ Verified in this worktree: and bounded LiveIO sequence/overflow/subscription behavior) - `cargo test --test session --offline` (the live client path includes event decoding/unsubscribe and rejects invalid subscription limits before connect) +- `sharecli surface layout-snapshot [--output PATH]` and + `sharecli surface layout-restore ` must be covered with CLI tests for + atomic output, malformed/duplicate snapshot rejection before socket connect, + and explicit missing-provider degradation; neither command may shell out or + mutate panes without a native provider. - `cargo run -p sharecli-fuse --bin fuse-runtime-probe` (read-only host evidence) - `cargo fmt --all -- --check` - `git diff --check` diff --git a/src/main.rs b/src/main.rs index 8fde3633..5dd36b54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -628,6 +628,25 @@ enum SurfaceCmd { #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] token: Option, }, + /// Capture the native Ghostty pane topology as a validated LayoutSnapshot + LayoutSnapshot { + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + /// Write the snapshot to this path instead of stdout + #[arg(long, short = 'o')] + output: Option, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, + /// Apply a validated LayoutSnapshot through the native Ghostty provider + LayoutRestore { + /// JSON file containing a LayoutSnapshot + input: std::path::PathBuf, + #[arg(long, env = "SHARECLI_GHOSTTY_SOCKET", default_value = "/tmp/sharecli-ghostty.sock")] + socket: std::path::PathBuf, + #[arg(long, env = "SHARECLI_GHOSTTY_TOKEN")] + token: Option, + }, } #[derive(Subcommand, Debug)] @@ -1308,10 +1327,42 @@ fn surface_cmd(cmd: &SurfaceCmd) -> Result<()> { } } } + SurfaceCmd::LayoutSnapshot { socket, output, token } => { + let client = GhosttyControlClient::new(socket, token.clone()); + let snapshot = client.snapshot_layout()?; + let encoded = serde_json::to_string_pretty(&snapshot)?; + if let Some(output) = output { + write_json_file(output, &encoded)?; + println!( + "{{\"ok\":true,\"path\":{}}}", + serde_json::to_string(&output.display().to_string())? + ); + } else { + println!("{encoded}"); + } + } + SurfaceCmd::LayoutRestore { input, socket, token } => { + let snapshot: LayoutSnapshot = serde_json::from_str(&std::fs::read_to_string(input)?)?; + let client = GhosttyControlClient::new(socket, token.clone()); + let report = client.restore_layout(&snapshot)?; + println!("{}", serde_json::to_string_pretty(&report)?); + } } Ok(()) } +/// Write a JSON artifact without exposing a shell or partially replacing an +/// existing snapshot. The rename is atomic on the same filesystem. +fn write_json_file(path: &std::path::Path, contents: &str) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + std::fs::create_dir_all(parent)?; + let tmp = path.with_extension(format!("sharecli-tmp-{}", std::process::id())); + std::fs::write(&tmp, format!("{contents}\n"))?; + std::fs::rename(&tmp, path) + .with_context(|| format!("atomically replace layout snapshot at {}", path.display()))?; + Ok(()) +} + /// `sharecli man` — emit sharecli(1) via clap_mangen (C09 L81.13). fn cli_man(install: bool) -> Result<()> { use clap::CommandFactory; diff --git a/src/session.rs b/src/session.rs index 0bdb7274..43bf928f 100644 --- a/src/session.rs +++ b/src/session.rs @@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sharecli_session::{ - SurfaceAdapter, SurfaceCapabilities, SurfaceEventKind, SurfaceRecord, MAX_EVENT_CHUNK_BYTES, - MAX_EVENT_QUEUE_CAPACITY, + LayoutRestoreReport, LayoutSnapshot, SurfaceAdapter, SurfaceCapabilities, SurfaceEventKind, + SurfaceRecord, MAX_EVENT_CHUNK_BYTES, MAX_EVENT_QUEUE_CAPACITY, }; use std::io::{BufRead, BufReader, Write}; #[cfg(unix)] @@ -259,6 +259,24 @@ impl GhosttyControlClient { Ok(()) } + /// Request a validated topology snapshot from the native Ghostty provider. + pub fn snapshot_layout(&self) -> anyhow::Result { + serde_json::from_value(self.request("surface.layout.snapshot", json!({}))?) + .map_err(Into::into) + } + + /// Apply a durable topology through the native Ghostty provider. + /// + /// Validation happens before the request is sent, so malformed or + /// duplicate surface trees never reach the app-side provider. + pub fn restore_layout(&self, snapshot: &LayoutSnapshot) -> anyhow::Result { + snapshot.validate()?; + serde_json::from_value( + self.request("surface.layout.restore", json!({"snapshot": snapshot}))?, + ) + .map_err(Into::into) + } + #[cfg(unix)] pub fn subscribe_surface( &self, @@ -324,6 +342,14 @@ impl SurfaceAdapter for GhosttyControlClient { fn discover(&self) -> anyhow::Result> { self.list_surfaces() } + + fn snapshot_layout(&self) -> anyhow::Result { + self.snapshot_layout() + } + + fn restore_layout(&self, snapshot: &LayoutSnapshot) -> anyhow::Result { + self.restore_layout(snapshot) + } } pub struct GhosttyAdapter;