From 1a351037fe3e23e258aa2e81b9c7e6ef09122973 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 15:42:11 +0200 Subject: [PATCH 1/3] feat(cli): add bundled launcher and single-instance repository handoff --- .github/workflows/ci.yml | 3 + .gitignore | 2 + Cargo.lock | 27 ++ Cargo.toml | 1 + README.md | 4 + ROADMAP.md | 8 +- TASKS.md | 18 +- crates/strand-headless/Cargo.toml | 18 ++ crates/strand-headless/src/launcher.rs | 99 +++++++ crates/strand-headless/src/main.rs | 17 ++ crates/strand-tauri/Cargo.toml | 2 + crates/strand-tauri/binaries/.gitkeep | 0 crates/strand-tauri/src/launcher.rs | 241 ++++++++++++++++++ crates/strand-tauri/src/main.rs | 9 + crates/strand-tauri/tauri.conf.json | 3 +- docs/learnings.md | 10 + docs/strand-cli.md | 6 +- scripts/build-companion.mjs | 21 ++ scripts/build-msix.ps1 | 2 + ui/src/App.tsx | 25 +- ui/src/demo/dispatch.ts | 2 + ui/src/lib/tauri.ts | 2 + ui/src/views/settings/IntegrationsSection.tsx | 12 + website/docs/settings.md | 13 + 24 files changed, 533 insertions(+), 12 deletions(-) create mode 100644 crates/strand-headless/Cargo.toml create mode 100644 crates/strand-headless/src/launcher.rs create mode 100644 crates/strand-headless/src/main.rs create mode 100644 crates/strand-tauri/binaries/.gitkeep create mode 100644 crates/strand-tauri/src/launcher.rs create mode 100644 scripts/build-companion.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bb171df..ddea7e6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,9 @@ jobs: - name: Test strand-core run: cargo test -p strand-core + - name: Test Strand companion + run: cargo test -p strand-headless + - name: Test strand-tauri run: cargo test -p strand-tauri diff --git a/.gitignore b/.gitignore index ddd79818..48a2ed0a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ # Tauri build artifacts /crates/strand-tauri/gen/ /crates/strand-tauri/target/ +/crates/strand-tauri/binaries/* +!/crates/strand-tauri/binaries/.gitkeep # Node / Vite node_modules/ diff --git a/Cargo.lock b/Cargo.lock index 4ea18990..63914db3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5931,6 +5931,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "strand-headless" +version = "1.5.1" +dependencies = [ + "serde", + "serde_json", + "strand-core", + "tempfile", +] + [[package]] name = "strand-tauri" version = "1.5.1" @@ -5956,6 +5966,7 @@ dependencies = [ "tauri-plugin-os", "tauri-plugin-process", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tauri-plugin-sql", "tauri-plugin-updater", "tauri-plugin-window-state", @@ -6407,6 +6418,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0cb5c412a5071b69bab6a6df1583cbb89460d4a83b6a24769b08d15b6b1e1" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-sql" version = "2.4.0" diff --git a/Cargo.toml b/Cargo.toml index 335e2895..705aa3e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/strand-azdo", "crates/strand-azdo-protocol", "crates/strand-core", + "crates/strand-headless", "crates/strand-tauri", ] diff --git a/README.md b/README.md index 40ac8df7..38df6451 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ the resolved app appearance automatically. ## Features +- **Command line launcher** — Settings → Integrations installs `strand` in + your user command directory. `strand PATH` opens and focuses a repository + in the existing desktop instance, including paths with spaces. - **Responsive refreshes** — repository updates coalesce during bursts of agent edits, hidden diff panes load patches when opened, and Files reuses its inventory until paths or ignore rules change. Workspace scans run with @@ -350,6 +353,7 @@ calls, so `pnpm dev` is useful for UI work without a Rust build. strand/ ├── crates/ │ ├── strand-core/ # Git engine (gix for reads, git2 for writes) +│ ├── strand-headless/ # CLI launcher, read-only companion and stdio engine │ ├── strand-azdo-protocol/ # Shared optional-helper JSON contract │ ├── strand-azdo/ # Azure DevOps Server REST helper CLI │ └── strand-tauri/ # Tauri 2 app shell + IPC commands diff --git a/ROADMAP.md b/ROADMAP.md index f048b0bf..f6a6d4ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2166,7 +2166,7 @@ and Store certification remain external gates. - Patch import/mailbox and Git bundle workflows - Expanded submodule lifecycle (add/remove/deinit/sync/URL/nested status) - Repository/ref/file custom actions with safe argv templates -- **CLI companion binary (`strand`)** — `strand ` opens the repo +- ◐ **CLI companion binary (`strand`)** — `strand ` opens the repo in the app; `strand diff/log/status/review --json` gives AI agents typed, full-context data the `git` porcelain can't (same serde types as the IPC layer). Read-only by design — no push/pull, no writes. @@ -2810,6 +2810,12 @@ GitHub/Azure review, Workbench and performance work retain their own status. --- +**F16 launcher shipped locally (2026-09-06):** The bundled `strand-cli` +companion installs as the user's `strand` command from Settings → Integrations +and the palette. A single-instance argv inbox opens requested repositories +after session restore and focuses the existing window. Read commands and SSH +transport follow in separately verified changes. + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index 4c7910bf..7f88a781 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2614,12 +2614,14 @@ extraction above as prerequisite. **Do not start before 1.0 ships** ### App integration -- ☐ P2 Wire `tauri-plugin-single-instance` into `strand-tauri` (second +- ☑ P2 Wire `tauri-plugin-single-instance` into `strand-tauri` (second launch forwards argv to the running instance) — prerequisite for - `strand ` -- ☐ P2 `strand `: forward to running app or launch it with the - path (macOS `open -a Strand --args`; exec elsewhere) -- ☐ P2 Settings action: install `strand` shim/symlink on PATH (the VS - Code `code`-command pattern); Windows `strand.cmd` variant -- ☐ P2 Ship the binary inside the app bundle + standalone per-release - download for headless boxes + `strand `; `launcher::LaunchInbox` queues argv until session restore) +- ☑ P2 `strand `: forward to running app or launch it with the + path (`strand-headless::launcher`, macOS `open -a Strand --args`; exec elsewhere) +- ☑ P2 Settings action: install `strand` on PATH (`app_install_cli`, private + executable + desktop locator avoids shell argument interpolation; Windows + user PATH registration, Unix ~/.local/bin with shell PATH instructions) +- ◐ P2 Ship the binary inside the app bundle + standalone per-release + download for headless boxes (`build-companion.mjs`, Tauri resources and MSIX + layout bundle the companion; standalone release matrix remains) diff --git a/crates/strand-headless/Cargo.toml b/crates/strand-headless/Cargo.toml new file mode 100644 index 00000000..213e131f --- /dev/null +++ b/crates/strand-headless/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "strand-headless" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Read-only Strand companion and stdio engine." + +[[bin]] +name = "strand-cli" +path = "src/main.rs" + +[dependencies] +strand-core = { path = "../strand-core" } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/strand-headless/src/launcher.rs b/crates/strand-headless/src/launcher.rs new file mode 100644 index 00000000..bb4242cd --- /dev/null +++ b/crates/strand-headless/src/launcher.rs @@ -0,0 +1,99 @@ +use std::{ + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +pub fn repository_path(args: &[String]) -> Result { + let path = match args { + [path] if !path.starts_with('-') => path, + [separator, path] if separator == "--" => path, + _ => { + return Err( + "Usage: strand PATH (use strand -- PATH for a path starting with '-')".into(), + ) + } + }; + let repo = strand_core::Repo::discover(path).map_err(|e| e.to_string())?; + Ok(repo.path().to_path_buf()) +} + +pub fn launch(args: &[String]) -> Result<(), String> { + let path = repository_path(args)?; + let current = std::env::current_exe().map_err(|e| e.to_string())?; + let desktop = std::env::var_os("STRAND_DESKTOP") + .map(PathBuf::from) + .or_else(|| { + std::fs::read_to_string(current.with_extension("desktop-path")) + .ok() + .map(PathBuf::from) + }); + let mut command = if let Some(desktop) = desktop { + if !desktop.is_absolute() || !desktop.is_file() || desktop == current { + return Err("STRAND_DESKTOP must name an existing absolute desktop executable.".into()); + } + Command::new(desktop) + } else { + desktop_command(¤t)? + }; + command + .arg("--open-repo") + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); + } + command + .spawn() + .map_err(|e| format!("Could not launch Strand: {e}"))?; + Ok(()) +} + +fn desktop_command(_current: &Path) -> Result { + #[cfg(target_os = "macos")] + { + let mut command = Command::new("/usr/bin/open"); + command.args(["-n", "-a", "Strand", "--args"]); + Ok(command) + } + #[cfg(not(target_os = "macos"))] + { + let sibling = _current + .parent() + .unwrap_or(Path::new("/")) + .join(if cfg!(windows) { + "strand.exe" + } else { + "strand" + }); + if sibling.is_file() && sibling != _current { + return Ok(Command::new(sibling)); + } + Err("Desktop app not found. Install the command from Settings → Integrations, or set STRAND_DESKTOP.".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn resolves_nested_paths_and_rejects_extra_arguments() { + let temp = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init", "--quiet"]) + .arg(temp.path()) + .status() + .unwrap(); + let nested = temp.path().join("space name"); + std::fs::create_dir(&nested).unwrap(); + assert_eq!( + repository_path(&[nested.to_string_lossy().into_owned()]).unwrap(), + strand_core::Repo::discover(temp.path()).unwrap().path() + ); + assert!(repository_path(&["--bad".into()]).is_err()); + assert!(repository_path(&[".".into(), "extra".into()]).is_err()); + } +} diff --git a/crates/strand-headless/src/main.rs b/crates/strand-headless/src/main.rs new file mode 100644 index 00000000..aec21ed5 --- /dev/null +++ b/crates/strand-headless/src/main.rs @@ -0,0 +1,17 @@ +mod launcher; + +fn main() { + let args: Vec<_> = std::env::args().skip(1).collect(); + if args == ["--help"] || args == ["-h"] { + println!("strand PATH\n\nOpen a repository in the Strand desktop app.\nSet STRAND_DESKTOP to an absolute desktop executable path for a standalone install."); + return; + } + if args == ["--version"] { + println!("strand {}", env!("CARGO_PKG_VERSION")); + return; + } + if let Err(message) = launcher::launch(&args) { + eprintln!("{message}"); + std::process::exit(2); + } +} diff --git a/crates/strand-tauri/Cargo.toml b/crates/strand-tauri/Cargo.toml index 0213b2dd..8e790d44 100644 --- a/crates/strand-tauri/Cargo.toml +++ b/crates/strand-tauri/Cargo.toml @@ -40,6 +40,7 @@ tauri-plugin-shell = "2" tauri-plugin-os = "2" tauri-plugin-notification = "2" tauri-plugin-window-state = "2" +tauri-plugin-single-instance = "2" serde.workspace = true serde_json.workspace = true @@ -66,6 +67,7 @@ windows-sys = { version = "0.61", features = [ "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", ] } [features] diff --git a/crates/strand-tauri/binaries/.gitkeep b/crates/strand-tauri/binaries/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/crates/strand-tauri/src/launcher.rs b/crates/strand-tauri/src/launcher.rs new file mode 100644 index 00000000..e40253a9 --- /dev/null +++ b/crates/strand-tauri/src/launcher.rs @@ -0,0 +1,241 @@ +//! A bounded, durable-until-drained argv inbox. Events only wake the consumer; +//! the frontend drains after installing its listener and restoring the session. +use crate::commands::{CmdError, CmdResult}; +use std::{collections::VecDeque, path::Path, sync::Mutex}; +use tauri::{Emitter, Manager, State}; + +#[derive(Default)] +pub struct LaunchInbox(Mutex>); + +pub fn request_path(args: &[String], cwd: &Path) -> Option { + let path = match args { + [_, flag, path] if flag == "--open-repo" => path, + [_, path] if !path.starts_with('-') => path, + _ => return None, + }; + let path = Path::new(path); + Some( + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } + .to_string_lossy() + .into_owned(), + ) +} + +pub fn receive(app: &tauri::AppHandle, args: &[String], cwd: &Path) { + if let Some(path) = request_path(args, cwd) { + if let Ok(mut inbox) = app.state::().0.lock() { + if inbox.len() < 32 && !inbox.contains(&path) { + inbox.push_back(path); + } + } + let _ = app.emit("app://open-request", ()); + } + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } +} + +#[tauri::command(async)] +pub fn app_take_open_requests(inbox: State<'_, LaunchInbox>) -> Vec { + inbox + .0 + .lock() + .map(|mut queue| queue.drain(..).collect()) + .unwrap_or_default() +} + +/// Install a private executable + desktop locator without a shell/argv shim. +/// The command directory is added to the user's PATH on Windows; Unix shells +/// report the exact export when ~/.local/bin isn't in PATH already. +#[tauri::command(async)] +pub async fn app_install_cli(app: tauri::AppHandle) -> CmdResult { + tokio::task::spawn_blocking(move || install(&app)) + .await + .map_err(|e| CmdError { + message: e.to_string(), + })? + .map_err(|message| CmdError { message }) +} + +fn install(app: &tauri::AppHandle) -> Result { + let desktop = std::env::current_exe().map_err(|e| e.to_string())?; + let name = if cfg!(windows) { + "strand-cli.exe" + } else { + "strand-cli" + }; + let bundled = app + .path() + .resource_dir() + .map_err(|e| e.to_string())? + .join("binaries") + .join(name); + let sibling = desktop.with_file_name(name); + let source = if bundled.is_file() { bundled } else { sibling }; + if !source.is_file() { + return Err( + "Companion missing. Build strand-headless or reinstall the desktop package.".into(), + ); + } + let dir = app + .path() + .home_dir() + .map_err(|e| e.to_string())? + .join(".local") + .join("bin"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let destination = dir.join(if cfg!(windows) { + "strand.exe" + } else { + "strand" + }); + let locator = destination.with_extension("desktop-path"); + // Do not replace an unrelated executable already using this command name. + if destination.exists() && !locator.exists() { + return Err(format!( + "{} already exists and is not managed by Strand.", + destination.display() + )); + } + std::fs::copy(&source, &destination).map_err(|e| e.to_string())?; + std::fs::write(locator, desktop.to_string_lossy().as_bytes()).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&destination, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| e.to_string())?; + } + #[cfg(windows)] + add_user_path(&dir)?; + Ok(format!( + "Installed {}. {}", + destination.display(), + if cfg!(windows) { + "Open a new terminal to use strand PATH.".to_owned() + } else { + format!( + "If needed, add {} to your shell PATH. Run strand PATH to open a repository.", + dir.display() + ) + } + )) +} + +#[cfg(windows)] +fn add_user_path(dir: &Path) -> Result<(), String> { + use windows_sys::Win32::System::Registry::*; + let wide = |s: &str| s.encode_utf16().chain(Some(0)).collect::>(); + let mut key = std::ptr::null_mut(); + unsafe { + if RegOpenKeyExW( + HKEY_CURRENT_USER, + wide("Environment").as_ptr(), + 0, + KEY_QUERY_VALUE | KEY_SET_VALUE, + &mut key, + ) != 0 + { + return Err("Could not open user PATH registry key.".into()); + } + let result: Result<(), String> = (|| { + let mut len = 0; + let mut kind = REG_EXPAND_SZ; + let name = wide("Path"); + let status = RegQueryValueExW( + key, + name.as_ptr(), + std::ptr::null(), + &mut kind, + std::ptr::null_mut(), + &mut len, + ); + if status != 0 && status != 2 { + return Err("Could not read user PATH.".into()); + } + if status == 0 && kind != REG_SZ && kind != REG_EXPAND_SZ { + return Err("User PATH is not a string registry value.".into()); + } + if len > 128 * 1024 { + return Err("User PATH exceeds supported size.".into()); + } + let mut value = vec![0u16; len as usize / 2 + 1]; + if status == 0 + && RegQueryValueExW( + key, + name.as_ptr(), + std::ptr::null(), + &mut kind, + value.as_mut_ptr().cast(), + &mut len, + ) != 0 + { + return Err("Could not read user PATH.".into()); + } + let old = String::from_utf16_lossy(&value) + .trim_end_matches('\0') + .to_string(); + let dir = dir.to_string_lossy(); + if old.split(';').any(|p| p.eq_ignore_ascii_case(&dir)) { + return Ok(()); + } + let updated = wide(&format!( + "{}{}{}", + old, + if old.is_empty() || old.ends_with(';') { + "" + } else { + ";" + }, + dir + )); + if RegSetValueExW( + key, + name.as_ptr(), + 0, + kind, + updated.as_ptr().cast(), + (updated.len() * 2) as u32, + ) != 0 + { + return Err("Could not update user PATH.".into()); + } + Ok(()) + })(); + RegCloseKey(key); + result?; + use windows_sys::Win32::UI::WindowsAndMessaging::*; + SendMessageTimeoutW( + HWND_BROADCAST, + WM_SETTINGCHANGE, + 0, + wide("Environment").as_ptr() as isize, + SMTO_ABORTIFHUNG, + 1000, + std::ptr::null_mut(), + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn argv_is_explicit_and_relative_to_sender() { + let cwd = std::env::temp_dir(); + assert_eq!( + request_path( + &["strand".into(), "--open-repo".into(), "space name".into()], + &cwd + ), + Some(cwd.join("space name").to_string_lossy().into_owned()) + ); + assert!(request_path(&["strand".into(), "--anything".into()], &cwd).is_none()); + } +} diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 0b3fad89..afe55e6e 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -117,6 +117,8 @@ fn install_crash_log(app: &tauri::App) { })); } +mod launcher; + fn main() { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("strand=info,strand_core=info")); @@ -133,6 +135,10 @@ fn main() { strand_core::init(); tauri::Builder::default() + .manage(launcher::LaunchInbox::default()) + .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { + launcher::receive(app, &args, std::path::Path::new(&cwd)); + })) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_notification::init()) @@ -147,6 +153,8 @@ fn main() { ) .manage(state::AppState::default()) .invoke_handler(tauri::generate_handler![ + launcher::app_take_open_requests, + launcher::app_install_cli, commands::repo_open, commands::microsoft_store_update_available, commands::microsoft_store_open_product, @@ -348,6 +356,7 @@ fn main() { } let _ = win.show(); } + launcher::receive(app.handle(), &std::env::args().collect::>(), &std::env::current_dir().unwrap_or_default()); Ok(()) }) .build(tauri::generate_context!()) diff --git a/crates/strand-tauri/tauri.conf.json b/crates/strand-tauri/tauri.conf.json index 11d07310..9452f685 100644 --- a/crates/strand-tauri/tauri.conf.json +++ b/crates/strand-tauri/tauri.conf.json @@ -7,7 +7,7 @@ "frontendDist": "../../ui/dist", "devUrl": "http://localhost:1420", "beforeDevCommand": "pnpm dev", - "beforeBuildCommand": "pnpm build" + "beforeBuildCommand": "node scripts/build-companion.mjs && pnpm build" }, "app": { "windows": [ @@ -45,6 +45,7 @@ }, "bundle": { "active": true, + "resources": ["binaries/*"], "createUpdaterArtifacts": true, "targets": ["app", "dmg", "msi", "deb", "appimage", "rpm"], "icon": [ diff --git a/docs/learnings.md b/docs/learnings.md index e50db001..8f645f45 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1,5 +1,15 @@ # Learnings +## Desktop launch arguments need an inbox (2026-09-06) + +Single-instance events can arrive before React subscribes or while persisted +tabs are restoring. Keep a bounded native inbox, use events only as wakeups, +and drain after session restore and listener registration. Resolve relative +paths against the sending process's cwd. The desktop binary already owns the +`strand` filename, so the bundle stores the headless companion as `strand-cli`; +the user command installation maps it to `strand` with an absolute desktop +locator, avoiding shell interpolation of repository paths. + Things we've learned while building Strand that aren't otherwise obvious from the PRD / ROADMAP / TASKS files. Append here when you discover something that future work (yours or another agent's) needs to respect. diff --git a/docs/strand-cli.md b/docs/strand-cli.md index 77fd6a51..138e275a 100644 --- a/docs/strand-cli.md +++ b/docs/strand-cli.md @@ -1,6 +1,10 @@ # `strand` CLI — feature design -Status (2026-06-12): **design only, scheduled post-1.0** (ROADMAP §1.1+, +Status (2026-09-06): **launcher implemented; read commands and daemon in progress**. +The desktop bundles `strand-cli` (the GUI executable already owns `strand`); +Settings → Integrations installs it as the user's `strand` command. Startup +and subsequent argv requests share a bounded inbox drained after session restore. +The remaining sections describe the staged target. Originally scheduled post-1.0 (ROADMAP §1.1+, where "CLI companion binary" has been a bullet since the start — this doc fleshes it out). Shares its foundation with [`remote-ssh.md`](./remote-ssh.md): both consume the transport-agnostic diff --git a/scripts/build-companion.mjs b/scripts/build-companion.mjs new file mode 100644 index 00000000..143c2bb6 --- /dev/null +++ b/scripts/build-companion.mjs @@ -0,0 +1,21 @@ +// Run from Tauri's beforeBuildCommand so the matching CLI is bundled on every +// desktop distribution route, including the separate MSIX layout. +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const target = process.env.TAURI_ENV_TARGET_TRIPLE; +const name = process.platform === 'win32' ? 'strand-cli.exe' : 'strand-cli'; +const out = resolve('crates/strand-tauri/binaries'); +mkdirSync(out, { recursive: true }); +if (target === 'universal-apple-darwin') { + const targets = ['aarch64-apple-darwin', 'x86_64-apple-darwin']; + for (const t of targets) execFileSync('cargo', ['build', '-p', 'strand-headless', '--release', '--target', t], { stdio: 'inherit' }); + execFileSync('lipo', ['-create', ...targets.map(t => `target/${t}/release/${name}`), '-output', `${out}/${name}`], { stdio: 'inherit' }); +} else { + execFileSync('cargo', ['build', '-p', 'strand-headless', '--release', ...(target ? ['--target', target] : [])], { stdio: 'inherit' }); + copyFileSync(resolve('target', ...(target ? [target] : []), 'release', name), `${out}/${name}`); +} +if (process.platform === 'darwin' && process.env.APPLE_SIGNING_IDENTITY) { + execFileSync('codesign', ['--force', '--options', 'runtime', '--timestamp', '--sign', process.env.APPLE_SIGNING_IDENTITY, `${out}/${name}`], { stdio: 'inherit' }); +} diff --git a/scripts/build-msix.ps1 b/scripts/build-msix.ps1 index f045bd17..d050fcf3 100644 --- a/scripts/build-msix.ps1 +++ b/scripts/build-msix.ps1 @@ -101,6 +101,8 @@ New-Item -ItemType Directory -Force -Path (Join-Path $layoutPath 'Assets') | Out New-Item -ItemType Directory -Force -Path $distPath | Out-Null Copy-Item -LiteralPath $executablePath -Destination (Join-Path $layoutPath 'strand.exe') +New-Item -ItemType Directory -Force -Path (Join-Path $layoutPath 'binaries') | Out-Null +Copy-Item -LiteralPath (Join-Path $repoRoot 'crates\strand-tauri\binaries\strand-cli.exe') -Destination (Join-Path $layoutPath 'binaries\strand-cli.exe') foreach ($asset in @('StoreLogo.png', 'Square150x150Logo.png', 'Square44x44Logo.png')) { Copy-Item ` -LiteralPath (Join-Path $repoRoot "crates\strand-tauri\icons\$asset") ` diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ea335854..c39b9a6a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1121,6 +1121,24 @@ export function App() { // tabs are open). Each step is idempotent, so StrictMode's double-invoke is // harmless. useEffect(() => { + let disposed = false; + let ready = false; + let draining = false; + let wakeAgain = false; + const drain = async () => { + if (disposed || !ready) return; + if (draining) { wakeAgain = true; return; } + draining = true; + try { + do { + wakeAgain = false; + for (const path of await tauri.appTakeOpenRequests()) await openByPath(path); + } while (wakeAgain && !disposed); + } catch (error) { + showToast(`Launch request failed: ${errMessage(error)}`, 'error'); + } finally { draining = false; } + }; + const unlisten = isTauri() ? listen('app://open-request', () => { void drain(); }) : Promise.resolve(() => {}); void refreshRecents(); void (async () => { await useWorkspaces.getState().load(); @@ -1128,9 +1146,13 @@ export function App() { await restoreSession(); } finally { useWorkspaces.getState().initAfterRestore(); + await unlisten; + ready = true; + if (isTauri()) await drain(); } })(); - }, [refreshRecents, restoreSession]); + return () => { disposed = true; void unlisten.then((stop) => stop()); }; + }, [refreshRecents, restoreSession, openByPath, showToast]); // Native desktop menu. Menu item actions read the latest callbacks // through this ref, so the menu itself only rebuilds when the repo-scoped @@ -1633,6 +1655,7 @@ export function App() { // Repo-independent — always available. const base: PaletteAction[] = [ { id: 'open', label: 'Open repository…', group: 'Actions', shortcut: keyHint('open-repo'), run: () => { void openViaDialog(); } }, + { id: 'install-cli', label: 'Install strand command…', group: 'Actions', keywords: 'terminal PATH launcher companion', run: () => { setSettingsSection('integrations'); setSettingsOpen(true); } }, { id: 'init', label: 'Initialize repository…', group: 'Actions', keywords: 'new create git init local repository', run: () => setInitRepoOpen(true) }, { id: 'clone', label: t('clone.paletteAction'), group: 'Actions', shortcut: keyHint('clone-repo'), run: () => setCloneOpen(true) }, { id: 'switch-repo', label: 'Switch repository…', group: 'Actions', shortcut: keyHint('switch-repo'), keywords: 'switch repo repository jump active picker quick open', run: () => setRepoSwitcherOpen(true) }, diff --git a/ui/src/demo/dispatch.ts b/ui/src/demo/dispatch.ts index 4298b70f..8041cea5 100644 --- a/ui/src/demo/dispatch.ts +++ b/ui/src/demo/dispatch.ts @@ -65,6 +65,8 @@ function prComment(author: string, body: string, path: string | null, prId: numb } export const handlers: Record = { + app_take_open_requests: () => [], + app_install_cli: () => unavailable('Installing the strand command'), // ---- app / environment ------------------------------------------------- microsoft_store_update_available: () => false, microsoft_store_open_product: () => unavailable('The Microsoft Store'), diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index e7356543..3551507c 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -115,6 +115,8 @@ export function errMessage(e: unknown): string { * frontend never calls `invoke` with a string literal. */ export const tauri = { + appTakeOpenRequests: () => invoke('app_take_open_requests'), + appInstallCli: () => invoke('app_install_cli'), microsoftStoreUpdateAvailable: () => invoke('microsoft_store_update_available'), microsoftStoreOpenProduct: () => diff --git a/ui/src/views/settings/IntegrationsSection.tsx b/ui/src/views/settings/IntegrationsSection.tsx index 5d41ea83..5c653cd0 100644 --- a/ui/src/views/settings/IntegrationsSection.tsx +++ b/ui/src/views/settings/IntegrationsSection.tsx @@ -21,9 +21,21 @@ export function IntegrationsSection() { const editorTool = useSettings((s) => s.editorTool); const terminalTool = useSettings((s) => s.terminalTool); const set = useSettings((s) => s.set); + const [cliStatus, setCliStatus] = useState(null); + const [installingCli, setInstallingCli] = useState(false); return (
+
+ Command line + +

{cliStatus ?? 'Install in ~/.local/bin. strand PATH opens a repository in this desktop instance. Windows adds the folder to your user PATH; on macOS/Linux, add it to your shell PATH if needed.'}

+
Date: Sun, 6 Sep 2026 15:53:36 +0200 Subject: [PATCH 2/3] feat(cli): add versioned read-only status log diff and review --- .github/workflows/ci.yml | 4 +- Cargo.lock | 141 ++++++++++++ Cargo.toml | 1 + README.md | 6 +- ROADMAP.md | 7 + TASKS.md | 21 +- crates/strand-core/Cargo.toml | 4 + crates/strand-core/src/diff.rs | 2 + crates/strand-core/src/file.rs | 2 + crates/strand-core/src/log.rs | 1 + crates/strand-core/src/refs.rs | 6 + crates/strand-core/src/repo.rs | 1 + crates/strand-core/src/snapshot.rs | 1 + crates/strand-core/src/status.rs | 2 + crates/strand-core/src/submodule.rs | 2 + crates/strand-core/src/tree.rs | 1 + crates/strand-headless/Cargo.toml | 2 + crates/strand-headless/src/cli.rs | 242 +++++++++++++++++++++ crates/strand-headless/src/main.rs | 31 +-- crates/strand-headless/tests/cli.rs | 191 +++++++++++++++++ crates/strand-ops/Cargo.toml | 16 ++ crates/strand-ops/src/lib.rs | 320 ++++++++++++++++++++++++++++ crates/strand-tauri/Cargo.toml | 1 + crates/strand-tauri/src/commands.rs | 8 +- docs/learnings.md | 12 ++ docs/strand-cli.md | 14 +- website/docs/settings.md | 24 +++ 27 files changed, 1033 insertions(+), 30 deletions(-) create mode 100644 crates/strand-headless/src/cli.rs create mode 100644 crates/strand-headless/tests/cli.rs create mode 100644 crates/strand-ops/Cargo.toml create mode 100644 crates/strand-ops/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddea7e6e..f8cdede2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: run: cargo test -p strand-core - name: Test Strand companion - run: cargo test -p strand-headless + run: cargo test -p strand-ops -p strand-headless - name: Test strand-tauri run: cargo test -p strand-tauri @@ -64,7 +64,7 @@ jobs: run: cargo test -p strand-azdo-protocol -p strand-azdo - name: Clippy (deny warnings) - run: cargo clippy -p strand-core -p strand-tauri -p strand-azdo-protocol -p strand-azdo -- -D warnings + run: cargo clippy -p strand-core -p strand-tauri -p strand-ops -p strand-headless -p strand-azdo-protocol -p strand-azdo -- -D warnings windows-helper: name: Azure DevOps Server helper (Windows) diff --git a/Cargo.lock b/Cargo.lock index 63914db3..43d1f87c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -718,6 +768,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clru" version = "0.6.3" @@ -736,6 +826,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -3062,6 +3158,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -4010,6 +4112,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.5" @@ -5925,6 +6033,7 @@ dependencies = [ "git2", "gix", "notify", + "schemars 0.8.22", "serde", "serde_json", "thiserror 1.0.69", @@ -5935,10 +6044,24 @@ dependencies = [ name = "strand-headless" version = "1.5.1" dependencies = [ + "clap", + "serde", + "serde_json", + "strand-core", + "strand-ops", + "tempfile", +] + +[[package]] +name = "strand-ops" +version = "1.5.1" +dependencies = [ + "schemars 0.8.22", "serde", "serde_json", "strand-core", "tempfile", + "url", ] [[package]] @@ -5958,6 +6081,7 @@ dependencies = [ "sqlx", "strand-azdo-protocol", "strand-core", + "strand-ops", "tar", "tauri", "tauri-build", @@ -6063,6 +6187,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -7239,6 +7374,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" diff --git a/Cargo.toml b/Cargo.toml index 705aa3e5..9615c584 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/strand-azdo-protocol", "crates/strand-core", "crates/strand-headless", + "crates/strand-ops", "crates/strand-tauri", ] diff --git a/README.md b/README.md index 38df6451..23748e58 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,10 @@ the resolved app appearance automatically. ## Features -- **Command line launcher** — Settings → Integrations installs `strand` in +- **Read-only command line companion** — `strand status/log/diff/review` + works without the desktop; `--json` emits a versioned typed payload and + `strand schema` describes it. Full-file review context uses the same engine + as the app. Settings → Integrations installs `strand` in your user command directory. `strand PATH` opens and focuses a repository in the existing desktop instance, including paths with spaces. - **Responsive refreshes** — repository updates coalesce during bursts of @@ -354,6 +357,7 @@ strand/ ├── crates/ │ ├── strand-core/ # Git engine (gix for reads, git2 for writes) │ ├── strand-headless/ # CLI launcher, read-only companion and stdio engine +│ ├── strand-ops/ # Shared read operations and versioned wire types │ ├── strand-azdo-protocol/ # Shared optional-helper JSON contract │ ├── strand-azdo/ # Azure DevOps Server REST helper CLI │ └── strand-tauri/ # Tauri 2 app shell + IPC commands diff --git a/ROADMAP.md b/ROADMAP.md index f6a6d4ce..5683c827 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2816,6 +2816,13 @@ and the palette. A single-instance argv inbox opens requested repositories after session restore and focuses the existing window. Read commands and SSH transport follow in separately verified changes. +**F16 read companion shipped locally (2026-09-06):** `strand-ops` and the clap +front-end provide headless status/snapshot, log/file history, diff variants, +full-context review, and derived JSON schemas. Machine results share the +desktop serde types in schema-v1 envelopes; errors and output limits are +explicit, with no Git mutation or implicit lazy fetch. Terminal syntax colors, +blame/conflicts and standalone distribution remain in the expanded CLI backlog. + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index 7f88a781..3d6ddd83 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2480,8 +2480,9 @@ ships** (ROADMAP §1.1+). ### Engine & daemon -- ☐ P2 Extract command handlers from `strand-tauri` into a - transport-agnostic `strand-ops` crate (shared by Tauri shell + daemon) +- ◐ P2 Extract command handlers from `strand-tauri` into a + transport-agnostic `strand-ops` crate (typed meta/status/snapshot shared with + the desktop; read-only companion allowlist implemented; writes remain local) - ☐ P2 `strandd` headless binary: `strand-ops` behind JSON-RPC over stdio; versioned handshake with capability flags; strict serde (`deny_unknown_fields`), per-frame size limits @@ -2595,22 +2596,24 @@ extraction above as prerequisite. **Do not start before 1.0 ships** ### Binary & commands -- ☐ P2 `strand-headless` crate: clap front-end over `strand-ops` with +- ◐ P2 `strand-headless` crate: clap front-end over `strand-ops` with `cli` + `--stdio` (daemon) entry modes; one static artifact, one hash manifest shared with remote-SSH bootstrap -- ☐ P2 Read commands: `status` (+ `--snapshot`), `diff` (`--staged`, +- ◐ P2 Read commands: `status` (+ `--snapshot`), `diff` (`--staged`, `--commit`, `--between`, `--since`, `--full-context` via the `*_full` - review ops), `log`, `blame`, `conflicts` + review ops), `log`, `blame`, `conflicts` (`cli.rs` implements status/diff/log + and file history; blame and structured conflict commands remain) - ☐ P2 Terminal diff renderer: Rust-native — `syntect` highlighting + truecolor ANSI through a pager (the `delta` model), theme ported from `tokens.css`. Decided: no JS runtime in the binary; OpenTUI/Pierre rejected for in-process use (see `docs/strand-cli.md` open questions) -- ☐ P2 `review` command: one payload (full-context diffs since base + - log + status) for agent/reviewer consumption -- ☐ P2 Machine output contract: `--json` reusing IPC serde types, +- ☑ P2 `review` command: one payload (full-context diffs since a pinned base + + recent HEAD log + status, before/after HEAD; `strand_ops::execute`) +- ◐ P2 Machine output contract: `--json` reusing IPC serde types, `schemaVersion` envelope, `strand schema` dump, NDJSON progress streaming, JSON errors on stderr + stable exit codes, no pager/locale - variance + variance (`Envelope`, derived `schema`, bounded encoding and stable errors + shipped; progress is reserved until a streaming operation exists) ### App integration diff --git a/crates/strand-core/Cargo.toml b/crates/strand-core/Cargo.toml index ac677e40..51526ea9 100644 --- a/crates/strand-core/Cargo.toml +++ b/crates/strand-core/Cargo.toml @@ -13,3 +13,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tracing.workspace = true +schemars = { version = "0.8", optional = true } + +[features] +schema = ["dep:schemars"] diff --git a/crates/strand-core/src/diff.rs b/crates/strand-core/src/diff.rs index 36ef83e1..f34fd200 100644 --- a/crates/strand-core/src/diff.rs +++ b/crates/strand-core/src/diff.rs @@ -7,6 +7,7 @@ use crate::{error::Result, repo::Repo}; /// What happened to a file between two trees / index states. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub enum DiffStatus { Added, Modified, @@ -23,6 +24,7 @@ pub enum DiffStatus { /// don't parse hunks on the Rust side until we need to (hunk-level staging /// in A3 will look at `patch` and a per-hunk index). #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct FileDiff { pub path: String, pub old_path: Option, diff --git a/crates/strand-core/src/file.rs b/crates/strand-core/src/file.rs index 294f275e..3a14efc7 100644 --- a/crates/strand-core/src/file.rs +++ b/crates/strand-core/src/file.rs @@ -25,6 +25,7 @@ const MAX_CONTENT_BYTES: usize = 2_000_000; const MAX_BLOB_BYTES: usize = 8_000_000; #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct FileContent { pub path: String, /// File text (empty when `binary`). Truncated to [`MAX_CONTENT_BYTES`]. @@ -57,6 +58,7 @@ pub enum BlobSource<'a> { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct FileHistoryEntry { pub hash: String, pub short_hash: String, diff --git a/crates/strand-core/src/log.rs b/crates/strand-core/src/log.rs index cfa30a55..116a1406 100644 --- a/crates/strand-core/src/log.rs +++ b/crates/strand-core/src/log.rs @@ -6,6 +6,7 @@ use crate::{ }; #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct Commit { pub hash: String, pub short_hash: String, diff --git a/crates/strand-core/src/refs.rs b/crates/strand-core/src/refs.rs index 7698e18a..aa41a004 100644 --- a/crates/strand-core/src/refs.rs +++ b/crates/strand-core/src/refs.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::{error::Result, repo::Repo}; #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct Branch { /// Short name, e.g. `main`. pub name: String, @@ -30,6 +31,7 @@ pub struct Branch { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct UpstreamRef { /// Short name as git presents it, e.g. `origin/main`. pub name: String, @@ -38,6 +40,7 @@ pub struct UpstreamRef { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct RemoteBranch { /// Short name, e.g. `origin/main`. pub name: String, @@ -54,6 +57,7 @@ pub struct RemoteBranch { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct Remote { pub name: String, pub url: Option, @@ -68,6 +72,7 @@ pub struct Remote { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct Tag { pub name: String, pub full_name: String, @@ -80,6 +85,7 @@ pub struct Tag { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct Refs { pub branches: Vec, /// Primary branch used to determine merged local branches. diff --git a/crates/strand-core/src/repo.rs b/crates/strand-core/src/repo.rs index 19085402..3148cc01 100644 --- a/crates/strand-core/src/repo.rs +++ b/crates/strand-core/src/repo.rs @@ -197,6 +197,7 @@ impl Repo { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct RepoMeta { pub name: String, pub path: String, diff --git a/crates/strand-core/src/snapshot.rs b/crates/strand-core/src/snapshot.rs index b82280fe..c069560f 100644 --- a/crates/strand-core/src/snapshot.rs +++ b/crates/strand-core/src/snapshot.rs @@ -21,6 +21,7 @@ use crate::{ }; #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct Snapshot { pub meta: RepoMeta, pub status: Vec, diff --git a/crates/strand-core/src/status.rs b/crates/strand-core/src/status.rs index dc05a56e..d9d6ed9f 100644 --- a/crates/strand-core/src/status.rs +++ b/crates/strand-core/src/status.rs @@ -4,6 +4,7 @@ use crate::{error::Result, repo::Repo}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "UPPERCASE")] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub enum StatusKind { Modified, Added, @@ -14,6 +15,7 @@ pub enum StatusKind { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct FileStatus { pub path: String, pub kind: StatusKind, diff --git a/crates/strand-core/src/submodule.rs b/crates/strand-core/src/submodule.rs index 2c3673eb..327bec36 100644 --- a/crates/strand-core/src/submodule.rs +++ b/crates/strand-core/src/submodule.rs @@ -19,6 +19,7 @@ use crate::{ /// git2's `SubmoduleStatus` bitset to the single badge the UI shows. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub enum SubmoduleState { /// No working tree checked out (never `init`-ed / `update`-d). Uninitialized, @@ -33,6 +34,7 @@ pub enum SubmoduleState { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct Submodule { /// Submodule name from `.gitmodules` (often equal to `path`). pub name: String, diff --git a/crates/strand-core/src/tree.rs b/crates/strand-core/src/tree.rs index a2e60422..6fbb54e7 100644 --- a/crates/strand-core/src/tree.rs +++ b/crates/strand-core/src/tree.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::{error::Result, repo::Repo, status::StatusKind}; #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct WorkTreeEntry { pub path: String, /// Change status if the file differs from HEAD/index; `None` for a clean diff --git a/crates/strand-headless/Cargo.toml b/crates/strand-headless/Cargo.toml index 213e131f..b4d6869c 100644 --- a/crates/strand-headless/Cargo.toml +++ b/crates/strand-headless/Cargo.toml @@ -11,6 +11,8 @@ path = "src/main.rs" [dependencies] strand-core = { path = "../strand-core" } +strand-ops = { path = "../strand-ops" } +clap = { version = "4", features = ["derive"] } serde.workspace = true serde_json.workspace = true diff --git a/crates/strand-headless/src/cli.rs b/crates/strand-headless/src/cli.rs new file mode 100644 index 00000000..5f0e6219 --- /dev/null +++ b/crates/strand-headless/src/cli.rs @@ -0,0 +1,242 @@ +use clap::{Parser, Subcommand}; +use std::io::{self, Write}; +use strand_ops::{DiffSource, OpError, ReadOp, ReadRequest, ReadResult, Result}; + +#[derive(Parser)] +#[command( + name = "strand", + version, + about = "Open Strand or read a repository without changing it", + subcommand_precedence_over_arg = true +)] +struct Cli { + /// Repository discovery starts here; defaults to the current directory. + #[arg(short = 'C', global = true)] + directory: Option, + /// Versioned JSON on stdout; a single JSON error on stderr on failure. + #[arg(long, global = true)] + json: bool, + /// Open the repository in the desktop. Use -- PATH for command-like names. + path: Option, + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Action { + /// Working-tree/index status, optionally with metadata, refs and files. + Status { + #[arg(long)] + snapshot: bool, + }, + /// Recent history (all refs by default), or rename-following file history. + Log { + #[arg(short = 'n', long, default_value_t = 50)] + limit: usize, + #[arg(long)] + head: bool, + #[arg(long)] + file: Option, + }, + /// Unified patches. Whole-file context is available for unstaged/since. + Diff { + #[arg(long, group = "source")] + staged: bool, + #[arg(long, group = "source")] + commit: Option, + #[arg(long, num_args = 2, group = "source")] + between: Vec, + #[arg(long, group = "source")] + since: Option, + #[arg(long)] + full_context: bool, + }, + /// Full-context changes since a base, recent HEAD history and status. + Review { + #[arg(long, default_value = "HEAD")] + since: String, + #[arg(short = 'n', long, default_value_t = 50)] + limit: usize, + }, + /// JSON schemas for the versioned output, request and error types. + Schema, +} + +pub fn run(args: Vec) -> Result<()> { + let cli = match Cli::try_parse_from(args) { + Ok(cli) => cli, + Err(error) + if matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) => + { + return output(error.to_string().as_bytes()); + } + Err(error) => return Err(OpError::new("invalid_request", error.to_string())), + }; + if cli.path.is_some() && cli.command.is_some() { + return Err(OpError::new( + "invalid_request", + "Choose a path to open or a read command, not both.", + )); + } + let Some(action) = cli.command else { + let path = cli.path.ok_or_else(|| { + OpError::new( + "invalid_request", + "Pass a repository path or a read command; see strand --help.", + ) + })?; + let path = if let Some(directory) = cli.directory { + std::path::Path::new(&directory) + .join(path) + .to_string_lossy() + .into_owned() + } else { + path + }; + crate::launcher::launch(&["--".into(), path]) + .map_err(|e| OpError::new("invalid_request", e))?; + if cli.json { + output(b"{\"schemaVersion\":1,\"launched\":true}\n")?; + } + return Ok(()); + }; + let op = match action { + Action::Schema => return output(&strand_ops::encode(&strand_ops::schema())?), + Action::Status { snapshot: false } => ReadOp::Status {}, + Action::Status { snapshot: true } => ReadOp::Snapshot {}, + Action::Log { limit, head, file } => match file { + Some(path) => ReadOp::FileHistory { path, limit }, + None => ReadOp::Log { + limit, + head_only: head, + }, + }, + Action::Review { since, limit } => ReadOp::Review { since, limit }, + Action::Diff { + staged, + commit, + between, + since, + full_context, + } => { + if full_context && (staged || commit.is_some() || !between.is_empty()) { + return Err(OpError::new( + "invalid_request", + "--full-context is supported for unstaged diffs and --since only.", + )); + } + let source = if staged { + DiffSource::Staged {} + } else if let Some(revision) = commit { + DiffSource::Commit { revision } + } else if between.len() == 2 { + DiffSource::Between { + from: between[0].clone(), + to: between[1].clone(), + } + } else if let Some(revision) = since { + DiffSource::Since { + revision, + full_context, + } + } else { + DiffSource::Unstaged { full_context } + }; + ReadOp::Diff { source } + } + }; + let envelope = strand_ops::execute(&ReadRequest { + repository: cli.directory.unwrap_or_else(|| ".".into()), + op, + })?; + // Enforce the same bound before printing any bytes, including human output. + let encoded = strand_ops::encode(&envelope)?; + if cli.json { + output(&encoded) + } else { + let text = human(&envelope.result); + if text.len() >= strand_ops::MAX_FRAME_BYTES { + return Err(OpError::new( + "output_limit", + "Human output exceeds 8 MiB; narrow the request.", + )); + } + output(text.as_bytes()) + } +} + +fn output(bytes: &[u8]) -> Result<()> { + match io::stdout().lock().write_all(bytes) { + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(()), + Err(error) => Err(OpError::new("io", error.to_string())), + Ok(()) => Ok(()), + } +} + +fn human(result: &ReadResult) -> String { + let value = match result { + ReadResult::Status(files) => { + if files.is_empty() { + "Working tree clean\n".into() + } else { + files + .iter() + .map(|f| { + format!( + "{} {:10?} {}\n", + if f.staged { "index" } else { "work " }, + f.kind, + f.path + ) + }) + .collect() + } + } + ReadResult::Log(commits) => commits + .iter() + .map(|c| format!("{} {} — {}\n", c.short_hash, c.subject, c.author_name)) + .collect(), + ReadResult::FileHistory(commits) => commits + .iter() + .map(|c| format!("{} {} (+{} -{})\n", c.short_hash, c.subject, c.adds, c.dels)) + .collect(), + ReadResult::Diff(files) => files + .iter() + .map(|f| { + if f.binary { + format!("Binary file: {}\n", f.path) + } else { + f.patch.clone() + } + }) + .collect(), + ReadResult::Review(review) => format!( + "Review since {}\n\n{}\n{}", + review.base, + human(&ReadResult::Status(review.status.clone())), + human(&ReadResult::Diff(review.diffs.clone())) + ), + ReadResult::Snapshot(snapshot) => format!( + "{} · {} · {} ahead / {} behind\n{}", + snapshot.meta.name, + snapshot.meta.branch, + snapshot.meta.ahead, + snapshot.meta.behind, + human(&ReadResult::Status(snapshot.status.clone())) + ), + _ => serde_json::to_string_pretty(result).unwrap_or_default() + "\n", + }; + // Repository-controlled strings must not execute terminal escape sequences. + let mut safe = String::with_capacity(value.len()); + for c in value.chars() { + if c.is_control() && c != '\n' && c != '\t' { + safe.extend(c.escape_default()); + } else { + safe.push(c); + } + } + safe +} diff --git a/crates/strand-headless/src/main.rs b/crates/strand-headless/src/main.rs index aec21ed5..edb5b328 100644 --- a/crates/strand-headless/src/main.rs +++ b/crates/strand-headless/src/main.rs @@ -1,17 +1,24 @@ +mod cli; mod launcher; fn main() { - let args: Vec<_> = std::env::args().skip(1).collect(); - if args == ["--help"] || args == ["-h"] { - println!("strand PATH\n\nOpen a repository in the Strand desktop app.\nSet STRAND_DESKTOP to an absolute desktop executable path for a standalone install."); - return; - } - if args == ["--version"] { - println!("strand {}", env!("CARGO_PKG_VERSION")); - return; - } - if let Err(message) = launcher::launch(&args) { - eprintln!("{message}"); - std::process::exit(2); + // A read of a partial clone must report a missing object, not fetch it as + // an implicit side effect. This process has not started any threads yet. + std::env::set_var("GIT_NO_LAZY_FETCH", "1"); + strand_core::init(); + let args: Vec<_> = std::env::args().collect(); + let json = args.iter().any(|arg| arg == "--json"); + if let Err(error) = cli::run(args) { + if json { + eprintln!("{}", serde_json::to_string(&error).unwrap()); + } else { + eprintln!("{}: {}", error.code, error.message); + } + std::process::exit(match error.code.as_str() { + "invalid_request" => 2, + "repository" => 3, + "output_limit" => 4, + _ => 5, + }); } } diff --git a/crates/strand-headless/tests/cli.rs b/crates/strand-headless/tests/cli.rs new file mode 100644 index 00000000..cb8f57f3 --- /dev/null +++ b/crates/strand-headless/tests/cli.rs @@ -0,0 +1,191 @@ +use serde_json::Value; +use std::{ + collections::BTreeMap, + fs, + path::Path, + process::{Command, Output}, +}; + +fn git(path: &Path, args: &[&str]) { + let result = Command::new("git") + .arg("-C") + .arg(path) + .args(["-c", "commit.gpgsign=false", "-c", "core.hooksPath="]) + .args(args) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); +} +fn fixture() -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + git(temp.path(), &["init", "-q"]); + git(temp.path(), &["config", "user.name", "Companion Test"]); + git( + temp.path(), + &["config", "user.email", "companion@example.com"], + ); + let text: String = (1..=40).map(|i| format!("line {i}\n")).collect(); + fs::write(temp.path().join("space name.txt"), &text).unwrap(); + git(temp.path(), &["add", "."]); + git(temp.path(), &["commit", "-qm", "initial"]); + fs::write( + temp.path().join("space name.txt"), + text.replace("line 20", "staged change"), + ) + .unwrap(); + git(temp.path(), &["add", "."]); + fs::write( + temp.path().join("space name.txt"), + text.replace("line 20", "unstaged change"), + ) + .unwrap(); + fs::write(temp.path().join("binary.bin"), [0, 1, 2, 3]).unwrap(); + temp +} +fn run(path: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_strand-cli")) + .current_dir(path) + .args(args) + .output() + .unwrap() +} +fn json(output: Output) -> Value { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stderr.is_empty()); + serde_json::from_slice(&output.stdout).unwrap() +} +fn files(root: &Path) -> BTreeMap> { + fn walk(root: &Path, path: &Path, out: &mut BTreeMap>) { + for entry in fs::read_dir(path).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + walk(root, &p, out); + } else { + out.insert( + p.strip_prefix(root).unwrap().to_string_lossy().into_owned(), + fs::read(p).unwrap(), + ); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +#[test] +fn reads_reuse_engine_shapes_and_leave_every_repository_byte_unchanged() { + let repo = fixture(); + let before = files(repo.path()); + let status = json(run(repo.path(), &["status", "--json"])); + assert_eq!(status["schemaVersion"], 1); + assert_eq!(status["result"]["data"].as_array().unwrap().len(), 3); + let staged = json(run(repo.path(), &["--json", "diff", "--staged"])); + assert!(staged["result"]["data"][0]["patch"] + .as_str() + .unwrap() + .contains("+staged change")); + let full = json(run(repo.path(), &["diff", "--json", "--full-context"])); + let patch = full["result"]["data"] + .as_array() + .unwrap() + .iter() + .find(|d| d["path"] == "space name.txt") + .unwrap()["patch"] + .as_str() + .unwrap(); + assert!( + patch.contains("line 1\n") + && patch.contains("line 40\n") + && patch.contains("+unstaged change") + ); + let snapshot = json(run( + repo.path(), + &["-C", ".", "status", "--snapshot", "--json"], + )); + assert!(snapshot["result"]["data"]["meta"]["head_oid"].is_string()); + assert!(snapshot["result"]["data"]["log"].is_null()); + let log = json(run(repo.path(), &["log", "--json", "-n", "1"])); + assert_eq!(log["result"]["data"][0]["subject"], "initial"); + let review = json(run(repo.path(), &["review", "--json", "--since", "HEAD"])); + assert_eq!( + review["result"]["data"]["head_before"], + review["result"]["data"]["head_after"] + ); + assert_eq!(review["result"]["data"]["status"], status["result"]["data"]); + json(run( + repo.path(), + &["log", "--file", "space name.txt", "--json"], + )); + json(run(repo.path(), &["diff", "--commit", "HEAD", "--json"])); + json(run( + repo.path(), + &["diff", "--between", "HEAD", "HEAD", "--json"], + )); + assert_eq!(before, files(repo.path())); +} + +#[test] +fn machine_errors_are_single_json_on_stderr_and_output_is_deterministic() { + let repo = fixture(); + let a = run(repo.path(), &["status", "--json"]); + assert_eq!(a.stdout, run(repo.path(), &["--json", "status"]).stdout); + for args in [ + vec!["--json", "push"], + vec!["--json", "log", "-n", "0"], + vec!["--json", "diff", "--staged", "--since", "HEAD"], + vec!["--json", "diff", "--staged", "--full-context"], + ] { + let out = run(repo.path(), &args); + assert_eq!(out.status.code(), Some(2)); + assert!(out.stdout.is_empty()); + assert_eq!( + serde_json::from_slice::(&out.stderr).unwrap()["code"], + "invalid_request" + ); + } + let error = run( + repo.path(), + &["--json", "diff", "--since", "missing-revision"], + ); + assert_eq!(error.status.code(), Some(3)); + let schema = json(run(repo.path(), &["schema"])); + assert!(schema["output"]["definitions"]["FileDiff"].is_object()); + assert_eq!(schema["schemaVersion"], 1); +} + +#[test] +fn unborn_repository_and_linked_worktree_identity() { + let empty = tempfile::tempdir().unwrap(); + git(empty.path(), &["init", "-q"]); + assert_eq!( + json(run(empty.path(), &["status", "--json"]))["result"]["data"], + serde_json::json!([]) + ); + let repo = fixture(); + let sibling = tempfile::tempdir().unwrap(); + git( + repo.path(), + &[ + "worktree", + "add", + "--detach", + sibling.path().to_str().unwrap(), + "HEAD", + ], + ); + let snap = json(run(sibling.path(), &["status", "--snapshot", "--json"])); + assert_eq!(snap["result"]["data"]["meta"]["is_linked_worktree"], true); + assert_ne!( + snap["result"]["data"]["meta"]["path"], + snap["result"]["data"]["meta"]["common_dir"] + ); +} diff --git a/crates/strand-ops/Cargo.toml b/crates/strand-ops/Cargo.toml new file mode 100644 index 00000000..1cc88dcb --- /dev/null +++ b/crates/strand-ops/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "strand-ops" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Transport-independent, read-only Strand operations and wire contract." + +[dependencies] +strand-core = { path = "../strand-core", features = ["schema"] } +serde.workspace = true +serde_json.workspace = true +schemars = "0.8" +url.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/strand-ops/src/lib.rs b/crates/strand-ops/src/lib.rs new file mode 100644 index 00000000..1984968a --- /dev/null +++ b/crates/strand-ops/src/lib.rs @@ -0,0 +1,320 @@ +//! Shared read allowlist. No operation here mutates Git state or fetches objects. +//! Local desktop hot paths can keep calling typed functions without serializing. +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::io::{self, BufRead, Write}; +use strand_core::{ + diff::FileDiff, + file::{FileContent, FileHistoryEntry}, + log::Commit, + repo::RepoMeta, + snapshot::Snapshot, + status::FileStatus, + Repo, +}; + +pub const SCHEMA_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 1; +pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_LOG: usize = 1000; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct OpError { + pub code: String, + pub message: String, +} +impl OpError { + pub fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } +} +impl From for OpError { + fn from(e: strand_core::Error) -> Self { + Self::new("repository", e.to_string()) + } +} +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum DiffSource { + Unstaged { + full_context: bool, + }, + Staged {}, + Commit { + revision: String, + }, + Between { + from: String, + to: String, + }, + Since { + revision: String, + full_context: bool, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ReadOp { + Meta {}, + Status {}, + Snapshot {}, + Log { + limit: usize, + head_only: bool, + }, + FileHistory { + path: String, + limit: usize, + }, + Diff { + source: DiffSource, + }, + Review { + since: String, + limit: usize, + }, + File { + path: String, + revision: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReadRequest { + pub repository: String, + pub op: ReadOp, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct Review { + pub base: String, + /// HEAD before and after the bundle allow consumers to detect intervening + /// commits. Working-tree reads, like the desktop snapshot, are not atomic. + pub head_before: Option, + pub head_after: Option, + pub diffs: Vec, + pub log: Vec, + pub status: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", content = "data", rename_all = "snake_case")] +pub enum ReadResult { + Meta(RepoMeta), + Status(Vec), + Snapshot(Snapshot), + Log(Vec), + FileHistory(Vec), + Diff(Vec), + Review(Review), + File(FileContent), +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Envelope { + pub schema_version: u32, + /// Canonical path on the executing machine. SSH clients attach their host + /// identity outside this value, never reinterpret it as a local path. + pub repository: String, + pub result: ReadResult, +} + +// Preserve core's error type on the direct local path (see strand-core's +// result_large_err rationale); serialization belongs only to wire consumers. +#[allow(clippy::result_large_err)] +pub fn meta(path: &str) -> strand_core::Result { + Repo::discover(path)?.meta() +} +#[allow(clippy::result_large_err)] +pub fn status(path: &str) -> strand_core::Result> { + Repo::discover(path)?.status() +} +#[allow(clippy::result_large_err)] +pub fn snapshot(path: &str) -> strand_core::Result { + Repo::discover(path)?.snapshot() +} + +pub fn execute(request: &ReadRequest) -> Result { + if request.repository.starts_with("ssh://") { + return Err(OpError::new( + "invalid_request", + "The engine requires a filesystem path on its own machine.", + )); + } + let absolute = std::fs::canonicalize(&request.repository) + .map_err(|error| OpError::new("repository", error.to_string()))?; + let repo = Repo::discover(absolute)?; + let limit = |n: usize| { + if (1..=MAX_LOG).contains(&n) { + Ok(n) + } else { + Err(OpError::new( + "invalid_request", + "Log limit must be between 1 and 1000.", + )) + } + }; + let result = match &request.op { + ReadOp::Meta {} => ReadResult::Meta(repo.meta()?), + ReadOp::Status {} => ReadResult::Status(repo.status()?), + ReadOp::Snapshot {} => ReadResult::Snapshot(repo.snapshot()?), + ReadOp::Log { + limit: n, + head_only, + } => ReadResult::Log(if *head_only { + repo.log_head(limit(*n)?)? + } else { + repo.log(limit(*n)?)? + }), + ReadOp::FileHistory { path, limit: n } => { + relative_path(path)?; + ReadResult::FileHistory(repo.file_history(path, limit(*n)?)?) + } + ReadOp::Diff { source } => ReadResult::Diff(match source { + DiffSource::Unstaged { full_context: true } => repo.diff_unstaged_full()?, + DiffSource::Unstaged { + full_context: false, + } => repo.diff_unstaged()?, + DiffSource::Staged {} => repo.diff_staged()?, + DiffSource::Commit { revision } => repo.diff_commit(revision)?, + DiffSource::Between { from, to } => repo.diff_between(from, to)?, + DiffSource::Since { + revision, + full_context: true, + } => repo.diff_since_full(revision)?, + DiffSource::Since { + revision, + full_context: false, + } => repo.diff_since(revision)?, + }), + ReadOp::Review { since, limit: n } => { + let n = limit(*n)?; + // Freeze a mutable base ref to an OID before building the payload. + let base = repo.merge_base(since, since)?; + let before = repo.meta()?.head_oid; + let diffs = repo.diff_since_full(&base)?; + let log = repo.log_head(n)?; + let status = repo.status()?; + ReadResult::Review(Review { + base, + head_before: before, + head_after: repo.meta()?.head_oid, + diffs, + log, + status, + }) + } + ReadOp::File { path, revision } => { + relative_path(path)?; + ReadResult::File(repo.file_content(path, revision.as_deref())?) + } + }; + Ok(Envelope { + schema_version: SCHEMA_VERSION, + repository: repo.path().to_string_lossy().into_owned(), + result, + }) +} + +pub fn relative_path(path: &str) -> Result<()> { + if path.is_empty() + || path.contains(['\\', '\0', ':']) + || path.starts_with('/') + || path + .split('/') + .any(|p| p == ".." || p == "." || p.is_empty()) + { + return Err(OpError::new( + "invalid_request", + "File paths must be repository-relative with no traversal.", + )); + } + Ok(()) +} + +/// Never use read_line: a peer can omit the newline and grow it indefinitely. +pub fn read_frame(reader: &mut impl BufRead) -> io::Result>> { + let mut frame = Vec::new(); + loop { + let bytes = reader.fill_buf()?; + if bytes.is_empty() { + return if frame.is_empty() { + Ok(None) + } else { + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated frame", + )) + }; + } + let newline = bytes.iter().position(|&b| b == b'\n'); + let n = newline.map_or(bytes.len(), |n| n + 1); + if frame.len() + n > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "frame exceeds 8 MiB", + )); + } + frame.extend_from_slice(&bytes[..n]); + reader.consume(n); + if newline.is_some() { + return Ok(Some(frame)); + } + } +} + +struct BoundedBuffer(Vec); +impl Write for BoundedBuffer { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if self.0.len() + bytes.len() >= MAX_FRAME_BYTES { + return Err(io::Error::other("result exceeds 8 MiB; narrow the request")); + } + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} +pub fn encode(value: &impl Serialize) -> Result> { + let mut buffer = BoundedBuffer(Vec::new()); + serde_json::to_writer(&mut buffer, value) + .map_err(|e| OpError::new("output_limit", e.to_string()))?; + buffer.0.push(b'\n'); + Ok(buffer.0) +} + +pub fn schema() -> serde_json::Value { + serde_json::json!({ "schemaVersion": SCHEMA_VERSION, "output": schemars::schema_for!(Envelope), "request": schemars::schema_for!(ReadRequest), "error": schemars::schema_for!(OpError) }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn bounded_frames_reject_partial_and_oversized_input() { + assert!(read_frame(&mut &b"{}"[..]).is_err()); + assert_eq!(read_frame(&mut &b"{}\n"[..]).unwrap().unwrap(), b"{}\n"); + assert!(read_frame(&mut vec![b'x'; MAX_FRAME_BYTES + 1].as_slice()).is_err()); + assert!(encode(&"x".repeat(MAX_FRAME_BYTES)).is_err()); + } + #[test] + fn read_allowlist_is_strict() { + for kind in ["commit", "fetch", "push", "stage", "run", "clone"] { + assert!(serde_json::from_value::(serde_json::json!({"kind": kind})).is_err()); + } + assert!(serde_json::from_str::(r#"{"kind":"status","command":"push"}"#).is_err()); + for path in ["../secret", "/etc/passwd", "C:/secret", "a\\b", "a/../b"] { + assert!(relative_path(path).is_err()); + } + } +} diff --git a/crates/strand-tauri/Cargo.toml b/crates/strand-tauri/Cargo.toml index 8e790d44..cb7f33ed 100644 --- a/crates/strand-tauri/Cargo.toml +++ b/crates/strand-tauri/Cargo.toml @@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] } [dependencies] strand-core = { path = "../strand-core" } +strand-ops = { path = "../strand-ops" } strand-azdo-protocol = { path = "../strand-azdo-protocol" } tauri = { version = "2", features = ["macos-private-api"] } diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index e0ec2642..f3a42863 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -139,7 +139,7 @@ async fn run_blocking( #[tauri::command(async)] pub async fn repo_open(path: String, state: State<'_, AppState>) -> CmdResult { - let meta = run_blocking("open", move || Ok(Repo::discover(&path)?.meta()?)).await?; + let meta = run_blocking("open", move || Ok(strand_ops::meta(&path)?)).await?; if let Ok(mut paths) = state.open_paths.lock() { paths.insert(meta.path.clone()); } @@ -164,12 +164,12 @@ pub async fn microsoft_store_open_product() -> CmdResult<()> { #[tauri::command(async)] pub async fn repo_meta(path: String) -> CmdResult { - run_blocking("meta", move || Ok(Repo::discover(&path)?.meta()?)).await + run_blocking("meta", move || Ok(strand_ops::meta(&path)?)).await } #[tauri::command(async)] pub async fn repo_status(path: String) -> CmdResult> { - run_blocking("status", move || Ok(Repo::discover(&path)?.status()?)).await + run_blocking("status", move || Ok(strand_ops::status(&path)?)).await } /// One-call refresh bundle: meta + status + work tree + refs + submodules @@ -177,7 +177,7 @@ pub async fn repo_status(path: String) -> CmdResult> { /// post-change refresh path calls this instead of five separate commands. #[tauri::command(async)] pub async fn repo_snapshot(path: String) -> CmdResult { - run_blocking("snapshot", move || Ok(Repo::discover(&path)?.snapshot()?)).await + run_blocking("snapshot", move || Ok(strand_ops::snapshot(&path)?)).await } /// Start watching `path`'s working tree; emits a `repo://changed` event with diff --git a/docs/learnings.md b/docs/learnings.md index 8f645f45..41025d01 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -10,6 +10,18 @@ paths against the sending process's cwd. The desktop binary already owns the the user command installation maps it to `strand` with an absolute desktop locator, avoiding shell interpolation of repository paths. +## Headless reads are a versioned allowlist (2026-09-06) + +`strand-ops::ReadOp` is shared by the companion and remote engine. Do not route +arbitrary Tauri command names or shell commands through it. Local meta/status/ +snapshot calls stay typed and in process. Output schemas derive from the core +serde types behind the `schema` feature; changing an existing shape is a public +contract change. Serde's internally tagged unit variants can ignore extra +fields even with `deny_unknown_fields`: use empty struct variants and retain +the unknown-field regression test. The headless process disables Git lazy +fetch before starting threads, so reads of partial clones cannot initiate a +network write as a hidden side effect. + Things we've learned while building Strand that aren't otherwise obvious from the PRD / ROADMAP / TASKS files. Append here when you discover something that future work (yours or another agent's) needs to respect. diff --git a/docs/strand-cli.md b/docs/strand-cli.md index 138e275a..ceacb594 100644 --- a/docs/strand-cli.md +++ b/docs/strand-cli.md @@ -1,10 +1,20 @@ # `strand` CLI — feature design -Status (2026-09-06): **launcher implemented; read commands and daemon in progress**. +Status (2026-09-06): **launcher and read-only status/log/diff/review implemented**. The desktop bundles `strand-cli` (the GUI executable already owns `strand`); Settings → Integrations installs it as the user's `strand` command. Startup and subsequent argv requests share a bounded inbox drained after session restore. -The remaining sections describe the staged target. Originally scheduled post-1.0 (ROADMAP §1.1+, +`strand-ops` owns the read allowlist and schema-v1 envelope, sharing core serde +types with the desktop. `--json` has stable error codes and an 8 MiB output +limit; `schema` derives its JSON schemas from these types. `status --snapshot` +uses today's desktop Snapshot (metadata, status, files, refs, submodules; +history is intentionally separate). `review` pins its base to an OID and +reports HEAD before/after; it is a sequence of reads, not an atomic disk snapshot. +`diff --full-context` applies to unstaged/`--since`, matching existing core ops. +Human output is plain, terminal-control-safe text. Syntax colors/pager, +blame/structured conflicts, standalone release artifacts, and remote bootstrap +remain staged work. The remaining sections record the target design. +Originally scheduled post-1.0 (ROADMAP §1.1+, where "CLI companion binary" has been a bullet since the start — this doc fleshes it out). Shares its foundation with [`remote-ssh.md`](./remote-ssh.md): both consume the transport-agnostic diff --git a/website/docs/settings.md b/website/docs/settings.md index b287874d..71c47d2e 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -13,6 +13,30 @@ otherwise the desktop starts and opens the repository after session restore. Quote paths containing spaces. Existing unrelated `strand` executables in the installation directory are never replaced. +The companion also works headlessly. These commands only read Git state: + +```sh +strand -C /path/to/repo status --snapshot --json +strand log -n 50 --json +strand log --file src/main.rs --json +strand diff --staged +strand diff --commit HEAD --json +strand diff --between main HEAD --json +strand diff --since main --full-context --json +strand review --since main --json +strand schema +``` + +`--json` emits one `{schemaVersion: 1, repository, result: {kind, data}}` +envelope. The data uses Strand's desktop types. Errors produce no stdout and +one `{code, message}` object on stderr: exit 2 for invalid requests, 3 for +repository errors, 4 for the 8 MiB output limit, and 5 for output errors. +Log limits range from 1–1,000. Full context is available for unstaged diffs and +`--since`; incompatible selectors fail explicitly. Snapshot excludes history; +Review includes recent HEAD history and reports its before/after HEAD because +concurrent repository changes can occur between reads. No command stages, +commits, fetches, pushes or invokes a pager. + Open the Settings dialog with `Mod+,`, the gear button in the status bar, or the command palette ("Settings…"). The dialog has nine sections — Appearance, Diff, Keyboard, Git, Hosting, Integrations, AI, Updates, and Privacy. Most changes apply live; git identity and Azure DevOps Server profiles have explicit save actions. The sidebar is a keyboard-navigable list: `↑`/`↓` move between sections, `Home`/`End` jump to the first or last, and `Escape` closes the dialog. From 9da310190cce02c7438f3dadf5e397b9b9d769d8 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 17:12:23 +0200 Subject: [PATCH 3/3] feat(ssh): add bounded read-only remote repository inspection --- Cargo.lock | 2 +- README.md | 5 + ROADMAP.md | 17 +- TASKS.md | 46 +- crates/strand-core/src/file.rs | 31 + crates/strand-core/src/repo.rs | 3 + crates/strand-headless/src/daemon.rs | 248 ++++++ crates/strand-headless/src/main.rs | 4 +- crates/strand-headless/tests/daemon.rs | 182 ++++ crates/strand-ops/Cargo.toml | 2 +- crates/strand-ops/src/lib.rs | 37 +- crates/strand-ops/src/protocol.rs | 164 ++++ crates/strand-ops/src/remote.rs | 81 ++ crates/strand-tauri/src/main.rs | 7 + crates/strand-tauri/src/remote_repos.rs | 791 ++++++++++++++++++ .../strand-tauri/tests/fixtures/ssh_peer.rs | 36 + docs/cli-ssh-verification-2026-09-06.md | 88 ++ docs/git-client-feature-audit-2026-09-06.md | 4 +- docs/learnings.md | 20 + docs/remote-ssh.md | 50 +- docs/strand-cli.md | 7 +- ui/src/App.tsx | 17 +- ui/src/components/Topbar.tsx | 5 + ui/src/demo/dispatch.ts | 4 + ui/src/lib/remoteRepos.ts | 29 + ui/src/lib/tauri.ts | 5 + ui/src/stores/remoteRepos.test.ts | 93 ++ ui/src/stores/remoteRepos.ts | 113 +++ ui/src/styles/remoteRepos.css | 12 + ui/src/views/RemoteReposDialog.tsx | 124 +++ website/docs/manifest.json | 1 + website/docs/remote-repositories.md | 63 ++ 32 files changed, 2259 insertions(+), 32 deletions(-) create mode 100644 crates/strand-headless/src/daemon.rs create mode 100644 crates/strand-headless/tests/daemon.rs create mode 100644 crates/strand-ops/src/protocol.rs create mode 100644 crates/strand-ops/src/remote.rs create mode 100644 crates/strand-tauri/src/remote_repos.rs create mode 100644 crates/strand-tauri/tests/fixtures/ssh_peer.rs create mode 100644 docs/cli-ssh-verification-2026-09-06.md create mode 100644 ui/src/lib/remoteRepos.ts create mode 100644 ui/src/stores/remoteRepos.test.ts create mode 100644 ui/src/stores/remoteRepos.ts create mode 100644 ui/src/styles/remoteRepos.css create mode 100644 ui/src/views/RemoteReposDialog.tsx create mode 100644 website/docs/remote-repositories.md diff --git a/Cargo.lock b/Cargo.lock index 43d1f87c..7b94c4e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6056,12 +6056,12 @@ dependencies = [ name = "strand-ops" version = "1.5.1" dependencies = [ + "percent-encoding", "schemars 0.8.22", "serde", "serde_json", "strand-core", "tempfile", - "url", ] [[package]] diff --git a/README.md b/README.md index 23748e58..0b89a6f6 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,11 @@ the resolved app appearance automatically. Review findings are structured, path/line-validated, stale-diff guarded, and require explicit acceptance before they become notes; repository files are never changed by an AI review. +- **Read-only SSH repositories** — inspect remote status, history, full-context + reviews and bounded file snapshots through system OpenSSH, with watching, + connection health, cancellation and reconnect. Requires a manually installed + compatible companion on the POSIX host; see the + [setup guide](./website/docs/remote-repositories.md). - **Fast by design** — reads go through [gix](https://github.com/GitoxideLabs/gitoxide), writes through git2 and your system `git`. Performance targets live in [`PRD.md`](./PRD.md) §8 and are measured in diff --git a/ROADMAP.md b/ROADMAP.md index 5683c827..56ab6e0f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2148,7 +2148,10 @@ and Store certification remain external gates. destination. With no saved layout it is the existing full-size Work surface; customization composes registered Strand surfaces into nested panes with per-workspace persistence, templates, and one stable live Work runtime. -- **Remote repos over SSH** — open a repo on a remote machine (agent +- ◐ **Remote repos over SSH** — read-only inspector, shared stdio companion, + bounded watches/files and system-SSH lifecycle shipped locally 2026-09-06. + Automatic installation/distribution and ordinary remote tabs remain open. + Target: open a repo on a remote machine (agent devbox, VPS) and use Strand locally against it. Headless `strandd` daemon over JSON-RPC/stdio, system `ssh` for auth/transport (Strand never touches credentials). Designed 2026-06-12: `docs/remote-ssh.md` @@ -2823,6 +2826,18 @@ desktop serde types in schema-v1 envelopes; errors and output limits are explicit, with no Git mutation or implicit lazy fetch. Terminal syntax colors, blame/conflicts and standalone distribution remain in the expanded CLI backlog. +**F17 SSH read foundation shipped locally (2026-09-06):** The same companion +now serves protocol-v1 JSON-RPC on stdio. `RemoteRepos` uses system OpenSSH with +strict host checking, multiplexed bounded reads, keepalives, retry deadlines and +process-tree cancellation. The read-only inspector shows status, history, +full-context diffs/reviews and versioned file chunks, with coalesced watching, +visible execution context, recents and manual reconnect. Local commands remain +in process. Windows WebView2 and real system-SSH loopback verification covered +watch changes, stale chunks, reconnection, malformed peers, cancellation and +host-key rejection; see `docs/cli-ssh-verification-2026-09-06.md`. Native host +release validation, signed static artifacts/SFTP bootstrap and ordinary remote +tabs remain explicitly open. + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index 3d6ddd83..6c336ea0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2483,39 +2483,50 @@ ships** (ROADMAP §1.1+). - ◐ P2 Extract command handlers from `strand-tauri` into a transport-agnostic `strand-ops` crate (typed meta/status/snapshot shared with the desktop; read-only companion allowlist implemented; writes remain local) -- ☐ P2 `strandd` headless binary: `strand-ops` behind JSON-RPC over +- ☑ P2 `strandd` headless binary: `strand-ops` behind JSON-RPC over stdio; versioned handshake with capability flags; strict serde - (`deny_unknown_fields`), per-frame size limits -- ☐ P2 Remote watcher: `watch.rs` runs inside `strandd`, events stream - back as notifications, remote-side debounce/coalescing + (`deny_unknown_fields`), per-frame size limits (`strand-cli --stdio`, protocol 1, + four read workers, 8 MiB frames; same read-only binary as CLI) +- ☑ P2 Remote watcher: `watch.rs` runs inside `strandd`, events stream + back as notifications, remote-side debounce/coalescing (`daemon::serve`, + bounded watch/output queues with trailing invalidation) +- ☑ P2 Bounded remote file reads (core `file_chunk`, 64 KiB reads and metadata + version checks; inspector caps accumulated previews at 1 MiB) - ☐ P2 Static builds of `strandd`: linux x86_64/aarch64 (musl) + darwin; SHA-256 manifest baked into the signed app bundle ### Transport & lifecycle -- ☐ P2 Transport router in `strand-tauri`: plain path → in-proc (zero +- ◐ P2 Transport router in `strand-tauri`: plain path → in-proc (zero overhead, no hot-path regression); `ssh://host/path` → host's stdio - channel -- ☐ P2 SSH connection manager: spawn system `ssh` (inherits + channel (read-only `RemoteRepos` transport and isolated inspector shipped; + ordinary remote tab integration remains open) +- ☑ P2 SSH connection manager: spawn system `ssh` (inherits `~/.ssh/config`, known_hosts, agent, ProxyJump — Strand never touches credentials, never auto-accepts host keys); keepalives; one multiplexed - connection per host + connection per host (`remote_repos::Session`, strict host checking, + bounded diagnostics/pending requests and process-tree shutdown) - ☐ P2 Bootstrap: probe/upload `strandd` over SFTP, verify SHA-256 before exec, re-bootstrap on version mismatch -- ☐ P2 Reconnect: exponential backoff; reads retry transparently, +- ◐ P2 Reconnect: exponential backoff; reads retry transparently, writes never auto-retry (re-query state, user confirms); per-op-class - timeouts; kill + respawn a hung daemon -- ☐ P2 Connection health UI: topbar indicator, disconnected state for + timeouts; kill + respawn a hung daemon (read retries/deadlines/cancellation + implemented; remote writes remain outside the foundation) +- ◐ P2 Connection health UI: topbar indicator, disconnected state for remote tabs, manual "reconnect now"; local repos unaffected by a dead - link + link (`RemoteReposDialog` + topbar/palette health and reconnect shipped; + ordinary remote tabs remain open) ### UI surface -- ☐ P2 Connect-to-host flow (host list from `~/.ssh/config` aliases) + - remote repo open; `ssh://` paths in recents/tabs +- ◐ P2 Connect-to-host flow (host list from `~/.ssh/config` aliases) + + remote repo open; `ssh://` paths in recents/tabs (explicit alias/address input + and inspector recents shipped; alias discovery and ordinary tabs remain) - ☐ P2 Remote directory browser (native dialogs can't browse remote FS) -- ☐ P2 Capability-flag gating: hide `external.rs` ops for remote repos - (v1); evaluate "open terminal" → `ssh -t` later +- ◐ P2 Capability-flag gating: hide `external.rs` ops for remote repos + (v1); evaluate "open terminal" → `ssh -t` later (read-only inspector exposes + only negotiated reads and blocks local repository shortcuts; future remote + tab routing still needs a full capability audit) --- @@ -2598,7 +2609,8 @@ extraction above as prerequisite. **Do not start before 1.0 ships** - ◐ P2 `strand-headless` crate: clap front-end over `strand-ops` with `cli` + `--stdio` (daemon) entry modes; one static artifact, one hash - manifest shared with remote-SSH bootstrap + manifest shared with remote-SSH bootstrap (both entry modes implemented; + signed static host-artifact matrix and bootstrap manifest remain) - ◐ P2 Read commands: `status` (+ `--snapshot`), `diff` (`--staged`, `--commit`, `--between`, `--since`, `--full-context` via the `*_full` review ops), `log`, `blame`, `conflicts` (`cli.rs` implements status/diff/log diff --git a/crates/strand-core/src/file.rs b/crates/strand-core/src/file.rs index 3a14efc7..4befd327 100644 --- a/crates/strand-core/src/file.rs +++ b/crates/strand-core/src/file.rs @@ -72,7 +72,38 @@ pub struct FileHistoryEntry { pub dels: u32, } +/// One bounded working-tree read for transports. The metadata token detects +/// ordinary concurrent edits between chunks; it is not a content hash. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct FileChunk { + pub bytes: Vec, + pub offset: u64, + pub next_offset: u64, + pub total: u64, + pub version: String, +} + impl Repo { + pub fn file_chunk(&self, path: &str, offset: u64, length: usize, expected: Option<&str>) -> Result { + use std::io::{Read, Seek, SeekFrom}; + if !(1..=65_536).contains(&length) { return Err(Error::Other("File chunk must be 1–65536 bytes.".into())); } + let mut file = std::fs::File::open(self.workdir_path(path)?)?; + let meta = file.metadata()?; + if !meta.is_file() || offset > meta.len() { return Err(Error::Other("Invalid file or chunk offset.".into())); } + let token = |meta: &std::fs::Metadata| -> Result { + let time = meta.modified()?.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); + Ok(format!("{}:{time}", meta.len())) + }; + let version = token(&meta)?; + if expected.is_some_and(|expected| expected != version) { return Err(Error::Other("File changed; restart reading at byte 0.".into())); } + file.seek(SeekFrom::Start(offset))?; + let mut bytes = Vec::with_capacity(length); + (&mut file).take(length as u64).read_to_end(&mut bytes)?; + if token(&file.metadata()?)? != version { return Err(Error::Other("File changed during read; retry.".into())); } + Ok(FileChunk { next_offset: offset + bytes.len() as u64, bytes, offset, total: meta.len(), version }) + } + /// Read a file's content. `rev = None` reads the working-tree copy from disk /// (with the same path-traversal guard the conflict reader uses); `rev = /// Some(spec)` reads the blob from that revision's tree. diff --git a/crates/strand-core/src/repo.rs b/crates/strand-core/src/repo.rs index 3148cc01..eb6d211c 100644 --- a/crates/strand-core/src/repo.rs +++ b/crates/strand-core/src/repo.rs @@ -22,6 +22,9 @@ pub struct Repo { impl Repo { /// Discover and open the repository containing `path`. pub fn discover(path: impl AsRef) -> Result { + if path.as_ref().to_str().is_some_and(|path| path.starts_with("ssh://")) { + return Err(crate::Error::Other("SSH repository addresses require the remote transport; they are not local filesystem paths.".into())); + } let gix = gix::discover(path.as_ref())?; let workdir = gix .work_dir() diff --git a/crates/strand-headless/src/daemon.rs b/crates/strand-headless/src/daemon.rs new file mode 100644 index 00000000..5f392dc4 --- /dev/null +++ b/crates/strand-headless/src/daemon.rs @@ -0,0 +1,248 @@ +//! Stdio only, four concurrent reads, sixteen watchers, bounded output queue. +//! EOF terminates the process, including any in-flight read worker threads. +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + io::{self, BufReader, Write}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, Mutex, + }, + time::Duration, +}; +use strand_ops::{protocol::*, OpError, ReadRequest, Result}; + +type Jobs = Arc>>>; +struct Watch { + _handle: strand_core::watch::RepoWatcher, + dirty: Arc, + files: Arc, +} + +fn params(value: Value) -> Result { + serde_json::from_value(value) + .map_err(|error| OpError::new("invalid_request", error.to_string())) +} +fn send(tx: &mpsc::SyncSender>, response: Response) -> bool { + let bytes = strand_ops::encode(&response) + .unwrap_or_else(|error| strand_ops::encode(&Response::error(response.id, error)).unwrap()); + tx.send(bytes).is_ok() +} + +pub fn serve() -> Result<()> { + let (tx, rx) = mpsc::sync_channel::>(8); + let writer = std::thread::spawn(move || { + let mut stdout = io::stdout().lock(); + for bytes in rx { + if stdout + .write_all(&bytes) + .and_then(|_| stdout.flush()) + .is_err() + { + std::process::exit(0); + } + } + }); + let jobs: Jobs = Arc::new(Mutex::new(HashMap::new())); + let watches = Arc::new(Mutex::new(HashMap::::new())); + let stopped = Arc::new(AtomicBool::new(false)); + let watch_map = watches.clone(); + let watch_tx = tx.clone(); + let stop = stopped.clone(); + let notifier = std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(200)); + let map = watch_map.lock().unwrap(); + for (path, watch) in map.iter() { + if !watch.dirty.swap(false, Ordering::Relaxed) { + continue; + } + let files_changed = watch.files.swap(false, Ordering::Relaxed); + let frame = Notification { + jsonrpc: "2.0".into(), + method: "changed".into(), + params: Changed { + repository: path.clone(), + files_changed, + }, + }; + if watch_tx + .try_send(strand_ops::encode(&frame).unwrap()) + .is_err() + { + watch.dirty.store(true, Ordering::Relaxed); + if files_changed { + watch.files.store(true, Ordering::Relaxed); + } + } + } + } + }); + let mut reader = BufReader::new(io::stdin()); + let mut greeted = false; + // Require strictly increasing request IDs, bounding replay tracking. + let mut last_id = None; + let result = (|| { + while let Some(bytes) = strand_ops::read_frame(&mut reader) + .map_err(|e| OpError::new("protocol", e.to_string()))? + { + let request: Request = serde_json::from_slice(&bytes) + .map_err(|e| OpError::new("protocol", e.to_string()))?; + if request.jsonrpc != "2.0" || last_id.is_some_and(|id| request.id <= id) { + return Err(OpError::new( + "protocol", + "Invalid JSON-RPC version or non-increasing request id.", + )); + } + last_id = Some(request.id); + let id = request.id; + if !greeted { + let hello = params::(request.params)?; + if request.method != "hello" + || hello.protocol_version != strand_ops::PROTOCOL_VERSION + { + send(&tx, Response::error(id, OpError::new("protocol", "Protocol mismatch; install a compatible Strand companion on the host."))); + return Ok(()); + } + greeted = true; + send(&tx, Response::result(id, json!(Hello::default()))); + continue; + } + let outcome: Result = match request.method.as_str() { + "read" => { + let read: ReadRequest = match params(request.params) { + Ok(read) => read, + Err(error) => { + send(&tx, Response::error(id, error)); + continue; + } + }; + let mut active = jobs.lock().unwrap(); + if active.len() >= 4 { + Err(OpError::new( + "busy", + "At most four reads may run concurrently.", + )) + } else { + let cancelled = Arc::new(AtomicBool::new(false)); + active.insert(id, cancelled.clone()); + let jobs = jobs.clone(); + let tx = tx.clone(); + std::thread::spawn(move || { + let result = strand_ops::execute(&read) + .and_then(|result| strand_ops::encode(&result)) + .and_then(|bytes| { + serde_json::from_slice::(&bytes) + .map_err(|e| OpError::new("protocol", e.to_string())) + }); + let mut active = jobs.lock().unwrap(); + if active.remove(&id).is_some() { + let response = if cancelled.load(Ordering::Relaxed) { + Response::error( + id, + OpError::new("cancelled", "Read cancelled."), + ) + } else { + match result { + Ok(result) => Response::result(id, json!(result)), + Err(error) => Response::error(id, error), + } + }; + drop(active); + send(&tx, response); + } + }); + continue; + } + } + "cancel" => { + match params::(request.params) { + Ok(cancel) => { + // Mark, but retain the slot until the native read returns: + // cancellation cannot be abused to create unbounded workers. + if let Some(flag) = jobs.lock().unwrap().get(&cancel.id) { + flag.store(true, Ordering::Relaxed); + } + Ok(Value::Null) + } + Err(error) => Err(error), + } + } + "watch" | "unwatch" => { + let repository = params::(request.params); + repository.and_then(|repository| { + let repo = strand_core::Repo::discover(&repository.repository)?; + let path = repo + .path() + .canonicalize() + .map_err(|e| OpError::new("repository", e.to_string()))? + .to_string_lossy() + .into_owned(); + let mut map = watches.lock().unwrap(); + if request.method == "unwatch" { + map.remove(&path); + return Ok(Value::Null); + } + if !map.contains_key(&path) { + if map.len() >= 16 { + return Err(OpError::new( + "busy", + "At most sixteen repository watches per connection.", + )); + } + let dirty = Arc::new(AtomicBool::new(false)); + let files = Arc::new(AtomicBool::new(false)); + let d = dirty.clone(); + let f = files.clone(); + let watcher = strand_core::watch::watch( + repo.path(), + repo.git_dir(), + Duration::from_millis(400), + move |changed| { + if changed { + f.store(true, Ordering::Relaxed); + } + d.store(true, Ordering::Relaxed); + }, + )?; + map.insert( + path.clone(), + Watch { + _handle: watcher, + dirty, + files, + }, + ); + } + Ok(json!({ "repository": path })) + }) + } + _ => Err(OpError::new( + "invalid_request", + "Unknown method; the remote engine is read-only.", + )), + }; + if !send( + &tx, + match outcome { + Ok(value) => Response::result(id, value), + Err(error) => Response::error(id, error), + }, + ) { + break; + } + } + Ok(()) + })(); + stopped.store(true, Ordering::Relaxed); + watches.lock().unwrap().clear(); + let _ = notifier.join(); + // Do not join in-flight native reads after EOF; returning from main ends + // them. For an idle orderly shutdown, drain the writer before exiting. + if jobs.lock().unwrap().is_empty() { + drop(tx); + let _ = writer.join(); + } + result +} diff --git a/crates/strand-headless/src/main.rs b/crates/strand-headless/src/main.rs index edb5b328..7d792e0c 100644 --- a/crates/strand-headless/src/main.rs +++ b/crates/strand-headless/src/main.rs @@ -1,4 +1,5 @@ mod cli; +mod daemon; mod launcher; fn main() { @@ -8,7 +9,8 @@ fn main() { strand_core::init(); let args: Vec<_> = std::env::args().collect(); let json = args.iter().any(|arg| arg == "--json"); - if let Err(error) = cli::run(args) { + let result = if args.get(1).is_some_and(|arg| arg == "--stdio") && args.len() == 2 { daemon::serve() } else { cli::run(args) }; + if let Err(error) = result { if json { eprintln!("{}", serde_json::to_string(&error).unwrap()); } else { diff --git a/crates/strand-headless/tests/daemon.rs b/crates/strand-headless/tests/daemon.rs new file mode 100644 index 00000000..6a758460 --- /dev/null +++ b/crates/strand-headless/tests/daemon.rs @@ -0,0 +1,182 @@ +use serde_json::{json, Value}; +use std::{ + io::{BufRead, BufReader, Write}, + process::{Child, ChildStdin, Command, Stdio}, + sync::mpsc, + time::Duration, +}; +struct Peer { + child: Child, + input: Option, + output: mpsc::Receiver, + next: u64, +} +impl Peer { + fn new() -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_strand-cli")) + .arg("--stdio") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let input = child.stdin.take(); + let stdout = child.stdout.take().unwrap(); + let (tx, output) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if tx + .send(serde_json::from_str(&line.unwrap()).unwrap()) + .is_err() + { + break; + } + } + }); + Self { + child, + input, + output, + next: 0, + } + } + fn send(&mut self, method: &str, params: Value) -> u64 { + self.next += 1; + writeln!( + self.input.as_mut().unwrap(), + "{}", + json!({"jsonrpc":"2.0","id":self.next,"method":method,"params":params}) + ) + .unwrap(); + self.input.as_mut().unwrap().flush().unwrap(); + self.next + } + fn receive(&self) -> Value { + self.output + .recv_timeout(Duration::from_secs(10)) + .expect("daemon response deadline") + } + fn hello(&mut self) { + self.send("hello", json!({"protocolVersion":1})); + let hello = self.receive(); + assert_eq!(hello["result"]["readOnly"], true); + } + fn wait_exit(&mut self) { + for _ in 0..100 { + if self.child.try_wait().unwrap().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("daemon did not exit on EOF/malformed frame"); + } +} +impl Drop for Peer { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} +fn repo() -> tempfile::TempDir { + let repo = tempfile::tempdir().unwrap(); + assert!(Command::new("git") + .args(["init", "-q"]) + .arg(repo.path()) + .status() + .unwrap() + .success()); + repo +} + +#[test] +fn handshake_mismatch_unknown_fields_and_truncated_frames_fail_closed() { + let mut peer = Peer::new(); + peer.send("hello", json!({"protocolVersion":99})); + assert_eq!(peer.receive()["error"]["data"]["code"], "protocol"); + peer.wait_exit(); + let mut peer = Peer::new(); + peer.send("hello", json!({"protocolVersion":1,"execute":"bad"})); + peer.wait_exit(); + let mut peer = Peer::new(); + peer.input + .take() + .unwrap() + .write_all(b"{\"jsonrpc\":") + .unwrap(); + peer.wait_exit(); + let mut peer = Peer::new(); + peer.hello(); + peer.send( + "read", + json!({"repository":".","op":{"kind":"status","execute":"bad"}}), + ); + assert_eq!(peer.receive()["error"]["data"]["code"], "invalid_request"); + peer.send("push", json!({})); + assert_eq!(peer.receive()["error"]["code"], -32602); +} + +#[test] +fn multiplexed_reads_file_chunks_watch_and_eof_lifecycle() { + let repo = repo(); + let path = repo.path().to_string_lossy().into_owned(); + std::fs::write(repo.path().join("large.txt"), vec![b'x'; 150_000]).unwrap(); + let mut peer = Peer::new(); + peer.hello(); + let a = peer.send("read", json!({"repository":path,"op":{"kind":"status"}})); + let b = peer.send("read", json!({"repository":path,"op":{"kind":"snapshot"}})); + let mut ids = vec![ + peer.receive()["id"].as_u64().unwrap(), + peer.receive()["id"].as_u64().unwrap(), + ]; + ids.sort(); + assert_eq!(ids, vec![a, b]); + peer.send("read", json!({"repository":path,"op":{"kind":"file_chunk","path":"large.txt","offset":0,"length":65536,"version":null}})); + let chunk = peer.receive(); + assert_eq!( + chunk["result"]["result"]["data"]["bytes"] + .as_array() + .unwrap() + .len(), + 65536 + ); + let version = chunk["result"]["result"]["data"]["version"].clone(); + std::fs::write(repo.path().join("large.txt"), b"changed").unwrap(); + peer.send("read", json!({"repository":path,"op":{"kind":"file_chunk","path":"large.txt","offset":0,"length":65536,"version":version}})); + assert_eq!(peer.receive()["error"]["data"]["code"], "repository"); + peer.send("read", json!({"repository":path,"op":{"kind":"file_chunk","path":"../outside","offset":0,"length":1,"version":null}})); + assert_eq!(peer.receive()["error"]["data"]["code"], "invalid_request"); + peer.send("watch", json!({"repository":path})); + let watch = peer.receive(); + assert!(watch["result"]["repository"].is_string()); + for i in 0..30 { + std::fs::write(repo.path().join("burst.txt"), i.to_string()).unwrap(); + } + let event = peer.receive(); + assert_eq!(event["method"], "changed"); + assert!(event["params"]["files_changed"].is_boolean()); + peer.send("unwatch", json!({"repository":path})); + assert!(peer.receive()["result"].is_null()); + peer.input.take(); + peer.wait_exit(); +} + +#[test] +fn cancellation_does_not_expand_worker_budget_and_duplicate_ids_close_connection() { + let repo = repo(); + let path = repo.path().to_string_lossy().into_owned(); + let mut peer = Peer::new(); + peer.hello(); + let id = peer.send("read", json!({"repository":path,"op":{"kind":"status"}})); + let cancel = peer.send("cancel", json!({"id":id})); + let frames = [peer.receive(), peer.receive()]; + assert!(frames + .iter() + .any(|r| r["id"] == cancel && r["result"].is_null())); + assert!(frames + .iter() + .any(|r| r["id"] == id + && (r["result"].is_object() || r["error"]["data"]["code"] == "cancelled"))); + peer.next = 0; + peer.send("read", json!({"repository":path,"op":{"kind":"status"}})); + peer.wait_exit(); +} diff --git a/crates/strand-ops/Cargo.toml b/crates/strand-ops/Cargo.toml index 1cc88dcb..256072db 100644 --- a/crates/strand-ops/Cargo.toml +++ b/crates/strand-ops/Cargo.toml @@ -10,7 +10,7 @@ strand-core = { path = "../strand-core", features = ["schema"] } serde.workspace = true serde_json.workspace = true schemars = "0.8" -url.workspace = true +percent-encoding = "2" [dev-dependencies] tempfile = "3" diff --git a/crates/strand-ops/src/lib.rs b/crates/strand-ops/src/lib.rs index 1984968a..994bb8b7 100644 --- a/crates/strand-ops/src/lib.rs +++ b/crates/strand-ops/src/lib.rs @@ -1,11 +1,13 @@ //! Shared read allowlist. No operation here mutates Git state or fetches objects. //! Local desktop hot paths can keep calling typed functions without serializing. +pub mod protocol; +pub mod remote; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::io::{self, BufRead, Write}; use strand_core::{ diff::FileDiff, - file::{FileContent, FileHistoryEntry}, + file::{FileChunk, FileContent, FileHistoryEntry}, log::Commit, repo::RepoMeta, snapshot::Snapshot, @@ -84,6 +86,12 @@ pub enum ReadOp { path: String, revision: Option, }, + FileChunk { + path: String, + offset: u64, + length: usize, + version: Option, + }, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -116,6 +124,24 @@ pub enum ReadResult { Diff(Vec), Review(Review), File(FileContent), + FileChunk(FileChunk), +} + +impl ReadOp { + pub fn accepts(&self, result: &ReadResult) -> bool { + matches!( + (self, result), + (Self::Meta {}, ReadResult::Meta(_)) + | (Self::Status {}, ReadResult::Status(_)) + | (Self::Snapshot {}, ReadResult::Snapshot(_)) + | (Self::Log { .. }, ReadResult::Log(_)) + | (Self::FileHistory { .. }, ReadResult::FileHistory(_)) + | (Self::Diff { .. }, ReadResult::Diff(_)) + | (Self::Review { .. }, ReadResult::Review(_)) + | (Self::File { .. }, ReadResult::File(_)) + | (Self::FileChunk { .. }, ReadResult::FileChunk(_)) + ) + } } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -217,6 +243,15 @@ pub fn execute(request: &ReadRequest) -> Result { relative_path(path)?; ReadResult::File(repo.file_content(path, revision.as_deref())?) } + ReadOp::FileChunk { + path, + offset, + length, + version, + } => { + relative_path(path)?; + ReadResult::FileChunk(repo.file_chunk(path, *offset, *length, version.as_deref())?) + } }; Ok(Envelope { schema_version: SCHEMA_VERSION, diff --git a/crates/strand-ops/src/protocol.rs b/crates/strand-ops/src/protocol.rs new file mode 100644 index 00000000..a4a563f3 --- /dev/null +++ b/crates/strand-ops/src/protocol.rs @@ -0,0 +1,164 @@ +//! JSON-RPC 2.0 frames. Unknown envelope/parameter fields fail closed. +use crate::{OpError, MAX_FRAME_BYTES, PROTOCOL_VERSION}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Request { + pub jsonrpc: String, + pub id: u64, + pub method: String, + pub params: Value, +} +impl Request { + pub fn new(id: u64, method: &str, params: Value) -> Self { + Self { + jsonrpc: "2.0".into(), + id, + method: method.into(), + params, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RpcError { + pub code: i32, + pub message: String, + pub data: OpError, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Response { + pub jsonrpc: String, + pub id: u64, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present_result" + )] + pub result: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present_error" + )] + pub error: Option, +} +// JSON-RPC permits a null result. Option's default deserializer loses the +// distinction between an absent field and a successful `result: null`. +fn present_result<'de, D: serde::Deserializer<'de>>( + d: D, +) -> std::result::Result, D::Error> { + Value::deserialize(d).map(Some) +} +fn present_error<'de, D: serde::Deserializer<'de>>( + d: D, +) -> std::result::Result, D::Error> { + RpcError::deserialize(d).map(Some) +} +impl Response { + pub fn result(id: u64, result: Value) -> Self { + Self { + jsonrpc: "2.0".into(), + id, + result: Some(result), + error: None, + } + } + pub fn error(id: u64, error: OpError) -> Self { + Self { + jsonrpc: "2.0".into(), + id, + result: None, + error: Some(RpcError { + code: if error.code == "invalid_request" { + -32602 + } else { + -32000 + }, + message: error.message.clone(), + data: error, + }), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Changed { + pub repository: String, + pub files_changed: bool, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Notification { + pub jsonrpc: String, + pub method: String, + pub params: Changed, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Frame { + Response(Response), + Notification(Notification), +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelloRequest { + pub protocol_version: u32, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Hello { + pub protocol_version: u32, + pub schema_version: u32, + pub version: String, + pub platform: String, + pub read_only: bool, + pub watch: bool, + pub file_chunks: bool, + pub max_frame_bytes: usize, +} +impl Default for Hello { + fn default() -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + schema_version: crate::SCHEMA_VERSION, + version: env!("CARGO_PKG_VERSION").into(), + platform: std::env::consts::OS.into(), + read_only: true, + watch: true, + file_chunks: true, + max_frame_bytes: MAX_FRAME_BYTES, + } + } +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RepositoryRequest { + pub repository: String, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CancelRequest { + pub id: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn null_result_is_present_but_null_error_is_invalid() { + let encoded = crate::encode(&Response::result(1, Value::Null)).unwrap(); + let response: Response = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(response.result, Some(Value::Null)); + assert!(response.error.is_none()); + assert!( + serde_json::from_str::(r#"{"jsonrpc":"2.0","id":1,"error":null}"#).is_err() + ); + } +} diff --git a/crates/strand-ops/src/remote.rs b/crates/strand-ops/src/remote.rs new file mode 100644 index 00000000..adeb27c6 --- /dev/null +++ b/crates/strand-ops/src/remote.rs @@ -0,0 +1,81 @@ +use crate::{OpError, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RemoteIdentity { + pub host: String, + pub path: String, +} + +impl RemoteIdentity { + pub fn parse(address: &str) -> Result { + let invalid = || { + OpError::new("invalid_request", "Use ssh://HOST-ALIAS/absolute/repository/path; configure users and ports in OpenSSH config.") + }; + let raw = address.strip_prefix("ssh://").ok_or_else(invalid)?; + let (host, path) = raw.split_once('/').ok_or_else(invalid)?; + if host.len() > 253 + || !host + .bytes() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric()) + || !host + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-_.".contains(&c)) + || path.contains(['?', '#']) + { + return Err(invalid()); + } + let path = percent_encoding::percent_decode_str(path) + .decode_utf8() + .map_err(|_| invalid())?; + if path.len() > 4096 + || path.chars().any(|c| c.is_control() || c == '\\') + || path.split('/').any(|c| c == "." || c == "..") + { + return Err(invalid()); + } + Ok(Self { + host: host.to_ascii_lowercase(), + path: format!("/{}", path.trim_end_matches('/')), + }) + } + pub fn address(&self) -> String { + let path = self + .path + .split('/') + .map(|part| { + percent_encoding::utf8_percent_encode(part, percent_encoding::NON_ALPHANUMERIC) + .to_string() + }) + .collect::>() + .join("/"); + format!("ssh://{}{path}", self.host) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn identity_preserves_host_and_encoded_absolute_path() { + let id = RemoteIdentity::parse("ssh://DevBox/home/me/space%20name").unwrap(); + assert_eq!(id.path, "/home/me/space name"); + assert_eq!(id.address(), "ssh://devbox/home/me/space%20name"); + let percent = RemoteIdentity::parse("ssh://host/space%2520name").unwrap(); + assert_eq!(RemoteIdentity::parse(&percent.address()).unwrap(), percent); + for bad in [ + "/local", + "ssh://-oProxyCommand=bad/repo", + "ssh://me@host/repo", + "ssh://host:22/repo", + "ssh://host/a/../b", + "ssh://host/%2e%2e/b", + "ssh://host/repo?x", + "ssh://host/repo%00", + ] { + assert!(RemoteIdentity::parse(bad).is_err(), "{bad}"); + } + } +} diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index afe55e6e..3cfbe9f1 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -10,6 +10,7 @@ mod path_env; mod pull_requests; mod state; mod terminal; +mod remote_repos; use tauri::Manager; @@ -136,6 +137,7 @@ fn main() { tauri::Builder::default() .manage(launcher::LaunchInbox::default()) + .manage(std::sync::Arc::new(remote_repos::RemoteRepos::default())) .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { launcher::receive(app, &args, std::path::Path::new(&cwd)); })) @@ -155,6 +157,10 @@ fn main() { .invoke_handler(tauri::generate_handler![ launcher::app_take_open_requests, launcher::app_install_cli, + remote_repos::remote_repo_read, + remote_repos::remote_repo_cancel, + remote_repos::remote_repo_watch, + remote_repos::remote_repo_disconnect, commands::repo_open, commands::microsoft_store_update_available, commands::microsoft_store_open_product, @@ -378,6 +384,7 @@ fn main() { tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } ) { app.state::().terminals.close_all(None); + app.state::>().stop_all(); } }); } diff --git a/crates/strand-tauri/src/remote_repos.rs b/crates/strand-tauri/src/remote_repos.rs new file mode 100644 index 00000000..1f703303 --- /dev/null +++ b/crates/strand-tauri/src/remote_repos.rs @@ -0,0 +1,791 @@ +//! Optional SSH transport. No local repository command acquires these locks. +use crate::{ + ai::bin, + commands::{CmdError, CmdResult}, +}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + io::{BufReader, Read, Write}, + process::{Child, Command, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, Mutex, + }, + time::{Duration, Instant}, +}; +use strand_ops::{ + protocol::*, remote::RemoteIdentity, Envelope, OpError, ReadOp, ReadRequest, Result, +}; +use tauri::{Emitter, State}; + +const TIMEOUT: Duration = Duration::from_secs(30); +type Pending = Mutex>>>; +type Events = Arc) + Send + Sync>; + +#[derive(Clone, Serialize)] +pub struct Health { + pub host: String, + pub state: String, + pub error: Option, +} + +struct Process { + child: Child, + stopped: bool, + #[cfg(windows)] + job: bin::WindowsJob, +} +// The Windows job is uniquely owned, only accessed while holding the process +// mutex, and Win32 process/job operations are not thread-affine. +#[cfg(windows)] +unsafe impl Send for Process {} +impl Process { + fn kill(&mut self) { + if self.stopped { + return; + } + self.stopped = true; + #[cfg(windows)] + bin::kill_process_tree(&mut self.child, &self.job); + #[cfg(unix)] + bin::kill_process_tree(&mut self.child); + let _ = self.child.wait(); + } +} +impl Drop for Process { + fn drop(&mut self) { + self.kill(); + } +} + +struct Session { + host: String, + process: Mutex, + outgoing: mpsc::SyncSender>, + // Allocate IDs and enqueue under the same lock: parallel callers cannot + // put request 2 on the wire before request 1. + next: Mutex, + pending: Pending, + alive: AtomicBool, + stderr: Mutex>, + events: Events, +} +impl Session { + fn spawn(mut command: Command, host: String, events: Events) -> Result> { + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command + .spawn() + .map_err(|e| OpError::new("connection", format!("Could not start system SSH: {e}")))?; + #[cfg(windows)] + let job = match bin::WindowsJob::assign(&child) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(OpError::new("connection", error)); + } + }; + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + let (tx, rx) = mpsc::sync_channel::>(16); + let session = Arc::new(Self { + host, + process: Mutex::new(Process { + child, + stopped: false, + #[cfg(windows)] + job, + }), + outgoing: tx, + next: Mutex::new(0), + pending: Mutex::new(HashMap::new()), + alive: AtomicBool::new(true), + stderr: Mutex::new(Vec::new()), + events, + }); + let weak = Arc::downgrade(&session); + std::thread::spawn(move || { + for bytes in rx { + if stdin.write_all(&bytes).and_then(|_| stdin.flush()).is_err() { + if let Some(session) = weak.upgrade() { + session.fail("connection", "SSH input closed."); + } + break; + } + } + }); + let weak = Arc::downgrade(&session); + std::thread::spawn(move || { + let mut buffer = [0u8; 4096]; + let mut total = 0; + while let Ok(n) = stderr.read(&mut buffer) { + if n == 0 { + break; + } + let Some(session) = weak.upgrade() else { + break; + }; + total += n; + let mut text = session.stderr.lock().unwrap(); + let keep = n.min(16_384usize.saturating_sub(text.len())); + text.extend_from_slice(&buffer[..keep]); + drop(text); + if total > 65_536 { + session.fail("protocol", "SSH diagnostics exceeded 64 KiB."); + break; + } + } + }); + let weak = Arc::downgrade(&session); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let bytes = strand_ops::read_frame(&mut reader); + let Some(session) = weak.upgrade() else { + break; + }; + let bytes = match bytes { + Ok(Some(bytes)) => bytes, + Ok(None) => { + session.fail("connection", "SSH connection closed. Authenticate with ssh HOST in a terminal and verify ~/.strand/bin/strand is installed."); + break; + } + Err(error) => { + session.fail("protocol", &format!("Invalid SSH frame: {error}")); + break; + } + }; + let frame = serde_json::from_slice::(&bytes); + match frame { + Ok(Frame::Response(response)) + if response.jsonrpc == "2.0" + && response.result.is_some() != response.error.is_some() => + { + let pending = session.pending.lock().unwrap().remove(&response.id); + if let Some(pending) = pending { + let _ = pending.send(match response.error { + Some(error) => Err(error.data), + None => Ok(response.result.unwrap()), + }); + } else { + session.fail("protocol", "SSH response has an unknown request id."); + break; + } + } + Ok(Frame::Notification(event)) + if event.jsonrpc == "2.0" + && event.method == "changed" + && event.params.repository.len() <= 4096 => + { + (session.events)(&session.host, "changed", Some(&event.params.repository)); + } + _ => { + session.fail("protocol", "Malformed SSH response; connection closed."); + break; + } + } + } + }); + Ok(session) + } + + fn fail(&self, code: &str, message: &str) { + if !self.alive.swap(false, Ordering::SeqCst) { + return; + } + self.process.lock().unwrap().kill(); + let diagnostic = String::from_utf8_lossy(&self.stderr.lock().unwrap()) + .trim() + .to_owned(); + let message = if diagnostic.is_empty() { + message.to_owned() + } else { + format!("{message}\n{diagnostic}") + }; + for (_, pending) in self.pending.lock().unwrap().drain() { + let _ = pending.send(Err(OpError::new(code, &message))); + } + (self.events)(&self.host, "disconnected", Some(&message)); + } + + fn request( + &self, + method: &str, + params: Value, + timeout: Duration, + cancel: &AtomicBool, + ) -> Result { + if !self.alive.load(Ordering::SeqCst) { + return Err(OpError::new("connection", "SSH is disconnected.")); + } + let (tx, rx) = mpsc::sync_channel(1); + { + let mut next = self.next.lock().unwrap(); + let mut pending = self.pending.lock().unwrap(); + // Pair registration with fail()'s pending drain. EOF can race the + // initial alive check; never leave a new waiter behind that drain. + if !self.alive.load(Ordering::SeqCst) { + return Err(OpError::new("connection", "SSH is disconnected.")); + } + if pending.len() >= 16 { + return Err(OpError::new( + "busy", + "SSH connection already has sixteen pending requests.", + )); + } + *next += 1; + let bytes = strand_ops::encode(&Request::new(*next, method, params))?; + pending.insert(*next, tx); + if self.outgoing.try_send(bytes).is_err() { + pending.remove(&*next); + return Err(OpError::new("busy", "SSH output queue is full.")); + } + } + let start = Instant::now(); + loop { + if cancel.load(Ordering::SeqCst) { + self.fail( + "cancelled", + "SSH connection cancelled; all reads on this host stopped.", + ); + return Err(OpError::new("cancelled", "Read cancelled.")); + } + if start.elapsed() >= timeout { + self.fail( + "timeout", + "SSH request timed out; the connection was stopped.", + ); + return Err(OpError::new("timeout", "SSH request timed out.")); + } + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(value) => return value, + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(OpError::new("connection", "SSH request closed.")) + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + } + } +} + +#[derive(Default)] +struct Host { + session: Mutex>>, +} +#[derive(Default)] +pub struct RemoteRepos { + hosts: Mutex>>, + operations: Mutex)>>, +} + +fn ssh_command(host: &str) -> Result { + let program = bin::resolve_cli("ssh", None).ok_or_else(|| { + OpError::new( + "connection", + "Install system OpenSSH and authenticate in your terminal first.", + ) + })?; + let mut command = bin::base_command(&program, true); + // Fixed remote command only. Repository paths travel as JSON over stdin. + command.args([ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=2", + "--", + host, + "exec \"$HOME/.strand/bin/strand\" --stdio", + ]); + Ok(command) +} + +impl RemoteRepos { + fn host(&self, host: &str) -> Result> { + let mut hosts = self.hosts.lock().unwrap(); + if !hosts.contains_key(host) && hosts.len() >= 16 { + return Err(OpError::new( + "busy", + "Disconnect an SSH host before opening another (limit 16).", + )); + } + Ok(hosts.entry(host.into()).or_default().clone()) + } + fn connect( + &self, + identity: &RemoteIdentity, + events: Events, + cancel: &AtomicBool, + ) -> Result> { + let host = self.host(&identity.host)?; + // Per-host only; no local command waits here. Manual cancellation uses + // operation flags and never needs this handshake lock. + let mut slot = host.session.lock().unwrap(); + if let Some(session) = slot.as_ref().filter(|s| s.alive.load(Ordering::SeqCst)) { + return Ok(session.clone()); + } + (events)(&identity.host, "connecting", None); + let session = Session::spawn( + ssh_command(&identity.host)?, + identity.host.clone(), + events.clone(), + )?; + let hello = session.request( + "hello", + json!(HelloRequest { + protocol_version: strand_ops::PROTOCOL_VERSION + }), + Duration::from_secs(15), + cancel, + )?; + let hello: Hello = + serde_json::from_value(hello).map_err(|e| OpError::new("protocol", e.to_string()))?; + if hello.protocol_version != strand_ops::PROTOCOL_VERSION + || hello.schema_version != strand_ops::SCHEMA_VERSION + || !hello.read_only + || !hello.watch + || !hello.file_chunks + || hello.max_frame_bytes != strand_ops::MAX_FRAME_BYTES + { + session.fail( + "protocol", + "Incompatible remote companion; install the matching protocol version.", + ); + return Err(OpError::new("protocol", "Incompatible remote companion.")); + } + *slot = Some(session.clone()); + (events)(&identity.host, "connected", None); + Ok(session) + } + + fn read( + &self, + identity: &RemoteIdentity, + op: ReadOp, + events: Events, + cancel: &AtomicBool, + ) -> Result { + // Only reads retry, at 250ms then 1s. A final failure remains visible; + // no offline queue, background spin, or replay of writes exists. + for attempt in 0..3 { + if cancel.load(Ordering::SeqCst) { + return Err(OpError::new("cancelled", "Read cancelled.")); + } + let result = self + .connect(identity, events.clone(), cancel) + .and_then(|session| { + let value = session.request( + "read", + json!(ReadRequest { + repository: identity.path.clone(), + op: op.clone() + }), + if matches!(op, ReadOp::Diff { .. } | ReadOp::Review { .. }) { + Duration::from_secs(60) + } else { + TIMEOUT + }, + cancel, + )?; + let result = serde_json::from_value::(value) + .map_err(|e| OpError::new("protocol", e.to_string())); + match result { + Ok(result) + if result.schema_version == strand_ops::SCHEMA_VERSION + && result.repository.starts_with('/') + && result.repository.len() <= 4096 + && op.accepts(&result.result) => + { + Ok(result) + } + _ => { + session.fail("protocol", "Malformed or incompatible remote result."); + Err(OpError::new( + "protocol", + "Malformed or incompatible remote result.", + )) + } + } + }); + match result { + Ok(result) => return Ok(result), + Err(error) if attempt < 2 && error.code == "connection" => { + (events)(&identity.host, "reconnecting", Some(&error.message)); + let until = Instant::now() + Duration::from_millis(250 * 4u64.pow(attempt)); + while Instant::now() < until { + if cancel.load(Ordering::SeqCst) { + return Err(OpError::new("cancelled", "Read cancelled.")); + } + std::thread::sleep(Duration::from_millis(25)); + } + } + Err(error) => return Err(error), + } + } + unreachable!() + } + + pub fn stop_all(&self) { + for (_, cancel) in self.operations.lock().unwrap().values() { + cancel.store(true, Ordering::SeqCst); + } + for host in self.hosts.lock().unwrap().values() { + if let Some(session) = host.session.lock().unwrap().as_ref() { + session.fail("cancelled", "SSH disconnected."); + } + } + } +} + +fn events(app: tauri::AppHandle) -> Events { + Arc::new(move |host, state, detail| { + if state == "changed" { + let _ = app.emit( + "ssh://changed", + json!({ "host": host, "repository": detail }), + ); + } else { + let _ = app.emit( + "ssh://health", + Health { + host: host.into(), + state: state.into(), + error: detail.map(str::to_string), + }, + ); + } + }) +} +fn cmd_error(error: OpError) -> CmdError { + CmdError { + message: format!("{}: {}", error.code, error.message), + } +} + +#[tauri::command(async)] +pub async fn remote_repo_read( + address: String, + op: ReadOp, + request_id: String, + app: tauri::AppHandle, + state: State<'_, Arc>, +) -> CmdResult { + let identity = RemoteIdentity::parse(&address).map_err(cmd_error)?; + if request_id.is_empty() || request_id.len() > 128 { + return Err(cmd_error(OpError::new( + "invalid_request", + "Invalid request id.", + ))); + } + let cancel = Arc::new(AtomicBool::new(false)); + { + let mut ops = state.operations.lock().unwrap(); + if ops.len() >= 32 || ops.contains_key(&request_id) { + return Err(cmd_error(OpError::new( + "busy", + "Duplicate request or too many SSH operations.", + ))); + } + ops.insert(request_id.clone(), (identity.host.clone(), cancel.clone())); + } + let manager = state.inner().clone(); + let result = + tokio::task::spawn_blocking(move || manager.read(&identity, op, events(app), &cancel)) + .await; + state.operations.lock().unwrap().remove(&request_id); + result + .map_err(|e| CmdError { + message: e.to_string(), + })? + .map_err(cmd_error) +} + +#[tauri::command(async)] +pub fn remote_repo_cancel(request_id: String, state: State<'_, Arc>) { + if let Some((_, cancel)) = state.operations.lock().unwrap().get(&request_id) { + cancel.store(true, Ordering::SeqCst); + } +} + +#[tauri::command(async)] +pub async fn remote_repo_watch( + address: String, + enabled: bool, + state: State<'_, Arc>, +) -> CmdResult<()> { + let identity = RemoteIdentity::parse(&address).map_err(cmd_error)?; + let manager = state.inner().clone(); + tokio::task::spawn_blocking(move || { + let cancel = AtomicBool::new(false); + let host = manager.host(&identity.host)?; + let session = host.session.lock().unwrap().clone(); + // Unwatch/close never connects to an unavailable host. + let session = + if let Some(session) = session.filter(|session| session.alive.load(Ordering::SeqCst)) { + session + } else if enabled { + return Err(OpError::new( + "connection", + "Read the repository before starting its watch.", + )); + } else { + return Ok(()); + }; + session + .request( + if enabled { "watch" } else { "unwatch" }, + json!(RepositoryRequest { + repository: identity.path + }), + TIMEOUT, + &cancel, + ) + .map(|_| ()) + }) + .await + .map_err(|e| CmdError { + message: e.to_string(), + })? + .map_err(cmd_error) +} + +#[tauri::command(async)] +pub async fn remote_repo_disconnect( + address: String, + state: State<'_, Arc>, +) -> CmdResult<()> { + let identity = RemoteIdentity::parse(&address).map_err(cmd_error)?; + for (host, cancel) in state.operations.lock().unwrap().values() { + if *host == identity.host { + cancel.store(true, Ordering::SeqCst); + } + } + let host = state.hosts.lock().unwrap().remove(&identity.host); + if let Some(host) = host { + tokio::task::spawn_blocking(move || { + if let Some(session) = host.session.lock().unwrap().take() { + session.fail("cancelled", "SSH disconnected by user."); + } + }) + .await + .map_err(|e| CmdError { + message: e.to_string(), + })?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::OnceLock; + + fn peer(mode: &str) -> Arc { + static PEER: OnceLock<(tempfile::TempDir, std::path::PathBuf)> = OnceLock::new(); + let (_, program) = PEER.get_or_init(|| { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("peer.rs"); + let program = dir + .path() + .join(format!("peer{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&source, include_str!("../tests/fixtures/ssh_peer.rs")).unwrap(); + assert!(Command::new("rustc") + .arg(&source) + .arg("-o") + .arg(&program) + .status() + .unwrap() + .success()); + (dir, program) + }); + let mut command = bin::base_command(program, true); + command.arg(mode); + Session::spawn(command, "fixture".into(), Arc::new(|_, _, _| {})).unwrap() + } + + #[test] + fn concurrent_ids_and_null_results_keep_the_session_alive() { + let session = peer("null"); + let threads: Vec<_> = (0..12) + .map(|_| { + let session = session.clone(); + std::thread::spawn(move || { + session.request( + "unwatch", + json!({}), + Duration::from_secs(5), + &AtomicBool::new(false), + ) + }) + }) + .collect(); + for thread in threads { + assert_eq!(thread.join().unwrap().unwrap(), Value::Null); + } + assert_eq!(*session.next.lock().unwrap(), 12); + assert!(session.alive.load(Ordering::SeqCst)); + assert!(session.pending.lock().unwrap().is_empty()); + let weak = Arc::downgrade(&session); + drop(session); + // Reader/writer threads must not retain the child through an Arc cycle. + for _ in 0..100 { + if weak.upgrade().is_none() { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("transport retained the session after its owner dropped"); + } + + #[test] + fn malformed_oversized_unknown_and_truncated_frames_stop_the_peer() { + for mode in [ + "malformed", + "unknown", + "truncated", + "oversized", + "stderr", + "eof", + ] { + let session = peer(mode); + let error = session + .request( + "read", + json!({}), + Duration::from_secs(5), + &AtomicBool::new(false), + ) + .unwrap_err(); + assert_eq!( + error.code, + if mode == "eof" { + "connection" + } else { + "protocol" + }, + "{mode}: {error:?}" + ); + assert!(!session.alive.load(Ordering::SeqCst)); + assert!(session + .process + .lock() + .unwrap() + .child + .try_wait() + .unwrap() + .is_some()); + assert!(session.pending.lock().unwrap().is_empty()); + } + } + + #[test] + fn timeout_and_cancel_kill_hung_peers_and_drain_pending_requests() { + let session = peer("hang"); + let start = Instant::now(); + assert_eq!( + session + .request( + "read", + json!({}), + Duration::from_millis(100), + &AtomicBool::new(false) + ) + .unwrap_err() + .code, + "timeout" + ); + assert!(start.elapsed() < Duration::from_secs(3)); + assert!(session + .process + .lock() + .unwrap() + .child + .try_wait() + .unwrap() + .is_some()); + + let session = peer("hang"); + let cancel = Arc::new(AtomicBool::new(false)); + let reads: Vec<_> = (0..2) + .map(|_| { + let session = session.clone(); + let cancel = cancel.clone(); + std::thread::spawn(move || { + session.request("read", json!({}), Duration::from_secs(5), &cancel) + }) + }) + .collect(); + for _ in 0..100 { + if session.pending.lock().unwrap().len() == 2 { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(session.pending.lock().unwrap().len(), 2); + cancel.store(true, Ordering::SeqCst); + for read in reads { + assert_eq!(read.join().unwrap().unwrap_err().code, "cancelled"); + } + assert!(session.pending.lock().unwrap().is_empty()); + assert!(session + .process + .lock() + .unwrap() + .child + .try_wait() + .unwrap() + .is_some()); + } + + #[test] + fn eof_during_registration_never_leaves_a_waiter_after_the_drain() { + let session = peer("hang"); + let pending = session.pending.lock().unwrap(); + let read = { + let session = session.clone(); + std::thread::spawn(move || { + session.request( + "read", + json!({}), + Duration::from_secs(5), + &AtomicBool::new(false), + ) + }) + }; + // Wait until registration owns the ID lock and is blocked on pending. + let deadline = Instant::now() + Duration::from_secs(3); + while session.next.try_lock().is_ok() { + assert!(Instant::now() < deadline); + std::thread::yield_now(); + } + let failure = { + let session = session.clone(); + std::thread::spawn(move || session.fail("connection", "fixture EOF")) + }; + while session.alive.load(Ordering::SeqCst) { + assert!(Instant::now() < deadline); + std::thread::yield_now(); + } + drop(pending); + assert_eq!(read.join().unwrap().unwrap_err().code, "connection"); + failure.join().unwrap(); + assert!(session.pending.lock().unwrap().is_empty()); + } +} diff --git a/crates/strand-tauri/tests/fixtures/ssh_peer.rs b/crates/strand-tauri/tests/fixtures/ssh_peer.rs new file mode 100644 index 00000000..723e6fdb --- /dev/null +++ b/crates/strand-tauri/tests/fixtures/ssh_peer.rs @@ -0,0 +1,36 @@ +// Dependency-free peer compiled by the transport tests. Never used by the app. +use std::io::{self, BufRead, Write}; +fn main() { + let mode = std::env::args().nth(1).unwrap(); + for line in io::stdin().lock().lines() { + let line = line.unwrap(); + let id = line + .split("\"id\":") + .nth(1) + .unwrap() + .split(',') + .next() + .unwrap(); + match mode.as_str() { + "null" => println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":null}}"), + "malformed" => println!("invalid JSON"), + "unknown" => println!("{{\"jsonrpc\":\"2.0\",\"id\":9999,\"result\":null}}"), + "truncated" => { + print!("{{"); + return; + } + "eof" => return, + "oversized" => { + io::stdout() + .write_all(&vec![b'x'; 8 * 1024 * 1024 + 1]) + .unwrap(); + } + "stderr" => { + io::stderr().write_all(&vec![b'x'; 70_000]).unwrap(); + } + "hang" => std::thread::sleep(std::time::Duration::from_secs(600)), + _ => panic!("unknown fixture mode"), + } + io::stdout().flush().unwrap(); + } +} diff --git a/docs/cli-ssh-verification-2026-09-06.md b/docs/cli-ssh-verification-2026-09-06.md new file mode 100644 index 00000000..baf47b11 --- /dev/null +++ b/docs/cli-ssh-verification-2026-09-06.md @@ -0,0 +1,88 @@ +# F16 / F17 foundation verification — 2026-09-06 + +Scope: a bundled launcher, versioned read-only CLI and shared SSH inspection +foundation. No remote mutation APIs or generic command execution were added. + +## Implementation stages + +1. `1a35103`: bundled `strand-cli`, user `strand` installation, desktop locator + and bounded single-instance repository handoff after session restoration. +2. `207a1f4`: `strand-ops`, schema-v1 status/log/diff/review commands, stable + machine errors, bounded encoding and shared desktop read types. +3. SSH foundation: the same binary's `--stdio` protocol, native system-SSH + manager and isolated read-only inspector. See [remote-ssh.md](./remote-ssh.md) + for limits and [the user guide](../website/docs/remote-repositories.md) for + the current manual setup. + +## Automated regression coverage + +- CLI integration tests exercise status/snapshot, history, diff sources and + review; binary and half-staged files; unborn repositories and linked + worktrees; deterministic errors; and byte-for-byte repository preservation. +- Protocol tests reject unknown read fields, incomplete/oversized frames and + invalid remote identities. A regression preserves successful JSON-RPC + `result: null` while rejecting a null error object. +- Daemon process tests cover handshake mismatch, disallowed methods, multiplexed + IDs, chunks and changed-file tokens, traversal rejection, watcher bursts, + cancellation, duplicate IDs and EOF teardown. +- Desktop transport tests launch a dependency-free compiled peer. They cover + concurrent requests, null results, malformed/oversized/truncated frames, + unknown response IDs, excessive diagnostics, EOF, timeout, cancellation and + liveness changes during request registration. Pending reads drain and child + processes exit; dropping the owner does not retain the session in a cycle. +- Six UI store tests cover subscribing before the first snapshot, canonical + identity and recents, watch-burst coalescing, stale response rejection after + close/host switch, bounded idle reconnection and recovery from a bad revision. + +The TypeScript check and full frontend suite passed: **76 files / 431 tests**. +`cargo check -p strand-core -p strand-tauri` and clippy with warnings denied +passed. The full native run passed **162 core, 121 desktop and 11 companion/ops +tests**. The frontend production build and normal desktop build (without the +verification CDP override) also passed. Vite retained its existing large-chunk +and mixed-import warnings. + +## Windows desktop and system-SSH verification + +The repository's `verify` workflow drove an isolated WebView2 profile and app +identifier through CDP. The tracked Tauri configuration was not edited. Tests +used temporary repositories and only this task's processes. + +Launcher checks covered a cold startup request, delivery to an already-running +instance, a path containing spaces, the Settings/palette install flow and the +installed command. The temporary command and Windows PATH change were restored. + +For SSH, Windows system OpenSSH connected to an isolated localhost SSH server +using generated test keys, a pinned known-host entry and a temporary SSH alias. +The server launched the native Windows companion. A test-only bridge translated +the fixture's POSIX identity to its Windows disk path and back. This validates +the actual desktop/SSH/daemon channel, but is **not native Linux/macOS host +evidence**. The temporary SSH configuration was removed and its original bytes +verified afterward. + +The app pass verified: + +- Connection and canonical repository identity, with remote execution context. +- A full-context diff rendered by the shared Pierre component. +- Two 64 KiB file reads, rejection of a stale append, and explicit reload. +- Recent history and review with a pinned base OID. +- A burst of writes updating the final content of an already-modified file. +- Automatic reconnect and watcher restoration after an idle connection loss. +- Visible failure for malformed peer output, while local status still worked + (8.3 ms in this small debug fixture; not a PRD performance benchmark). +- Cancellation of a hung handshake, changed-host-key rejection before remote + execution, and recovery after restoring the expected host key. +- Suppressed local view shortcuts inside the inspector, Escape closing and + disconnecting, and local navigation afterward. +- Byte-for-byte preservation of every fixture `.git` file across inspection. + +## Remaining acceptance work + +- Native macOS/Linux launcher installation and native POSIX SSH host passes. +- Standalone/static host builds, signed hash manifest, SFTP bootstrap and repair. +- Host-alias discovery, remote directory browsing and ordinary remote tabs. +- Expanded CLI blame/conflict commands and syntax-colored pager output. +- Large-repository/long-WAN performance measurement. This change keeps local + reads in process and bounds transport/UI work, but does not certify PRD §8. + +These remain open in TASKS and ROADMAP; the wider June design is not declared +complete by this foundation. diff --git a/docs/git-client-feature-audit-2026-09-06.md b/docs/git-client-feature-audit-2026-09-06.md index 3147a7bc..ceda7f69 100644 --- a/docs/git-client-feature-audit-2026-09-06.md +++ b/docs/git-client-feature-audit-2026-09-06.md @@ -82,7 +82,7 @@ request subsequent pages, instead of merely increasing the first-page limit. | **F13 — Review evolution and actionable feedback export** | **Partial.** [PullRequests](../ui/src/views/PullRequests.tsx) tracks file patch hashes and “Changed since viewed”; it does not offer a diff between reviewed heads/iterations, suggestion application, or hosted unresolved-feedback export. Local Review notes/export already exist. | Compare an explicit reviewed boundary to current head, handle rebases/force-pushes, preview and validate suggestion application, export unresolved feedback with provider/file/line context. Today: provider comparisons or local ref comparison. | | **F14 — Publish a new hosted repository** | **Missing.** [InitRepoDialog](../ui/src/views/InitRepoDialog.tsx) creates local repos and [remote.rs](../crates/strand-core/src/remote.rs) manages Git remote config; no provider repository-creation operation exists. PRD §6.1 already schedules hosted creation. | Choose provider/account/organization, name and visibility; show the concrete destination before creation; add the remote and explicitly choose initial push, with recovery from partial failure. Today: create on the provider, then add the remote in Strand. | | **F15 — User-defined Git actions** | **Partial foundation.** [integrations.ts](../ui/src/lib/integrations.ts) supports editor/terminal templates; [Workbench commands](../ui/src/workbench/commands.ts) and bundled plugins are developer registries, not a user-defined repo/ref/file action editor. | Define scoped executable/argv templates, preview resolved arguments and working directory, expose context/palette entries, capture bounded output and cancel. Reuse exact selection context and avoid shell interpolation. Today: terminal or external scripts. | -| **F16 — CLI launcher and read-only companion** | **Design only.** [Cargo workspace](../Cargo.toml) contains core/Tauri/Azure helper crates, with no `strand-ops`/CLI companion; [strand-cli.md](./strand-cli.md) is explicitly a design. No desktop argument/deep-link repo-opening handler was found. | First deliver `strand PATH` with single-instance handoff and platform registration; then implement versioned read-only status/log/diff/review output. Keep mutating Git commands out of the planned companion scope. Today: app Open and the Git CLI. | +| **F16 — CLI launcher and read-only companion** | **Foundation implemented locally after this audit.** [strand-headless](../crates/strand-headless/src/main.rs), [strand-ops](../crates/strand-ops/src/lib.rs) and the [desktop launcher](../crates/strand-tauri/src/launcher.rs) provide repository handoff, command installation and schema-v1 read output. | `strand PATH` and read-only status/log/diff/review are implemented. Native macOS/Linux installation validation, standalone release artifacts and the expanded CLI backlog remain open; see [verification](./cli-ssh-verification-2026-09-06.md). No mutating Git commands are exposed. | F08 and F09 address different costs: sparse checkout reduces the populated working tree; clone depth/filter options affect acquired history or objects. @@ -95,7 +95,7 @@ fixtures, independently of adding its setup dialog. | ID / feature | Current gap and code evidence | Completion criterion / current fallback | | --- | --- | --- | -| **F17 — Work on repositories located on an SSH host** | **Design only.** [remote-ssh.md](./remote-ssh.md) records the daemon/transport design; [Repo::discover](../crates/strand-core/src/repo.rs) and [commands.rs](../crates/strand-tauri/src/commands.rs) open local filesystem paths. SSH Git remotes for fetch/push already work and are a different feature. | Remote repo identity, versioned daemon protocol, system-SSH authentication, bounded file/watch streaming, reconnect/cancellation and clear local/remote execution context. Today: SSH terminal, or a local clone. | +| **F17 — Work on repositories located on an SSH host** | **Read-only foundation implemented locally after this audit.** [daemon](../crates/strand-headless/src/daemon.rs), [system-SSH transport](../crates/strand-tauri/src/remote_repos.rs) and [inspector](../ui/src/views/RemoteReposDialog.tsx) implement remote identity, handshake, bounded reads/watches, reconnect/cancellation and visible execution context. Local commands remain in process. | Read-only inspection requires a manually installed compatible POSIX companion. Native host validation, signed static host artifacts/SFTP bootstrap, host browsing and ordinary remote tabs remain open. See [verification](./cli-ssh-verification-2026-09-06.md); SSH Git fetch/push remotes remain a different feature. | | **F18 — Advanced refs and tag editing** | **Missing/partial.** No Git notes/replace-ref management operations exist. [tag.rs](../crates/strand-core/src/tag.rs) has a force primitive, but [TagDialog](../ui/src/views/TagDialog.tsx) and the sidebar do not expose retarget/edit flows. Local review notes are not Git notes. | Explicit notes/replace-ref inspection and management; separate tag retarget/re-annotation with current/new target comparison and remote-aware confirmation. Signed tag creation belongs to F03. Today: Git CLI. | | **F19 — Git-flow orchestration** | **Missing.** Normal branch/merge/rebase exist, but no git-flow configuration or start/finish feature/release/hotfix actions in [core modules](../crates/strand-core/src/lib.rs) or app commands. | Opt-in tool detection/configuration and inspectable start/finish operations with progress, conflicts and recovery. Prioritize only with demand from teams using Git-flow. Today: ordinary branches or external git-flow tools. | diff --git a/docs/learnings.md b/docs/learnings.md index 41025d01..3a980486 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1,5 +1,25 @@ # Learnings +## SSH reads must stay isolated and bounded (2026-09-06) + +Remote identities never enter local filesystem commands. The first SSH surface +is an isolated read-only inspector; suspend local repository shortcuts and +native repository menu actions while it owns focus. Keep the system SSH command +fixed and pass repository paths in JSON, with strict host-key checking and +terminal-owned authentication. Local reads must never acquire transport locks. + +Retain cancellation slots until native workers actually stop. Desktop +cancellation kills the whole host connection and drains its waiters; EOF, +timeouts and protocol errors use the same teardown. Register pending requests +under the drain lock and recheck liveness there to avoid an EOF race. Watch +coalescing needs trailing invalidation and UI generations, including when an +already-modified file changes again. + +JSON-RPC `result: null` is a successful response. Serde `Option` normally +collapses it into a missing field: use presence-preserving deserialization and +keep the null-result regression. File chunk metadata tokens detect ordinary +edits; they are not content hashes or atomic snapshots. + ## Desktop launch arguments need an inbox (2026-09-06) Single-instance events can arrive before React subscribes or while persisted diff --git a/docs/remote-ssh.md b/docs/remote-ssh.md index 5b01215b..b14ea0e1 100644 --- a/docs/remote-ssh.md +++ b/docs/remote-ssh.md @@ -1,9 +1,51 @@ # Remote repos over SSH — feature design -Status (2026-06-12): **design only, scheduled post-1.0** (ROADMAP §1.1+). -Nothing here is implemented. This document records the decided -architecture so pre-1.0 work doesn't accidentally close the door on it — -see "What 1.0 must not break" below. +Status (2026-09-06): **F17 read-only foundation implemented locally**. +`strand-cli --stdio` serves the versioned `strand-ops` allowlist. The desktop's +**SSH repositories** inspector connects through system OpenSSH and displays +status, history, full-context changes/reviews, and bounded file previews. Local +repository commands stay in process; remote identities are rejected by local +`Repo::discover`. Remote inspection has separate state and no mutation controls. + +The host must currently have a compatible companion installed at +`~/.strand/bin/strand`. Automatic SFTP installation, signed host-artifact +manifests, the Linux/macOS release matrix, host discovery/directory browsing, +ordinary remote tabs and remote writes remain open. The sections below record +the broader target design; they are not claims about the current inspector. +See [the user guide](../website/docs/remote-repositories.md) for today's setup. + +## Implemented protocol and limits + +- `ssh://HOST-ALIAS/absolute/path` identifies the repository; configure user, + port and jump hosts in OpenSSH config. Paths travel in JSON on stdin, never + in a shell command. Only the fixed `exec "$HOME/.strand/bin/strand" --stdio` + command executes remotely. `BatchMode=yes` and `StrictHostKeyChecking=yes` + keep authentication and host-key enrollment in the terminal. +- JSON-RPC 2.0 uses increasing integer IDs, newline-delimited UTF-8 frames and + an 8 MiB frame limit. Protocol 1 negotiates schema 1 and read/watch/file-chunk + capabilities. Unknown methods/fields, malformed frames and unknown response + IDs fail closed. `hello`, `read`, `watch`, `unwatch` and `cancel` are the only + methods; read operations share the CLI's typed schema. +- The daemon permits four concurrent reads, sixteen repository watches and + eight queued output frames. Watch events debounce at 400 ms and coalesce on + a 200 ms notifier; a full queue preserves a trailing notification. File reads + are at most 64 KiB with an offset and metadata version token. A changed token + rejects appending to an old preview; these are not atomic disk snapshots. +- The desktop permits sixteen hosts, sixteen pending requests per connection + and thirty-two IPC reads overall. Reads retry connection failures twice + (250 ms, then 1 s). Handshake/read/diff deadlines are 15/30/60 seconds; + timeout or cancellation kills the SSH process tree and fails every pending + read on that host. Cancellation does not interrupt another host or a local + operation. No write queue or write replay exists. +- EOF ends the daemon and its watchers. Daemon-side cancellation retains the + worker slot until the native read returns; desktop cancellation tears down + the connection for prompt interruption. Diagnostics retain at most 16 KiB; + a peer emitting more than 64 KiB of stderr is stopped. +- The inspector keeps a single diff mounted, shows at most 500 matching file + names (filter to narrow), and caps file previews at 1 MiB. Watch refreshes + coalesce with a trailing read and reject stale generations. Closing the + inspector cancels reads and disconnects; successful remote addresses alone + are saved as recents. Disconnect leaves a visibly stale snapshot. ## Why diff --git a/docs/strand-cli.md b/docs/strand-cli.md index ceacb594..b849d89f 100644 --- a/docs/strand-cli.md +++ b/docs/strand-cli.md @@ -13,12 +13,15 @@ reports HEAD before/after; it is a sequence of reads, not an atomic disk snapsho `diff --full-context` applies to unstaged/`--since`, matching existing core ops. Human output is plain, terminal-control-safe text. Syntax colors/pager, blame/structured conflicts, standalone release artifacts, and remote bootstrap -remain staged work. The remaining sections record the target design. +remain staged work. The same executable now serves protocol-v1 read-only +JSON-RPC with `--stdio`; see [remote-ssh.md](./remote-ssh.md) for its negotiated +capabilities, limits and manual host installation. The remaining sections +record the target design. Originally scheduled post-1.0 (ROADMAP §1.1+, where "CLI companion binary" has been a bullet since the start — this doc fleshes it out). Shares its foundation with [`remote-ssh.md`](./remote-ssh.md): both consume the transport-agnostic -`strand-ops` crate, and the CLI and the remote daemon are proposed as +`strand-ops` crate, and the CLI and the remote daemon are implemented as **one binary**. Read that doc first. ## Why diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c39b9a6a..fcb005dd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -22,6 +22,7 @@ import { ToastViewport, type ToastMessage } from './components/ToastViewport'; import { FONTS, useSettings } from './stores/settings'; import { usePullRequests } from './stores/pullRequests'; import { useRepo, type View } from './stores/repo'; +import { useRemoteRepos } from './stores/remoteRepos'; import { useRepoIcons } from './stores/repoIcons'; import { DEFAULT_WORKSPACE_ID, useWorkspaces } from './stores/workspaces'; import { useWorkspaceReview } from './stores/workspaceReview'; @@ -121,6 +122,7 @@ const RebaseEditor = lazy(() => import('./views/RebaseEditor').then((m) => ({ de const MaintenanceDialog = lazy(() => import('./views/MaintenanceDialog').then((m) => ({ default: m.MaintenanceDialog }))); const WorkspaceManagerDialog = lazy(() => import('./views/WorkspaceManagerDialog').then((m) => ({ default: m.WorkspaceManagerDialog }))); const PullRequests = lazy(() => import('./views/PullRequests').then((m) => ({ default: m.PullRequests }))); +const RemoteReposDialog = lazy(() => import('./views/RemoteReposDialog').then((m) => ({ default: m.RemoteReposDialog }))); /** Whole-UI zoom bounds + step for the browser-style Ctrl/⌘ +/− shortcuts. * Ctrl+= / Ctrl++ zoom in, Ctrl+- out, Ctrl+0 resets to 100%. */ @@ -333,6 +335,8 @@ export function App() { const [workbenchEditing, setWorkbenchEditing] = useState(false); const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [remoteReposOpen, setRemoteReposOpen] = useState(false); + const remoteHealth = useRemoteRepos((state) => state.health); const [settingsSection, setSettingsSection] = useState('appearance'); const [cloneOpen, setCloneOpen] = useState(false); const [initRepoOpen, setInitRepoOpen] = useState(false); @@ -1166,7 +1170,7 @@ export function App() { openSettingsAt('updates'); void useUpdates.getState().check(); }, - openPalette: () => setPaletteOpen((o) => !o), + openPalette: () => { if (!remoteReposOpen) setPaletteOpen((o) => !o); }, showView: (v) => { if (v === 'work') setWorkbenchEditing(false); setView(v); @@ -1184,7 +1188,7 @@ export function App() { openInEditor, openInTerminal, }; - const hasRepo = Boolean(meta); + const hasRepo = Boolean(meta) && !remoteReposOpen; useEffect(() => { if (!isTauri()) return; // Accelerators track the resolved bindings, so a remap in Settings updates @@ -1370,6 +1374,9 @@ export function App() { // Esc always closes the palette / repo switcher (their own handlers cover // the focused case; this is the global fallback). if (e.key === 'Escape') { setPaletteOpen(false); setRepoSwitcherOpen(false); return; } + // SSH inspection has its own focus model. A remote keypress must never + // dispatch a mutation against the local repository behind the dialog. + if (document.querySelector('.remote-repo-context')) return; // Browser-style UI zoom — Ctrl/⌘ with +, −, or 0. These sit outside the // rebindable registry: + / = (plus their Shift and numpad variants) don't // map to a single canonical binding, and zoom keys are conventionally @@ -1656,6 +1663,9 @@ export function App() { const base: PaletteAction[] = [ { id: 'open', label: 'Open repository…', group: 'Actions', shortcut: keyHint('open-repo'), run: () => { void openViaDialog(); } }, { id: 'install-cli', label: 'Install strand command…', group: 'Actions', keywords: 'terminal PATH launcher companion', run: () => { setSettingsSection('integrations'); setSettingsOpen(true); } }, + { id: 'ssh-repositories', label: 'Open repository on SSH host…', group: 'Actions', keywords: 'remote daemon read status log diff review', run: () => setRemoteReposOpen(true) }, + { id: 'ssh-reconnect', label: 'Reconnect SSH repository…', group: 'Actions', run: () => setRemoteReposOpen(true) }, + { id: 'ssh-disconnect', label: 'Disconnect SSH repository', group: 'Actions', run: () => { void useRemoteRepos.getState().disconnect(); } }, { id: 'init', label: 'Initialize repository…', group: 'Actions', keywords: 'new create git init local repository', run: () => setInitRepoOpen(true) }, { id: 'clone', label: t('clone.paletteAction'), group: 'Actions', shortcut: keyHint('clone-repo'), run: () => setCloneOpen(true) }, { id: 'switch-repo', label: 'Switch repository…', group: 'Actions', shortcut: keyHint('switch-repo'), keywords: 'switch repo repository jump active picker quick open', run: () => setRepoSwitcherOpen(true) }, @@ -2162,6 +2172,8 @@ export function App() {
setRemoteReposOpen(true)} + remoteHealth={remoteHealth} onOpenPalette={() => setPaletteOpen(true)} onFetch={onFetch} onPull={onPull} @@ -2352,6 +2364,7 @@ export function App() {
{paletteOpen && setPaletteOpen(false)} />} + {remoteReposOpen && setRemoteReposOpen(false)} />} {repoSwitcherOpen && ( diff --git a/ui/src/components/Topbar.tsx b/ui/src/components/Topbar.tsx index 356b4fdf..77a062eb8 100644 --- a/ui/src/components/Topbar.tsx +++ b/ui/src/components/Topbar.tsx @@ -11,6 +11,8 @@ import { useRepo } from '../stores/repo'; import type { PullMode, PushMode } from '../lib/types'; interface Props { + onOpenRemote: () => void; + remoteHealth: string; onOpenPalette: () => void; onFetch: (prune?: boolean) => void; onPull: (mode?: PullMode, autostash?: boolean) => void; @@ -56,6 +58,8 @@ interface Props { } export function Topbar({ + onOpenRemote, + remoteHealth, onOpenPalette, onFetch, onPull, @@ -325,6 +329,7 @@ export function Topbar({ {platform === 'mac' ? '⌘K' : 'Ctrl K'} + {showWinControls && } ); diff --git a/ui/src/demo/dispatch.ts b/ui/src/demo/dispatch.ts index 8041cea5..f8499bf7 100644 --- a/ui/src/demo/dispatch.ts +++ b/ui/src/demo/dispatch.ts @@ -67,6 +67,10 @@ function prComment(author: string, body: string, path: string | null, prId: numb export const handlers: Record = { app_take_open_requests: () => [], app_install_cli: () => unavailable('Installing the strand command'), + remote_repo_read: () => unavailable('SSH repositories'), + remote_repo_cancel: () => undefined, + remote_repo_watch: () => undefined, + remote_repo_disconnect: () => undefined, // ---- app / environment ------------------------------------------------- microsoft_store_update_available: () => false, microsoft_store_open_product: () => unavailable('The Microsoft Store'), diff --git a/ui/src/lib/remoteRepos.ts b/ui/src/lib/remoteRepos.ts new file mode 100644 index 00000000..b5579e6f --- /dev/null +++ b/ui/src/lib/remoteRepos.ts @@ -0,0 +1,29 @@ +import type { Commit, FileContent, FileDiff, FileStatus, RepoMeta, Snapshot } from './types'; + +export type RemoteReadOp = + | { kind: 'meta' | 'status' | 'snapshot' } + | { kind: 'log'; limit: number; head_only: boolean } + | { kind: 'diff'; source: { kind: 'since'; revision: string; full_context: boolean } } + | { kind: 'review'; since: string; limit: number } + | { kind: 'file_chunk'; path: string; offset: number; length: number; version: string | null }; +export interface RemoteFileChunk { bytes: number[]; offset: number; next_offset: number; total: number; version: string } +export interface RemoteReview { base: string; head_before: string | null; head_after: string | null; status: FileStatus[]; log: Commit[]; diffs: FileDiff[] } +export type RemoteResult = + | { kind: 'meta'; data: RepoMeta } + | { kind: 'status'; data: FileStatus[] } + | { kind: 'snapshot'; data: Snapshot } + | { kind: 'log'; data: Commit[] } + | { kind: 'diff'; data: FileDiff[] } + | { kind: 'review'; data: RemoteReview } + | { kind: 'file'; data: FileContent } + | { kind: 'file_chunk'; data: RemoteFileChunk }; +export interface RemoteEnvelope { schemaVersion: number; repository: string; result: RemoteResult } +export interface RemoteHealth { host: string; state: string; error: string | null } + +export function remoteHost(address: string): string { + try { return new URL(address).hostname.toLowerCase(); } catch { return ''; } +} +export function canonicalRemoteAddress(address: string, repository: string): string { + if (!repository.startsWith('/') || repository.length > 4096 || /[\u0000-\u001f\\]/.test(repository)) throw new Error('Remote engine returned an invalid repository path.'); + return `ssh://${remoteHost(address)}${repository.split('/').map(encodeURIComponent).join('/')}`; +} diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 3551507c..a4d48311 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -1,4 +1,5 @@ import { Channel, invoke } from '@tauri-apps/api/core'; +import type { RemoteEnvelope, RemoteReadOp } from './remoteRepos'; import type { AiProvider, @@ -115,6 +116,10 @@ export function errMessage(e: unknown): string { * frontend never calls `invoke` with a string literal. */ export const tauri = { + remoteRepoRead: (address: string, op: RemoteReadOp, requestId: string) => invoke('remote_repo_read', { address, op, requestId }), + remoteRepoCancel: (requestId: string) => invoke('remote_repo_cancel', { requestId }), + remoteRepoWatch: (address: string, enabled: boolean) => invoke('remote_repo_watch', { address, enabled }), + remoteRepoDisconnect: (address: string) => invoke('remote_repo_disconnect', { address }), appTakeOpenRequests: () => invoke('app_take_open_requests'), appInstallCli: () => invoke('app_install_cli'), microsoftStoreUpdateAvailable: () => diff --git a/ui/src/stores/remoteRepos.test.ts b/ui/src/stores/remoteRepos.test.ts new file mode 100644 index 00000000..0b4d5b5a --- /dev/null +++ b/ui/src/stores/remoteRepos.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RemoteEnvelope } from '../lib/remoteRepos'; +import type { Snapshot } from '../lib/types'; + +const tauri = vi.hoisted(() => ({ remoteRepoRead: vi.fn(), remoteRepoCancel: vi.fn(), remoteRepoDisconnect: vi.fn(), remoteRepoWatch: vi.fn() })); +vi.mock('../lib/tauri', () => ({ tauri, errMessage: (e: unknown) => e instanceof Error ? e.message : String(e) })); +vi.stubGlobal('localStorage', { getItem: () => null, setItem: vi.fn() }); +import { useRemoteRepos } from './remoteRepos'; +const initial = useRemoteRepos.getState(); +const snapshot: Snapshot = { + meta: { name: 'repo', path: '/repo', branch: 'main', head_oid: 'abc', ahead: 0, behind: 0, detached: false, operation: null, common_dir: '/repo/.git', is_linked_worktree: false }, + status: [], work_tree: [], refs: { branches: [], primary_branch: null, remote_branches: [], tags: [], remotes: [] }, submodules: [], +}; +const envelope = (repository = '/repo'): RemoteEnvelope => ({ schemaVersion: 1, repository, result: { kind: 'snapshot', data: snapshot } }); +function deferred() { let resolve!: (value: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; } +beforeEach(() => { + vi.clearAllMocks(); + tauri.remoteRepoRead.mockReset().mockResolvedValue(envelope()); + tauri.remoteRepoWatch.mockReset().mockResolvedValue(undefined); + tauri.remoteRepoDisconnect.mockResolvedValue(undefined); + tauri.remoteRepoCancel.mockResolvedValue(undefined); + useRemoteRepos.setState(initial, true); +}); +afterEach(async () => { await useRemoteRepos.getState().disconnect(); }); + +describe('remote inspection lifecycle', () => { + it('canonicalizes the remote identity and subscribes before the first snapshot', async () => { + tauri.remoteRepoRead.mockResolvedValueOnce({ ...envelope('/space repo'), result: { kind: 'meta', data: snapshot.meta } }); + await useRemoteRepos.getState().connect('ssh://DevBox/alias'); + expect(useRemoteRepos.getState()).toMatchObject({ address: 'ssh://devbox/space%20repo', health: 'connected', snapshot, busy: false }); + expect(tauri.remoteRepoWatch.mock.invocationCallOrder[0]).toBeLessThan(tauri.remoteRepoRead.mock.invocationCallOrder[1]); + expect(useRemoteRepos.getState().recents).toEqual(['ssh://devbox/space%20repo']); + }); + + it('coalesces a watch burst and publishes only the trailing snapshot', async () => { + useRemoteRepos.setState({ address: 'ssh://box/repo', health: 'connected' }); + const old = deferred(); + tauri.remoteRepoRead.mockReturnValueOnce(old.promise); + const run = useRemoteRepos.getState().refresh(); + for (let n = 0; n < 30; n++) void useRemoteRepos.getState().refresh(); + old.resolve(envelope('/stale')); + await run; + expect(tauri.remoteRepoRead).toHaveBeenCalledTimes(2); + expect(tauri.remoteRepoWatch).toHaveBeenCalledTimes(1); + expect(useRemoteRepos.getState().snapshot).toEqual(snapshot); + }); + + it('ignores reads that complete after disconnect and does not reconnect on its own close event', async () => { + useRemoteRepos.setState({ address: 'ssh://box/repo', health: 'connected' }); + const pending = deferred(); + tauri.remoteRepoRead.mockReturnValueOnce(pending.promise); + const run = useRemoteRepos.getState().refresh(); + await useRemoteRepos.getState().disconnect(); + useRemoteRepos.getState().healthEvent({ host: 'box', state: 'disconnected', error: null }); + pending.resolve(envelope()); await run; + expect(tauri.remoteRepoCancel).toHaveBeenCalledTimes(1); + expect(tauri.remoteRepoRead).toHaveBeenCalledTimes(1); + expect(useRemoteRepos.getState()).toMatchObject({ health: 'disconnected', snapshot: null, busy: false }); + }); + + it('switches hosts while the previous refresh is pending without publishing old data', async () => { + useRemoteRepos.setState({ address: 'ssh://old/repo', health: 'connected' }); + const pending = deferred(); + tauri.remoteRepoRead.mockReturnValueOnce(pending.promise); + const run = useRemoteRepos.getState().refresh(); + const connect = useRemoteRepos.getState().connect('ssh://new/repo'); + await vi.waitFor(() => expect(tauri.remoteRepoWatch).toHaveBeenCalled()); + pending.resolve(envelope('/old')); await Promise.all([run, connect]); + expect(useRemoteRepos.getState()).toMatchObject({ address: 'ssh://new/repo', health: 'connected', snapshot, busy: false }); + expect(tauri.remoteRepoRead.mock.calls.at(-1)?.[0]).toBe('ssh://new/repo'); + }); + + it('reconnects once after an idle drop and leaves a final failure visible', async () => { + useRemoteRepos.setState({ address: 'ssh://box/repo', health: 'connected' }); + tauri.remoteRepoRead.mockRejectedValue(new Error('connection: host unavailable')); + useRemoteRepos.getState().healthEvent({ host: 'unrelated', state: 'disconnected', error: 'ignore' }); + expect(tauri.remoteRepoRead).not.toHaveBeenCalled(); + useRemoteRepos.getState().healthEvent({ host: 'box', state: 'disconnected', error: 'lost' }); + await vi.waitFor(() => expect(useRemoteRepos.getState().busy).toBe(false)); + useRemoteRepos.getState().healthEvent({ host: 'box', state: 'disconnected', error: 'final' }); + expect(tauri.remoteRepoRead).toHaveBeenCalledTimes(1); + expect(useRemoteRepos.getState().health).toBe('disconnected'); + }); + + it('keeps a healthy connection usable after an invalid review base', async () => { + useRemoteRepos.setState({ address: 'ssh://box/repo', health: 'connected', snapshot }); + tauri.remoteRepoRead.mockResolvedValueOnce(envelope()).mockRejectedValueOnce(new Error('repository: invalid revision')); + await useRemoteRepos.getState().selectMode('review', 'missing'); + expect(useRemoteRepos.getState()).toMatchObject({ health: 'connected', error: 'repository: invalid revision', busy: false, snapshot }); + await useRemoteRepos.getState().selectMode('status'); + expect(useRemoteRepos.getState()).toMatchObject({ mode: 'status', error: null }); + }); +}); diff --git a/ui/src/stores/remoteRepos.ts b/ui/src/stores/remoteRepos.ts new file mode 100644 index 00000000..475ec874 --- /dev/null +++ b/ui/src/stores/remoteRepos.ts @@ -0,0 +1,113 @@ +import { create } from 'zustand'; +import { tauri, errMessage } from '../lib/tauri'; +import { canonicalRemoteAddress, remoteHost, type RemoteEnvelope, type RemoteHealth, type RemoteReadOp, type RemoteResult } from '../lib/remoteRepos'; +import type { Snapshot } from '../lib/types'; + +type Mode = 'status' | 'log' | 'diff' | 'review' | 'files'; +interface State { + address: string; + health: string; + error: string | null; + busy: boolean; + mode: Mode; + since: string; + snapshot: Snapshot | null; + result: RemoteResult | null; + generation: number; + recents: string[]; + connect: (address: string) => Promise; + refresh: () => Promise; + disconnect: () => Promise; + selectMode: (mode: Mode, since?: string) => Promise; + healthEvent: (event: RemoteHealth) => void; +} +let generation = 0; +let refreshRun: Promise | null = null; +let trailing = false; +const requests = new Set(); +function loadRecents(): string[] { + try { const value: unknown = JSON.parse(localStorage.getItem('strand:ssh-recents:v1') ?? '[]'); return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.startsWith('ssh://') && item.length < 8192).slice(0, 16) : []; } catch { return []; } +} +async function read(address: string, op: RemoteReadOp): Promise { + const id = crypto.randomUUID(); requests.add(id); + try { return await tauri.remoteRepoRead(address, op, id); } + finally { requests.delete(id); } +} +function cancelReads() { for (const id of requests) void tauri.remoteRepoCancel(id); } + +export const useRemoteRepos = create((set, get) => ({ + address: '', health: 'disconnected', error: null, busy: false, mode: 'status', since: 'HEAD', snapshot: null, result: null, generation: 0, recents: loadRecents(), + connect: async (address) => { + const old = get().address; + const token = ++generation; + cancelReads(); + set({ address, health: 'connecting', busy: true, error: null, snapshot: null, result: null, generation: token }); + try { + if (old) await tauri.remoteRepoDisconnect(old); + if (token !== generation) return; + const meta = await read(address, { kind: 'meta' }); + if (token !== generation) return; + const canonical = canonicalRemoteAddress(address, meta.repository); + set({ address: canonical }); + // Subscribe before the first snapshot so there is no lost-write gap. + await tauri.remoteRepoWatch(canonical, true); + if (token !== generation) return; + const recents = [canonical, ...get().recents.filter((item) => item !== canonical)].slice(0, 16); + try { localStorage.setItem('strand:ssh-recents:v1', JSON.stringify(recents)); } catch { /* Inspection still works when storage is unavailable. */ } + set({ health: 'connected', recents }); + await get().refresh(); + } catch (error) { if (token === generation) set({ health: 'disconnected', error: errMessage(error) }); } + finally { if (token === generation) set({ busy: false }); } + }, + refresh: () => { + if (refreshRun) { trailing = true; return refreshRun; } + refreshRun = (async () => { + do { + trailing = false; + const token = generation; + const { address, mode, since } = get(); + if (!address) return; + set({ busy: true, error: null }); + try { + const snapshot = await read(address, { kind: 'snapshot' }); + if (token !== generation || trailing) continue; + if (snapshot.result.kind !== 'snapshot') throw new Error('Remote snapshot response has the wrong type.'); + const op: RemoteReadOp | null = mode === 'log' ? { kind: 'log', limit: 50, head_only: true } + : mode === 'diff' ? { kind: 'diff', source: { kind: 'since', revision: 'HEAD', full_context: true } } + : mode === 'review' ? { kind: 'review', since, limit: 50 } : null; + const result = op ? (await read(address, op)).result : null; + if (token !== generation || trailing) continue; + await tauri.remoteRepoWatch(address, true); // Restore watch after a read reconnects. + if (token !== generation || trailing) continue; + set({ snapshot: snapshot.result.data, result, health: 'connected' }); + } catch (error) { + if (token === generation) { + const message = errMessage(error); + set({ error: message, health: /^(connection|protocol|timeout|cancelled):/.test(message) ? 'disconnected' : get().health }); + } + } + finally { if (token === generation) set({ busy: false }); } + } while (trailing); + })().finally(() => { refreshRun = null; }); + return refreshRun; + }, + disconnect: async () => { + ++generation; + trailing = false; + cancelReads(); + const address = get().address; + set({ health: 'disconnected', busy: false, generation, error: null }); + if (address) await tauri.remoteRepoDisconnect(address); + }, + selectMode: async (mode, since) => { + ++generation; + set({ mode, since: since ?? get().since, result: null, generation }); + await get().refresh(); + }, + healthEvent: (event) => { + if (event.host !== remoteHost(get().address)) return; + const reconnect = event.state === 'disconnected' && get().health === 'connected' && !get().busy; + set({ health: event.state, error: event.error }); + if (reconnect) void get().refresh(); + }, +})); diff --git a/ui/src/styles/remoteRepos.css b/ui/src/styles/remoteRepos.css new file mode 100644 index 00000000..2eafa5df --- /dev/null +++ b/ui/src/styles/remoteRepos.css @@ -0,0 +1,12 @@ +.remote-repo-controls { display: flex; align-items: end; gap: 8px; flex-wrap: wrap; padding: 0 16px; } +.remote-repo-dialog > .clone-head + .remote-repo-controls { padding-top: 12px; } +.remote-repo-dialog > .settings-hint { margin: 8px 16px; } +.remote-repo-controls .settings-field { flex: 1; min-width: 260px; } +.remote-repo-context { padding: 10px 16px; font-family: var(--font-mono); overflow-wrap: anywhere; } +.remote-repo-error { margin: 8px 16px; color: var(--del); white-space: pre-wrap; max-height: 100px; overflow: auto; } +.remote-repo-body { height: min(58vh, 620px); min-height: 260px; margin-top: 12px; border-top: 1px solid var(--border); } +.remote-repo-files { display: flex; flex-direction: column; gap: 8px; height: 100%; padding: 8px; min-width: 0; } +.remote-repo-list { flex: 1; min-height: 0; width: 100%; background: var(--bg-base); color: var(--text); border: 1px solid var(--border); font-family: var(--font-mono); } +.remote-repo-list option { padding: 4px; } +.remote-repo-scroll { height: 100%; overflow: auto; padding: 8px; } +.remote-repo-scroll pre { font-family: var(--font-mono); white-space: pre; } diff --git a/ui/src/views/RemoteReposDialog.tsx b/ui/src/views/RemoteReposDialog.tsx new file mode 100644 index 00000000..06580ac6 --- /dev/null +++ b/ui/src/views/RemoteReposDialog.tsx @@ -0,0 +1,124 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { Dialog } from '../components/Dialog'; +import { Diff } from '../components/Diff'; +import { errMessage, tauri } from '../lib/tauri'; +import { remoteHost, type RemoteFileChunk, type RemoteHealth } from '../lib/remoteRepos'; +import { useRemoteRepos } from '../stores/remoteRepos'; +import '../styles/remoteRepos.css'; + +export function RemoteReposDialog({ onClose }: { onClose: () => void }) { + const state = useRemoteRepos(); + const [address, setAddress] = useState(state.address || 'ssh://devbox/home/me/repo'); + const [since, setSince] = useState(state.since); + const [filter, setFilter] = useState(''); + const [selected, setSelected] = useState(''); + const [chunk, setChunk] = useState(null); + const [fileBytes, setFileBytes] = useState([]); + const [fileError, setFileError] = useState(null); + const [fileBusy, setFileBusy] = useState(false); + const fileRequest = useRef(null); + const fileGeneration = useRef(0); + const addressRef = useRef(null); + + useEffect(() => { + const focus = requestAnimationFrame(() => addressRef.current?.focus()); + const health = listen('ssh://health', (event) => useRemoteRepos.getState().healthEvent(event.payload)); + const changes = listen<{ host: string; repository: string }>('ssh://changed', (event) => { + const current = useRemoteRepos.getState(); + if (event.payload.host === remoteHost(current.address)) void current.refresh(); + }); + return () => { + cancelAnimationFrame(focus); + void health.then((stop) => stop()); void changes.then((stop) => stop()); + ++fileGeneration.current; + if (fileRequest.current) void tauri.remoteRepoCancel(fileRequest.current); + void useRemoteRepos.getState().disconnect(); + }; + }, []); + useEffect(() => { + setSelected(''); setChunk(null); setFileBytes([]); setFileError(null); setFileBusy(false); ++fileGeneration.current; + }, [state.address, state.mode, state.generation]); + + const diffs = state.result?.kind === 'diff' ? state.result.data : state.result?.kind === 'review' ? state.result.data.diffs : []; + const paths = state.mode === 'files' ? state.snapshot?.work_tree.map((f) => f.path) ?? [] + : state.mode === 'status' ? [...new Set(state.snapshot?.status.map((f) => f.path) ?? [])] : diffs.map((f) => f.path); + const matches = useMemo(() => paths.filter((path) => path.toLowerCase().includes(filter.toLowerCase())), [paths, filter]); + const visible = matches.slice(0, 500); + const diff = diffs.find((diff) => diff.path === selected); + + async function loadFile(path: string, append = false) { + const token = ++fileGeneration.current; + const id = crypto.randomUUID(); + fileRequest.current = id; + setFileBusy(true); setFileError(null); + try { + const response = await tauri.remoteRepoRead(state.address, { kind: 'file_chunk', path, offset: append ? chunk?.next_offset ?? 0 : 0, length: 65536, version: append ? chunk?.version ?? null : null }, id); + if (token !== fileGeneration.current) return; + if (response.result.kind !== 'file_chunk') throw new Error('Invalid file response.'); + setChunk(response.result.data); + const bytes = response.result.data.bytes; + setFileBytes((old) => append ? [...old, ...bytes] : bytes); + } catch (error) { if (token === fileGeneration.current) setFileError(errMessage(error)); } + finally { if (fileRequest.current === id) fileRequest.current = null; if (token === fileGeneration.current) setFileBusy(false); } + } + function select(path: string) { + setSelected(path); setChunk(null); setFileBytes([]); setFileError(null); + if (state.mode === 'files') void loadFile(path); + } + const canRead = !!state.snapshot && !state.busy && state.health === 'connected'; + return +
+ + + + +
+

System OpenSSH uses your host alias, known_hosts and SSH agent. Authenticate in a terminal first; install the compatible companion as ~/.strand/bin/strand on the host.

+
{state.address || 'No remote repository'} · {state.health} · Read only · Git and file reads execute on the SSH host
+ {state.error &&

{state.error}

} +
+ + {state.mode === 'review' && <> setSince(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void state.selectMode('review', since); } }} />} + + {state.snapshot && {state.snapshot.meta.branch} · {state.snapshot.status.length} status entries} +
+
+ {state.mode === 'log' ?
{state.result?.kind === 'log' && state.result.data.map((commit) =>

{commit.short_hash} {commit.subject} — {commit.author_name}

)}
: + + +
+ setFilter(event.target.value)} /> + + {visible.length} of {matches.length} files{matches.length > 500 ? ' · narrow the filter' : ''} +
+
+ + +
+ {state.health !== 'connected' && state.snapshot &&

Disconnected · displaying the last snapshot. Reconnect to refresh.

} + {state.mode === 'status' && (selected ? state.snapshot?.status.filter((file) => file.path === selected).map((file) =>

{file.staged ? 'Index' : 'Working tree'} · {file.kind} · {file.path}

) :

Select a file to inspect its status.

)} + {(state.mode === 'diff' || state.mode === 'review') && (diff ? diff.binary ?

Binary change: {diff.path}

: :

Select a changed file to inspect its diff.

)} + {state.result?.kind === 'review' &&

Pinned base: {state.result.data.base}{state.result.data.head_before !== state.result.data.head_after ? ' · HEAD changed during the read; refresh before reviewing.' : ''}

} + {state.mode === 'files' && <> + {fileError &&

{fileError}

} + {chunk &&

{fileBytes.length} / {chunk.total} bytes · read-only snapshot

} + {fileBytes.includes(0) ?

Binary file.

:
{new TextDecoder().decode(new Uint8Array(fileBytes))}
} + {chunk && chunk.next_offset < chunk.total && fileBytes.length < 1_048_576 && } + {fileBytes.length >= 1_048_576 && chunk && chunk.next_offset < chunk.total &&

Preview stopped at 1 MiB.

} + {fileBusy &&

Reading file…

} + } +
+
+
} +
+
; +} diff --git a/website/docs/manifest.json b/website/docs/manifest.json index 0a53c5d1..205a6cff 100644 --- a/website/docs/manifest.json +++ b/website/docs/manifest.json @@ -5,6 +5,7 @@ { "file": "work", "title": "Workbench: Files & Terminals", "description": "Use Strand as a Git client with an integrated file browser, lightweight editor, previews, and repository terminals." }, { "file": "custom-view", "title": "Customize the Workbench", "description": "Arrange live Strand surfaces per workspace, or keep the default full-size Work surface." }, { "file": "repositories-and-workspaces", "title": "Repositories & Workspaces", "description": "Manage multiple Git repositories, tabs, recent projects, and cross-repository workspaces in Strand." }, + { "file": "remote-repositories", "title": "Repositories over SSH", "description": "Connect through system OpenSSH to inspect remote repository status, history, diffs and file snapshots." }, { "file": "reviewing-agent-changes", "title": "Reviewing Agent Changes", "description": "Review AI coding-agent changes with whole-file diffs, baselines, notes, and reusable feedback in Strand." }, { "file": "pull-requests", "title": "Pull Requests", "description": "Browse and review GitHub and Azure DevOps pull requests from the Strand desktop Git client." }, { "file": "worktrees", "title": "Worktrees", "description": "Use Strand's Git worktree GUI to isolate agent tasks, compare attempts, review changes, and merge the winner." }, diff --git a/website/docs/remote-repositories.md b/website/docs/remote-repositories.md new file mode 100644 index 00000000..c280a686 --- /dev/null +++ b/website/docs/remote-repositories.md @@ -0,0 +1,63 @@ +# Repositories over SSH + +Use **Open repository on SSH host…** in the command palette or the **SSH** topbar button +to inspect a repository on another machine. This first version is read only: +Git and file reads run on that host, and the inspector shows its address and +connection state throughout. Your local repository stays open behind it. + +## Prepare the host + +Configure a host alias in your normal OpenSSH config. Use your terminal to +verify its host key and authenticate first. Strand uses system OpenSSH with +strict host-key checking and noninteractive authentication, including your SSH +agent and configured jump hosts. It does not ask for or save private keys or +passwords. Unknown or changed host keys and interactive login requirements +appear as connection errors; resolve them in your terminal. + +Install a compatible Strand companion on the host as `~/.strand/bin/strand`. +Automatic installation and standalone host downloads are still planned. For +now, build the companion from the same Strand source on a supported POSIX host: + +```sh +cargo build --release -p strand-headless +mkdir -p ~/.strand/bin +install -m 755 target/release/strand-cli ~/.strand/bin/strand +``` + +The host also needs Git on its PATH. The companion uses `--stdio` for its +connection to Strand; protocol compatibility is checked before any repository +read. Windows SSH hosts are not supported by this first connection flow. + +## Connect and inspect + +Enter an address such as `ssh://devbox/home/me/project` and select **Connect**. +Put the user, port and jump-host configuration in the host alias, rather than +in the address. Successful addresses appear as suggestions on later visits. + +Choose a view: + +- **Status** shows working-tree and index changes separately for the selected file. +- **Changes since HEAD** displays a full-context diff against the checked-out commit. +- **Recent history** shows the latest 50 commits reachable from HEAD. +- **Review since…** compares against your chosen revision, resolved to a fixed + commit for that read. Refresh if HEAD changes during the review. +- **Files** previews working-tree files in 64 KiB chunks, up to 1 MiB. Use + **Reload file** for a fresh snapshot; appending fails if the file changed. + +Filter the file list to narrow large repositories. Tab moves between controls, +arrow keys select files and views, and Escape closes the inspector. The divider +resizes the list and inspection pane. Remote inspection has no stage, commit, +push, editor or terminal actions. Local repository shortcuts are suspended +while the inspector is open. + +## Connection changes + +File watching refreshes repository status and the active diff/history view. +The **Refresh** button requests a new read. An interrupted connection retries +reads twice with a short delay; a final failure remains visible. Use +**Reconnect now** after fixing authentication, a missing/incompatible companion, +or an unavailable host. No failed operation is saved for later execution. + +**Disconnect**, **Cancel connection**, or closing the inspector stops the SSH +connection and all of its reads. If a snapshot remains visible, it is marked +disconnected. Local repositories continue to work during an SSH failure.