diff --git a/Cargo.lock b/Cargo.lock index 855dda1..a299b14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1257,7 +1257,7 @@ dependencies = [ "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "regex", @@ -1753,7 +1753,7 @@ version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4811,6 +4811,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -5096,7 +5102,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -7166,7 +7172,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ed4d5c6ae95e08ac768883c8401cf0e8deb4e6e1d6a4e1fd3d2ec4f0ec63200" dependencies = [ - "heck", + "heck 0.5.0", "itertools 0.14.0", "prost", "prost-types", @@ -7866,7 +7872,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ - "heck", + "heck 0.4.1", "itertools 0.14.0", "log", "multimap", @@ -9937,7 +9943,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", @@ -9950,7 +9956,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -9962,7 +9968,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -11109,7 +11115,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7b026f540b148b81043c720889dbb942b08659aa8a43f624ac4f04dbfc1861" dependencies = [ - "heck", + "heck 0.5.0", "log", "proc-macro2", "quote", @@ -12564,7 +12570,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -12575,7 +12581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap", "prettyplease", "syn 2.0.117", @@ -13270,7 +13276,7 @@ dependencies = [ [[package]] name = "xai-grok-pager" -version = "0.2.101" +version = "0.2.102" dependencies = [ "agent-client-protocol", "ansi-to-tui", @@ -13359,7 +13365,7 @@ dependencies = [ [[package]] name = "xai-grok-pager-bin" -version = "0.2.101" +version = "0.2.102" dependencies = [ "anyhow", "clap", @@ -13572,8 +13578,11 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2 0.10.9", + "thiserror 2.0.18", "toml", "tracing", + "url", "xai-grok-config", ] @@ -13618,7 +13627,7 @@ dependencies = [ [[package]] name = "xai-grok-shell" -version = "0.2.101" +version = "0.2.102" dependencies = [ "agent-client-protocol", "anyhow", @@ -14009,7 +14018,7 @@ dependencies = [ [[package]] name = "xai-grok-version" -version = "0.2.101" +version = "0.2.102" dependencies = [ "semver", ] diff --git a/SOURCE_REV b/SOURCE_REV index b7282d2..8dc4cfe 100644 --- a/SOURCE_REV +++ b/SOURCE_REV @@ -1 +1 @@ -2ec0f0c8488842da03a71eeee3c61154957ca919 +124d85bc5dc6e7805560215fcc6d5413944920e1 diff --git a/crates/codegen/xai-crash-handler/src/handler.rs b/crates/codegen/xai-crash-handler/src/handler.rs index dc11c43..327be17 100644 --- a/crates/codegen/xai-crash-handler/src/handler.rs +++ b/crates/codegen/xai-crash-handler/src/handler.rs @@ -451,16 +451,24 @@ mod imp { Ok(p) => p, Err(_) => return false, }; + // Owner-only: crash blobs hold stack IPs / fault addresses. let fd = unsafe { libc::open( c_path.as_ptr(), libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC, - 0o644, + 0o600, ) }; if fd < 0 { return false; } + // open's mode is create-only; tighten upgrades of older 0644 blobs. + if unsafe { libc::fchmod(fd, 0o600) } != 0 { + unsafe { + libc::close(fd); + } + return false; + } CRASH_FD.store(fd, Ordering::Relaxed); // Store version string. @@ -920,4 +928,55 @@ mod tests { "full install should replace the minimal handler" ); } + + #[test] + fn install_creates_owner_only_crash_blob() { + use std::os::unix::fs::PermissionsExt; + + let _guard = SIGNAL_STATE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!( + "xai-crash-handler-test-0600-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create crash dir"); + + assert!(super::install(&dir, "test-version")); + let path = dir.join("last-crash.bin"); + let mode = std::fs::metadata(&path).expect("meta").permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "new last-crash.bin must be owner-only"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn install_tightens_preexisting_0644_crash_blob() { + use std::os::unix::fs::PermissionsExt; + + let _guard = SIGNAL_STATE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!( + "xai-crash-handler-test-tighten-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create crash dir"); + + let path = dir.join("last-crash.bin"); + std::fs::write(&path, b"old").expect("seed"); + let mut perms = std::fs::metadata(&path).expect("meta").permissions(); + perms.set_mode(0o644); + std::fs::set_permissions(&path, perms).expect("set 0644"); + assert_eq!( + std::fs::metadata(&path).expect("meta").permissions().mode() & 0o777, + 0o644 + ); + + assert!(super::install(&dir, "test-version")); + let mode = std::fs::metadata(&path).expect("meta").permissions().mode(); + assert_eq!( + mode & 0o777, + 0o600, + "install must fchmod preexisting 0644 blobs to owner-only" + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/codegen/xai-crash-handler/src/lib.rs b/crates/codegen/xai-crash-handler/src/lib.rs index 2f0fd34..1e0b603 100644 --- a/crates/codegen/xai-crash-handler/src/lib.rs +++ b/crates/codegen/xai-crash-handler/src/lib.rs @@ -121,9 +121,9 @@ pub fn check_previous_crash(crash_dir: &Path) -> Option { let frames = symbolicate::resolve_frames(&blob); let report_text = symbolicate::format_report(&blob, &frames); - // Write the human-readable report. + // Write the human-readable report (owner-only when the OS supports it). let report_path = crash_dir.join("last-crash-report.txt"); - let _ = std::fs::write(&report_path, &report_text); + let _ = write_owner_only(&report_path, report_text.as_bytes()); // Archive to history/ (keep last MAX_HISTORY). archive_report(crash_dir, &report_text, blob.timestamp); @@ -142,12 +142,42 @@ pub fn check_previous_crash(crash_dir: &Path) -> Option { }) } +/// Write `contents` with owner-only permissions when the platform allows it. +/// +/// Crash reports may include source paths and backtraces; when they land under +/// `$GROK_HOME` they must not be world-readable. +fn write_owner_only(path: &Path, contents: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + // mode() only applies on create — force owner-only before writing so a + // preexisting 0644 file never holds sensitive content while world-readable. + let mut perms = file.metadata()?.permissions(); + perms.set_mode(0o600); + file.set_permissions(perms)?; + file.write_all(contents)?; + file.flush()?; + Ok(()) + } + #[cfg(not(unix))] + { + std::fs::write(path, contents) + } +} + fn archive_report(crash_dir: &Path, report_text: &str, timestamp: u64) { let history_dir = crash_dir.join("history"); let _ = std::fs::create_dir_all(&history_dir); let filename = format!("crash-{}.txt", timestamp); - let _ = std::fs::write(history_dir.join(&filename), report_text); + let _ = write_owner_only(&history_dir.join(&filename), report_text.as_bytes()); // Prune old reports beyond MAX_HISTORY. if let Ok(mut entries) = std::fs::read_dir(&history_dir) { @@ -175,4 +205,55 @@ mod tests { let dir = PathBuf::from("/tmp/xai-crash-handler-test-nonexistent"); assert!(check_previous_crash(&dir).is_none()); } + + #[cfg(unix)] + fn unique_tmp_dir(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "xai-crash-handler-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("create tmp dir"); + dir + } + + #[cfg(unix)] + #[test] + fn write_owner_only_creates_0600() { + use std::os::unix::fs::PermissionsExt; + + let dir = unique_tmp_dir("create-0600"); + let path = dir.join("report.txt"); + write_owner_only(&path, b"secret").expect("write"); + let mode = std::fs::metadata(&path).expect("meta").permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "new file must be owner-only"); + assert_eq!(std::fs::read(&path).expect("read"), b"secret"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn write_owner_only_tightens_preexisting_0644() { + use std::os::unix::fs::PermissionsExt; + + let dir = unique_tmp_dir("tighten-0644"); + let path = dir.join("report.txt"); + std::fs::write(&path, b"old").expect("seed"); + let mut perms = std::fs::metadata(&path).expect("meta").permissions(); + perms.set_mode(0o644); + std::fs::set_permissions(&path, perms).expect("set 0644"); + assert_eq!( + std::fs::metadata(&path).expect("meta").permissions().mode() & 0o777, + 0o644 + ); + + write_owner_only(&path, b"new-secret").expect("overwrite"); + let mode = std::fs::metadata(&path).expect("meta").permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "overwrite must tighten to owner-only"); + assert_eq!(std::fs::read(&path).expect("read"), b"new-secret"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/codegen/xai-grok-agent/src/plugins/git_install.rs b/crates/codegen/xai-grok-agent/src/plugins/git_install.rs index d6e2eca..9ad48ad 100644 --- a/crates/codegen/xai-grok-agent/src/plugins/git_install.rs +++ b/crates/codegen/xai-grok-agent/src/plugins/git_install.rs @@ -123,6 +123,51 @@ pub fn parse_install_source(input: &str, cwd: &Path) -> InstallSource { } } +/// A full commit sha (40-hex SHA-1 or 64-hex SHA-256) — the only thing the +/// pin policy accepts; branches, tags, and short prefixes are mutable or forgeable. +pub fn is_full_commit_sha(s: &str) -> bool { + (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// The require-sha gate every remote plugin fetch goes through: policy on + no +/// full-hex pin → typed refusal. Local-directory installs are exempt (the +/// operator controls that disk; nothing is fetched). +pub fn ensure_pinned( + require_sha: bool, + sha: Option<&str>, + plugin: &str, + url: &str, +) -> Result<(), InstallError> { + if !require_sha || sha.map(str::trim).is_some_and(is_full_commit_sha) { + return Ok(()); + } + tracing::warn!( + plugin, + url, + "refusing unpinned remote plugin code (require_sha)" + ); + Err(InstallError::UnpinnedRemoteRefused { + plugin: plugin.to_owned(), + url: url.to_owned(), + }) +} + +/// Prefer an explicit full-sha pin; if only `git_ref` is a full commit sha, +/// hoist it into the sha slot so the verified clone path is used. Catalog pins +/// published as `ref` still need this. +pub fn hoist_pin_slots<'a>( + git_ref: Option<&'a str>, + git_sha: Option<&'a str>, +) -> (Option<&'a str>, Option<&'a str>) { + match git_sha.map(str::trim).filter(|s| !s.is_empty()) { + Some(s) => (git_ref, Some(s)), + None => match git_ref.map(str::trim).filter(|s| is_full_commit_sha(s)) { + Some(s) => (None, Some(s)), + None => (git_ref, None), + }, + } +} + /// Check if a string looks like a GitHub `owner/repo` shorthand. /// /// Returns `true` for strings like `user/repo` or `user/repo@v1.0` @@ -163,7 +208,40 @@ fn repo_source_id(source: &InstallSource) -> String { pub fn install_from_source( source: &InstallSource, registry: &InstallRegistry, + require_sha: bool, ) -> Result { + install_from_source_with_label(source, registry, require_sha, None) +} + +/// Like [`install_from_source`]; when `plugin_label` is set it appears in +/// pin-refusal errors instead of the git URL (marketplace catalog names). +pub fn install_from_source_with_label( + source: &InstallSource, + registry: &InstallRegistry, + require_sha: bool, + plugin_label: Option<&str>, +) -> Result { + let source = &match source { + InstallSource::Git { + url, + git_ref, + git_sha, + subdir, + } => { + let (r, s) = hoist_pin_slots(git_ref.as_deref(), git_sha.as_deref()); + InstallSource::Git { + url: url.clone(), + git_ref: r.map(str::to_owned), + git_sha: s.map(str::to_owned), + subdir: subdir.clone(), + } + } + other => other.clone(), + }; + if let InstallSource::Git { url, git_sha, .. } = source { + let label = plugin_label.unwrap_or(url.as_str()); + ensure_pinned(require_sha, git_sha.as_deref(), label, url)?; + } let source_id = repo_source_id(source); let repo_key = InstallRegistry::repo_key(&source_id); @@ -579,27 +657,34 @@ pub enum UpdateStatus { /// - Tag installs: pinned — no-op /// - Commit installs: pinned — no-op /// - Local installs: no-op (explicit update); [`super::local_refresh`] re-copies on session spawn / reload -pub fn update_repo(repo_key: &str, repo: &InstalledRepo) -> Result { +pub fn update_repo( + repo_key: &str, + repo: &InstalledRepo, + require_sha: bool, +) -> Result { match &repo.kind { InstallKind::Local { .. } => Ok(UpdateStatus::LiveLocal), InstallKind::Git { + url, git_ref, commit, subdir, - .. } => { // Check if pinned if let Some(r) = git_ref { - // Heuristic: if the ref looks like a commit hash (40 hex chars) + // Heuristic: if the ref looks like a commit hash // or a version tag (starts with v and contains dots), it's pinned. - let is_tag_or_commit = r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit()) - || (r.starts_with('v') && r.contains('.')); + let is_tag_or_commit = + is_full_commit_sha(r) || (r.starts_with('v') && r.contains('.')); if is_tag_or_commit { return Ok(UpdateStatus::Pinned { ref_name: r.clone(), }); } } + // An update pulls whatever the mutable ref now points at — the same + // unpinned fetch the install gate refuses. + ensure_pinned(require_sha, None, repo_key, url)?; let old_commit = Some(commit.clone()); @@ -1156,7 +1241,7 @@ mod tests { marketplace: None, }; - match update_repo("acme-deadbeef", &repo).expect("update should succeed") { + match update_repo("acme-deadbeef", &repo, false).expect("update should succeed") { UpdateStatus::Updated(result) => { assert_eq!(result.plugins.len(), 1); assert_eq!(result.plugins[0].name, "acme"); @@ -1220,10 +1305,120 @@ mod tests { marketplace: None, }; - match update_repo("acme-deadbeef", &repo) { + match update_repo("acme-deadbeef", &repo, false) { Err(InstallError::InstallFailed { .. }) => {} Err(e) => panic!("expected InstallFailed, got {e:?}"), Ok(_) => panic!("expected InstallFailed when stored subdir is missing, got Ok"), } } + + #[test] + fn ensure_pinned_accepts_only_full_hex_shas() { + let sha1 = "a".repeat(40); + let sha256 = "b".repeat(64); + assert!(ensure_pinned(false, None, "p", "u").is_ok()); + assert!(ensure_pinned(true, Some(&sha1), "p", "u").is_ok()); + assert!(ensure_pinned(true, Some(&sha256), "p", "u").is_ok()); + for bad in [ + None, + Some("main"), + Some("deadbeef"), + Some(""), + Some("v1.2.3"), + ] { + assert!( + matches!( + ensure_pinned(true, bad, "p", "u"), + Err(InstallError::UnpinnedRemoteRefused { .. }) + ), + "{bad:?} must be refused" + ); + } + } + + #[test] + fn hoist_pin_slots_moves_full_sha_ref_into_sha_slot() { + let sha = "a".repeat(40); + assert_eq!( + hoist_pin_slots(Some(sha.as_str()), None), + (None, Some(sha.as_str())) + ); + assert_eq!( + hoist_pin_slots(Some("main"), Some(sha.as_str())), + (Some("main"), Some(sha.as_str())) + ); + assert_eq!(hoist_pin_slots(Some("main"), None), (Some("main"), None)); + assert_eq!( + hoist_pin_slots(Some(sha.as_str()), Some(" ")), + (None, Some(sha.as_str())), + "blank sha is treated as absent so a full-sha ref can still hoist" + ); + } + + #[test] + fn install_from_source_gates_and_hoists_sha_pins() { + let install = tempfile::tempdir().unwrap(); + let registry = InstallRegistry::empty(install.path().join("installed-plugins")); + + let unpinned = InstallSource::Git { + url: "https://example.com/repo.git".into(), + git_ref: Some("main".into()), + git_sha: None, + subdir: None, + }; + assert!( + matches!( + install_from_source(&unpinned, ®istry, true), + Err(InstallError::UnpinnedRemoteRefused { .. }) + ), + "unpinned git source must be refused before any fetch" + ); + + // Pinned path needs a real git binary (remote CI sandboxes often lack it). + if !git_available() { + eprintln!("skipping pin-hoist install: `git` binary not available in test sandbox"); + return; + } + + // Real pinned install from a local origin. allowAnySHA1InWant matches + // make_local_repo so fetch-by-sha against file:// succeeds. + let (origin, sha) = make_local_repo(); + let pinned_via_ref = InstallSource::Git { + url: format!("file://{}", origin.path().display()), + git_ref: Some(sha.clone()), // full sha in the REF slot (url@sha syntax) + git_sha: None, + subdir: None, + }; + let result = install_from_source(&pinned_via_ref, ®istry, true) + .expect("a full-sha ref satisfies the pin policy via the hoist"); + assert_eq!( + result.commit.as_deref(), + Some(sha.as_str()), + "the installed checkout is the pinned commit" + ); + } + + #[test] + fn update_repo_gates_unpinned_branch_updates() { + let repo = InstalledRepo { + kind: InstallKind::Git { + url: "https://example.com/repo.git".into(), + git_ref: Some("main".into()), + commit: "c0ffee".into(), + subdir: None, + }, + installed_at: String::new(), + updated_at: String::new(), + path: PathBuf::from("/nonexistent"), + plugins: std::collections::HashMap::new(), + marketplace: None, + }; + assert!( + matches!( + update_repo("acme-deadbeef", &repo, true), + Err(InstallError::UnpinnedRemoteRefused { .. }) + ), + "a mutable-ref update must be refused under the pin policy" + ); + } } diff --git a/crates/codegen/xai-grok-agent/src/plugins/install_registry.rs b/crates/codegen/xai-grok-agent/src/plugins/install_registry.rs index a19b7ac..84d61c2 100644 --- a/crates/codegen/xai-grok-agent/src/plugins/install_registry.rs +++ b/crates/codegen/xai-grok-agent/src/plugins/install_registry.rs @@ -334,6 +334,13 @@ pub enum InstallError { #[error("SHA verification failed: expected {expected}, got {actual}")] ShaMismatch { expected: String, actual: String }, + #[error( + "refusing unpinned remote plugin code for '{plugin}' from {url}: \ + marketplace.require_sha / GROK_MARKETPLACE_REQUIRE_SHA is enabled and \ + no full commit sha (40/64 hex) is pinned" + )] + UnpinnedRemoteRefused { plugin: String, url: String }, + #[error("install failed: {detail}")] InstallFailed { detail: String }, } diff --git a/crates/codegen/xai-grok-agent/src/plugins/local_refresh.rs b/crates/codegen/xai-grok-agent/src/plugins/local_refresh.rs index 8352eb3..586c379 100644 --- a/crates/codegen/xai-grok-agent/src/plugins/local_refresh.rs +++ b/crates/codegen/xai-grok-agent/src/plugins/local_refresh.rs @@ -380,6 +380,7 @@ mod tests { subdir: subdir.map(str::to_string), }, registry, + false, ) .unwrap(); let now = chrono::Utc::now().to_rfc3339(); diff --git a/crates/codegen/xai-grok-config-types/src/lib.rs b/crates/codegen/xai-grok-config-types/src/lib.rs index 8231ea6..c230cf0 100644 --- a/crates/codegen/xai-grok-config-types/src/lib.rs +++ b/crates/codegen/xai-grok-config-types/src/lib.rs @@ -227,8 +227,6 @@ pub struct RemoteSettings { /// When `Some(true)`, capture workspace files for non-git project dirs (client default: off). #[serde(default)] pub non_git_workspace_capture: Option, - #[serde(default)] - pub persistent_local_shell: Option, /// Release channel: `"stable"` or `"alpha"`. /// Fallback when no local `[cli] channel` or `--alpha`/`--stable` flag is set. #[serde(default)] @@ -819,6 +817,9 @@ pub struct ContextualHintsRemote { /// Word-select tip after double-click fold/nav (settings discoverability). #[serde(default)] pub word_select: Option, + /// SSH wrap session-load tip (recommend `grok wrap ssh` for remote sessions). + #[serde(default)] + pub ssh_wrap: Option, } /// Tolerant deserializer for `Option>`. /// Parses as Vec, tries each as RemoteAnnouncement, drops failures. diff --git a/crates/codegen/xai-grok-config-types/src/mcp.rs b/crates/codegen/xai-grok-config-types/src/mcp.rs index a33ee59..2a34e86 100644 --- a/crates/codegen/xai-grok-config-types/src/mcp.rs +++ b/crates/codegen/xai-grok-config-types/src/mcp.rs @@ -45,6 +45,7 @@ pub enum McpServerTransportConfig { cwd: Option, }, StreamableHttp { + #[serde(default, alias = "urlTemplate", alias = "url_template")] url: String, #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] transport_type: Option, @@ -78,6 +79,90 @@ pub struct McpJsonOAuthBlock { pub callback_port: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct McpSetupConfig { + #[serde(default)] + pub fields: Vec, + #[serde(default, alias = "values")] + pub variables: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct McpSetupField { + pub id: String, + pub label: String, + #[serde(rename = "type")] + pub field_type: McpSetupFieldType, + #[serde(default)] + pub required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(default)] + pub options: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum McpSetupFieldType { + Select, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct McpSetupOption { + pub label: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct McpSetupDerivedValue { + pub from: String, + pub map: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct McpPreferenceSource { + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct McpServerPreferences { + #[serde(default)] + pub values: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct McpPreferencesFile { + pub version: u32, + #[serde(default)] + pub servers: HashMap, +} + +impl Default for McpPreferencesFile { + fn default() -> Self { + Self { + version: 1, + servers: HashMap::new(), + } + } +} + +#[derive(Debug, Clone)] +pub enum McpSetupResolution { + Resolved(Box), + Required(McpSetupConfig), + Invalid(String), +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpServerConfig { #[serde(flatten)] @@ -87,6 +172,8 @@ pub struct McpServerConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub oauth: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub setup: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub startup_timeout_sec: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_timeout_sec: Option, @@ -100,7 +187,123 @@ pub struct McpServerConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub expose_image_base64: Option, } + +fn render_setup_template( + input: &str, + variables: &HashMap, +) -> Result { + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("{{") { + let (prefix, after_start) = rest.split_at(start); + out.push_str(prefix); + let after_start = &after_start[2..]; + let Some(end) = after_start.find("}}") else { + return Err("unterminated setup variable template".to_string()); + }; + let key = after_start[..end].trim(); + let Some(value) = variables.get(key) else { + return Err(format!("unresolved setup variable '{key}'")); + }; + out.push_str(value); + rest = &after_start[end + 2..]; + } + out.push_str(rest); + Ok(out) +} + +fn render_setup_templates( + config: &mut McpServerConfig, + variables: &HashMap, +) -> Result<(), String> { + let sub = |s: &str| render_setup_template(s, variables); + match &mut config.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + cwd, + } => { + *command = sub(command)?; + for arg in args.iter_mut() { + *arg = sub(arg)?; + } + if let Some(env) = env.as_mut() { + for value in env.values_mut() { + *value = sub(value)?; + } + } + if let Some(cwd) = cwd.as_mut() { + *cwd = sub(cwd)?; + } + } + McpServerTransportConfig::StreamableHttp { url, headers, .. } => { + *url = sub(url)?; + if let Some(headers) = headers.as_mut() { + for value in headers.values_mut() { + *value = sub(value)?; + } + } + } + } + Ok(()) +} + impl McpServerConfig { + /// Resolve `setup` templates using stored preferences. + /// + /// v0 supports exactly one select field with options. Multi-field schemas + /// are Invalid until the TUI can collect them. + pub fn resolve_setup(&self, preferences: Option<&McpServerPreferences>) -> McpSetupResolution { + let Some(setup) = self.setup.as_ref() else { + return McpSetupResolution::Resolved(Box::new(self.clone())); + }; + + if setup.fields.len() != 1 { + return McpSetupResolution::Invalid( + "setup schema must declare exactly one select field (v0)".to_string(), + ); + } + let field = &setup.fields[0]; + if !matches!(field.field_type, McpSetupFieldType::Select) || field.options.is_empty() { + return McpSetupResolution::Invalid( + "setup field must be a non-empty select (v0)".to_string(), + ); + } + + let Some(preferences) = preferences else { + return McpSetupResolution::Required(setup.clone()); + }; + + let Some(value) = preferences.values.get(&field.id) else { + return McpSetupResolution::Required(setup.clone()); + }; + if !field.options.iter().any(|option| option.value == *value) { + return McpSetupResolution::Required(setup.clone()); + } + + let mut variables = HashMap::new(); + for (name, derived) in &setup.variables { + if derived.from != field.id { + return McpSetupResolution::Invalid(format!( + "setup variable '{name}' references unknown field '{}'", + derived.from + )); + } + let Some(mapped) = derived.map.get(value) else { + return McpSetupResolution::Required(setup.clone()); + }; + variables.insert(name.clone(), mapped.clone()); + } + + let mut resolved = self.clone(); + resolved.setup = None; + match render_setup_templates(&mut resolved, &variables) { + Ok(()) => McpSetupResolution::Resolved(Box::new(resolved)), + Err(e) => McpSetupResolution::Invalid(e), + } + } + pub fn expand_strings(&mut self, sub: &dyn Fn(&str) -> String) { match &mut self.transport { McpServerTransportConfig::Stdio { @@ -134,7 +337,7 @@ impl McpServerConfig { } pub fn to_acp_mcp_server(&self, name: impl Into) -> Option { - if !self.enabled { + if !self.enabled || self.setup.is_some() { return None; } let name = name.into(); @@ -167,6 +370,9 @@ impl McpServerConfig { headers, .. } => { + if url.is_empty() { + return None; + } let mut http_headers: Vec = headers .as_ref() .map(|h| { @@ -274,3 +480,178 @@ pub struct McpConfig { #[serde(default, rename = "mcpServers")] pub mcp_servers: IndexMap, } + +#[cfg(test)] +mod tests { + use super::*; + + fn site_select_setup_json() -> &'static str { + r#"{ + "mcpServers": { + "acme": { + "type": "http", + "urlTemplate": "{{url}}", + "setup": { + "fields": [{ + "id": "site", + "label": "Site", + "type": "select", + "required": true, + "default": "us1", + "options": [ + {"label": "US1", "value": "us1"}, + {"label": "US5", "value": "us5"} + ] + }], + "values": { + "url": { + "from": "site", + "map": { + "us1": "https://mcp.example.com/v1/mcp", + "us5": "https://mcp.us5.example.com/v1/mcp" + } + } + } + } + } + } + }"# + } + + #[test] + fn mcp_setup_schema_parses_and_missing_preference_requires_setup() { + let config: McpConfig = serde_json::from_str(site_select_setup_json()).unwrap(); + let server = config.mcp_servers.get("acme").unwrap(); + let setup = server.setup.as_ref().unwrap(); + assert_eq!(setup.fields[0].id, "site"); + assert_eq!(setup.fields[0].default.as_deref(), Some("us1")); + assert!(setup.variables.contains_key("url")); + assert!(matches!( + server.resolve_setup(None), + McpSetupResolution::Required(_) + )); + assert!(server.to_acp_mcp_server("acme").is_none()); + } + + #[test] + fn mcp_setup_valid_preference_resolves_mapped_url() { + let config: McpConfig = serde_json::from_str(site_select_setup_json()).unwrap(); + let server = config.mcp_servers.get("acme").unwrap(); + let prefs = McpServerPreferences { + values: HashMap::from([("site".to_string(), "us5".to_string())]), + source: None, + updated_at: None, + }; + let resolved = match server.resolve_setup(Some(&prefs)) { + McpSetupResolution::Resolved(config) => config, + other => panic!("expected resolved config, got {other:?}"), + }; + assert!(resolved.setup.is_none()); + assert!(resolved.to_acp_mcp_server("acme").is_some()); + match &resolved.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + assert_eq!(url, "https://mcp.us5.example.com/v1/mcp"); + } + _ => panic!("expected http config"), + } + } + + #[test] + fn mcp_setup_invalid_preference_value_requires_setup() { + let setup = McpSetupConfig { + fields: vec![McpSetupField { + id: "site".into(), + label: "Site".into(), + field_type: McpSetupFieldType::Select, + required: true, + default: Some("us1".into()), + options: vec![McpSetupOption { + label: "US1".into(), + value: "us1".into(), + }], + }], + variables: HashMap::new(), + }; + let config = McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "{{url}}".into(), + transport_type: None, + bearer_token_env_var: None, + headers: None, + oauth_client_id: None, + oauth_client_secret_env_var: None, + oauth_scopes: None, + }, + enabled: true, + oauth: None, + setup: Some(setup), + startup_timeout_sec: None, + tool_timeout_sec: None, + tool_timeouts: None, + expose_image_base64: None, + }; + let prefs = McpServerPreferences { + values: HashMap::from([("site".to_string(), "us5".to_string())]), + source: None, + updated_at: None, + }; + assert!(matches!( + config.resolve_setup(Some(&prefs)), + McpSetupResolution::Required(_) + )); + } + + #[test] + fn mcp_setup_multi_field_schema_is_invalid() { + let setup = McpSetupConfig { + fields: vec![ + McpSetupField { + id: "a".into(), + label: "A".into(), + field_type: McpSetupFieldType::Select, + required: true, + default: None, + options: vec![McpSetupOption { + label: "1".into(), + value: "1".into(), + }], + }, + McpSetupField { + id: "b".into(), + label: "B".into(), + field_type: McpSetupFieldType::Select, + required: true, + default: None, + options: vec![McpSetupOption { + label: "2".into(), + value: "2".into(), + }], + }, + ], + variables: HashMap::new(), + }; + let config = McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com".into(), + transport_type: None, + bearer_token_env_var: None, + headers: None, + oauth_client_id: None, + oauth_client_secret_env_var: None, + oauth_scopes: None, + }, + enabled: true, + oauth: None, + setup: Some(setup), + startup_timeout_sec: None, + tool_timeout_sec: None, + tool_timeouts: None, + expose_image_base64: None, + }; + assert!(matches!( + config.resolve_setup(None), + McpSetupResolution::Invalid(_) + )); + assert!(config.to_acp_mcp_server("x").is_none()); + } +} diff --git a/crates/codegen/xai-grok-config/src/fs_atomic.rs b/crates/codegen/xai-grok-config/src/fs_atomic.rs index f879414..19fba6d 100644 --- a/crates/codegen/xai-grok-config/src/fs_atomic.rs +++ b/crates/codegen/xai-grok-config/src/fs_atomic.rs @@ -1,5 +1,5 @@ -//! Atomic file writes, shared by the managed-cache marker and the signature -//! sidecar writers. +//! Atomic file writes, shared by the managed-cache marker, the signature +//! sidecar, and downstream identifier caches (e.g. the telemetry agent id). use std::path::Path; @@ -7,7 +7,7 @@ use std::path::Path; /// name is unique per writer (pid + counter) and `create_new`, so concurrent /// writers don't collide. `mode` (unix only) is applied at temp-file creation, so /// the final file never exists with looser permissions. -pub(crate) fn write_atomically( +pub fn write_atomically( final_path: &Path, contents: &str, mode: Option, diff --git a/crates/codegen/xai-grok-config/src/lib.rs b/crates/codegen/xai-grok-config/src/lib.rs index 7dc682f..bc29328 100644 --- a/crates/codegen/xai-grok-config/src/lib.rs +++ b/crates/codegen/xai-grok-config/src/lib.rs @@ -15,7 +15,7 @@ pub mod campaigns; pub mod config_override; -mod fs_atomic; +pub mod fs_atomic; mod loader; mod macos_managed; mod managed_cache; @@ -40,8 +40,9 @@ pub use loader::{ }; pub use macos_managed::MDM_REQUIREMENTS_SOURCE; pub use managed_cache::{ - MANAGED_CONFIG_CACHE_FILE, ServingIdentity, SyncMarker, confirmed_team_switch, - confirmed_team_switch_at, is_managed_config_hard_stale_for, is_managed_config_stale_for, + MANAGED_CONFIG_CACHE_FILE, ServingIdentity, SyncMarker, bump_rollback_floor, + bump_rollback_floor_with_now, confirmed_team_switch, confirmed_team_switch_at, + is_managed_config_hard_stale_for, is_managed_config_stale_for, managed_config_identity_changed_at, managed_deployment_id, managed_policy_compromised_for, mark_managed_config_synced, mark_managed_config_synced_at, normalize_identity, }; @@ -51,8 +52,8 @@ pub use paths::{ grok_application_in, grok_home, sessions_cwd_dir, system_config_dir, user_grok_home, }; pub use validation::{ - RequirementsError, RequirementsLayer, RequirementsSource, fail_closed_flag_from_str, - load_merged_requirements, requirements_layers, validate_requirements, + RequirementsError, RequirementsLayer, RequirementsSource, load_merged_requirements, + requirements_layers, validate_requirements, }; pub use version_overrides::{VersionOverrideError, apply_version_overrides}; diff --git a/crates/codegen/xai-grok-config/src/managed_cache.rs b/crates/codegen/xai-grok-config/src/managed_cache.rs index 8ed59c4..c8b1528 100644 --- a/crates/codegen/xai-grok-config/src/managed_cache.rs +++ b/crates/codegen/xai-grok-config/src/managed_cache.rs @@ -34,6 +34,17 @@ struct ManagedConfigCache { /// Served opt-in (`fail_closed = true`); `default` false so a pre-upgrade or un-opted marker never fails closed. #[serde(default)] fail_closed: bool, + /// Local-clock high-water mark. At-rest signed checks use `max(now, floor)` so a + /// rolled-back clock cannot un-expire a policy. Session starts and the background + /// tick raise it; a successful fetch resets it to `now` (reconnect heals a + /// forward-clock-inflated floor). As forgeable as the rest of the marker — defeats + /// a passive clock change, not a file edit. + #[serde(default)] + rollback_floor: u64, + /// Fields written by newer binaries, preserved when this binary rewrites only the + /// floor. A full sync rewrites the marker from scratch. + #[serde(flatten)] + extra: serde_json::Map, } /// What the cache is bound to (one value, so a (team, key) combo can't form). The @@ -110,6 +121,13 @@ pub fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) { had_requirements, key_fingerprint: normalize_identity(key_fingerprint), fail_closed, + // Reset (not max): reconnect must clear an inflated floor. Residual: fetch + // verify is unclamped and managed_config_url is user-writable, so a rolled-back + // clock plus a still-valid replayed envelope can reinstate a superseded policy + // and reset the floor; that path does not self-heal online. A server-side + // policy-version counter is the eventual close. + rollback_floor: synced_at.unwrap_or(0), + extra: Default::default(), }; match serde_json::to_string(&cache) { Ok(json) => write_marker_atomically(home, &json), @@ -117,6 +135,37 @@ pub fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) { } } +/// Raise an existing marker's floor to the wall clock. Dark build → no-op. Caller holds +/// the managed-config lock so this serializes with the fetch-path floor reset. +pub fn bump_rollback_floor(home: &Path) { + bump_rollback_floor_with_now(home, crate::signed_policy::now_unix()); +} + +/// Test seam for [`bump_rollback_floor`] with an injected timestamp. +#[doc(hidden)] +pub fn bump_rollback_floor_with_now(home: &Path, now: u64) { + if !crate::signed_policy::verification_active() { + return; + } + raise_rollback_floor(home, now); +} + +/// `max(prior, now)` — never lowers, never creates a marker (purge must stay purged). +fn raise_rollback_floor(home: &Path, now: u64) { + let Some(mut cache) = read_managed_config_cache(home) else { + return; + }; + let raised = cache.rollback_floor.max(now); + if raised == cache.rollback_floor { + return; + } + cache.rollback_floor = raised; + match serde_json::to_string(&cache) { + Ok(json) => write_marker_atomically(home, &json), + Err(e) => tracing::warn!("failed to serialize managed config cache: {e}"), + } +} + /// Atomic write of the marker; best-effort (failure is logged, never surfaced). fn write_marker_atomically(home: &Path, json: &str) { if let Err(e) = @@ -300,19 +349,34 @@ fn expected_signed_principal<'a>( serving_team_id(identity).or_else(|| cache.and_then(|c| c.principal.as_deref())) } +/// At-rest signed checks: `max(wall clock, floor)`. Fetch-time verify stays unclamped +/// so a fresh envelope can reset an inflated floor (see shell `verify_signed_envelope`). +fn effective_now(cache: Option<&ManagedConfigCache>) -> u64 { + crate::signed_policy::now_unix().max(cache.map_or(0, |c| c.rollback_floor)) +} + /// A signing-enabled build over a legacy unsigned / edited / forged or foreign-bound -/// cache refetches a signed copy. Dark build or no policy on disk → false, so this is -/// inert until a key is provisioned. +/// cache refetches a signed copy; likewise when an imposing claim has no policy +/// sidecar satisfying it — the states the gate refuses on, so refusal always comes +/// with a pending self-heal. Dark build or no policy on disk → false. fn signed_cache_needs_refetch( home: &Path, cache: Option<&ManagedConfigCache>, identity: &ServingIdentity, ) -> bool { - crate::signed_policy::cloud_cache_signature_invalid( - home, - expected_signed_principal(cache, identity), - crate::signed_policy::now_unix(), - ) + let expected_principal = expected_signed_principal(cache, identity); + let now = effective_now(cache); + // Verdict match first: Trusted short-circuits the claim's read + verify. + crate::signed_policy::cloud_cache_signature_invalid(home, expected_principal, now) + || (matches!( + crate::signed_policy::signed_cache_compromised(home, expected_principal, now), + crate::signed_policy::SignedVerdict::NoAuthenticSidecar + | crate::signed_policy::SignedVerdict::SidecarUnreadable + ) && crate::signed_policy::managed_identity_claim_imposes( + home, + expected_principal, + now, + )) } fn is_managed_config_hard_stale_for_at(home: &Path, identity: &ServingIdentity) -> bool { @@ -368,11 +432,10 @@ fn managed_policy_compromised_once( identity: &ServingIdentity, ) -> (bool, crate::signed_policy::SignedVerdict) { let cache = read_managed_config_cache(home); - let signed_verdict = crate::signed_policy::signed_cache_compromised( - home, - expected_signed_principal(cache.as_ref(), identity), - crate::signed_policy::now_unix(), - ); + let expected_principal = expected_signed_principal(cache.as_ref(), identity); + let now = effective_now(cache.as_ref()); + let signed_verdict = + crate::signed_policy::signed_cache_compromised(home, expected_principal, now); // The signature binds a deployment_id, not the local deploy key, so a Trusted verdict // can't attest the configured key — pass the fingerprint mismatch through so it gates // on every path. @@ -381,6 +444,7 @@ fn managed_policy_compromised_once( .is_some_and(|c| cache_key_fingerprint_mismatch(c, identity)); let compromised = managed_policy_compromised_decision( signed_verdict, + || crate::signed_policy::managed_identity_claim_imposes(home, expected_principal, now), key_fingerprint_mismatch, cache.as_ref(), home, @@ -392,8 +456,13 @@ fn managed_policy_compromised_once( /// Combine the signed verdict with the best-effort marker fallback — one row per /// verdict; each row's reasoning lives on its [`SignedVerdict`] variant doc. Split /// out so the signed↔marker integration is unit-testable without a compiled-in key. +/// `claim_imposes` ([`crate::signed_policy::managed_identity_claim_imposes`]) is +/// consulted lazily, only on `NoAuthenticSidecar`, and outranks the forgeable-marker +/// fallbacks there — stripping the policy sidecar (even with a forged marker) cannot +/// downgrade a claimed fail-closed principal. A read blip stays lenient. fn managed_policy_compromised_decision( signed_verdict: crate::signed_policy::SignedVerdict, + claim_imposes: impl FnOnce() -> bool, key_fingerprint_mismatch: bool, cache: Option<&ManagedConfigCache>, home: &Path, @@ -440,12 +509,24 @@ fn managed_policy_compromised_decision( SignedVerdict::Compromised => true, // Trusted clears the gate — except the deploy-key fingerprint, which the signature can't attest. SignedVerdict::Trusted => key_fingerprint_mismatch && marker_compromised(), - SignedVerdict::NoAuthenticSidecar => sidecar_required_but_missing() || marker_compromised(), + SignedVerdict::NoAuthenticSidecar => { + let refused = claim_imposes(); + if refused { + tracing::warn!( + "managed policy fail-closed gate: refusing session — the signed is-managed \ + claim requires an authentic policy sidecar and none is present" + ); + } + refused || sidecar_required_but_missing() || marker_compromised() + } SignedVerdict::SidecarUnreadable => marker_compromised(), SignedVerdict::Inactive => marker_compromised(), } } +/// Same-machine marker: more than a few minutes of future skew is not genuine. +const MAX_FUTURE_SYNCED_AT_SKEW: std::time::Duration = std::time::Duration::from_secs(5 * 60); + /// Stale when never synced, past the threshold, identity differs, a served artifact is now missing, /// or (keyed builds) the signed cache no longer verifies. No home → nothing to refresh into → not /// stale. Reads the marker once. @@ -465,13 +546,15 @@ fn managed_config_stale_at(home: Option<&Path>, identity: &ServingIdentity) -> b return true; } match cache.synced_at { - // `duration_since` errs when `synced_at` is in the future (clock skew); - // treat that as freshly synced rather than stale. Some(secs) => { - let synced_at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs); - std::time::SystemTime::now() - .duration_since(synced_at) - .is_ok_and(|age| age > managed_config_stale_threshold()) + // Against `effective_now` (max of wall clock and floor) so repeated small + // rollbacks / a halted clock cannot keep age under the threshold forever. + // u64 seconds avoid SystemTime overflow panics for out-of-range timestamps. + let now = effective_now(Some(&cache)); + let age = now.saturating_sub(secs); + let skew = secs.saturating_sub(now); + age > managed_config_stale_threshold().as_secs() + || skew > MAX_FUTURE_SYNCED_AT_SKEW.as_secs() } None => true, } diff --git a/crates/codegen/xai-grok-config/src/managed_cache/claim_tests.rs b/crates/codegen/xai-grok-config/src/managed_cache/claim_tests.rs new file mode 100644 index 0000000..36d65ac --- /dev/null +++ b/crates/codegen/xai-grok-config/src/managed_cache/claim_tests.rs @@ -0,0 +1,100 @@ +//! The is-managed claim in the gate decision and the staleness refetch +//! (sidecar-removal downgrade closure). + +use super::super::*; +use super::team; + +/// Headline: a stripped policy sidecar + imposing claim refuses even over a fully +/// forged permissive marker; without the claim that state is the pre-fix downgrade. +#[test] +fn claim_refuses_stripped_sidecar_even_with_forged_marker() { + use crate::signed_policy::SignedVerdict; + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + // The forged-marker shape: permissive, nothing served, matching principal. + let forged = ManagedConfigCache { + principal: Some("team-007".into()), + fail_closed: false, + ..Default::default() + }; + assert!( + managed_policy_compromised_decision( + SignedVerdict::NoAuthenticSidecar, + || true, + false, + Some(&forged), + home, + &team("team-007") + ), + "an imposing claim outranks the forged marker when the policy sidecar is gone" + ); + assert!( + !managed_policy_compromised_decision( + SignedVerdict::NoAuthenticSidecar, + || false, + false, + Some(&forged), + home, + &team("team-007") + ), + "without the claim this exact state is the (documented) marker downgrade" + ); +} + +/// A policy-sidecar read blip stays lenient: the claim is not consulted on +/// `SidecarUnreadable` (rationale on the variant doc). +#[test] +fn claim_not_consulted_on_sidecar_read_blip() { + use crate::signed_policy::SignedVerdict; + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap(); + let served = ManagedConfigCache { + principal: Some("team-007".into()), + had_requirements: true, + fail_closed: true, + ..Default::default() + }; + assert!( + !managed_policy_compromised_decision( + SignedVerdict::SidecarUnreadable, + || true, + false, + Some(&served), + home, + &team("team-007") + ), + "a transient sidecar read blip must not refuse, claim or no claim" + ); +} + +/// Dark build: a claim file on disk changes neither the gate nor staleness. +#[test] +fn claim_paths_are_inert_in_dark_build() { + assert!(!crate::signed_policy::verification_active()); + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + mark_managed_config_synced_at( + home, + SyncMarker { + principal: Some("team-a"), + had_managed_config: false, + had_requirements: false, + key_fingerprint: None, + fail_closed: false, + }, + ); + std::fs::write( + home.join(crate::signed_policy::MANAGED_IDENTITY_SIDECAR_FILE), + "{\"signed_payload\":\"{}\",\"signature\":\"\",\"key_id\":\"\"}", + ) + .unwrap(); + assert!( + !managed_policy_compromised_for_at(home, &team("team-a")), + "dark build: a claim file must not make the gate fail closed" + ); + assert!( + !is_managed_config_hard_stale_for_at(home, &team("team-a")), + "dark build: a claim file must not force a refetch" + ); +} diff --git a/crates/codegen/xai-grok-config/src/managed_cache/tests.rs b/crates/codegen/xai-grok-config/src/managed_cache/tests.rs index 89564ca..262304b 100644 --- a/crates/codegen/xai-grok-config/src/managed_cache/tests.rs +++ b/crates/codegen/xai-grok-config/src/managed_cache/tests.rs @@ -26,6 +26,7 @@ fn signed_verdict_overrides_marker_both_ways() { // Signed says NOT compromised → proceed, overriding the marker's tamper signal. assert!(!managed_policy_compromised_decision( SignedVerdict::Trusted, + || false, false, Some(&cache), home, @@ -39,6 +40,7 @@ fn signed_verdict_overrides_marker_both_ways() { }; assert!(managed_policy_compromised_decision( SignedVerdict::Compromised, + || false, false, Some(&intact), home, @@ -65,6 +67,7 @@ fn signed_verdict_does_not_skip_deploy_key_fingerprint() { // opted-in cache. assert!(managed_policy_compromised_decision( SignedVerdict::Trusted, + || false, true, // deploy-key fingerprint mismatch Some(&opted_in), home, @@ -73,6 +76,7 @@ fn signed_verdict_does_not_skip_deploy_key_fingerprint() { // A matching fingerprint trusts the signed verdict as before. assert!(!managed_policy_compromised_decision( SignedVerdict::Trusted, + || false, false, Some(&opted_in), home, @@ -87,6 +91,7 @@ fn signed_verdict_does_not_skip_deploy_key_fingerprint() { }; assert!(!managed_policy_compromised_decision( SignedVerdict::Trusted, + || false, true, Some(&opted_out), home, @@ -96,6 +101,7 @@ fn signed_verdict_does_not_skip_deploy_key_fingerprint() { // this opted-OUT marker. assert!(managed_policy_compromised_decision( SignedVerdict::Compromised, + || false, true, Some(&opted_out), home, @@ -122,6 +128,7 @@ fn unreadable_sidecar_falls_back_to_marker() { assert!( !managed_policy_compromised_decision( SignedVerdict::SidecarUnreadable, + || false, false, Some(&served_fail_closed), home, @@ -133,6 +140,7 @@ fn unreadable_sidecar_falls_back_to_marker() { std::fs::remove_file(home.join("requirements.toml")).unwrap(); assert!(managed_policy_compromised_decision( SignedVerdict::SidecarUnreadable, + || false, false, Some(&served_fail_closed), home, @@ -159,6 +167,7 @@ fn missing_sidecar_under_fail_closed_marker_refuses() { assert!( managed_policy_compromised_decision( SignedVerdict::NoAuthenticSidecar, + || false, false, Some(&served_fail_closed), home, @@ -174,6 +183,7 @@ fn missing_sidecar_under_fail_closed_marker_refuses() { }; assert!(!managed_policy_compromised_decision( SignedVerdict::NoAuthenticSidecar, + || false, false, Some(&served_nothing), home, @@ -188,6 +198,7 @@ fn missing_sidecar_under_fail_closed_marker_refuses() { }; assert!(!managed_policy_compromised_decision( SignedVerdict::NoAuthenticSidecar, + || false, false, Some(&opted_out), home, @@ -196,6 +207,7 @@ fn missing_sidecar_under_fail_closed_marker_refuses() { // No marker at all → nothing to enforce. assert!(!managed_policy_compromised_decision( SignedVerdict::NoAuthenticSidecar, + || false, false, None, home, @@ -219,6 +231,7 @@ fn inactive_verdict_falls_through_to_marker() { }; assert!(managed_policy_compromised_decision( SignedVerdict::Inactive, + || false, false, Some(&missing), home, @@ -233,6 +246,7 @@ fn inactive_verdict_falls_through_to_marker() { }; assert!(!managed_policy_compromised_decision( SignedVerdict::Inactive, + || false, false, Some(&optout), home, @@ -241,6 +255,7 @@ fn inactive_verdict_falls_through_to_marker() { // No marker at all → nothing to enforce. assert!(!managed_policy_compromised_decision( SignedVerdict::Inactive, + || false, false, None, home, @@ -1147,3 +1162,161 @@ fn cache_identity_mismatch_ignores_whitespace_only_diffs() { let empty = ManagedConfigCache::default(); assert!(cache_identity_mismatch(&empty, &team("team-a"))); } + +/// Tick raises an existing floor, never lowers it, and never creates a marker. +#[test] +fn rollback_floor_ticks_up_never_down_and_never_creates_a_marker() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let floor = |home: &Path| read_managed_config_cache(home).map_or(0, |c| c.rollback_floor); + + raise_rollback_floor(home, 5_000); + assert!( + read_managed_config_cache(home).is_none(), + "the tick must not create a marker" + ); + + mark_managed_config_synced_at( + home, + SyncMarker { + principal: Some("team-a"), + had_managed_config: false, + had_requirements: false, + key_fingerprint: None, + fail_closed: false, + }, + ); + let base = floor(home); + assert!( + base >= 1_700_000_000, + "a fetch seeds the floor at the wall clock" + ); + + raise_rollback_floor(home, base + 1_000); + assert_eq!(floor(home), base + 1_000); + raise_rollback_floor(home, base); + assert_eq!(floor(home), base + 1_000, "the tick never lowers the floor"); +} + +/// The floor RMW preserves marker fields this binary doesn't know (mixed-version homes). +#[test] +fn floor_bump_preserves_unknown_marker_fields() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + std::fs::write( + home.join(MANAGED_CONFIG_CACHE_FILE), + r#"{"synced_at":1700000000,"rollback_floor":1700000000,"from_the_future":true}"#, + ) + .unwrap(); + raise_rollback_floor(home, 1_700_000_100); + let marker = std::fs::read_to_string(home.join(MANAGED_CONFIG_CACHE_FILE)).unwrap(); + let v: serde_json::Value = serde_json::from_str(&marker).unwrap(); + assert_eq!(v["rollback_floor"].as_u64(), Some(1_700_000_100)); + assert_eq!( + v["from_the_future"], + serde_json::Value::Bool(true), + "the RMW must not strip fields a newer binary wrote: {marker}" + ); +} + +/// Successful fetch resets (never maxes) an inflated floor to the wall clock. +#[test] +fn fetch_resets_an_inflated_rollback_floor() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + std::fs::write( + home.join(MANAGED_CONFIG_CACHE_FILE), + r#"{"rollback_floor":9999999999}"#, + ) + .unwrap(); + + mark_managed_config_synced_at( + home, + SyncMarker { + principal: Some("team-a"), + had_managed_config: false, + had_requirements: false, + key_fingerprint: None, + fail_closed: false, + }, + ); + let floor = read_managed_config_cache(home).map_or(0, |c| c.rollback_floor); + assert!( + (1_700_000_000..9_999_999_999).contains(&floor), + "the fetch must reset the inflated floor to the wall clock, got {floor}" + ); +} + +/// Dark build: public tick is a no-op over an existing marker. +#[test] +fn bump_rollback_floor_is_inert_when_dark() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + mark_managed_config_synced_at( + home, + SyncMarker { + principal: Some("team-a"), + had_managed_config: false, + had_requirements: false, + key_fingerprint: None, + fail_closed: false, + }, + ); + let floor = |home: &Path| read_managed_config_cache(home).map_or(0, |c| c.rollback_floor); + let base = floor(home); + assert!(!crate::signed_policy::verification_active()); + bump_rollback_floor_with_now(home, base + 10_000); + assert_eq!( + floor(home), + base, + "dark build: the tick must not move the floor" + ); +} + +/// Far-future `synced_at` is stale; modest forward skew stays fresh. +#[test] +fn managed_config_stale_for_far_future_sync() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + // ~year 3000: beyond the skew allowance. + std::fs::write( + home.join(MANAGED_CONFIG_CACHE_FILE), + "{\"synced_at\":32503680000}", + ) + .unwrap(); + assert!( + managed_config_stale_at(Some(home), &ServingIdentity::None), + "a far-future synced_at must not freeze the refetch timer" + ); + + // Past `SystemTime`'s range: must read stale, not panic (would kill the sync task). + std::fs::write( + home.join(MANAGED_CONFIG_CACHE_FILE), + format!("{{\"synced_at\":{}}}", u64::MAX), + ) + .unwrap(); + assert!( + managed_config_stale_at(Some(home), &ServingIdentity::None), + "an out-of-range synced_at reads stale" + ); + + let in_a_minute = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 60; + std::fs::write( + home.join(MANAGED_CONFIG_CACHE_FILE), + format!("{{\"synced_at\":{in_a_minute}}}"), + ) + .unwrap(); + assert!( + !managed_config_stale_at(Some(home), &ServingIdentity::None), + "a minute of genuine clock skew still reads fresh" + ); +} + +// The is-managed claim gate tests live in a sibling child module (this file is +// past the 1k-line mark); same private access via the #[path] include below. +#[path = "claim_tests.rs"] +mod claim_tests; diff --git a/crates/codegen/xai-grok-config/src/signed_policy.rs b/crates/codegen/xai-grok-config/src/signed_policy.rs index 0f97efc..b7e84b6 100644 --- a/crates/codegen/xai-grok-config/src/signed_policy.rs +++ b/crates/codegen/xai-grok-config/src/signed_policy.rs @@ -8,10 +8,15 @@ //! Inert until a public key is provisioned: with no embedded keys the cache //! marker stays the (best-effort) authority. use base64::Engine; -pub use prod_mc_cli_chat_proxy_types::{SignatureEnvelope, SignedPayload, now_unix}; +pub use prod_mc_cli_chat_proxy_types::{ + MANAGED_IDENTITY_TYP, MANAGED_POLICY_TYP, ManagedIdentityClaim, SignatureEnvelope, + SignedPayload, now_unix, +}; /// Compiled-in trusted Ed25519 public keys, `(key_id, raw 32 bytes)`; more than one /// entry only during a rotation. Empty ships dark (see [`verification_active`]). /// Compile-time, not an env flag: the local attacker controls their env. +/// Provisioning order: keyed clients reject `typ`-less envelopes, so the +/// typ-emitting server must be fully rolled out before any client embeds a key. pub const EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS: &[(&str, &[u8])] = &[]; const _: () = { let keys = EMBEDDED_DEPLOYMENT_CONFIG_PUBKEYS; @@ -57,6 +62,9 @@ fn with_embedded_keys(f: impl FnOnce(&[(&str, &[u8])]) -> R) -> R { } /// Sidecar persisted next to the policy so the load-time gate can re-verify it offline. pub const SIGNATURE_SIDECAR_FILE: &str = "managed_config.sig.json"; +/// The is-managed claim's own sidecar (see +/// [`prod_mc_cli_chat_proxy_types::ManagedIdentityClaim`]). +pub const MANAGED_IDENTITY_SIDECAR_FILE: &str = "managed_identity.sig.json"; #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum SigError { #[error("signature is not valid base64")] @@ -65,6 +73,8 @@ pub enum SigError { SignatureMismatch, #[error("signed payload is not valid JSON")] BadPayload, + #[error("signed payload carries the wrong message type")] + WrongType, #[error("signed payload names a key_id outside the trusted set")] UnknownKeyId, #[error("signed policy is bound to a different principal")] @@ -92,7 +102,8 @@ pub fn embedded_key_id_trusted(key_id: &str) -> bool { /// Verify `signature_b64` over `signed_payload` against `trusted_keys`, returning the /// parsed payload. The verifying key is selected by the SIGNED payload's `key_id` — /// safe to read pre-verification because selection can only land within the trusted -/// set (a forged id either misses or picks a key the signature won't match). Pure: +/// set (a forged id either misses or picks a key the signature won't match). Requires +/// the [`MANAGED_POLICY_TYP`] tag (a claim must never verify as a policy). Pure: /// callers supply the keys so tests can use throwaway keypairs. pub fn verify_signed_payload( signed_payload: &str, @@ -101,17 +112,43 @@ pub fn verify_signed_payload( ) -> Result { let payload: SignedPayload = serde_json::from_str(signed_payload).map_err(|_| SigError::BadPayload)?; + verify_signature_with_keys(signed_payload, signature_b64, trusted_keys, &payload.key_id)?; + if payload.typ != MANAGED_POLICY_TYP { + return Err(SigError::WrongType); + } + Ok(payload) +} +/// [`verify_signed_payload`]'s mirror for claims (requires [`MANAGED_IDENTITY_TYP`]). +pub fn verify_managed_identity_claim( + signed_payload: &str, + signature_b64: &str, + trusted_keys: &[(&str, &[u8])], +) -> Result { + let claim: ManagedIdentityClaim = + serde_json::from_str(signed_payload).map_err(|_| SigError::BadPayload)?; + verify_signature_with_keys(signed_payload, signature_b64, trusted_keys, &claim.key_id)?; + if claim.typ != MANAGED_IDENTITY_TYP { + return Err(SigError::WrongType); + } + Ok(claim) +} +/// Shared Ed25519 check: select the trusted key named by the signed bytes' `key_id`, verify. +fn verify_signature_with_keys( + signed_payload: &str, + signature_b64: &str, + trusted_keys: &[(&str, &[u8])], + key_id: &str, +) -> Result<(), SigError> { let (_, public_key) = trusted_keys .iter() - .find(|(id, _)| *id == payload.key_id) + .find(|(id, _)| *id == key_id) .ok_or(SigError::UnknownKeyId)?; let sig = base64::engine::general_purpose::STANDARD .decode(signature_b64.trim()) .map_err(|_| SigError::BadSignatureEncoding)?; ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key) .verify(signed_payload.as_bytes(), &sig) - .map_err(|_| SigError::SignatureMismatch)?; - Ok(payload) + .map_err(|_| SigError::SignatureMismatch) } /// Fetch-time identity binding for a VERIFIED payload, expiry enforced: a /// deployment-signed payload is trusted on signature alone; a team-signed payload @@ -159,6 +196,26 @@ pub fn verify_fetched( ) -> Result { with_embedded_keys(|keys| verify_fetched_with_keys(sidecar, keys, active_team_id, now_unix)) } +/// Fetch-time claim verification (signature + expiry; binding is the caller's rule). +pub fn verify_fetched_claim( + sidecar: &SignatureEnvelope, + now_unix: u64, +) -> Result { + with_embedded_keys(|keys| verify_fetched_claim_with_keys(sidecar, keys, now_unix)) +} +/// Key-injected core of [`verify_fetched_claim`] so tests can supply throwaway keys. +fn verify_fetched_claim_with_keys( + sidecar: &SignatureEnvelope, + trusted_keys: &[(&str, &[u8])], + now_unix: u64, +) -> Result { + let claim = + verify_managed_identity_claim(&sidecar.signed_payload, &sidecar.signature, trusted_keys)?; + if now_unix > claim.expires_at { + return Err(SigError::Expired); + } + Ok(claim) +} /// Key-injected core of [`verify_fetched`] so tests can supply throwaway keypairs. fn verify_fetched_with_keys( sidecar: &SignatureEnvelope, @@ -233,11 +290,13 @@ enum SidecarRead { Unreadable, } fn read_sidecar(home: &std::path::Path) -> SidecarRead { - let path = sidecar_path(home); - if non_regular_file_at(&path) { + read_envelope_at(&sidecar_path(home)) +} +fn read_envelope_at(path: &std::path::Path) -> SidecarRead { + if non_regular_file_at(path) { return SidecarRead::Absent; } - let json = match std::fs::read_to_string(&path) { + let json = match std::fs::read_to_string(path) { Ok(json) => json, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return SidecarRead::Absent, Err(_) => return SidecarRead::Unreadable, @@ -251,9 +310,60 @@ fn read_sidecar(home: &std::path::Path) -> SidecarRead { /// Written 0600 on unix: for a deployment-key principal the signed payload embeds /// the key, so the sidecar is a second at-rest copy of a bearer credential. pub fn write_sidecar(home: &std::path::Path, sidecar: &SignatureEnvelope) -> std::io::Result<()> { + write_envelope_at(&sidecar_path(home), sidecar) +} +pub(crate) fn managed_identity_sidecar_path(home: &std::path::Path) -> std::path::PathBuf { + home.join(MANAGED_IDENTITY_SIDECAR_FILE) +} +/// [`write_sidecar`] for the claim (0600 for uniformity; the claim has no secret). +pub fn write_managed_identity_sidecar( + home: &std::path::Path, + sidecar: &SignatureEnvelope, +) -> std::io::Result<()> { + write_envelope_at(&managed_identity_sidecar_path(home), sidecar) +} +fn write_envelope_at(path: &std::path::Path, sidecar: &SignatureEnvelope) -> std::io::Result<()> { let json = serde_json::to_string(sidecar) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - crate::fs_atomic::write_atomically(&sidecar_path(home), &json, Some(0o600)) + crate::fs_atomic::write_atomically(path, &json, Some(0o600)) +} +/// Whether an authentic claim IMPOSES fail-closed enforcement: verified, bound to +/// the KNOWN `expected_principal`, in-date vs the caller-clamped `now_unix`, and +/// `fail_closed`. Anything else imposes nothing: permissive (must not override a +/// now-fail_closed marker), unknown principal (a planted claim must not brick a +/// signed-out victim), foreign, expired, forged, or absent. +pub fn managed_identity_claim_imposes( + home: &std::path::Path, + expected_principal: Option<&str>, + now_unix: u64, +) -> bool { + if !verification_active() { + return false; + } + with_embedded_keys(|keys| { + managed_identity_claim_imposes_with_keys(home, keys, expected_principal, now_unix) + }) +} +/// Key-injected core of [`managed_identity_claim_imposes`] so tests can supply throwaway keys. +fn managed_identity_claim_imposes_with_keys( + home: &std::path::Path, + trusted_keys: &[(&str, &[u8])], + expected_principal: Option<&str>, + now_unix: u64, +) -> bool { + let Some(expected) = expected_principal else { + return false; + }; + let SidecarRead::Present(sidecar) = read_envelope_at(&managed_identity_sidecar_path(home)) + else { + return false; + }; + let Ok(claim) = + verify_managed_identity_claim(&sidecar.signed_payload, &sidecar.signature, trusted_keys) + else { + return false; + }; + claim.principal == expected && now_unix <= claim.expires_at && claim.fail_closed } /// True when signature verification is active AND a cloud-cache policy on disk is /// NOT covered by a valid, in-date, identity-bound, content-matching signature. @@ -367,6 +477,8 @@ pub enum SignedVerdict { /// The sidecar exists but a transient IO error (EACCES-style, never plain absence /// or a squatting non-file) blocked the read. Not tamper evidence: the gate falls /// back to the marker decision, and the refetch trigger fires to rewrite it. + /// The claim is deliberately NOT consulted here: a genuine blip must not + /// refuse, and a chmod-capable attacker could delete the claim anyway. SidecarUnreadable, /// Authentic sidecar; the policy is valid for this principal (or never opted into /// fail-closed enforcement). diff --git a/crates/codegen/xai-grok-config/src/signed_policy/claim_tests.rs b/crates/codegen/xai-grok-config/src/signed_policy/claim_tests.rs new file mode 100644 index 0000000..1633181 --- /dev/null +++ b/crates/codegen/xai-grok-config/src/signed_policy/claim_tests.rs @@ -0,0 +1,228 @@ +//! The server-signed is-managed claim: verifiers, domain separation, and the +//! impose/defer signal (sidecar-removal downgrade closure). + +use super::super::*; +use super::{keyset, payload, sign, test_keypair}; + +fn claim(principal: &str, fail_closed: bool, expires_at: u64) -> ManagedIdentityClaim { + ManagedIdentityClaim { + typ: MANAGED_IDENTITY_TYP.into(), + principal: principal.into(), + fail_closed, + expires_at, + key_id: "v1".into(), + } +} + +fn sign_claim( + kp: &ring::signature::Ed25519KeyPair, + claim: &ManagedIdentityClaim, +) -> SignatureEnvelope { + let signed_payload = serde_json::to_string(claim).unwrap(); + let sig = kp.sign(signed_payload.as_bytes()); + SignatureEnvelope { + signed_payload, + signature: base64::engine::general_purpose::STANDARD.encode(sig.as_ref()), + key_id: claim.key_id.clone(), + } +} + +fn write_claim(home: &std::path::Path, sidecar: &SignatureEnvelope) { + write_managed_identity_sidecar(home, sidecar).unwrap(); +} + +/// The required `typ` closes signature confusion: neither message type substitutes +/// for the other, even genuinely signed by the same key. +#[test] +fn domain_separation_rejects_cross_type_substitution() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let (kp, pubkey) = test_keypair(); + let keys = keyset("v1", &pubkey); + + // An authentic identity claim must NOT verify as a policy payload. + let claim_sidecar = sign_claim(&kp, &claim("team-007", true, 4_000_000_000)); + assert_eq!( + verify_signed_payload( + &claim_sidecar.signed_payload, + &claim_sidecar.signature, + &keys + ), + Err(SigError::WrongType), + "an identity claim must be rejected by the policy verifier" + ); + + // End-to-end: the authentic claim copied over the policy sidecar (policy files + // deleted) must read NoAuthenticSidecar, never Trusted — the pre-fix exploit + // started such a fail_closed principal unmanaged. + std::fs::write( + sidecar_path(home), + serde_json::to_string(&claim_sidecar).unwrap(), + ) + .unwrap(); + assert_eq!( + signed_cache_compromised_with_keys(home, &keys, Some("team-007"), 1_000), + SignedVerdict::NoAuthenticSidecar, + "a substituted claim is not an authentic policy verdict" + ); + + // Reverse: a policy envelope must not verify as a claim (its shape has no + // `principal`, so it fails at parse). + let policy_sidecar = sign(&kp, &payload()); + assert_eq!( + verify_managed_identity_claim( + &policy_sidecar.signed_payload, + &policy_sidecar.signature, + &keys + ), + Err(SigError::BadPayload), + "a policy envelope must be rejected by the claim verifier" + ); + + // And a claim-shaped blob carrying the POLICY tag trips the typ guard itself. + let mut wrong_typ = claim("team-007", true, 4_000_000_000); + wrong_typ.typ = MANAGED_POLICY_TYP.into(); + let bad = sign_claim(&kp, &wrong_typ); + assert_eq!( + verify_managed_identity_claim(&bad.signed_payload, &bad.signature, &keys), + Err(SigError::WrongType) + ); +} + +/// The claim imposes ONLY when authentic + bound + fail_closed; permissive, +/// foreign, unknown-principal, forged, and absent claims are all silent. +#[test] +fn claim_imposes_only_for_bound_fail_closed_claim() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let (kp, pubkey) = test_keypair(); + let keys = keyset("v1", &pubkey); + + // Absent → silent. + assert!(!managed_identity_claim_imposes_with_keys( + home, + &keys, + Some("team-007"), + 1_000 + )); + + write_claim( + home, + &sign_claim(&kp, &claim("team-007", true, 4_000_000_000)), + ); + assert!( + managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 1_000), + "an authentic bound fail_closed claim imposes" + ); + assert!( + !managed_identity_claim_imposes_with_keys(home, &keys, Some("team-evil"), 1_000), + "a claim for another principal must not bind us" + ); + assert!( + !managed_identity_claim_imposes_with_keys(home, &keys, None, 1_000), + "an unbindable claim must not gate startup" + ); + + write_claim( + home, + &sign_claim(&kp, &claim("team-007", false, 4_000_000_000)), + ); + assert!( + !managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 1_000), + "a permissive claim defers to the marker" + ); + + let mut forged = sign_claim(&kp, &claim("team-007", true, 4_000_000_000)); + forged.signature = base64::engine::general_purpose::STANDARD.encode([0u8; 64]); + write_claim(home, &forged); + assert!( + !managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 1_000), + "a forged claim imposes nothing" + ); +} + +/// An expired claim is silent (callers pass the floor-clamped now, so a rolled-back +/// clock cannot un-expire it). +#[test] +fn expired_claim_is_silent() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let (kp, pubkey) = test_keypair(); + let keys = keyset("v1", &pubkey); + write_claim(home, &sign_claim(&kp, &claim("team-007", true, 2_000))); + + assert!(managed_identity_claim_imposes_with_keys( + home, + &keys, + Some("team-007"), + 1_000 + )); + assert!( + !managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 3_000), + "past expiry → silent" + ); +} + +/// Fetch-time claim verification enforces expiry (the persist gate). +#[test] +fn verify_fetched_claim_rejects_expired() { + let (kp, pubkey) = test_keypair(); + let keys = keyset("v1", &pubkey); + let sidecar = sign_claim(&kp, &claim("team-007", true, 2_000)); + assert!(verify_fetched_claim_with_keys(&sidecar, &keys, 1_000).is_ok()); + assert_eq!( + verify_fetched_claim_with_keys(&sidecar, &keys, 3_000), + Err(SigError::Expired) + ); +} + +/// Corrupt claim bytes read as Absent (not Present): impose is silent, never +/// refuses on garbage — same read_envelope_at path as the policy sidecar. +#[test] +fn corrupt_claim_file_is_silent() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let (kp, pubkey) = test_keypair(); + let keys = keyset("v1", &pubkey); + std::fs::write(managed_identity_sidecar_path(home), "{not-json").unwrap(); + assert!( + !managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 1_000), + "unparseable claim bytes must not impose" + ); + // A half-shaped envelope (missing signature fields) is the same Absent path. + std::fs::write(managed_identity_sidecar_path(home), "{\"key_id\":\"v1\"}").unwrap(); + assert!(!managed_identity_claim_imposes_with_keys( + home, + &keys, + Some("team-007"), + 1_000 + )); + // Sanity: a real claim at the same path still imposes. + write_claim( + home, + &sign_claim(&kp, &claim("team-007", true, 4_000_000_000)), + ); + assert!(managed_identity_claim_imposes_with_keys( + home, + &keys, + Some("team-007"), + 1_000 + )); +} + +/// A directory squatting the claim slot is Absent (non-regular), never an +/// imposing claim — and never the lenient Unreadable blip (that is EACCES on a +/// regular file only). +#[test] +fn directory_squatting_claim_slot_is_silent() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let (_kp, pubkey) = test_keypair(); + let keys = keyset("v1", &pubkey); + let path = managed_identity_sidecar_path(home); + std::fs::create_dir(&path).unwrap(); + assert!( + !managed_identity_claim_imposes_with_keys(home, &keys, Some("team-007"), 1_000), + "a directory at the claim path must not impose" + ); +} diff --git a/crates/codegen/xai-grok-config/src/signed_policy/tests.rs b/crates/codegen/xai-grok-config/src/signed_policy/tests.rs index 5a2a997..f903efe 100644 --- a/crates/codegen/xai-grok-config/src/signed_policy/tests.rs +++ b/crates/codegen/xai-grok-config/src/signed_policy/tests.rs @@ -25,6 +25,7 @@ fn sign(kp: &ring::signature::Ed25519KeyPair, payload: &SignedPayload) -> Signat fn payload() -> SignedPayload { SignedPayload { + typ: MANAGED_POLICY_TYP.into(), version: 1, deployment_id: None, team_id: Some("team-007".into()), @@ -54,6 +55,7 @@ fn write_policy(home: &std::path::Path, p: &SignedPayload) { fn server_wire_format_is_client_verifiable() { let (kp, pubkey) = test_keypair(); let signed_payload = serde_json::json!({ + "typ": "grok.managed_policy.v1", "deployment_id": serde_json::Value::Null, "team_id": "team-007", "managed_config": "[cli]\n", @@ -82,6 +84,7 @@ fn server_wire_format_is_client_verifiable() { fn missing_fail_closed_defaults_false() { let (kp, pubkey) = test_keypair(); let signed_payload = serde_json::json!({ + "typ": "grok.managed_policy.v1", "team_id": "team-007", "expires_at": 4_000_000_000u64, "key_id": "v1", @@ -956,3 +959,8 @@ fn rotation_selects_the_trusted_key_by_signed_key_id() { Err(SigError::SignatureMismatch) ); } + +// The is-managed claim tests live in a sibling child module (this file is at the +// 1k-line mark); same private access via the #[path] include below. +#[path = "claim_tests.rs"] +mod claim_tests; diff --git a/crates/codegen/xai-grok-config/src/validation.rs b/crates/codegen/xai-grok-config/src/validation.rs index bb0fbce..00cb7da 100644 --- a/crates/codegen/xai-grok-config/src/validation.rs +++ b/crates/codegen/xai-grok-config/src/validation.rs @@ -8,19 +8,21 @@ use crate::paths::{system_config_dir, user_grok_home}; use crate::version_overrides::{VersionOverrideError, apply_version_overrides}; use prod_mc_cli_chat_proxy_types::FAIL_CLOSED_KEY; -/// The canonical opt-in key + string parse live in the shared types crate, next to -/// the signed payload that carries the flag, so the server-side signer and this -/// client parse the same semantics. -pub use prod_mc_cli_chat_proxy_types::fail_closed_flag_from_str; -/// Read the `fail_closed` opt-in from a parsed requirements layer — same semantics as -/// [`fail_closed_flag_from_str`]. Env tightening (file vs `GROK_MANAGED_CONFIG_FAIL_CLOSED`) -/// is layered on top by [`resolve_fail_closed_mode`], not here. +/// `fail_closed` from a requirements table; non-bool → warn once and treat as false. fn fail_closed_flag(requirements: &toml::Value) -> bool { - requirements - .get(FAIL_CLOSED_KEY) - .and_then(|v| v.as_bool()) - .unwrap_or(false) + use prod_mc_cli_chat_proxy_types::{FailClosedFlag, fail_closed_flag_status_from_value}; + let status = fail_closed_flag_status_from_value(requirements); + if matches!(status, FailClosedFlag::Invalid) { + static WARN_ONCE: std::sync::Once = std::sync::Once::new(); + WARN_ONCE.call_once(|| { + tracing::warn!( + "requirements fail_closed is present but not a boolean \ + (e.g. fail_closed = \"true\"); treating as false - use fail_closed = true" + ); + }); + } + status.is_enabled() } /// Env override for [`FAIL_CLOSED_KEY`]. Named for prefix-alignment @@ -352,15 +354,13 @@ minimum_version = "not-a-version" } #[test] - fn fail_closed_flag_from_str_reads_the_opt_in() { - assert!(fail_closed_flag_from_str("fail_closed = true\n")); - assert!(!fail_closed_flag_from_str("fail_closed = false\n")); - // Missing key, a non-bool value, malformed TOML, and empty all read as - // not-opted-in (best-effort false) rather than panicking. - assert!(!fail_closed_flag_from_str("[features]\ntelemetry = true\n")); - assert!(!fail_closed_flag_from_str("fail_closed = \"yes\"\n")); - assert!(!fail_closed_flag_from_str("[unclosed")); - assert!(!fail_closed_flag_from_str("")); + fn fail_closed_flag_reads_the_opt_in() { + let flag = |s: &str| fail_closed_flag(&toml::from_str::(s).unwrap()); + assert!(flag("fail_closed = true\n")); + assert!(!flag("fail_closed = false\n")); + assert!(!flag("[features]\ntelemetry = true\n")); + assert!(!flag("fail_closed = \"yes\"\n")); + assert!(!flag("")); } #[test] diff --git a/crates/codegen/xai-grok-hooks/src/discovery.rs b/crates/codegen/xai-grok-hooks/src/discovery.rs index fbfdadd..462f5bb 100644 --- a/crates/codegen/xai-grok-hooks/src/discovery.rs +++ b/crates/codegen/xai-grok-hooks/src/discovery.rs @@ -78,14 +78,18 @@ impl HookRegistry { /// Recompile the `matcher` field on every [`HookSpec`] from its /// `configured_matcher` pattern string. /// - /// After deserialization, the compiled [`HookMatcher`] is `None` - /// (it is `#[serde(skip)]`). This method rebuilds it using the - /// same logic as the original parse path, via [`HookMatcher::new`]. + /// After deserialization the compiled [`HookMatcher`] is `None` + /// (`#[serde(skip)]`). This rebuilds it via [`HookMatcher::new`]. /// - /// Specs whose `configured_matcher` is `None` (match-all) are - /// left untouched. Invalid patterns are logged and the spec's - /// matcher remains `None` (match-all fallback), which is the - /// safest fail-open behavior. + /// Specs whose `configured_matcher` is `None` (intentional match-all) + /// are left untouched. Invalid patterns cannot be rejected the way the + /// parse path does (`HookError::InvalidMatcher` + skip the hook): the + /// registry is already live, so we install [`HookMatcher::never`] + /// instead: fail closed rather than widening to match all. + /// + /// Call this after any serde / wire restore (e.g. workspace proxy + /// `wire_to_hook_registry`). Until then, a configured pattern with + /// `matcher: None` behaves as match-all. pub fn recompile_matchers(&mut self) { for specs in self.hooks.values_mut() { for spec in specs.iter_mut() { @@ -97,9 +101,10 @@ impl HookRegistry { hook = %spec.name, pattern = %pattern, error = %e, - "hooks: failed to recompile matcher after deserialization" + "hooks: hook will match no tools until its matcher pattern is fixed" ); - // Leave matcher as None → match-all (fail-open). + // Fail closed: invalid matcher must not match-all. + spec.matcher = Some(HookMatcher::never()); } } } @@ -889,4 +894,103 @@ mod tests { assert!(errors.is_empty(), "errors: {errors:?}"); assert_eq!(registry.len(), 1); } + + /// Wire/serde-shaped spec: compiled matcher cleared, pattern still set. + fn recompile_test_spec( + name: &str, + configured_matcher: Option<&str>, + ) -> crate::config::HookSpec { + use std::path::PathBuf; + crate::config::HookSpec { + name: name.into(), + event: HookEventName::PreToolUse, + handler_type: "command".into(), + configured_matcher: configured_matcher.map(str::to_owned), + matcher: None, + enabled: true, + command: Some(PathBuf::from("hook.sh")), + command_raw: Some("hook.sh".into()), + url: None, + url_raw: None, + timeout_ms: 5_000, + source_dir: PathBuf::from("/tmp"), + extra_env: Default::default(), + } + } + + #[test] + fn recompile_matchers_fail_closed_on_invalid_pattern() { + // Serde skips `matcher`; recompile must not leave it None (match-all). + let mut registry = HookRegistry::default(); + registry.append_specs(vec![recompile_test_spec("broken", Some("[invalid"))]); + registry.recompile_matchers(); + + let hooks = registry.hooks_for(HookEventName::PreToolUse); + assert_eq!(hooks.len(), 1); + let matcher = hooks[0] + .matcher + .as_ref() + .expect("invalid matcher must compile to never-match, not stay None"); + assert!(!matcher.is_match("run_terminal_command")); + assert!(!matcher.is_match("read_file")); + assert!(!matcher.is_match("Bash")); + } + + #[test] + fn recompile_matchers_restores_valid_pattern() { + let mut registry = HookRegistry::default(); + registry.append_specs(vec![recompile_test_spec("ok", Some("Bash"))]); + registry.recompile_matchers(); + + let matcher = registry.hooks_for(HookEventName::PreToolUse)[0] + .matcher + .as_ref() + .expect("valid matcher should recompile"); + assert!(matcher.is_match("run_terminal_command")); + assert!(!matcher.is_match("read_file")); + } + + #[test] + fn recompile_matchers_leaves_intentional_match_all() { + let mut registry = HookRegistry::default(); + registry.append_specs(vec![recompile_test_spec("all", None)]); + registry.recompile_matchers(); + + assert!( + registry.hooks_for(HookEventName::PreToolUse)[0] + .matcher + .is_none(), + "no configured pattern must stay match-all (matcher None)" + ); + } + + #[test] + fn recompile_matchers_isolates_invalid_sibling() { + let mut registry = HookRegistry::default(); + registry.append_specs(vec![ + recompile_test_spec("ok", Some("Bash")), + recompile_test_spec("broken", Some("[invalid")), + ]); + registry.recompile_matchers(); + + let hooks = registry.hooks_for(HookEventName::PreToolUse); + assert_eq!(hooks.len(), 2); + let by_name: std::collections::HashMap<_, _> = + hooks.iter().map(|h| (h.name.as_str(), h)).collect(); + + let ok = by_name["ok"] + .matcher + .as_ref() + .expect("valid sibling must recompile"); + assert!(ok.is_match("run_terminal_command")); + assert!(!ok.is_match("read_file")); + + let broken = by_name["broken"] + .matcher + .as_ref() + .expect("invalid sibling must become never-match"); + assert!(!broken.is_match("run_terminal_command")); + assert!(!broken.is_match("Bash")); + assert!(!broken.is_match("read_file")); + } } diff --git a/crates/codegen/xai-grok-hooks/src/matcher.rs b/crates/codegen/xai-grok-hooks/src/matcher.rs index 1f17b9c..687c031 100644 --- a/crates/codegen/xai-grok-hooks/src/matcher.rs +++ b/crates/codegen/xai-grok-hooks/src/matcher.rs @@ -21,6 +21,9 @@ pub struct HookMatcher { #[derive(Debug, Clone)] enum MatcherKind { All, + /// Matches no tool names. Used when a configured matcher fails to compile + /// after deserialization — fail closed rather than widen to match-all. + Never, Exact(Vec), Regex(Regex), } @@ -39,9 +42,18 @@ impl HookMatcher { Ok(Self { kind }) } + /// Matcher that never matches. Prefer this over `None` on a [`HookSpec`] when a + /// pattern was configured but could not be compiled (fail-closed). + pub(crate) fn never() -> Self { + Self { + kind: MatcherKind::Never, + } + } + pub fn is_match(&self, tool_name: &str) -> bool { match &self.kind { MatcherKind::All => true, + MatcherKind::Never => false, MatcherKind::Exact(names) => names.iter().any(|n| n == tool_name), MatcherKind::Regex(regex) => { regex.is_match(tool_name) @@ -134,6 +146,15 @@ mod tests { assert!(HookMatcher::new("[invalid").is_err()); } + #[test] + fn never_matches_nothing() { + let m = HookMatcher::never(); + assert!(!m.is_match("read_file")); + assert!(!m.is_match("run_terminal_command")); + assert!(!m.is_match("")); + assert!(!m.is_match("*")); + } + #[test] fn star_and_empty_match_all() { for pat in ["*", ""] { diff --git a/crates/codegen/xai-grok-mcp/src/credentials.rs b/crates/codegen/xai-grok-mcp/src/credentials.rs index 58a4bb8..e18750a 100644 --- a/crates/codegen/xai-grok-mcp/src/credentials.rs +++ b/crates/codegen/xai-grok-mcp/src/credentials.rs @@ -15,6 +15,36 @@ use url::Url; use crate::rmcp; +/// Ensure credential paths are owner-only (Unix `0o600`). +/// +/// Local helper (not shell-base): `xai-grok-mcp` sits below `config-types` in the +/// dep graph, and shell-base pulls shared→config-types→mcp — a cycle if linked. +/// Windows ACL tightening stays on auth via shell-base; MCP is Unix-first here. +fn ensure_owner_only_permissions(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + match std::fs::metadata(path) { + Ok(metadata) => { + let mode = metadata.permissions().mode(); + if mode & 0o777 != 0o600 { + let mut perms = metadata.permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(path, perms)?; + } + Ok(()) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + type Result = std::result::Result; #[derive(Debug, thiserror::Error)] @@ -70,6 +100,15 @@ impl McpCredentialStore { return Ok(Self::default()); } let content = std::fs::read_to_string(path)?; + // Tighten world-readable credential files on load (hand copies, etc.). + // Best-effort: chmod failure must not block using existing tokens. + if let Err(e) = ensure_owner_only_permissions(path) { + tracing::warn!( + path = %path.display(), + error = %e, + "mcp credentials: failed to enforce owner-only permissions" + ); + } let store: McpCredentialStore = serde_json::from_str(&content)?; Ok(store) } @@ -188,7 +227,18 @@ impl McpCredentialStore { writer.flush()?; } + // `mode(0o600)` only applies on create; tighten before rename. + // Fail hard on tmp: credentials are not published yet. + ensure_owner_only_permissions(&tmp_path)?; std::fs::rename(&tmp_path, path)?; + // Best-effort after rename: new tokens are already published. + if let Err(e) = ensure_owner_only_permissions(path) { + tracing::warn!( + error = %e, + path = %path.display(), + "mcp: failed to ensure owner-only permissions after credential save" + ); + } Ok(()) } @@ -432,4 +482,37 @@ mod tests { let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(&dir); } + + #[cfg(unix)] + #[test] + fn save_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("creds.json"); + let mut store = McpCredentialStore::default(); + let url = Url::parse("https://test.example.com/mcp").unwrap(); + store.insert_rmcp("test", &url, test_stored_creds("c")); + store.save_to(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[cfg(unix)] + #[test] + fn load_tightens_world_readable_credentials() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("creds.json"); + let mut store = McpCredentialStore::default(); + let url = Url::parse("https://test.example.com/mcp").unwrap(); + store.insert_rmcp("test", &url, test_stored_creds("c")); + store.save_to(&path).unwrap(); + let mut loose = std::fs::metadata(&path).unwrap().permissions(); + loose.set_mode(0o644); + std::fs::set_permissions(&path, loose).unwrap(); + + let _ = McpCredentialStore::load_from(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } } diff --git a/crates/codegen/xai-grok-memory/src/backend.rs b/crates/codegen/xai-grok-memory/src/backend.rs index 70245df..c1c34d1 100644 --- a/crates/codegen/xai-grok-memory/src/backend.rs +++ b/crates/codegen/xai-grok-memory/src/backend.rs @@ -18,6 +18,77 @@ use super::embedding::EmbeddingProvider as _; use super::storage::MemoryStorage; use super::watcher::MemoryFileWatcher; +/// Embedding-client credentials scoped to a trusted endpoint. Only +/// [`Self::for_endpoint`] retains a live credential; the empty default fails closed. +#[derive(Clone, Default)] +pub struct EndpointScopedCredentials { + endpoint: Option, + auth_credentials: Option>, + api_key_provider: Option, +} + +// Manual Debug that redacts the credential handles; only their presence shows. +impl std::fmt::Debug for EndpointScopedCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EndpointScopedCredentials") + .field("endpoint", &self.endpoint) + .field("has_auth_credentials", &self.auth_credentials.is_some()) + .field("has_api_key_provider", &self.api_key_provider.is_some()) + .finish() + } +} + +impl EndpointScopedCredentials { + pub fn none() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.auth_credentials.is_none() && self.api_key_provider.is_none() + } + + /// Retains the credentials only for a trusted, parsable `endpoint`; otherwise drops them. + pub fn for_endpoint( + endpoint: &str, + is_trusted: impl FnOnce(&str) -> bool, + auth_credentials: Option>, + api_key_provider: Option, + ) -> Self { + if is_trusted(endpoint) + && let Ok(url) = reqwest::Url::parse(endpoint) + { + return Self { + endpoint: Some(url), + auth_credentials, + api_key_provider, + }; + } + if auth_credentials.is_some() || api_key_provider.is_some() { + tracing::info!( + target: xai_grok_telemetry::memory_log::TARGET, + endpoint, + "memory embeddings: session credentials withheld for non-first-party endpoint; its own key, if any, still applies" + ); + } + Self::none() + } + + fn auth_credentials(&self) -> Option<&Arc> { + self.auth_credentials.as_ref() + } + + fn api_key_provider(&self) -> Option<&xai_grok_tools::types::SharedApiKeyProvider> { + self.api_key_provider.as_ref() + } + + fn approved_for(&self, base_url: &str) -> bool { + match &self.endpoint { + None => self.is_empty(), + Some(endpoint) => reqwest::Url::parse(base_url).is_ok_and(|url| &url == endpoint), + } + } +} + /// All configuration needed to build a fully-wired [`MemoryBackendImpl`] for a live session. /// /// Grouping these in one struct ensures every call site — ToolBridge, first-turn @@ -30,7 +101,8 @@ pub struct MemoryBackendParams { pub session_id: String, /// Embedding provider config — `None` forces FTS-only fallback everywhere. pub embed_config: Option, - /// Base URL for embedding API calls (CLI proxy). + /// Base URL for embedding API calls (CLI proxy). Must match the endpoint + /// `embedding_credentials` was scoped to; mismatch fails closed. pub embed_base_url: String, /// API key for embedding API calls. pub embed_api_key: Option, @@ -47,10 +119,7 @@ pub struct MemoryBackendParams { /// - `"injection"` — first-turn memory context injection /// - `"compaction_recovery"` — post-compaction context re-injection pub search_source: &'static str, - /// Dynamic API key provider — when set, `make_embedding_provider()` resolves - /// the key per-call instead of using the static `embed_api_key`. - pub api_key_provider: Option, - pub auth_credentials: Option>, + pub embedding_credentials: EndpointScopedCredentials, } impl MemoryBackendParams { @@ -59,8 +128,7 @@ impl MemoryBackendParams { pub async fn make_embedding_provider(&self) -> Option { build_embedding_provider( self.embed_config.as_ref(), - self.auth_credentials.as_ref(), - self.api_key_provider.as_ref(), + &self.embedding_credentials, self.embed_api_key.as_deref(), &self.embed_base_url, ) @@ -70,8 +138,7 @@ impl MemoryBackendParams { async fn build_embedding_provider( config: Option<&xai_grok_config_types::MemoryEmbeddingConfig>, - auth_credentials: Option<&Arc>, - api_key_provider: Option<&xai_grok_tools::types::SharedApiKeyProvider>, + credentials: &EndpointScopedCredentials, static_api_key: Option<&str>, base_url: &str, ) -> Option { @@ -80,9 +147,19 @@ async fn build_embedding_provider( return None; } - // Prefer the refresh-capable credential provider — the middleware gives - // 401 retry for free without any per-call key resolution. - if let Some(creds) = auth_credentials { + // Enforce at runtime, in release too: a `debug_assert` would compile out of + // shipped binaries and let a scoped credential reach an unapproved URL. + let credentials_approved = credentials.approved_for(base_url); + if !credentials_approved { + tracing::error!( + target: xai_grok_telemetry::memory_log::TARGET, + base_url, + approved = ?credentials.endpoint, + "memory embeddings: scoped credentials do not match the request URL; dropping them" + ); + } + + if credentials_approved && let Some(creds) = credentials.auth_credentials() { let client = super::embedding::build_middleware_client(creds.clone()); return super::embedding::ApiEmbeddingProvider::from_config( config, @@ -91,13 +168,12 @@ async fn build_embedding_provider( ); } - // Fallback: resolve API key per-call, wrap in a static middleware client - // (no 401 refresh, but auth header is still stamped by middleware). - let api_key = match api_key_provider { - Some(p) => p.current_api_key_async().await, - None => None, - } - .or_else(|| static_api_key.map(|s| s.to_owned()))?; + let per_call_key = if credentials_approved && let Some(p) = credentials.api_key_provider() { + p.current_api_key_async().await + } else { + None + }; + let api_key = per_call_key.or_else(|| static_api_key.map(|s| s.to_owned()))?; super::embedding::ApiEmbeddingProvider::from_session(config, base_url.to_owned(), api_key) } @@ -129,10 +205,7 @@ pub struct MemoryBackendImpl { /// Only the ToolBridge backend's counter is shared back to the session actor; /// injection and compaction-recovery backends use their own local counters. pub search_counter: std::sync::Arc, - /// Dynamic API key provider for embedding requests. - api_key_provider: Option, - /// Refresh-capable credential provider for embedding HTTP middleware. - auth_credentials: Option>, + embedding_credentials: EndpointScopedCredentials, } impl MemoryBackendImpl { @@ -150,8 +223,7 @@ impl MemoryBackendImpl { stale_claim_secs: 60, session_id: String::new(), search_source: "tool", - api_key_provider: None, - auth_credentials: None, + embedding_credentials: EndpointScopedCredentials::none(), search_counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)), } } @@ -200,8 +272,7 @@ impl MemoryBackendImpl { async fn make_embedding_provider(&self) -> Option { build_embedding_provider( self.embed_config.as_ref(), - self.auth_credentials.as_ref(), - self.api_key_provider.as_ref(), + &self.embedding_credentials, self.embed_api_key.as_deref(), &self.embed_base_url, ) @@ -232,8 +303,7 @@ impl MemoryBackendImpl { if let Some(w) = ¶ms.watcher { backend = backend.with_watcher(w.clone(), params.stale_claim_secs); } - backend.api_key_provider = params.api_key_provider.clone(); - backend.auth_credentials = params.auth_credentials.clone(); + backend.embedding_credentials = params.embedding_credentials.clone(); backend } } @@ -543,8 +613,7 @@ mod factory_tests { watcher: None, stale_claim_secs: 60, search_source: "tool", - api_key_provider: None, - auth_credentials: None, + embedding_credentials: EndpointScopedCredentials::none(), } } @@ -1147,9 +1216,13 @@ mod factory_tests { watcher: None, stale_claim_secs: 60, search_source: "tool", - api_key_provider: Some(probe), - // No auth_credentials — forces the api_key_provider fallback path. - auth_credentials: None, + // Trusted endpoint + no auth_credentials exercises the api_key_provider path. + embedding_credentials: EndpointScopedCredentials::for_endpoint( + "http://example/v1", + |_| true, + None, + Some(probe), + ), }; let provider = params.make_embedding_provider().await; @@ -1177,6 +1250,15 @@ mod tests { use tempfile::TempDir; use xai_grok_config_types::MemoryIndexConfig; + /// An api-key provider that fails the test if its key is ever resolved, + /// proving a scoped-away credential is never consulted. + struct PanicKey; + impl xai_grok_tools::types::ApiKeyProvider for PanicKey { + fn current_api_key(&self) -> Option { + panic!("scoped-away credential must not be resolved"); + } + } + fn setup_index(tmp: &TempDir) -> (PathBuf, MemoryStorage) { init_sqlite_vec(); let global = tmp.path().join("memory"); @@ -1222,6 +1304,125 @@ mod tests { assert_send_sync::(); } + /// If credentials approved for one endpoint are used to build against a + /// different URL (a wiring bug), they are dropped at build time rather than + /// sent to the wrong endpoint. The session provider would panic if resolved. + #[tokio::test] + async fn test_build_drops_credentials_when_request_url_differs() { + let session: xai_grok_tools::types::SharedApiKeyProvider = Arc::new(PanicKey); + + let scoped = EndpointScopedCredentials::for_endpoint( + "https://api.x.ai/v1", + |_| true, + None, + Some(session), + ); + assert!(!scoped.is_empty(), "trusted endpoint keeps the credential"); + + let config = xai_grok_config_types::MemoryEmbeddingConfig { + model: Some("test-embedding-model".to_string()), + ..Default::default() + }; + let provider = build_embedding_provider( + Some(&config), + &scoped, + Some("byok-static-key"), + "https://other.example/v1", + ) + .await; + assert!( + provider.is_some(), + "mismatched request URL must fall back to the static key, not the scoped credential" + ); + } + + /// A trusted, URL-matching endpoint builds the provider from the + /// refresh-capable session credential and never consults the per-call + /// api-key provider. The api-key provider panics if resolved. + #[tokio::test] + async fn test_trusted_endpoint_prefers_session_credential() { + struct StubAuth; + impl xai_grok_auth::HttpAuth for StubAuth { + fn apply( + &self, + builder: reqwest::RequestBuilder, + _base_url: &str, + ) -> reqwest::RequestBuilder { + builder + } + } + #[async_trait::async_trait] + impl xai_grok_auth::AuthCredentialProvider for StubAuth { + fn snapshot(&self) -> xai_grok_auth::CredentialSnapshot { + xai_grok_auth::CredentialSnapshot::default() + } + async fn refresh_after_unauthorized(&self) -> bool { + false + } + } + + let auth: Arc = Arc::new(StubAuth); + let api_key: xai_grok_tools::types::SharedApiKeyProvider = Arc::new(PanicKey); + let scoped = EndpointScopedCredentials::for_endpoint( + "https://api.x.ai/v1", + |_| true, + Some(auth), + Some(api_key), + ); + assert!(!scoped.is_empty(), "trusted endpoint keeps the credential"); + + let config = xai_grok_config_types::MemoryEmbeddingConfig { + model: Some("test-embedding-model".to_string()), + ..Default::default() + }; + let provider = + build_embedding_provider(Some(&config), &scoped, None, "https://api.x.ai/v1").await; + assert!( + provider.is_some(), + "trusted endpoint must build a provider from the session credential" + ); + } + + #[test] + fn endpoint_scoped_credentials_trust_gate_and_url_match() { + struct AnyKey; + impl xai_grok_tools::types::ApiKeyProvider for AnyKey { + fn current_api_key(&self) -> Option { + None + } + } + let key = || Arc::new(AnyKey) as xai_grok_tools::types::SharedApiKeyProvider; + + let denied = EndpointScopedCredentials::for_endpoint( + "https://byok.example/v1", + |_| false, + None, + Some(key()), + ); + assert!(denied.is_empty(), "untrusted endpoint drops the credential"); + + let scoped = EndpointScopedCredentials::for_endpoint( + "https://api.x.ai/v1", + |_| true, + None, + Some(key()), + ); + assert!(!scoped.is_empty(), "trusted endpoint keeps the credential"); + assert!( + scoped.approved_for("https://API.x.ai/v1"), + "host casing normalizes" + ); + assert!( + !scoped.approved_for("https://api.x.ai/v2"), + "different path rejected" + ); + assert!( + !scoped.approved_for("https://other.example/v1"), + "different host rejected" + ); + assert!(!scoped.approved_for("not-a-url"), "unparsable fails closed"); + } + #[tokio::test] async fn test_search_with_punctuation_in_query() { let tmp = TempDir::new().unwrap(); diff --git a/crates/codegen/xai-grok-memory/src/lib.rs b/crates/codegen/xai-grok-memory/src/lib.rs index b8f1b7b..c891d30 100644 --- a/crates/codegen/xai-grok-memory/src/lib.rs +++ b/crates/codegen/xai-grok-memory/src/lib.rs @@ -37,7 +37,7 @@ pub mod storage; pub mod text_utils; pub mod watcher; -pub use backend::{MemoryBackendImpl, MemoryBackendParams}; +pub use backend::{EndpointScopedCredentials, MemoryBackendImpl, MemoryBackendParams}; pub use index::{MemoryIndex, init_sqlite_vec}; pub use storage::{MemoryScope, MemoryStorage}; diff --git a/crates/codegen/xai-grok-pager-bin/Cargo.toml b/crates/codegen/xai-grok-pager-bin/Cargo.toml index a102002..06e359a 100644 --- a/crates/codegen/xai-grok-pager-bin/Cargo.toml +++ b/crates/codegen/xai-grok-pager-bin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager-bin" -version = "0.2.101" +version = "0.2.102" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager-minimal/src/panel.rs b/crates/codegen/xai-grok-pager-minimal/src/panel.rs index cdf1b23..0a77f4b 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/panel.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/panel.rs @@ -530,6 +530,9 @@ mod tests { status, tool_count: tools, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: Vec::new(), enabled: true, source: "local".to_string(), diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/content.rs b/crates/codegen/xai-grok-pager-pty-harness/src/content.rs index d98088d..3e7838b 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/content.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/content.rs @@ -19,7 +19,9 @@ pub use xai_grok_test_support::mock_server::MockModelEntry as MockModel; pub use xai_grok_test_support::mock_server::StorageUpload; // SSE event builders for `enqueue_response` scripts (reasoning turns etc.). pub use xai_grok_test_support::sse; -pub use xai_grok_test_support::{ScriptedResponse, SseEvent}; +pub use xai_grok_test_support::{ + InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher, ScriptedResponse, SseEvent, +}; /// Drives content into the pager by serving a mock inference endpoint that /// the bundled shell agent hits for `/v1/chat/completions` and `/v1/responses`. @@ -96,21 +98,9 @@ impl ContentController { ("GROK_TELEMETRY_ENABLED".into(), "false".into()), ("GROK_FEEDBACK_ENABLED".into(), "false".into()), ("GROK_TRACE_UPLOAD".into(), "false".into()), - // Next-prompt autocomplete fires an extra background model call - // at every turn end (default ON). Off by default in PTY tests so - // the mock's fixed response can't leak in as ghost text and - // scripted per-path FIFOs aren't consumed by it. Tests exercising - // the feature re-enable it via extra env. + // Keep unrelated autocomplete work out of PTY timing assertions. ("GROK_PROMPT_SUGGESTIONS".into(), "false".into()), - // No inference retries in tests. The mock always answers 200, so a - // retry only ever fires when a turn is deliberately stalled - // (`hold_agent_completions` / a long `chunk_delay`). On a slow - // runner that stall can exceed the client's first-token budget and - // retry the request — and because the mock serves `set_agent_turns` - // by popping one response per REQUEST, a retry consumes the next - // turn's slot, misaligning every following turn (the promoted queue - // prompt then hangs waiting for a response that was already popped). - // Pinning retries to 0 keeps one request == one turn. + // Compatibility set_turns remains request-FIFO, so retries stay off. ("GROK_MAX_RETRIES".into(), "0".into()), ] } @@ -140,11 +130,11 @@ impl ContentController { self.server.set_chunk_delay(delay); } - /// Hold every agent turn's completion until [`release_agent_completions`] - /// is called. Keeps a turn deterministically "streaming" so a test can - /// interact with it (queue edits/removals) without racing turn end. + /// Hold foreground completions until [`release_agent_completions`]. + /// Prefer [`expect_response_blocked`] for new tests. /// /// [`release_agent_completions`]: Self::release_agent_completions + /// [`expect_response_blocked`]: Self::expect_response_blocked pub fn hold_agent_completions(&self) { self.server.hold_agent_completions(); } @@ -157,8 +147,27 @@ impl ContentController { self.server.release_agent_completions(); } - /// Queue one response per agent turn (FIFO) so each carries a distinct - /// sentinel. See [`MockInferenceServer::set_agent_turns`]. + /// Register a named response for the next matching inference request. + pub fn expect_response( + &self, + name: impl Into, + matcher: InferenceRequestMatcher, + response: ScriptedResponse, + ) -> InferenceExpectation { + self.server.expect_response(name, matcher, response) + } + + /// Register one named response held immediately before its terminal event. + pub fn expect_response_blocked( + &self, + name: impl Into, + matcher: InferenceRequestMatcher, + response: ScriptedResponse, + ) -> InferenceExpectation { + self.server.expect_response_blocked(name, matcher, response) + } + + /// Queue one compatibility response per foreground turn. pub fn set_turns(&self, turns: impl IntoIterator) { self.server.set_agent_turns(turns); } diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs b/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs index e3b221f..7b70558 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs @@ -35,7 +35,10 @@ pub mod scripted; pub mod scroll_matrix; pub mod timing; -pub use content::{ContentController, MockModel, ScriptedResponse, SseEvent, sse}; +pub use content::{ + ContentController, InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher, MockModel, + ScriptedResponse, SseEvent, sse, +}; pub use env::pager_binary; pub use flows::{ inference_request_count, oauth_env_for_pager, seed_fake_oauth, submit_turn, diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/pty.rs b/crates/codegen/xai-grok-pager-pty-harness/src/pty.rs index 998ea75..b38c048 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/pty.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/pty.rs @@ -228,6 +228,8 @@ impl Drop for PtyController { } } +const CLIPBOARD_SINK_ENV_VARS: &[&str] = &["GROK_OSC52_SINK", "LC_GROK_OSC52_SINK"]; + /// Host terminal identity markers stripped from the child environment. /// /// The pager's terminal detection @@ -305,6 +307,12 @@ fn apply_child_env(cmd: &mut CommandBuilder, env: &[(&str, &str)]) { for ssh_var in ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "SSH_AUTH_SOCK"] { cmd.env_remove(ssh_var); } + // A harness launched under `grok wrap` must not silently confirm clipboard + // delivery for no-sink scenarios. Explicit sink tests re-inject a marker + // through `env` after this hygiene pass. + for sink_var in CLIPBOARD_SINK_ENV_VARS { + cmd.env_remove(sink_var); + } // Neutralize parent-terminal identity bleed: agent hosts often export // TERM_PROGRAM=ghostty/iTerm/etc. (and mux/editor markers) which make // the child pager adopt that host's key/modifier/clipboard quirks even @@ -362,6 +370,9 @@ mod tests { for color_var in ["NO_COLOR", "CLICOLOR", "CLICOLOR_FORCE"] { cmd.env(color_var, "polluted"); } + for sink_var in CLIPBOARD_SINK_ENV_VARS { + cmd.env(sink_var, "polluted"); + } // Unrelated vars must survive the hygiene pass untouched. cmd.env("GROK_SCROLL_LOG", "/tmp/scroll.jsonl"); @@ -385,6 +396,12 @@ mod tests { "color override {color_var} leaked into the child env" ); } + for sink_var in CLIPBOARD_SINK_ENV_VARS { + assert!( + cmd.get_env(sink_var).is_none(), + "clipboard sink marker {sink_var} leaked into the child env" + ); + } assert_eq!( cmd.get_env("TERM").and_then(|v| v.to_str()), Some("xterm-256color") @@ -411,6 +428,7 @@ mod tests { ("TERM_PROGRAM", "vscode"), ("NVIM", "/tmp/fake-nvim.sock"), ("TERM", "xterm-kitty"), + ("GROK_OSC52_SINK", "1"), ], ); @@ -431,5 +449,10 @@ mod tests { cmd.get_env("TERM").and_then(|v| v.to_str()), Some("xterm-kitty") ); + assert_eq!( + cmd.get_env("GROK_OSC52_SINK").and_then(|v| v.to_str()), + Some("1"), + "explicit sink scenarios must be able to re-inject the marker" + ); } } diff --git a/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs b/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs index 040587e..7517b10 100644 --- a/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs +++ b/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs @@ -3,10 +3,13 @@ //! Re-exports [`ClipboardProvider`] and [`InternalClipboard`] from //! `xai-ratatui-textarea`, and adds [`SystemClipboard`] backed by `arboard`. //! -//! Multi-fire writes (native / tmux / OSC 52); user-facing success is [`trust`]. +//! Multi-fire writes (native / tmux / OSC 52); delivery evidence is [`ClipboardDelivery`]. mod trust; +pub use trust::{ + ClipboardDelivery, NativeClipboardPreflight, expected_delivery, native_clipboard_preflight, +}; pub use xai_ratatui_textarea::{ClipboardProvider, InternalClipboard}; use std::sync::OnceLock; @@ -182,10 +185,10 @@ fn write_tmux_buffer(text: &str) -> bool { pub struct SystemClipboard; impl SystemClipboard { - /// Full write route; `true` when a trusted leg succeeded ([`trust`]). - pub fn try_set(text: &str) -> bool { + /// Full write route classified by the environment-based delivery policy. + pub fn try_set(text: &str) -> ClipboardDelivery { let legs = clipboard_write_with_route(text, clipboard_route()); - toast_for_legs(&legs, text).reported_success() + decision_for_legs(&legs, text).delivery } } @@ -266,16 +269,17 @@ pub struct CopyResult { pub message: &'static str, /// Toast duration in ticks (30fps: 30 = ~1s, 120 = ~4s). pub ticks: u8, - pub success: bool, + /// Evidence that the write reached the destination named by the UI. + pub delivery: ClipboardDelivery, } -/// Kind of clipboard copy toast (success route or failure). +/// Kind of clipboard feedback (success route, unverified send, or failure). /// /// Telemetry labels come from `IntoStaticStr` (`snake_case`); user-facing copy -/// lives in [`ClipboardToastKind::message`] (intentionally different). +/// lives in [`ClipboardFeedback::message`] (intentionally different). #[derive(Debug, Clone, Copy, Eq, PartialEq, strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] -pub(crate) enum ClipboardToastKind { +pub(crate) enum ClipboardFeedback { /// Plain successful copy (native clipboard). Copied, /// Successful copy mirrored into the tmux paste buffer. @@ -284,28 +288,33 @@ pub(crate) enum ClipboardToastKind { CopiedOscContainer, /// Successful copy via OSC 52 over SSH/remote. CopiedOscRemote, - /// VS Code over SSH/remote + non-ASCII: OSC 52 may mojibake; prefer Shift+select. + /// OSC 52 emitted over SSH, but the outer terminal's support is unknown. + UnverifiedOscRemote, + /// OSC 52 emitted from a displayless container with unknown outer support. + UnverifiedOscContainer, + /// VS Code over SSH/remote + non-ASCII: OSC 52 may mojibake. VsCodeSshNonAscii, - /// All trusted clipboard backends failed. + /// No route reached the user's local clipboard from a remote/container topology. + FailedRemote, + /// All trusted clipboard backends failed in a local topology. Failed, } -impl ClipboardToastKind { +impl ClipboardFeedback { /// User-facing toast message for this kind. fn message(self) -> &'static str { match self { Self::Copied => "Copied!", Self::CopiedTmux => "Copied to tmux buffer, paste with prefix + ]", - Self::CopiedOscContainer => { - "Copied via OSC 52 (container). If paste fails, hold Shift (or Fn) and drag to select & copy natively." - } - Self::CopiedOscRemote => { - "Copied via OSC 52. If paste fails, hold Shift (or Fn) and drag to select & copy natively." + Self::CopiedOscContainer => "Copied via OSC 52 from the container.", + Self::CopiedOscRemote => "Copied via OSC 52.", + Self::UnverifiedOscRemote | Self::UnverifiedOscContainer => { + "Copy sent. If paste fails, use grok wrap or /minimal." } Self::VsCodeSshNonAscii => { - "Copied. In case VSCode via SSH garbles non-ASCII text, use native copy (shift+select)." + "Copied. VS Code over SSH may garble non-ASCII; use /minimal if needed." } - Self::Failed => "Copy failed. Try /minimal for terminal native rendering", + Self::FailedRemote | Self::Failed => "Copy failed. Try /terminal-setup or /minimal.", } } @@ -316,35 +325,40 @@ impl ClipboardToastKind { Self::CopiedTmux | Self::CopiedOscContainer | Self::CopiedOscRemote + | Self::UnverifiedOscRemote + | Self::UnverifiedOscContainer | Self::VsCodeSshNonAscii + | Self::FailedRemote | Self::Failed => 120, } } - pub(crate) fn reported_success(self) -> bool { - !matches!(self, Self::Failed) - } - - fn to_result(self) -> CopyResult { + fn to_result(self, delivery: ClipboardDelivery) -> CopyResult { CopyResult { message: self.message(), ticks: self.ticks(), - success: self.reported_success(), + delivery, } } } -fn toast_for_legs(legs: &ClipboardWriteLegs, text: &str) -> ClipboardToastKind { - trust::resolve_copy_toast( +fn decision_for_legs(legs: &ClipboardWriteLegs, text: &str) -> trust::ClipboardDecision { + let remote = is_remote(); + let container = is_container_no_display(); + let mut decision = trust::resolve_copy_decision( legs, text, crate::terminal::terminal_context().brand, crate::host::HostOs::current(), crate::host::DisplayServer::current(), - is_remote(), - is_container_no_display(), + remote, + container, osc52_sink_active(), - ) + ); + if decision.delivery == ClipboardDelivery::Failed && (remote || container) { + decision.feedback = ClipboardFeedback::FailedRemote; + } + decision } /// Write text and return a toast; emits `grok-shell-clipboard_copy` when enabled. @@ -352,18 +366,17 @@ pub fn copy_text(text: &str) -> CopyResult { let started = std::time::Instant::now(); let route = clipboard_route(); let legs = clipboard_write_with_route(text, route); - let kind = toast_for_legs(&legs, text); - let success = kind.reported_success(); - if !success { + let decision = decision_for_legs(&legs, text); + if decision.delivery.is_failed() { tracing::warn!( len = text.len(), display_server = %crate::host::DisplayServer::current(), "clipboard write failed on all trusted backends" ); } - let result = kind.to_result(); - let toast_kind: &'static str = kind.into(); - log_clipboard_copy_event(text, route, &legs, success, toast_kind, started); + let result = decision.feedback.to_result(decision.delivery); + let toast_kind: &'static str = decision.feedback.into(); + log_clipboard_copy_event(text, route, &legs, decision, toast_kind, started); result } @@ -371,7 +384,7 @@ fn log_clipboard_copy_event( text: &str, route: &ClipboardRoute, legs: &ClipboardWriteLegs, - reported_success: bool, + decision: trust::ClipboardDecision, toast_kind: &'static str, started: std::time::Instant, ) { @@ -393,7 +406,10 @@ fn log_clipboard_copy_event( data_control: legs.data_control, tmux_ok: legs.tmux_ok, osc52_ok: legs.osc52_ok, - reported_success, + delivery: decision.delivery.telemetry_label(), + osc52_sink: osc52_sink_active(), + container_no_display: is_container_no_display(), + reported_success: decision.delivery.reported_success(), toast_kind, duration_ms: started.elapsed().as_millis() as u64, }); @@ -1672,49 +1688,99 @@ mod tests { } #[test] - fn clipboard_toast_kind_messages_and_telemetry_match_legacy() { - let cases: [(ClipboardToastKind, &str, &str, u8); 6] = [ - (ClipboardToastKind::Copied, "Copied!", "copied", 30), + fn clipboard_feedback_contract() { + let cases: [(ClipboardFeedback, ClipboardDelivery, &str, &str, u8); 9] = [ ( - ClipboardToastKind::CopiedTmux, + ClipboardFeedback::Copied, + ClipboardDelivery::Confirmed, + "Copied!", + "copied", + 30, + ), + ( + ClipboardFeedback::CopiedTmux, + ClipboardDelivery::Confirmed, "Copied to tmux buffer, paste with prefix + ]", "copied_tmux", 120, ), ( - ClipboardToastKind::CopiedOscContainer, - "Copied via OSC 52 (container). If paste fails, hold Shift (or Fn) and drag to select & copy natively.", + ClipboardFeedback::CopiedOscContainer, + ClipboardDelivery::Confirmed, + "Copied via OSC 52 from the container.", "copied_osc_container", 120, ), ( - ClipboardToastKind::CopiedOscRemote, - "Copied via OSC 52. If paste fails, hold Shift (or Fn) and drag to select & copy natively.", + ClipboardFeedback::CopiedOscRemote, + ClipboardDelivery::Confirmed, + "Copied via OSC 52.", "copied_osc_remote", 120, ), ( - ClipboardToastKind::VsCodeSshNonAscii, - "Copied. In case VSCode via SSH garbles non-ASCII text, use native copy (shift+select).", + ClipboardFeedback::UnverifiedOscRemote, + ClipboardDelivery::Unverified, + "Copy sent. If paste fails, use grok wrap or /minimal.", + "unverified_osc_remote", + 120, + ), + ( + ClipboardFeedback::UnverifiedOscContainer, + ClipboardDelivery::Unverified, + "Copy sent. If paste fails, use grok wrap or /minimal.", + "unverified_osc_container", + 120, + ), + ( + ClipboardFeedback::VsCodeSshNonAscii, + ClipboardDelivery::Confirmed, + "Copied. VS Code over SSH may garble non-ASCII; use /minimal if needed.", "vs_code_ssh_non_ascii", 120, ), ( - ClipboardToastKind::Failed, - "Copy failed. Try /minimal for terminal native rendering", + ClipboardFeedback::FailedRemote, + ClipboardDelivery::Failed, + "Copy failed. Try /terminal-setup or /minimal.", + "failed_remote", + 120, + ), + ( + ClipboardFeedback::Failed, + ClipboardDelivery::Failed, + "Copy failed. Try /terminal-setup or /minimal.", "failed", 120, ), ]; - for (kind, message, telemetry, ticks) in cases { - let label: &'static str = kind.into(); - assert_eq!(kind.message(), message, "message for {kind:?}"); - assert_eq!(label, telemetry, "telemetry for {kind:?}"); - assert_eq!(kind.ticks(), ticks, "ticks for {kind:?}"); - let result = kind.to_result(); + for (feedback, delivery, message, telemetry, ticks) in cases { + let result = feedback.to_result(delivery); + assert_eq!(feedback.message(), message); + assert_eq!(Into::<&'static str>::into(feedback), telemetry); assert_eq!(result.message, message); assert_eq!(result.ticks, ticks); - assert_eq!(result.success, kind.reported_success()); + assert_eq!(result.delivery, delivery); } } + + #[test] + fn clipboard_fallbacks_are_short_and_actionable() { + for feedback in [ + ClipboardFeedback::UnverifiedOscRemote, + ClipboardFeedback::UnverifiedOscContainer, + ClipboardFeedback::FailedRemote, + ClipboardFeedback::Failed, + ] { + assert!(feedback.message().chars().count() + 4 < 80, "{feedback:?}"); + assert!(!feedback.message().contains("Shift"), "{feedback:?}"); + assert!(!feedback.message().contains("Fn"), "{feedback:?}"); + assert!(feedback.message().contains("/minimal"), "{feedback:?}"); + } + assert!( + ClipboardFeedback::UnverifiedOscRemote + .message() + .contains("grok wrap") + ); + } } diff --git a/crates/codegen/xai-grok-pager-render/src/clipboard/trust.rs b/crates/codegen/xai-grok-pager-render/src/clipboard/trust.rs index 602b4db..ca03dcd 100644 --- a/crates/codegen/xai-grok-pager-render/src/clipboard/trust.rs +++ b/crates/codegen/xai-grok-pager-render/src/clipboard/trust.rs @@ -1,14 +1,132 @@ -//! Trusted success / toast policy for clipboard writes. +//! Environment-based delivery and toast policy for clipboard writes. //! -//! Writes still multi-fire every backend; this module decides whether we tell -//! the user it worked based on legs that actually reach the pasteboard they use. +//! Writes still multi-fire every backend; this module classifies whether a +//! successful leg is known to reach the destination named by the UI. use crate::host::{DisplayServer, HostOs}; use crate::terminal::TerminalName; -use super::{ClipboardToastKind, ClipboardWriteLegs}; +use super::{ClipboardFeedback, ClipboardWriteLegs}; -/// True when native legs wrote the **local** OS clipboard (not SSH/container). +/// Grok's evidence that a clipboard write reached its intended destination. +#[derive(Debug, Clone, Copy, Eq, PartialEq, strum::IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub enum ClipboardDelivery { + /// A successful write leg has a destination trusted by the environment policy. + Confirmed, + /// OSC 52 was emitted, but the outer terminal's clipboard support is unknown. + Unverified, + /// No usable write leg succeeded, or the destination is known not to support it. + Failed, +} + +impl ClipboardDelivery { + pub fn is_failed(self) -> bool { + self == Self::Failed + } + + pub fn reported_success(self) -> bool { + !self.is_failed() + } + + pub fn telemetry_label(self) -> &'static str { + self.into() + } +} + +/// Native clipboard route evidence available before a copy is attempted. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum NativeClipboardPreflight { + Disabled, + LocalAvailable, + RemoteOnly, + Unavailable, +} + +fn trusted_wayland_native(wl_copy: bool, arboard: bool, data_control: bool) -> bool { + wl_copy || (arboard && data_control) +} + +/// Classify the configured native route without claiming that a write succeeded. +pub fn native_clipboard_preflight( + route_native: bool, + host_os: HostOs, + display_server: DisplayServer, + remote: bool, + container: bool, + wayland_data_control: bool, + wl_copy_available: bool, +) -> NativeClipboardPreflight { + if !route_native { + return NativeClipboardPreflight::Disabled; + } + if remote || container { + return NativeClipboardPreflight::RemoteOnly; + } + match host_os { + HostOs::Linux => match display_server { + DisplayServer::Wayland + if trusted_wayland_native(wl_copy_available, true, wayland_data_control) => + { + NativeClipboardPreflight::LocalAvailable + } + DisplayServer::Wayland | DisplayServer::Unknown => { + NativeClipboardPreflight::Unavailable + } + DisplayServer::X11 => NativeClipboardPreflight::LocalAvailable, + DisplayServer::Quartz | DisplayServer::Win32 => NativeClipboardPreflight::Unavailable, + }, + HostOs::Macos | HostOs::Windows => NativeClipboardPreflight::LocalAvailable, + HostOs::Other => NativeClipboardPreflight::Unavailable, + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) struct ClipboardDecision { + pub(crate) delivery: ClipboardDelivery, + pub(crate) feedback: ClipboardFeedback, +} + +/// Classify one emitted OSC 52 write using the existing environment policy. +pub(crate) fn osc52_delivery( + brand: TerminalName, + remote: bool, + container: bool, + osc52_sink: bool, +) -> ClipboardDelivery { + if osc52_sink || brand.supports_osc52_clipboard() { + ClipboardDelivery::Confirmed + } else if brand == TerminalName::Unknown && (remote || container) { + ClipboardDelivery::Unverified + } else { + ClipboardDelivery::Failed + } +} + +/// Expected preflight confidence for an enabled clipboard route. +pub fn expected_delivery( + native: NativeClipboardPreflight, + route_tmux: bool, + route_osc52: bool, + brand: TerminalName, + remote: bool, + container: bool, + osc52_sink: bool, +) -> ClipboardDelivery { + if native == NativeClipboardPreflight::LocalAvailable { + return ClipboardDelivery::Confirmed; + } + let osc52 = route_osc52.then(|| osc52_delivery(brand, remote, container, osc52_sink)); + if osc52 == Some(ClipboardDelivery::Confirmed) || route_tmux { + return ClipboardDelivery::Confirmed; + } + if osc52 == Some(ClipboardDelivery::Unverified) { + return ClipboardDelivery::Unverified; + } + ClipboardDelivery::Failed +} + +/// True when native legs wrote the local OS clipboard rather than a remote host. pub(crate) fn trusted_native( legs: &ClipboardWriteLegs, host_os: HostOs, @@ -21,54 +139,18 @@ pub(crate) fn trusted_native( } match host_os { HostOs::Linux => match display_server { - // A verified wl-copy write, or an arboard write that went through - // the compositor's data-control protocol (focus-free, no XWayland - // bridge). Without data-control, arboard only reached the X11 side - // and the Wayland paste may never see it. - DisplayServer::Wayland => legs.wl_copy_ok || (legs.arboard_ok && legs.data_control), + DisplayServer::Wayland => { + trusted_wayland_native(legs.wl_copy_ok, legs.arboard_ok, legs.data_control) + } _ => legs.cli_ok || legs.arboard_ok, }, HostOs::Macos | HostOs::Windows | HostOs::Other => legs.cli_ok || legs.arboard_ok, } } -/// True when an OSC 52 write reaches the user's real clipboard. -/// -/// Normally this requires the detected terminal brand to natively apply OSC 52 -/// to the system pasteboard (fail closed). Two overrides widen the brand gate: -/// -/// - `osc52_sink`: when `grok wrap` is capturing this process's output (see -/// [`super::osc52_sink_active`]) the escape sequence is intercepted upstream -/// and copied to the *local* clipboard regardless of the (often misdetected, -/// e.g. over SSH) inner terminal brand, so the copy is trusted. -/// - `container` + `Unknown` brand: inside a container without a display server -/// (docker/podman), native legs *cannot* reach the user's pasteboard and the -/// container runtime does not forward brand env vars (`WT_SESSION`, -/// `TERM_PROGRAM`, …), so the brand is `Unknown` even when the outer terminal -/// (Windows Terminal, iTerm2, Ghostty, …) applies OSC 52 fine. Failing closed -/// here would mis-report *every* container copy as failed (GB report: -/// "Copy failed" toast in docker from Windows PowerShell while the copy -/// landed). The `CopiedOscContainer` toast already hedges with a fallback -/// instruction, so trust the emitted escape. A *detected* non-supporting -/// brand (env explicitly forwarded) stays fail-closed. -pub(crate) fn trusted_osc( - legs: &ClipboardWriteLegs, - brand: TerminalName, - container: bool, - osc52_sink: bool, -) -> bool { - legs.osc52_ok - && (brand.supports_osc52_clipboard() - || osc52_sink - || (container && brand == TerminalName::Unknown)) -} - -/// Toast from legs + env: native → OSC (incl. VS Code remote non-ASCII) → tmux → Failed. -// Pure decision function over independent environment inputs (host OS, display -// server, remote/container/sink flags). Bundling them into a struct would only -// move the argument list elsewhere and churn every call site/test. +/// Resolve the user-visible branch and delivery classification together. #[allow(clippy::too_many_arguments)] -pub(crate) fn resolve_copy_toast( +pub(crate) fn resolve_copy_decision( legs: &ClipboardWriteLegs, text: &str, brand: TerminalName, @@ -77,628 +159,404 @@ pub(crate) fn resolve_copy_toast( remote: bool, container: bool, osc52_sink: bool, -) -> ClipboardToastKind { +) -> ClipboardDecision { + let decision = |delivery, feedback| ClipboardDecision { delivery, feedback }; if trusted_native(legs, host_os, display_server, remote, container) { - return ClipboardToastKind::Copied; + return decision(ClipboardDelivery::Confirmed, ClipboardFeedback::Copied); } - if trusted_osc(legs, brand, container, osc52_sink) { - if remote && brand.is_vscode_family() && !text.is_ascii() { - return ClipboardToastKind::VsCodeSshNonAscii; + if legs.osc52_ok { + match osc52_delivery(brand, remote, container, osc52_sink) { + ClipboardDelivery::Confirmed => { + let feedback = if remote && brand.is_vscode_family() && !text.is_ascii() { + ClipboardFeedback::VsCodeSshNonAscii + } else if container { + ClipboardFeedback::CopiedOscContainer + } else if remote { + ClipboardFeedback::CopiedOscRemote + } else { + ClipboardFeedback::Copied + }; + return decision(ClipboardDelivery::Confirmed, feedback); + } + ClipboardDelivery::Unverified if !legs.tmux_ok => { + let feedback = if remote { + ClipboardFeedback::UnverifiedOscRemote + } else { + ClipboardFeedback::UnverifiedOscContainer + }; + return decision(ClipboardDelivery::Unverified, feedback); + } + ClipboardDelivery::Unverified | ClipboardDelivery::Failed => {} } - // Container before remote (matches prior route-flag toast order). - if container { - return ClipboardToastKind::CopiedOscContainer; - } - if remote { - return ClipboardToastKind::CopiedOscRemote; - } - return ClipboardToastKind::Copied; } if legs.tmux_ok { - return ClipboardToastKind::CopiedTmux; + return decision(ClipboardDelivery::Confirmed, ClipboardFeedback::CopiedTmux); } - ClipboardToastKind::Failed + decision(ClipboardDelivery::Failed, ClipboardFeedback::Failed) } #[cfg(test)] mod tests { use super::*; - use crate::clipboard::ClipboardWriteLegs; fn legs( - route_native: bool, cli_ok: bool, arboard_ok: bool, + data_control: bool, tmux_ok: bool, osc52_ok: bool, cli_ok_tools: &str, ) -> ClipboardWriteLegs { ClipboardWriteLegs { - route_native, + route_native: true, route_label: "test".into(), cli_tools_tried: String::new(), cli_ok_tools: cli_ok_tools.into(), - wl_copy_ok: cli_ok_tools.split('+').any(|t| t == "wl-copy"), + wl_copy_ok: cli_ok_tools.split('+').any(|tool| tool == "wl-copy"), cli_ok, arboard_ok, - data_control: false, + data_control, tmux_ok, osc52_ok, } } - /// Same as [`legs`] with the Wayland data-control flag set. - fn legs_data_control( - route_native: bool, - cli_ok: bool, - arboard_ok: bool, - tmux_ok: bool, - osc52_ok: bool, - cli_ok_tools: &str, - ) -> ClipboardWriteLegs { - ClipboardWriteLegs { - data_control: true, - ..legs( - route_native, - cli_ok, - arboard_ok, - tmux_ok, - osc52_ok, - cli_ok_tools, - ) - } - } - + #[allow(clippy::too_many_arguments)] fn resolve( legs: &ClipboardWriteLegs, + text: &str, brand: TerminalName, host_os: HostOs, display_server: DisplayServer, remote: bool, container: bool, - ) -> ClipboardToastKind { - resolve_copy_toast( + osc52_sink: bool, + ) -> ClipboardDecision { + resolve_copy_decision( legs, - "hello", + text, brand, host_os, display_server, remote, container, - false, + osc52_sink, ) } #[test] - fn macos_local_native_ok() { - let l = legs(true, true, false, false, false, "pbcopy"); - assert_eq!( - resolve( - &l, - TerminalName::Ghostty, - HostOs::Macos, - DisplayServer::Quartz, - false, - false - ), - ClipboardToastKind::Copied - ); + fn telemetry_projection_labels_and_historical_boolean_are_pinned() { + for (delivery, label, reported_success) in [ + (ClipboardDelivery::Confirmed, "confirmed", true), + (ClipboardDelivery::Unverified, "unverified", true), + (ClipboardDelivery::Failed, "failed", false), + ] { + assert_eq!(delivery.telemetry_label(), label); + assert_eq!(delivery.reported_success(), reported_success); + } } #[test] - fn macos_apple_terminal_osc_only_fails() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::AppleTerminal, - HostOs::Macos, - DisplayServer::Quartz, - false, - false - ), - ClipboardToastKind::Failed + fn local_trusted_native_is_confirmed() { + let decision = resolve( + &legs(true, false, false, false, false, "pbcopy"), + "hello", + TerminalName::Ghostty, + HostOs::Macos, + DisplayServer::Quartz, + false, + false, + false, ); - assert!(!TerminalName::AppleTerminal.supports_osc52_clipboard()); + assert_eq!(decision.delivery, ClipboardDelivery::Confirmed); + assert_eq!(decision.feedback, ClipboardFeedback::Copied); } #[test] - fn windows_local_native_ok() { - let l = legs(true, false, true, false, false, ""); - assert_eq!( - resolve( - &l, - TerminalName::WindowsTerminal, - HostOs::Windows, - DisplayServer::Win32, - false, - false - ), - ClipboardToastKind::Copied - ); + fn wayland_native_requires_verified_destination() { + let unverified = legs(false, true, false, false, false, ""); + assert!(!trusted_native( + &unverified, + HostOs::Linux, + DisplayServer::Wayland, + false, + false + )); + let data_control = legs(false, true, true, false, false, ""); + assert!(trusted_native( + &data_control, + HostOs::Linux, + DisplayServer::Wayland, + false, + false + )); + let wl_copy = legs(true, false, false, false, false, "wl-copy"); + assert!(trusted_native( + &wl_copy, + HostOs::Linux, + DisplayServer::Wayland, + false, + false + )); } #[test] - fn linux_x11_xclip_ok() { - let l = legs(true, true, false, false, true, "xclip"); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::X11, - false, - false - ), - ClipboardToastKind::Copied + fn remote_native_write_only_is_failed() { + let decision = resolve( + &legs(true, true, false, false, false, "xclip"), + "hello", + TerminalName::Ghostty, + HostOs::Linux, + DisplayServer::X11, + true, + false, + false, ); + assert_eq!(decision.delivery, ClipboardDelivery::Failed); } #[test] - fn linux_wayland_arboard_only_fails() { - let l = legs(true, false, true, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::Wayland, - false, - false - ), - ClipboardToastKind::Failed - ); - } - - // The enterprise clipboard shape after the fix: no CLI tool installed, but the - // arboard write went through the compositor's data-control protocol, so it - // is trusted native. - #[test] - fn linux_wayland_arboard_data_control_ok() { - let l = legs_data_control(true, false, true, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::Wayland, - false, - false - ), - ClipboardToastKind::Copied - ); - } - - // Without data-control (GNOME <= 47 or kill-switch), an arboard-only write - // keeps the `linux_wayland_arboard_only_fails` semantics. - #[test] - fn linux_wayland_arboard_without_data_control_still_fails() { - let l = legs(true, false, true, false, false, ""); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::Wayland, - false, - false - ), - ClipboardToastKind::Failed - ); - } - - // Data-control grants nothing when the arboard write itself failed. - #[test] - fn linux_wayland_data_control_without_arboard_fails() { - let l = legs_data_control(true, false, false, false, false, ""); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::Wayland, - false, - false - ), - ClipboardToastKind::Failed + fn known_osc_capable_terminal_is_confirmed() { + let decision = resolve( + &legs(false, false, false, false, true, ""), + "hello", + TerminalName::Ghostty, + HostOs::Linux, + DisplayServer::Unknown, + true, + false, + false, ); + assert_eq!(decision.delivery, ClipboardDelivery::Confirmed); + assert_eq!(decision.feedback, ClipboardFeedback::CopiedOscRemote); } #[test] - fn linux_wayland_wl_copy_ok() { - let l = legs(true, true, false, false, true, "wl-copy"); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::Wayland, - false, - false - ), - ClipboardToastKind::Copied + fn ssh_unknown_brand_osc_is_unverified() { + let decision = resolve( + &legs(false, false, false, false, true, ""), + "hello", + TerminalName::Unknown, + HostOs::Linux, + DisplayServer::Unknown, + true, + false, + false, ); + assert_eq!(decision.delivery, ClipboardDelivery::Unverified); + assert_eq!(decision.feedback, ClipboardFeedback::UnverifiedOscRemote); } #[test] - fn linux_wayland_xclip_only_not_trusted_native() { - let l = legs(true, true, true, false, true, "xclip"); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::Wayland, - false, - false - ), - ClipboardToastKind::Failed + fn container_unknown_brand_osc_is_unverified() { + let decision = resolve( + &legs(false, false, false, false, true, ""), + "hello", + TerminalName::Unknown, + HostOs::Linux, + DisplayServer::Unknown, + false, + true, + false, ); + assert_eq!(decision.delivery, ClipboardDelivery::Unverified); + assert_eq!(decision.feedback, ClipboardFeedback::UnverifiedOscContainer); } #[test] - fn linux_vte_osc_only_fails() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::X11, - false, - false - ), - ClipboardToastKind::Failed - ); - } - - #[test] - fn ssh_vte_osc_only_fails() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::Unknown, - true, - false - ), - ClipboardToastKind::Failed - ); - } - - #[test] - fn ssh_ghostty_osc_only_remote_toast() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::Ghostty, - HostOs::Linux, - DisplayServer::Unknown, - true, - false - ), - ClipboardToastKind::CopiedOscRemote - ); - } - - #[test] - fn ssh_iterm2_osc_only_remote_toast() { - // Guards the OSC-52 membership invariant the fix depends on. - assert!(TerminalName::Iterm2.supports_osc52_clipboard()); - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::Iterm2, - HostOs::Linux, - DisplayServer::Unknown, - true, - false - ), - ClipboardToastKind::CopiedOscRemote - ); - } - - #[test] - fn local_ghostty_osc_only_copied() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::Ghostty, - HostOs::Linux, - DisplayServer::X11, - false, - false - ), - ClipboardToastKind::Copied - ); - } - - #[test] - fn tmux_only_ok() { - let l = legs(true, false, false, true, false, ""); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::X11, - false, - false - ), - ClipboardToastKind::CopiedTmux - ); - } - - #[test] - fn vscode_ssh_ascii_trusted_osc_remote() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::VsCode, - HostOs::Linux, - DisplayServer::Unknown, - true, - false - ), - ClipboardToastKind::CopiedOscRemote - ); - } - - #[test] - fn vscode_ssh_non_ascii_trusted_osc() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve_copy_toast( - &l, - "café", - TerminalName::VsCode, - HostOs::Linux, - DisplayServer::Unknown, - true, - false, - false, - ), - ClipboardToastKind::VsCodeSshNonAscii - ); - } - - #[test] - fn vscode_ssh_non_ascii_untrusted_osc_fails() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve_copy_toast( - &l, - "café", - TerminalName::Vte, - HostOs::Linux, - DisplayServer::Unknown, - true, - false, - false, - ), - ClipboardToastKind::Failed - ); - } - - #[test] - fn all_fail() { - let l = legs(true, false, false, false, false, ""); - assert_eq!( - resolve( - &l, - TerminalName::Ghostty, - HostOs::Linux, - DisplayServer::X11, - false, - false - ), - ClipboardToastKind::Failed - ); - assert!(!ClipboardToastKind::Failed.reported_success()); - } - - #[test] - fn ssh_remote_native_not_trusted_without_osc() { - let l = legs(true, true, true, false, false, "xclip"); - assert_eq!( - resolve( - &l, - TerminalName::Vte, - HostOs::Linux, - DisplayServer::X11, - true, - false - ), - ClipboardToastKind::Failed - ); - } - - #[test] - fn container_ghostty_osc_container_toast() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::Ghostty, - HostOs::Linux, - DisplayServer::Unknown, - false, - true - ), - ClipboardToastKind::CopiedOscContainer - ); - } - - #[test] - fn remote_and_container_prefers_container_toast() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::Ghostty, - HostOs::Linux, - DisplayServer::Unknown, - true, - true - ), - ClipboardToastKind::CopiedOscContainer - ); - } - - // `grok wrap` sink: a brand that does NOT natively support OSC 52 (the - // common SSH case where the inner terminal is misdetected as Vte/Unknown) - // is still trusted when an upstream OSC 52 sink is capturing our output. - #[test] - fn wrapped_ssh_vte_osc_trusted_via_sink() { - let l = legs(true, false, false, false, true, ""); - // Without the sink: untrusted brand over SSH → Failed. - assert_eq!( - resolve_copy_toast( - &l, + fn known_unsupported_terminal_osc_is_failed() { + for brand in [TerminalName::AppleTerminal, TerminalName::Vte] { + let decision = resolve( + &legs(false, false, false, false, true, ""), "hello", - TerminalName::Vte, + brand, HostOs::Linux, DisplayServer::Unknown, true, false, false, - ), - ClipboardToastKind::Failed - ); - // With the sink active: trusted → success toast. - assert_eq!( - resolve_copy_toast( - &l, + ); + assert_eq!(decision.delivery, ClipboardDelivery::Failed, "{brand:?}"); + } + } + + #[test] + fn active_wrap_sink_with_osc_is_confirmed_for_any_brand() { + for brand in [TerminalName::Unknown, TerminalName::AppleTerminal] { + let decision = resolve( + &legs(false, false, false, false, true, ""), "hello", + brand, + HostOs::Linux, + DisplayServer::Unknown, + true, + false, + true, + ); + assert_eq!(decision.delivery, ClipboardDelivery::Confirmed, "{brand:?}"); + } + } + + #[test] + fn wrap_sink_without_osc_write_is_failed() { + let decision = resolve( + &legs(false, false, false, false, false, ""), + "hello", + TerminalName::Unknown, + HostOs::Linux, + DisplayServer::Unknown, + true, + false, + true, + ); + assert_eq!(decision.delivery, ClipboardDelivery::Failed); + } + + #[test] + fn tmux_success_wins_over_unverified_osc() { + let decision = resolve( + &legs(false, false, false, true, true, ""), + "hello", + TerminalName::Unknown, + HostOs::Linux, + DisplayServer::Unknown, + true, + false, + false, + ); + assert_eq!(decision.delivery, ClipboardDelivery::Confirmed); + assert_eq!(decision.feedback, ClipboardFeedback::CopiedTmux); + } + + #[test] + fn no_successful_leg_is_failed() { + let decision = resolve( + &legs(false, false, false, false, false, ""), + "hello", + TerminalName::Ghostty, + HostOs::Linux, + DisplayServer::Unknown, + true, + false, + false, + ); + assert_eq!(decision.delivery, ClipboardDelivery::Failed); + assert_eq!(decision.feedback, ClipboardFeedback::Failed); + } + + #[test] + fn vscode_ssh_non_ascii_stays_confirmed_with_warning_toast() { + let decision = resolve( + &legs(false, false, false, false, true, ""), + "café", + TerminalName::VsCode, + HostOs::Linux, + DisplayServer::Unknown, + true, + false, + false, + ); + assert_eq!(decision.delivery, ClipboardDelivery::Confirmed); + assert_eq!(decision.feedback, ClipboardFeedback::VsCodeSshNonAscii); + } + + #[test] + fn native_preflight_matches_observed_wayland_trust_matrix() { + for (data_control, wl_copy, expected) in [ + (false, false, NativeClipboardPreflight::Unavailable), + (false, true, NativeClipboardPreflight::LocalAvailable), + (true, false, NativeClipboardPreflight::LocalAvailable), + (true, true, NativeClipboardPreflight::LocalAvailable), + ] { + assert_eq!( + native_clipboard_preflight( + true, + HostOs::Linux, + DisplayServer::Wayland, + false, + false, + data_control, + wl_copy, + ), + expected, + "data_control={data_control} wl_copy={wl_copy}" + ); + } + assert_eq!( + native_clipboard_preflight( + true, + HostOs::Linux, + DisplayServer::Wayland, + true, + false, + true, + true, + ), + NativeClipboardPreflight::RemoteOnly + ); + } + + #[test] + fn expected_delivery_matches_preflight_routes() { + assert_eq!( + expected_delivery( + NativeClipboardPreflight::RemoteOnly, + false, + true, + TerminalName::Unknown, + true, + false, + false, + ), + ClipboardDelivery::Unverified + ); + assert_eq!( + expected_delivery( + NativeClipboardPreflight::RemoteOnly, + false, + true, TerminalName::Vte, - HostOs::Linux, - DisplayServer::Unknown, true, false, - true, + false, ), - ClipboardToastKind::CopiedOscRemote + ClipboardDelivery::Failed ); - } - - // Sink trust still requires an actual OSC 52 write to have happened - // (`osc52_ok`); it never fabricates success when no leg fired. - #[test] - fn wrapped_sink_without_osc_write_still_fails() { - let l = legs(true, false, false, false, false, ""); - assert!(!trusted_osc(&l, TerminalName::Vte, false, true)); assert_eq!( - resolve_copy_toast( - &l, - "hello", + expected_delivery( + NativeClipboardPreflight::RemoteOnly, + false, + true, TerminalName::Vte, - HostOs::Linux, - DisplayServer::Unknown, true, false, true, ), - ClipboardToastKind::Failed + ClipboardDelivery::Confirmed ); - } - - // Docker/podman from Windows PowerShell / cmd (or any host terminal): - // brand env vars are not forwarded into the container, so the brand is - // Unknown; native legs cannot work (no display server). The emitted - // OSC 52 is the copy path and must be trusted → hedged container toast, - // not "Copy failed" (regression test for the false-failure report). - #[test] - fn container_unknown_brand_osc_trusted() { - let l = legs(true, false, false, false, true, ""); assert_eq!( - resolve( - &l, + expected_delivery( + NativeClipboardPreflight::RemoteOnly, + true, + false, TerminalName::Unknown, - HostOs::Linux, - DisplayServer::Unknown, - false, - true - ), - ClipboardToastKind::CopiedOscContainer - ); - } - - // Container trust never fabricates success: no OSC 52 write → Failed. - #[test] - fn container_unknown_brand_without_osc_write_fails() { - let l = legs(true, false, false, false, false, ""); - assert!(!trusted_osc(&l, TerminalName::Unknown, true, false)); - assert_eq!( - resolve( - &l, - TerminalName::Unknown, - HostOs::Linux, - DisplayServer::Unknown, - false, - true - ), - ClipboardToastKind::Failed - ); - } - - // A *detected* non-supporting brand stays fail-closed even in a container - // (env was explicitly forwarded, so the detection is authoritative). - #[test] - fn container_detected_nonsupporting_brand_fails() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::AppleTerminal, - HostOs::Linux, - DisplayServer::Unknown, - false, - true - ), - ClipboardToastKind::Failed - ); - } - - // Unknown brand over SSH (not container) keeps failing closed — the - // container override is deliberately narrow; `grok wrap` is the SSH path. - #[test] - fn ssh_unknown_brand_osc_only_still_fails() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve( - &l, - TerminalName::Unknown, - HostOs::Linux, - DisplayServer::Unknown, true, - false - ), - ClipboardToastKind::Failed - ); - } - - // Sink in a container (no display) → container OSC toast. - #[test] - fn wrapped_container_osc_trusted_via_sink() { - let l = legs(true, false, false, false, true, ""); - assert_eq!( - resolve_copy_toast( - &l, - "hello", - TerminalName::Unknown, - HostOs::Linux, - DisplayServer::Unknown, false, - true, - true, + false, ), - ClipboardToastKind::CopiedOscContainer + ClipboardDelivery::Confirmed + ); + assert_eq!( + expected_delivery( + NativeClipboardPreflight::Unavailable, + false, + false, + TerminalName::Vte, + false, + false, + false, + ), + ClipboardDelivery::Failed ); } } diff --git a/crates/codegen/xai-grok-pager/Cargo.toml b/crates/codegen/xai-grok-pager/Cargo.toml index 2584c2c..aa5a489 100644 --- a/crates/codegen/xai-grok-pager/Cargo.toml +++ b/crates/codegen/xai-grok-pager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager" -version = "0.2.101" +version = "0.2.102" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/07-mcp-servers.md b/crates/codegen/xai-grok-pager/docs/user-guide/07-mcp-servers.md index d5bf3c5..7c6dae6 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/07-mcp-servers.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/07-mcp-servers.md @@ -225,7 +225,7 @@ Use the `url` form for hosted MCP servers and the `command` / `args` form for lo ### Native HTTP (hosted services) -You must authenticate OAuth-based MCP servers before you can use them. Grok stores the resulting tokens under `~/.grok/mcp_credentials.json`. After you edit `config.toml`, press `r` in the `/mcps` modal to refresh the server list. +You must authenticate OAuth-based MCP servers before you can use them. Grok stores the resulting tokens under `~/.grok/mcp_credentials.json` as local plaintext with owner-only file permissions (`0600` on Unix). Prefer full-disk encryption on the host. After you edit `config.toml`, press `r` in the `/mcps` modal to refresh the server list. ```toml [mcp_servers.linear] diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/09-plugins.md b/crates/codegen/xai-grok-pager/docs/user-guide/09-plugins.md index 179a8d8..588b58c 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/09-plugins.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/09-plugins.md @@ -132,11 +132,35 @@ The `` argument accepts: - `user/repo` -- GitHub shorthand - `user/repo@v1.0` -- pinned to a ref +- `user/repo@` -- pinned to an exact commit (verified after fetch) - `user/repo#subdir` -- subdirectory within the repo - `https://github.com/user/repo.git` -- full URL - `git@github.com:user/repo.git` -- SSH - `./local-dir` or `/absolute/path` -- local directory +### Requiring commit pins (`require_sha`) + +Remote plugins are not cryptographically signed: an install that tracks a +branch or tag runs whatever that ref points at tomorrow. Operators can require +every remote install and update to pin a full commit sha (40- or 64-hex, +verified against the fetched checkout): + +```toml +# config.toml +[marketplace] +require_sha = true +``` + +or `GROK_MARKETPLACE_REQUIRE_SHA=1`. Both are tighten-only: either one enables +the policy and neither can switch it back off. With the policy on, unpinned +remote installs, marketplace installs without a published `sha`, and updates of +branch-tracking installs are refused. + +Scope: the policy covers everything fetched from a remote git URL at install or +update time. Plugins vendored inside a marketplace source itself are copied +from that source's synced checkout and are not covered — pin your marketplace +source's content by publishing `sha` entries in `plugin-index.json`. + ### Marketplace commands ```bash diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md b/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md index 87f6918..4b311bb 100644 --- a/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md +++ b/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md @@ -41,7 +41,7 @@ Run this slash command: /terminal-setup ``` -The command reports the terminal, multiplexer, **color level**, **available themes**, and clipboard routes Grok detected, then lists any issues and how to fix them. When color is below truecolor, it explains how to unlock the truecolor-only themes (TokyoNight, RosePineMoon, OscuraMidnight) — or notes that Terminal.app is inherently 256-color. The aliases `/terminal-check` and `/terminal-info` run the same command. +The command reports the terminal, multiplexer, **color level**, **available themes**, and a compact **Clipboard** status table, then lists any issues and fixes. When color is below truecolor, it explains how to unlock the truecolor-only themes (TokyoNight, RosePineMoon, OscuraMidnight) — or notes that Terminal.app is inherently 256-color. The aliases `/terminal-check` and `/terminal-info` run the same command. --- @@ -84,13 +84,13 @@ Detection has these limitations: ### Problem: Clipboard problems -Grok writes to the clipboard through up to three routes, which match the **Clipboard routes** section of `/terminal-setup`: +Grok writes to the clipboard through up to three routes, shown in the **Clipboard** section of `/terminal-setup`: - **native** — Grok always writes to the native OS clipboard first. - **tmux buffer** — inside tmux, Grok also writes to the tmux paste buffer (`tmux load-buffer`). - **OSC 52** — Grok emits the OSC 52 escape sequence so the outer terminal updates its clipboard. Grok always emits OSC 52 inside tmux. Outside tmux, it emits OSC 52 on Linux, over SSH, or in a container without a display. -**Linux Wayland**: on compositors that support the data-control protocol (GNOME 48+, KDE, Sway, Hyprland — the `data-control` line in `/terminal-setup` shows `yes`) copies work even if the terminal loses focus mid-copy. On older compositors (GNOME 46/47), keep the terminal focused until the copy toast confirms, and install the `wl-clipboard` package (provides `wl-copy`) for the most reliable route — Grok shows a startup warning when this applies. If data-control misbehaves on your compositor, set `GROK_CLIPBOARD_NO_DATA_CONTROL=1` to stop Grok from speaking that protocol entirely — copies then go through the CLI tools (`wl-copy`/`xclip`). +**Linux Wayland**: on compositors that support the data-control protocol (GNOME 48+, KDE, Sway, Hyprland — the **Clipboard** section shows `data-control on`; the line is omitted off Wayland) copies work even if the terminal loses focus mid-copy. On older compositors (GNOME 46/47), keep the terminal focused until the copy toast confirms, and install the `wl-clipboard` package (provides `wl-copy`) for the most reliable route — Grok shows a startup warning when this applies. If data-control misbehaves on your compositor, set `GROK_CLIPBOARD_NO_DATA_CONTROL=1` to stop Grok from speaking that protocol entirely — copies then go through the CLI tools (`wl-copy`/`xclip`). **Linux X11 selections**: X11 **PRIMARY** and **CLIPBOARD** are separate. Selecting text usually fills PRIMARY; an explicit Copy action fills CLIPBOARD. In Grok: @@ -100,11 +100,17 @@ Grok writes to the clipboard through up to three routes, which match the **Clipb **SSH and selected text**: a remote Grok process usually cannot read the local terminal's PRIMARY or CLIPBOARD selection. Use terminal-native `Shift+Insert`, or hold `Shift` while middle-clicking when your terminal uses that gesture to bypass mouse reporting. The terminal then sends the local selection through the PTY instead of asking the remote process to access it. +**Unknown terminals over SSH**: when Grok cannot identify the outer terminal, it sends the copy but reports delivery as unverified. If paste fails, reconnect with `grok wrap ` or use `/minimal`. + **Known limitation — Apple Terminal + SSH**: Apple Terminal ignores OSC 52, so copying from a Grok session over SSH can't reach your local clipboard. Use the workaround below. **Temporary workaround**: Use `grok wrap ssh` instead of plain `ssh` (for example, `grok wrap ssh user@host`). It runs the command in a local PTY that intercepts OSC 52 sequences, including tmux-wrapped ones, and writes their contents to your local clipboard. The same command wraps anything else whose clipboard can't reach you — for example `grok wrap docker exec -it bash` or `grok wrap kubectl exec -it -- bash`. +`grok wrap` also protects your local terminal from dirty disconnects: if the wrapped command dies while a remote TUI has mouse reporting, the alternate screen, or similar modes enabled (for example the SSH connection drops mid-session), wrap resets those modes on exit instead of leaving the terminal spraying mouse escape codes. + +When Grok starts inside an SSH session that isn't already running under `grok wrap`, a one-time contextual tip above the prompt recommends `grok wrap ssh ` (it stops appearing on its own once you launch through wrap). To turn it off, set `ssh_wrap = false` under `[ui.contextual_hints]` in `~/.grok/config.toml`, or use `/settings` → **Show contextual hints** → **SSH wrap**. + > **Warning**: `grok wrap` is **experimental** and may misbehave in some setups. **iTerm2 setting**: diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs index df48aae..1971938 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/background.rs @@ -430,16 +430,6 @@ pub(super) fn handle_scheduled_task_inject_prompt( let human_schedule = payload["humanSchedule"].as_str().unwrap_or("unknown"); tracing::debug!(task_id, human_schedule, "Enqueuing scheduled cron prompt"); - let agent = app.agents.values_mut().find(|a| { - a.session - .session_id - .as_ref() - .is_some_and(|sid| sid.0.as_ref() == session_id) - }); - let Some(agent) = agent else { - return false; - }; - // Only the driver injects + runs the scheduled prompt. In leader mode the // `x.ai/scheduled_task_inject_prompt` notification is routed by the leader // to the SINGLE session driver (see `is_scheduled_task_inject_prompt` in @@ -450,28 +440,41 @@ pub(super) fn handle_scheduled_task_inject_prompt( // suppressed cron on an attacher-driver, leaving the loop stuck with no // output. The other clients render the resulting turn from the broadcast // deltas. (The de-dup guards below still prevent a double enqueue.) + let agent_id = { + let agent = app.agents.values_mut().find(|a| { + a.session + .session_id + .as_ref() + .is_some_and(|sid| sid.0.as_ref() == session_id) + }); + let Some(agent) = agent else { + return false; + }; - // Skip if this specific task is already running or queued. - if agent.cron_task_id.as_deref() == Some(task_id) { - tracing::debug!(task_id, "cron prompt skipped: task already running"); - return true; - } - let already_queued = agent - .session - .pending_prompts - .iter() - .any(|p| p.task_id.as_deref() == Some(task_id)); - if already_queued { - tracing::debug!(task_id, "cron prompt already queued, skipping duplicate"); - return true; - } + // Skip if this specific task is already running or queued. + if agent.cron_task_id.as_deref() == Some(task_id) { + tracing::debug!(task_id, "cron prompt skipped: task already running"); + return true; + } + let already_queued = agent + .session + .pending_prompts + .iter() + .any(|p| p.task_id.as_deref() == Some(task_id)); + if already_queued { + tracing::debug!(task_id, "cron prompt already queued, skipping duplicate"); + return true; + } - agent.session.enqueue_cron_prompt( - prompt.to_string(), - task_id.to_string(), - human_schedule.to_string(), - ); - let effects = super::super::dispatch::maybe_drain_queue(agent); + let agent_id = agent.session.id; + agent.session.enqueue_cron_prompt( + prompt.to_string(), + task_id.to_string(), + human_schedule.to_string(), + ); + agent_id + }; + let effects = super::super::dispatch::maybe_drain_queue_and_note_peek(app, agent_id); app.pending_effects.extend(effects); true diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs index 43c0065..763568c 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/session_notification.rs @@ -1,5 +1,5 @@ use super::*; -use xai_grok_shell::sampling::error::rate_limited_user_message; +use xai_grok_shell::sampling::error::format_rate_limited_user_message; /// Stash a live stop/stop_failure batch under `stash_pid` for the turn marker /// to fold. `merge_same_name` merges a same-name repeat instead of standalone. pub(super) fn stash_live_stop_batch( @@ -1272,7 +1272,7 @@ pub(super) fn apply_retry_state( scrollback.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired)); } else { let error = if *rate_limited { - rate_limited_user_message(is_api_key_auth).into() + format_rate_limited_user_message(Some(reason.as_str()), is_api_key_auth) } else { format!("failed after {attempts} retries: {reason}") }; diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mcp.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mcp.rs index a8c4845..251d294 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mcp.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mcp.rs @@ -47,6 +47,9 @@ status: McpServerDisplayStatus::Initializing, tool_count: 0, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: Vec::new(), enabled: true, source: "local".into(), @@ -517,6 +520,9 @@ status: crate::views::mcps_modal::McpServerDisplayStatus::Initializing, tool_count: 0, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: Vec::new(), enabled: true, source: "local".into(), diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs index f880d4c..2f99289 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/mod.rs @@ -1884,9 +1884,10 @@ pub(super) fn seed_owner_agent_with_open_modal(app: &mut AppView) { vec![ McpServerInfo { name : "alpha".into(), display_name : None, status : McpServerDisplayStatus::Initializing, tool_count : 0, auth_required : - false, tools : Vec::new(), enabled : true, source : "local".into(), - wire_source : McpWireSource::Local, plugin_name : None, - is_managed_gateway : false, } + false, setup_required : false, setup : None, setup_values : + std::collections::HashMap::new(), tools : Vec::new(), enabled : true, + source : "local".into(), wire_source : McpWireSource::Local, plugin_name + : None, is_managed_gateway : false, } ], ), ); diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs index b646ff8..9e116c3 100644 --- a/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs +++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/tests/session_events.rs @@ -41,9 +41,7 @@ let update = XaiSessionUpdate::ImageDropped { notes: notes.clone(), }; - let changed = apply_session_event(&update, &mut session, &mut scrollback, - false, - ); + let changed = apply_session_event(&update, &mut session, &mut scrollback, false); assert!(changed); assert_eq!(scrollback.len(), before + 1); let entry = scrollback.entries_mut().last().expect("entry pushed"); @@ -109,9 +107,7 @@ max_retries: 3, reason: "rate limited".into(), }; - apply_retry_state(&retry, &mut session, &mut scrollback, - false, - ); + apply_retry_state(&retry, &mut session, &mut scrollback, false); assert!( session.in_flight_prompt.is_none(), "RetryState bypasses session/update in_flight hook" @@ -131,9 +127,7 @@ is_rate_limited: true, }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( session.rate_limited, "rate_limited flag must be set when is_rate_limited is true" @@ -141,14 +135,33 @@ } #[test] - fn retry_exhausted_rate_limited_message_is_auth_aware() { - use xai_grok_shell::sampling::error::{ - RATE_LIMITED_USER_MESSAGE_API_KEY, RATE_LIMITED_USER_MESSAGE_OAUTH, + fn retry_exhausted_rate_limited_empty_reason_uses_oauth_fallback() { + use xai_grok_shell::sampling::error::RATE_LIMITED_USER_MESSAGE_OAUTH; + + let empty = RetryState::Exhausted { + attempts: 3, + reason: "".into(), + is_rate_limited: true, }; + let mut session = make_session(Some("s1")); + let mut scrollback = ScrollbackState::new(); + apply_retry_state(&empty, &mut session, &mut scrollback, false); + match last_session_event(&scrollback) { + Some(SessionEvent::RetryFailed { error, .. }) => { + assert_eq!(error, RATE_LIMITED_USER_MESSAGE_OAUTH); + } + other => panic!("expected empty-rate-limit RetryFailed, got {other:?}"), + } + } + + #[test] + fn retry_exhausted_rate_limited_surfaces_server_detail() { + let reason = + "The model is currently at capacity due to high demand. Please try again.".to_string(); let exhausted = RetryState::Exhausted { attempts: 3, - reason: "rate limited".into(), + reason: reason.clone(), is_rate_limited: true, }; @@ -157,17 +170,32 @@ apply_retry_state(&exhausted, &mut session, &mut scrollback, false); match last_session_event(&scrollback) { Some(SessionEvent::RetryFailed { error, .. }) => { - assert_eq!(error, RATE_LIMITED_USER_MESSAGE_OAUTH); + assert_eq!(error, reason); } - other => panic!("expected OAuth rate-limit RetryFailed, got {other:?}"), + other => panic!("expected detail RetryFailed, got {other:?}"), } + } + + #[test] + fn retry_exhausted_api_key_rewrites_consumer_subscription_upsell() { + use xai_grok_shell::sampling::error::RATE_LIMITED_USER_MESSAGE_API_KEY; + + let rpm = RetryState::Exhausted { + attempts: 2, + reason: "Some resource has been exhausted: You are sending requests too quickly. \ + Please slow down, or upgrade to a Grok subscription for higher limits: \ + https://grok.com/supergrok" + .into(), + is_rate_limited: true, + }; let mut session = make_session(Some("s1")); let mut scrollback = ScrollbackState::new(); - apply_retry_state(&exhausted, &mut session, &mut scrollback, true); + apply_retry_state(&rpm, &mut session, &mut scrollback, true); match last_session_event(&scrollback) { Some(SessionEvent::RetryFailed { error, .. }) => { assert_eq!(error, RATE_LIMITED_USER_MESSAGE_API_KEY); + assert!(!error.contains("grok.com/supergrok")); } other => panic!("expected API-key rate-limit RetryFailed, got {other:?}"), } @@ -185,9 +213,7 @@ is_rate_limited: false, }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( !session.rate_limited, "rate_limited flag must not be set when is_rate_limited is false" @@ -218,9 +244,7 @@ is_rate_limited: true, }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( session.rate_limited, "free-usage keeps rate_limited (TurnFailed/toast suppression)" @@ -254,9 +278,7 @@ is_rate_limited: false, }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( session.credit_limit_blocked, "credit_limit_blocked must be set for credit-limit 403" @@ -284,9 +306,7 @@ message: "status 403: run out of credits".into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( session.credit_limit_blocked, "credit_limit_blocked must be set for credit-limit 403" @@ -316,9 +336,7 @@ .into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( session.credit_limit_blocked, "credit_limit_blocked must be set for pool 402 balance exhausted" @@ -342,9 +360,7 @@ message: "internal server error".into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( !session.credit_limit_blocked, "credit_limit_blocked must NOT be set for non-credit-limit errors" @@ -390,9 +406,7 @@ .into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( matches!( last_session_event(&scrollback), @@ -421,9 +435,7 @@ message: "Unauthorized (401) from https://proxy/v1/messages".into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( session.in_flight_prompt.is_some(), "in_flight_prompt must be preserved on a recoverable auth failure" @@ -444,9 +456,7 @@ .into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!(matches!( last_session_event(&scrollback), Some(SessionEvent::ReAuthRequired) @@ -467,9 +477,7 @@ .into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!(matches!( last_session_event(&scrollback), Some(SessionEvent::RetryFailed { .. }) @@ -487,9 +495,7 @@ message: "internal server error".into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!(matches!( last_session_event(&scrollback), Some(SessionEvent::RetryFailed { .. }) @@ -510,9 +516,7 @@ .into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( matches!( last_session_event(&scrollback), @@ -537,9 +541,7 @@ message: "the prompt is too long for this model's context window".into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( matches!( last_session_event(&scrollback), @@ -569,7 +571,8 @@ session.note_context_used(43_000); - session.finish_turn(&mut scrollback); + session.finish_turn(&mut scrollback, + ); match last_session_event(&scrollback) { Some(SessionEvent::CompactionCompleted { tokens_before, @@ -597,7 +600,8 @@ summary_preview: None, }; assert!(apply_session_event(&update, &mut session, &mut scrollback, false)); - session.finish_turn(&mut scrollback); + session.finish_turn(&mut scrollback, + ); match last_session_event(&scrollback) { Some(SessionEvent::CompactionCompleted { tokens_after, .. }) => { assert_eq!( @@ -648,14 +652,13 @@ assert!(apply_session_event( &update, &mut agent.session, - &mut agent.scrollback, - false, - )); + &mut agent.scrollback, false)); refresh_context_used(&mut agent, 66_000); confirm_context_used(&mut agent, 43_000); - agent.session.finish_turn(&mut agent.scrollback); + agent.session.finish_turn(&mut agent.scrollback, + ); match last_session_event(&agent.scrollback) { Some(SessionEvent::CompactionCompleted { tokens_before, @@ -811,9 +814,7 @@ message: "incompatible history".into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( session.model_incompatible, "encrypted_content_mismatch should set model_incompatible flag" @@ -832,9 +833,7 @@ message: "bad request".into(), }, &mut session, - &mut scrollback, - false, - ); + &mut scrollback, false); assert!( !session.model_incompatible, "non-encrypted_content error types must not set model_incompatible" diff --git a/crates/codegen/xai-grok-pager/src/app/actions.rs b/crates/codegen/xai-grok-pager/src/app/actions.rs index 3b2cf0c..652b40a 100644 --- a/crates/codegen/xai-grok-pager/src/app/actions.rs +++ b/crates/codegen/xai-grok-pager/src/app/actions.rs @@ -344,6 +344,10 @@ pub enum Action { McpAuthTrigger { server_name: String, }, + McpSetupSubmit { + server_name: String, + values: std::collections::HashMap, + }, /// Reload skills list from the modal. ReloadSkills, /// Refresh MCP server list from the modal. @@ -522,6 +526,7 @@ pub enum Action { SetContextualHintSendNow(bool), SetContextualHintSmallScreen(bool), SetContextualHintWordSelect(bool), + SetContextualHintSshWrap(bool), /// Commit the active theme (canonical name, e.g. `"groknight"`, `"auto"`). SetTheme(String), /// Commit the theme used when the OS is in dark mode. Only updates @@ -1672,6 +1677,12 @@ pub enum Effect { session_id: acp::SessionId, server_name: String, }, + McpSetupSubmit { + agent_id: AgentId, + session_id: acp::SessionId, + server_name: String, + values: std::collections::HashMap, + }, /// Fetch hooks list from the shell (x.ai/hooks/list). FetchHooksList { agent_id: AgentId, @@ -1873,6 +1884,11 @@ pub enum Effect { }, /// Log out via `x.ai/auth/logout` (shell clears auth.json + in-memory state). Logout, + /// Cancel an in-flight interactive auth on the shell (`x.ai/auth/cancel`). + /// Used when the user abandons mid-session `/login` so the device-code + /// poll stops instead of running until the code expires. `request_seq` + /// scopes the cancel so a delayed RPC cannot tear down a successor login. + CancelAuth { request_seq: u64 }, /// Re-check subscription status via `x.ai/auth/check_subscription`. /// `verify` scopes the result to a deferred-gate verification (see /// [`crate::app::subscription`]); `None` for generic checks. @@ -2042,6 +2058,11 @@ pub enum SubagentKillOutcome { /// row alone rather than show a false terminal state. RpcFailed, } +#[derive(Debug)] +pub enum McpAuthTriggerOutcome { + Authenticated, + SetupRequired(crate::views::mcps_modal::McpSetupConfig), +} /// Result from a completed async [`Effect`]. /// /// Wrapped in `Action::TaskComplete` and dispatched synchronously. @@ -2319,6 +2340,11 @@ pub enum TaskResult { }, /// MCP auth trigger completed. McpAuthTriggerDone { + agent_id: AgentId, + server_name: String, + result: Result, + }, + McpSetupSubmitDone { agent_id: AgentId, server_name: String, result: Result<(), String>, @@ -2550,6 +2576,8 @@ pub enum TaskResult { }, /// Shell acknowledged logout (auth cleared). LogoutComplete, + /// Best-effort `x.ai/auth/cancel` finished (no UI update; state already left Authenticating). + AuthCancelComplete, /// Shell responded to `x.ai/auth/check_subscription`. `verify` echoes /// the generation from `Effect::CheckSubscription` for deferred-gate /// verifications. diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/media.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/media.rs index 78a47dd..122a4e7 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/media.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/media.rs @@ -533,7 +533,7 @@ impl AgentView { use crate::scrollback::blocks::mermaid_content::AffordanceKind; match kind { AffordanceKind::CopySource => { - if !self.copy_to_clipboard(&source) { + if self.copy_to_clipboard(&source).is_failed() { crate::unified_log::error( "mermaid.copy_source.failed", self.session.session_id.as_ref().map(|s| s.0.as_ref()), diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs index 79050ba..3270b96 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/modals.rs @@ -382,7 +382,14 @@ impl AgentView { }; } - // If in input mode, route to input handler. + // If in setup or input mode, route to the form handler. + if self + .extensions_modal + .as_ref() + .is_some_and(|s| s.mcp_setup.is_some()) + { + return self.handle_mcp_setup_key(key); + } if self .extensions_modal .as_ref() @@ -738,6 +745,40 @@ impl AgentView { } } + fn handle_mcp_setup_key(&mut self, key: &KeyEvent) -> InputOutcome { + use crate::views::extensions_modal::McpSetupOutcome; + + let Some(ref mut state) = self.extensions_modal else { + return InputOutcome::Unchanged; + }; + let Some(ref mut setup) = state.mcp_setup else { + return InputOutcome::Unchanged; + }; + + match setup.handle_key(key) { + McpSetupOutcome::Changed => InputOutcome::Changed, + McpSetupOutcome::Unchanged => InputOutcome::Unchanged, + McpSetupOutcome::Cancel => { + state.mcp_setup = None; + InputOutcome::Changed + } + McpSetupOutcome::Submit => { + let Some(values) = setup.values() else { + setup.error = Some("Select an option".to_string()); + return InputOutcome::Changed; + }; + let server_name = setup.server_name.clone(); + state.mcp_setup = None; + state.pending_action = Some(format!("Authenticating {server_name}...")); + state.pending_entry_index = None; + InputOutcome::Action(Action::McpSetupSubmit { + server_name, + values, + }) + } + } + } + /// Handle key events while the modal is in input mode (text field active). fn handle_modal_input_key(&mut self, key: &KeyEvent) -> InputOutcome { use crate::views::extensions_modal::ModalInputOutcome; @@ -1408,6 +1449,14 @@ impl AgentView { if server.is_managed_gateway { return InputOutcome::Action(Action::OpenManagedConnectors); } + if server.setup_required + && let Some(form) = + crate::views::extensions_modal::McpSetupFormState::new(server) + { + state.mcp_setup = Some(form); + state.picker_state.search_active = false; + return InputOutcome::Changed; + } // Drop repeats while an action is in flight on the same // row to avoid double-spawning the OAuth browser flow. let sel = state.picker_state.selected; @@ -2067,6 +2116,9 @@ mod extensions_action_target_tests { status: crate::views::mcps_modal::McpServerDisplayStatus::Initializing, tool_count: 0, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: Vec::new(), enabled, source: "local".into(), @@ -2576,6 +2628,9 @@ mod connectors_url_click_tests { status: McpServerDisplayStatus::Ready, tool_count: 0, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: vec![], enabled: true, source: "managed".into(), diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs index 64bb06e..9ccf77e 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/notices.rs @@ -270,10 +270,10 @@ impl AgentView { } /// Copy text to clipboard and show the result toast. - pub fn copy_to_clipboard(&mut self, text: &str) -> bool { - let r = crate::clipboard::copy_text(text); - self.show_toast_ticks(r.message, r.ticks); - r.success + pub fn copy_to_clipboard(&mut self, text: &str) -> crate::clipboard::ClipboardDelivery { + let result = crate::clipboard::copy_text(text); + self.show_toast_ticks(result.message, result.ticks); + result.delivery } /// Like [`copy_to_clipboard`] but debounces the toast to prevent diff --git a/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs b/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs index 8cb8707..870d45f 100644 --- a/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs +++ b/crates/codegen/xai-grok-pager/src/app/agent_view/paste.rs @@ -1600,7 +1600,9 @@ pub(super) mod paste_key_tests { .map(|(m, _)| m.clone()) .unwrap_or_default(); assert!( - toast.starts_with("Copied") || toast.starts_with("Copy failed"), + toast.starts_with("Copied") + || toast.starts_with("Copy sent") + || toast.starts_with("Copy failed"), "copy-source emits a clipboard toast, got {toast:?}", ); assert!( diff --git a/crates/codegen/xai-grok-pager/src/app/app_view.rs b/crates/codegen/xai-grok-pager/src/app/app_view.rs index 4abb120..5075930 100644 --- a/crates/codegen/xai-grok-pager/src/app/app_view.rs +++ b/crates/codegen/xai-grok-pager/src/app/app_view.rs @@ -915,6 +915,10 @@ pub struct AppView { /// first evaluation at a stable agent-view draw (regardless of outcome), /// so later resizes can never re-trigger the tip within this run. pub small_screen_tip_evaluated: bool, + /// One-shot gate for the SSH `grok wrap` tip: set after the first + /// evaluation at a stable agent-view draw (the environment gates are + /// process-constant, so one evaluation decides the run). + pub ssh_wrap_tip_evaluated: bool, /// Focus-scoped, opportunistically-polled clipboard-image tip state: poll /// throttle, changeCount delta-detection, fire cooldown, and changeCount /// dedup (macOS-only at the probe layer). @@ -955,6 +959,9 @@ pub struct AppView { pub auth_code_input: String, /// Monotonically increasing sequence number for auth requests. pub next_auth_request_seq: u64, + /// Abort handle for the in-flight `PollAuthUrl` task (with its request_seq). + /// Aborted alongside the Authenticate task in single-flight re-login. + pub auth_url_poll_handle: Option<(u64, tokio::task::AbortHandle)>, /// Every session/chat/worktree/prompt action deferred behind startup gates. pub deferred_startup: crate::app::session_startup::DeferredStartupActions, /// Whether deferred welcome-screen login should force OAuth. @@ -1275,6 +1282,7 @@ impl AppView { tip_seen_counts: Default::default(), last_known_terminal_rows: 0, small_screen_tip_evaluated: false, + ssh_wrap_tip_evaluated: false, clipboard_focus_tip: Default::default(), new_session_worktree_mode: WorktreeMode::Never, fork_worktree_mode: WorktreeMode::Ask, @@ -1289,6 +1297,7 @@ impl AppView { auth_start_mode: AuthMode::Pending, auth_code_input: String::new(), next_auth_request_seq: 1, + auth_url_poll_handle: None, deferred_startup: Default::default(), auth_use_oauth: false, auth_clipboard_copied: false, @@ -1647,6 +1656,16 @@ impl AppView { None } } + /// The active agent's view, when an agent tab is focused. + /// + /// Always the root agent, even when a subagent view is focused within the + /// tab; for subagent-aware resolution use `dispatch::ctx::get_active_agent`. + pub fn active_agent(&self) -> Option<&AgentView> { + match self.active_view { + ActiveView::Agent(id) => self.agents.get(&id), + _ => None, + } + } /// Session ID of the active agent, if one exists and has an established session. pub fn active_session_id(&self) -> Option<&str> { match self.active_view { @@ -3741,6 +3760,7 @@ impl AppView { } } self.maybe_trigger_small_screen_tip(); + self.maybe_trigger_ssh_wrap_tip(); let compact = self.appearance.prompt.compact; let (header_pad_left, header_pad_right, header_pad_top) = { let layout_cfg = &self.appearance.scrollback.layout; @@ -4076,6 +4096,11 @@ impl AppView { d.overlay_prev_hit.set(header.and_then(|c| c.prev_rect)); d.overlay_next_hit.set(header.and_then(|c| c.next_rect)); } + if let Some(d) = self.dashboard.as_mut() + && d.peek_viewport.is_some() + { + d.restore_peek_viewport(agents); + } if let Some(agent) = agents.get_mut(&id) { let announcement_banner_h = crate::views::announcements::session_banner_height( @@ -4425,6 +4450,59 @@ impl AppView { self.small_screen_tip_evaluated = true; super::dispatch::show_small_screen_tip(self); } + /// One-shot SSH `grok wrap` tip trigger, run at the top of every `draw` + /// right after [`Self::maybe_trigger_small_screen_tip`]. The welcome + /// screen has no ephemeral-tip row, so the first stable agent-view draw + /// is the earliest surface that can paint a session-load tip. Reads the + /// live environment (cached statics) and delegates to the injectable + /// inner so tests never depend on the host's SSH shape. + pub(crate) fn maybe_trigger_ssh_wrap_tip(&mut self) { + if self.ssh_wrap_tip_evaluated { + return; + } + static ENV_RECOMMENDS_WRAP: std::sync::OnceLock = std::sync::OnceLock::new(); + let env_recommends_wrap = *ENV_RECOMMENDS_WRAP.get_or_init(|| { + let ctx = crate::terminal::terminal_context(); + crate::diagnostics::ssh_wrap_hint( + ctx.is_ssh, + crate::clipboard::osc52_sink_active(), + ctx.is_official_vscode_remote, + ) + .is_some() + }); + self.maybe_trigger_ssh_wrap_tip_inner(env_recommends_wrap); + } + /// Inner trigger with the environment verdict injected + /// (`diagnostics::ssh_wrap_hint` on the live path). Same + /// defer-vs-consume rules as the small-screen trigger above, with two + /// deltas: the environment gates are process-constant, so a failing + /// verdict consumes the one-shot; and a busy tip slot defers instead of + /// replacing — both session-load tips can qualify on the same first + /// draw, and replacing would burn the other tip's once-per-session show, + /// while this one loses nothing by waiting for a later draw. + pub(crate) fn maybe_trigger_ssh_wrap_tip_inner(&mut self, env_recommends_wrap: bool) { + if self.ssh_wrap_tip_evaluated { + return; + } + let ActiveView::Agent(id) = self.active_view else { + return; + }; + let Some(agent) = self.agents.get(&id) else { + return; + }; + if agent.terminal_size_stale || agent.last_terminal_size == (0, 0) { + return; + } + if !env_recommends_wrap { + self.ssh_wrap_tip_evaluated = true; + return; + } + if !agent.ephemeral_tip_can_render() || agent.ephemeral_tip.is_active() { + return; + } + self.ssh_wrap_tip_evaluated = true; + super::dispatch::show_ssh_wrap_tip(self); + } /// Whether the clipboard-image tip may poll right now — the single in-window /// gate. Outside it the poll touches the pasteboard ZERO times: contextual /// hints on, the probe supported (macOS), past the fire cooldown, the @@ -5110,6 +5188,7 @@ pub(crate) mod tests { tip_seen_counts: Default::default(), last_known_terminal_rows: 0, small_screen_tip_evaluated: false, + ssh_wrap_tip_evaluated: false, clipboard_focus_tip: Default::default(), new_session_worktree_mode: WorktreeMode::Never, fork_worktree_mode: WorktreeMode::Ask, @@ -5124,6 +5203,7 @@ pub(crate) mod tests { auth_start_mode: AuthMode::Pending, auth_code_input: String::new(), next_auth_request_seq: 1, + auth_url_poll_handle: None, deferred_startup: Default::default(), auth_use_oauth: false, auth_clipboard_copied: false, @@ -5240,7 +5320,7 @@ pub(crate) mod tests { voice_state: VoiceState::Idle, } } - fn test_app_with_agent() -> AppView { + pub(crate) fn test_app_with_agent() -> AppView { let mut app = test_app(); let id = super::super::agent::AgentId(0); let mut agent = AgentView::new( diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/auth.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/auth.rs index 63ac21e..44ccee9 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/auth.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/auth.rs @@ -1,7 +1,7 @@ //! Login, logout, account switching, and auth-code submission dispatchers. use super::ctx::{restore_auth_return_view, show_welcome}; -use super::queue::maybe_drain_queue; +use super::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; use super::router::dispatch; use super::session::lifecycle::{clear_startup_actions, drain_startup_actions}; use crate::app::actions::{Action, Effect}; @@ -56,6 +56,33 @@ fn no_login_method_error(app: &AppView) -> String { } } +/// Abort any in-flight Authenticate/SwitchAccount task *and* its URL poll so a +/// new login cannot stack device-code mints or have a stale poll steal the +/// successor's URL (single-flight). No-op when not authenticating or when the +/// abort handles have not been installed yet. +fn abort_prior_auth(app: &mut AppView) { + if let AuthState::Authenticating { + handle, + request_seq, + .. + } = &mut app.auth_state + && let Some(h) = handle.take() + { + tracing::debug!( + request_seq, + "aborting prior in-flight auth task for single-flight" + ); + h.abort(); + } + if let Some((seq, h)) = app.auth_url_poll_handle.take() { + tracing::debug!( + request_seq = seq, + "aborting prior auth URL poll for single-flight" + ); + h.abort(); + } +} + /// Log out, then start a new login flow in a single sequential task. pub(super) fn dispatch_switch_account(app: &mut AppView) -> Vec { ensure_login_method(app); @@ -67,6 +94,8 @@ pub(super) fn dispatch_switch_account(app: &mut AppView) -> Vec { return vec![]; }; + abort_prior_auth(app); + let request_seq = app.next_auth_request_seq; app.next_auth_request_seq += 1; app.auth_code_input.clear(); @@ -197,6 +226,8 @@ pub(super) fn dispatch_login(app: &mut AppView) -> Vec { show_welcome(app); } + abort_prior_auth(app); + let request_seq = app.next_auth_request_seq; app.next_auth_request_seq += 1; app.auth_code_input.clear(); @@ -220,15 +251,22 @@ pub(super) fn dispatch_login(app: &mut AppView) -> Vec { /// Cancel a login that was started from inside a session and restore the /// caller's view. Only meaningful when `auth_return_view` is set (a -/// mid-session `/login` or 401 re-auth prompt). Any in-flight auth task is -/// left to finish in the background — its `AuthComplete`/`AuthFailed` -/// result is ignored because we move `auth_state` out of `Authenticating` -/// here (the request-seq/state guard in those handlers drops stale results) -/// and bump the seq so a fresh login does not collide. +/// mid-session `/login` or 401 re-auth prompt). Aborts the in-flight auth +/// task and tells the shell to cancel its device/loopback flow so a retry +/// does not race a still-polling prior mint. Bump the seq so a fresh login +/// does not collide with a late `AuthComplete`/`AuthFailed`. pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec { let Some(return_view) = app.auth_return_view.take() else { return vec![]; }; + // Capture the attempt's request_seq before abort clears Authenticating so + // the shell cancel is scoped to this attempt only (a delayed RPC must not + // cancel a fast re-login). + let cancel_seq = match &app.auth_state { + AuthState::Authenticating { request_seq, .. } => Some(*request_seq), + _ => None, + }; + abort_prior_auth(app); app.next_auth_request_seq += 1; app.auth_state = AuthState::Done; app.auth_show_raw_url = false; @@ -245,7 +283,12 @@ pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec { agent.reauth_stashed_prompt = None; strip_trailing_auth_error_blocks(agent); } - vec![] + // Ask the shell to cancel its in-flight interactive auth (device poll / + // loopback wait). Fire-and-forget: UI state is already restored. + match cancel_seq { + Some(request_seq) => vec![Effect::CancelAuth { request_seq }], + None => vec![], + } } /// User submitted a manually-pasted auth token in loopback mode. @@ -303,6 +346,7 @@ pub(super) fn handle_auth_complete( // have been started from the dashboard, not the agent // that 401'd). let mut retry_effects = Vec::new(); + let mut drained_ids = Vec::new(); for agent in app.agents.values_mut() { strip_trailing_auth_error_blocks(agent); // Auto-resubmit the prompt that failed on the expired @@ -315,8 +359,12 @@ pub(super) fn handle_auth_complete( )); agent.session.enqueue_in_flight_prompt_front(prompt); retry_effects.extend(maybe_drain_queue(agent)); + drained_ids.push(agent.session.id); } } + for id in drained_ids { + note_peek_page_flip_after_drain(app, id); + } let mut effects = dispatch(Action::RequestBundleStatus, app); if app.usage_visible { effects.push(Effect::FetchAppBilling); @@ -397,7 +445,7 @@ pub(super) fn handle_mcp_auth_trigger_done( app: &mut AppView, agent_id: AgentId, server_name: String, - result: Result<(), String>, + result: Result, ) -> Vec { let Some(agent) = app.agents.get_mut(&agent_id) else { return vec![]; @@ -405,20 +453,50 @@ pub(super) fn handle_mcp_auth_trigger_done( if let Some(ref mut modal) = agent.extensions_modal { modal.pending_action = None; modal.pending_entry_index = None; - if let Err(e) = result { - // String-match heuristic: directive vs name-embedded vs generic. - // Brittle if the shell ever quotes a name shape that doesn't - // match `server_name` here — replace with a structured - // discriminator on McpAuthTriggerResponse if that happens. - let msg = if e.starts_with("To authenticate") { - format!("{server_name}: {e}") - } else if e.contains(&server_name) { - format!("Auth failed: {e}") - } else { - format!("{server_name} auth failed: {e}") - }; - modal.modal_message = Some(crate::views::extensions_modal::ModalMessage::Error(msg)); - return vec![]; + match result { + Ok(crate::app::actions::McpAuthTriggerOutcome::Authenticated) => {} + Ok(crate::app::actions::McpAuthTriggerOutcome::SetupRequired(setup)) => { + let setup_values = match &modal.mcps_data { + crate::views::extensions_modal::TabDataState::Loaded(servers) => servers + .iter() + .find(|server| server.name == server_name) + .map(|server| server.setup_values.clone()) + .unwrap_or_default(), + _ => std::collections::HashMap::new(), + }; + if let Some(form) = crate::views::extensions_modal::McpSetupFormState::from_setup( + server_name.clone(), + setup, + setup_values, + ) { + modal.mcp_setup = Some(form); + } else { + modal.modal_message = + Some(crate::views::extensions_modal::ModalMessage::Error( + format!("{server_name}: setup schema is not supported in this UI"), + )); + } + return vec![]; + } + Err(e) => { + let msg = if e.starts_with("To authenticate") { + format!("{server_name}: {e}") + } else if e.contains(&server_name) { + format!("Auth failed: {e}") + } else { + format!("{server_name} auth failed: {e}") + }; + modal.modal_message = + Some(crate::views::extensions_modal::ModalMessage::Error(msg)); + if let Some(session_id) = agent.session.session_id.clone() { + return vec![Effect::FetchMcpsList { + agent_id, + session_id, + cache: false, + }]; + } + return vec![]; + } } } // No toast on success: the row transition from the FetchMcpsList @@ -432,3 +510,40 @@ pub(super) fn handle_mcp_auth_trigger_done( cache: false, }] } + +pub(super) fn handle_mcp_setup_submit_done( + app: &mut AppView, + agent_id: AgentId, + server_name: String, + result: Result<(), String>, +) -> Vec { + let Some(agent) = app.agents.get_mut(&agent_id) else { + return vec![]; + }; + if let Some(ref mut modal) = agent.extensions_modal { + if let Err(e) = result { + modal.pending_action = None; + modal.pending_entry_index = None; + modal.modal_message = Some(crate::views::extensions_modal::ModalMessage::Error( + format!("{server_name} setup failed: {e}"), + )); + return vec![]; + } + modal.pending_action = Some(format!("Authenticating {server_name}...")); + modal.pending_entry_index = None; + } + let Some(session_id) = agent.session.session_id.clone() else { + if let Some(ref mut modal) = agent.extensions_modal { + modal.pending_action = None; + modal.modal_message = Some(crate::views::extensions_modal::ModalMessage::Error( + format!("{server_name}: no active session for authentication"), + )); + } + return vec![]; + }; + vec![Effect::McpAuthTrigger { + agent_id, + session_id, + server_name, + }] +} diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/billing.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/billing.rs index d04601f..3f5fa08 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/billing.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/billing.rs @@ -1,6 +1,6 @@ //! Subscription tier checks, credit-limit upsells, and auto-topup handling. -use super::queue::maybe_drain_queue; +use super::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; use crate::app::actions::Effect; use crate::app::agent::AgentId; use crate::app::agent_view::AgentView; @@ -10,6 +10,9 @@ use std::time::Duration; use xai_grok_telemetry::events::{SuperGrokUpsell, SuperGrokUpsellClicked}; use xai_grok_telemetry::session_ctx::log_event; +// Free-usage detection lives in shell next to the well-known code + 429 copy. +pub(crate) use xai_grok_shell::sampling::error::is_free_usage_exhausted_error; + /// How long the pager auto-checks subscription status before stopping. /// After this, the user can still manually check via the [Refresh] button. pub(super) const PAYWALL_AUTO_CHECK_TIMEOUT: Duration = Duration::from_secs(10 * 60); @@ -83,37 +86,6 @@ pub(crate) fn is_credit_limit_error(http_status: Option, message: &str) -> } } -/// Well-known error code CCP returns (HTTP 429, flat body -/// `{"code": "...", "error": "..."}`) when a free-tier user exhausts the -/// free usage quota. Kept in sync with the shared well-known error code -/// `SUBSCRIPTION_FREE_USAGE_EXHAUSTED`. sampling-types' `parse_error_bytes` prepends the flat -/// `code` to the flattened message, so the code reaches the pager embedded -/// in `RetryState::Exhausted.reason` and the -32003 error's data string. -pub(crate) const FREE_USAGE_EXHAUSTED_ERROR_CODE: &str = "subscription:free-usage-exhausted"; - -/// Whether a rate-limit error is the free-usage-quota exhaustion (paywall) -/// rather than transient throttling. Text-sniff on the flattened message, -/// same precedent as [`is_credit_limit_error`]. -pub(crate) fn is_free_usage_exhausted_error(reason: &str) -> bool { - reason.contains(FREE_USAGE_EXHAUSTED_ERROR_CODE) -} - -/// Whether a rate-limited (-32003) ACP error is the free-usage exhaustion. -/// `data` may be a bare string or the `{message, promptUsage?}` object -/// `attach_prompt_usage` produces — always read via the shared detail helper. -pub(crate) fn acp_error_is_free_usage_exhausted(err: &agent_client_protocol::Error) -> bool { - err.data - .as_ref() - .and_then(xai_grok_shell::sampling::error::error_detail_from_data) - .as_deref() - .is_some_and(is_free_usage_exhausted_error) -} - -/// User-facing message for free-usage exhaustion. Shown by headless mode and -/// `format_acp_error` in place of auth-aware rate-limit copy. Deliberately -/// promises no reset duration — the quota window is backend-config-driven. -pub(crate) const FREE_USAGE_USER_MESSAGE: &str = "You\u{2019}ve reached your free Grok Build usage limit for now. Get SuperGrok for much higher limits, or try again later: https://grok.com/supergrok?referrer=grok-build"; - /// Open the credit-limit upsell on the given agent. /// /// **`max_tier = false`** (default): shows the Q&A question modal with @@ -556,6 +528,7 @@ pub(super) fn handle_credit_limit_recheck_complete( agent_id, silent: true, }); + note_peek_page_flip_after_drain(app, agent_id); effects } @@ -581,27 +554,3 @@ pub(super) fn dispatch_open_supergrok_url(app: &mut AppView) -> Vec { super::ctx::open_url_or_show(app, &url); vec![] } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn free_usage_dual_read_string_and_wrapped_object_data() { - let free = "subscription:free-usage-exhausted quota hit"; - let string_err = agent_client_protocol::Error::new(-32003, "Rate limited").data(free); - assert!(acp_error_is_free_usage_exhausted(&string_err)); - - // attach_prompt_usage wraps string data as {"message": ..., "promptUsage": ...}. - let wrapped = - agent_client_protocol::Error::new(-32003, "Rate limited").data(serde_json::json!({ - "message": free, - "promptUsage": { "inputTokens": 1, "outputTokens": 0, "numTurns": 1 } - })); - assert!(acp_error_is_free_usage_exhausted(&wrapped)); - assert!(!wrapped.data.as_ref().unwrap().is_string()); - - let other = agent_client_protocol::Error::new(-32003, "Rate limited").data("throttled"); - assert!(!acp_error_is_free_usage_exhausted(&other)); - } -} diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs index 3b253c2..10a2185 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/dashboard.rs @@ -6,7 +6,7 @@ use super::dashboard_telemetry::{ }; use super::modes::{dispatch_cycle_mode_and_sync, set_yolo_mode, yolo_enable_blocked}; use super::permissions::resolve_permission_queue_transition; -use super::queue::maybe_drain_queue; +use super::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; use super::router::dispatch; use super::session::lifecycle::{ dispatch_new_session_inner_with_id, dispatch_new_worktree_session, @@ -238,6 +238,7 @@ pub(super) fn dispatch_exit_dashboard(app: &mut AppView) -> Vec { // (`close_popup()` atomically clears the hit // rects too.) if let Some(d) = app.dashboard.as_mut() { + d.restore_peek_viewport(&mut app.agents); d.close_popup(); } log_dashboard_closed(app); @@ -268,6 +269,9 @@ pub(super) fn dispatch_dashboard_attach( // on a previously attached agent (legacy popup row-click path // reaches here without a key press) must not follow the user in. clear_pending_overlay_stop(app); + if let Some(d) = app.dashboard.as_mut() { + d.restore_peek_viewport(&mut app.agents); + } match id { DashboardRowId::TopLevel(agent_id) => { if !app.agents.contains_key(&agent_id) { @@ -395,6 +399,7 @@ pub(super) fn dispatch_dashboard_attach( /// fullscreen-with-frame layout. pub(super) fn dispatch_dashboard_overlay_exit(app: &mut AppView) -> Vec { if let Some(d) = app.dashboard.as_mut() { + d.restore_peek_viewport(&mut app.agents); d.close_popup(); } // Leaving the overlay by mouse (`[Dashboard]` click) doesn't pass @@ -635,6 +640,7 @@ pub(super) fn dispatch_dashboard_create_new_agent_with_detail(app: &mut AppView) apply_pending_dispatch_config(agent, pending_model.as_ref(), pending_mode, policy_block); } if let Some(d) = app.dashboard.as_mut() { + d.restore_peek_viewport(&mut app.agents); // Clear the dispatch input even though we don't enqueue // anything — a stray paste while the button is focused // (no typed Enter) shouldn't survive the view switch. @@ -953,6 +959,7 @@ pub(super) fn dispatch_dashboard_confirm_worktree( // Send+open: attach the dashboard's detail-view overlay onto the // agent (`dispatch_new_worktree_session` already set `active_view`). if let Some(d) = app.dashboard.as_mut() { + d.restore_peek_viewport(&mut app.agents); d.focus_row(crate::views::dashboard::DashboardRowId::TopLevel(new_id)); d.attached_agent = Some(new_id); } @@ -1030,6 +1037,7 @@ pub(super) fn dispatch_dashboard_overlay_cycle(app: &mut AppView, delta: i32) -> // the key-press disarm ever running. clear_pending_overlay_stop(app); if let Some(d) = app.dashboard.as_mut() { + d.restore_peek_viewport(&mut app.agents); d.attached_agent = Some(next_id); d.focus_row(DashboardRowId::TopLevel(next_id)); } @@ -1167,6 +1175,7 @@ pub(super) fn dispatch_dashboard_dispatch( // row so the overlay's `i/n [‹][›] [✗]` chips have an // anchor and Esc walks back to the dashboard. if let Some(d) = app.dashboard.as_mut() { + d.restore_peek_viewport(&mut app.agents); d.focus_row(crate::views::dashboard::DashboardRowId::TopLevel(new_id)); d.attached_agent = Some(new_id); } @@ -1662,24 +1671,28 @@ pub(super) fn dispatch_dashboard_peek_reply( return vec![]; } - let Some(agent) = app.agents.get_mut(&agent_id) else { - if let Some(d) = app.dashboard.as_mut() { - d.set_peek(None); - d.set_error_toast("Session no longer exists"); - } - return vec![]; - }; + let effects = { + let Some(agent) = app.agents.get_mut(&agent_id) else { + if let Some(d) = app.dashboard.as_mut() { + d.set_peek(None); + d.set_error_toast("Session no longer exists"); + } + return vec![]; + }; - // Enqueue + drain: idle → sends now, running → stays queued. - // Untrimmed so `chip_elements` byte ranges stay aligned with the stored text. - agent.session.enqueue_prompt(text); - if let Some(entry) = agent.session.pending_prompts.back_mut() { - entry.chip_elements = chip_elements; - if !images.is_empty() { - entry.images = images; + // Enqueue + drain: idle → sends now, running → stays queued. + // Untrimmed so `chip_elements` byte ranges stay aligned with the stored text. + agent.session.enqueue_prompt(text); + if let Some(entry) = agent.session.pending_prompts.back_mut() { + entry.chip_elements = chip_elements; + if !images.is_empty() { + entry.images = images; + } } - } - let effects = maybe_drain_queue(agent); + maybe_drain_queue(agent) + }; + // Note page-flip before restore on attach. + note_peek_page_flip_after_drain(app, agent_id); // Clear the reply draft now that it's been accepted, and drop any // stale error toast. @@ -1693,6 +1706,7 @@ pub(super) fn dispatch_dashboard_peek_reply( // with the session-overlay chrome, mirroring // `dispatch_dashboard_dispatch`'s attach branch. if let Some(d) = app.dashboard.as_mut() { + d.restore_peek_viewport(&mut app.agents); d.focus_row(DashboardRowId::TopLevel(agent_id)); d.attached_agent = Some(agent_id); } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs index d54922e..6397c45 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs @@ -37,16 +37,18 @@ mod turn; mod voice; pub(crate) use billing::{ - FREE_USAGE_USER_MESSAGE, UPSELL_URL_PAYG, UPSELL_URL_UPGRADE, - acp_error_is_free_usage_exhausted, is_credit_limit_error, is_free_usage_exhausted_error, + UPSELL_URL_PAYG, UPSELL_URL_UPGRADE, is_credit_limit_error, is_free_usage_exhausted_error, }; pub(crate) use modes::{downgrade_displayed_auto_if_gated, effective_auto}; pub(crate) use notes::{recap_unavailable_toast, scrollback_has_user_messages}; pub(crate) use permissions::resolve_permission_queue_transition; pub(crate) use prompt::dispatch_initial_prompt; -pub(in crate::app) use prompt::show_small_screen_tip; +pub(in crate::app) use prompt::{show_small_screen_tip, show_ssh_wrap_tip}; +#[cfg(test)] +pub(crate) use queue::maybe_drain_queue; pub(super) use queue::{ - apply_turn_start_shim, arm_send_now_and_paint, maybe_drain_queue, shim_renders_own_user_block, + apply_turn_start_shim, arm_send_now_and_paint, maybe_drain_queue_and_note_peek, + shim_renders_own_user_block, }; pub(in crate::app) use rewind::{find_user_prompt_entry_for_shell_index, shell_prompt_index_at}; pub(crate) use router::dispatch; diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/modes.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/modes.rs index d36b3d2..47b695a 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/modes.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/modes.rs @@ -1,7 +1,7 @@ //! Plan, yolo, auto, and permission mode transitions and toasts. use super::ctx::with_active_agent; -use super::queue::maybe_drain_queue; +use super::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; use super::session::lifecycle::skip_picker_and_create_session; use super::settings::ui::{refresh_open_settings_modals, save_success_toast}; use crate::app::actions::Effect; @@ -80,6 +80,7 @@ pub(super) fn dispatch_enter_plan_mode( .session .enqueue_prompt_with_skill_tokens(desc, skill_token_ranges); let drain = maybe_drain_queue(agent); + note_peek_page_flip_after_drain(app, id); let mut effects = Vec::with_capacity(1); for eff in drain { match eff { diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs index 36264bc..14eebca 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/prompt.rs @@ -7,7 +7,8 @@ use super::interject; use super::permissions::drain_permission_queue; use super::queue::{ apply_turn_start_shim, drain_prompt_state_to_last_queued, immediate_server_send_eligible, - maybe_drain_queue, push_server_queue_echo, retire_optimistic_echo, + maybe_drain_queue, note_peek_page_flip_after_drain, push_server_queue_echo, + retire_optimistic_echo, }; use super::router::dispatch; use super::session::fork::open_project_question; @@ -152,6 +153,36 @@ pub(in crate::app) fn show_small_screen_tip(app: &mut AppView) { } } +/// Show the one-shot "Over SSH? Run `grok wrap ssh ` locally…" hint at +/// the first stable agent-view draw of an unwrapped SSH session (environment +/// gates live in `AppView::maybe_trigger_ssh_wrap_tip`). Gated by the per-tip +/// `contextual_hints.ssh_wrap` gate (default ON). Seen-gated in-memory via +/// `app.tip_seen_counts`; nothing persists to disk. +/// +/// Called directly from the draw-path trigger — not routed as an `Action`, +/// so it returns `()` and "no effects from draw" holds structurally. +pub(in crate::app) fn show_ssh_wrap_tip(app: &mut AppView) { + if !app.contextual_hints.ssh_wrap { + return; + } + let ActiveView::Agent(id) = app.active_view else { + return; + }; + let Some(agent) = app.agents.get_mut(&id) else { + return; + }; + // Impression only when the tip actually takes the slot (mirrors undo/plan). + if agent.show_ephemeral_tip( + crate::tips::ssh_wrap::ssh_wrap_tip(), + &mut app.tip_seen_counts, + ) { + log_event(xai_grok_telemetry::events::ContextualTip { + tip: xai_grok_telemetry::events::ContextualTipKind::SshWrap, + action: xai_grok_telemetry::events::ContextualTipAction::Shown, + }); + } +} + pub(super) fn dispatch_show_plan_nudge(app: &mut AppView) -> Vec { if !app.contextual_hints.plan_mode { return vec![]; @@ -461,7 +492,7 @@ pub(super) fn dispatch_send_prompt_inner( if let Some(command) = command { if ctx.screen_mode.is_minimal() && !command.available_in_minimal() { // Central minimal gate: commands that drive the deleted - // fullscreen pane / dashboard (/find, /copy, /dashboard) + // fullscreen pane / dashboard (/find, /dashboard, …) // have nothing to act on in scrollback-native mode. // Surface a friendly system block instead of running them. CommandResult::Message(format!( @@ -790,6 +821,7 @@ pub(super) fn dispatch_send_prompt_inner( effects.extend(maybe_drain_queue(agent)); } } + note_peek_page_flip_after_drain(app, id); effects } @@ -883,7 +915,9 @@ pub(super) fn dispatch_send_bash_command(app: &mut AppView, command: String) -> agent.session.enqueue_bash_command(command.clone()); agent.prompt.set_text(""); - maybe_drain_queue(agent) + let effects = maybe_drain_queue(agent); + note_peek_page_flip_after_drain(app, id); + effects } /// Whether a load-result handler must stand down because a reconnect reload @@ -1429,6 +1463,8 @@ pub(super) fn handle_prompt_response( agent_id, silent: true, }); + // Agent borrow ends here; note needs dashboard + agents together. + note_peek_page_flip_after_drain(app, agent_id); return effects; } vec![] @@ -1475,7 +1511,9 @@ pub(super) fn handle_compact_complete( if app.reconnect_pending { return vec![]; } - return maybe_drain_queue(agent); + let effects = maybe_drain_queue(agent); + note_peek_page_flip_after_drain(app, agent_id); + return effects; } vec![] } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs index fb1caa8..d57b6ea 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/queue.rs @@ -758,6 +758,35 @@ pub(crate) fn apply_turn_start_shim( } } +/// After a successful queue drain, record a preserve page-flip on a matching +/// TopLevel dashboard peek lease (if any). +pub(crate) fn note_peek_page_flip_after_drain(app: &mut AppView, agent_id: AgentId) { + let page_flipped = app + .agents + .get(&agent_id) + .is_some_and(|a| a.scrollback.is_follow_preserve_scroll()); + if !page_flipped { + return; + } + let Some(mut dash) = app.dashboard.take() else { + return; + }; + dash.note_page_flip_for_lease(agent_id, &mut app.agents); + app.dashboard = Some(dash); +} + +/// Drain the next queued prompt and, when that page-flips under a lease, note it. +pub(crate) fn maybe_drain_queue_and_note_peek(app: &mut AppView, agent_id: AgentId) -> Vec { + let effects = { + let Some(agent) = app.agents.get_mut(&agent_id) else { + return vec![]; + }; + maybe_drain_queue(agent) + }; + note_peek_page_flip_after_drain(app, agent_id); + effects +} + /// Try to drain the next queued prompt (triggered after editing completes). pub(super) fn dispatch_drain_queue(app: &mut AppView) -> Vec { if app.reconnect_pending { @@ -766,10 +795,7 @@ pub(super) fn dispatch_drain_queue(app: &mut AppView) -> Vec { let ActiveView::Agent(id) = app.active_view else { return vec![]; }; - let Some(agent) = app.agents.get_mut(&id) else { - return vec![]; - }; - maybe_drain_queue(agent) + maybe_drain_queue_and_note_peek(app, id) } /// `Action::QueueInterjectShared` arm: map the (possibly edited) queue diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs index a47efd2..ed2f0af 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs @@ -77,14 +77,15 @@ use super::settings::setters::{ preview_auto_light_theme, preview_theme, set_ask_user_question_timeout_enabled, set_auto_dark_theme, set_auto_light_theme, set_auto_update, set_collapsed_edit_blocks, set_compact_mode, set_contextual_hint_image_input, set_contextual_hint_plan_mode, - set_contextual_hint_send_now, set_contextual_hint_small_screen, set_contextual_hint_undo, - set_contextual_hint_word_select, set_default_model, set_default_selected_permission, - set_display_refresh_auto_cadence, set_fork_secondary_model, set_group_tool_verbs, - set_hunk_tracker_mode, set_invert_scroll, set_keep_text_selection, set_max_thoughts_width, - set_multiline_mode, set_prompt_suggestions, set_remember_tool_approvals, set_render_mermaid, - set_respect_manual_folds, set_screen_mode, set_scroll_lines, set_scroll_mode, set_scroll_speed, - set_show_thinking_blocks, set_show_tips, set_simple_mode, set_theme, set_timeline, - set_timestamps, set_vim_mode, set_voice_capture_mode, set_voice_stt_language, + set_contextual_hint_send_now, set_contextual_hint_small_screen, set_contextual_hint_ssh_wrap, + set_contextual_hint_undo, set_contextual_hint_word_select, set_default_model, + set_default_selected_permission, set_display_refresh_auto_cadence, set_fork_secondary_model, + set_group_tool_verbs, set_hunk_tracker_mode, set_invert_scroll, set_keep_text_selection, + set_max_thoughts_width, set_multiline_mode, set_prompt_suggestions, + set_remember_tool_approvals, set_render_mermaid, set_respect_manual_folds, set_screen_mode, + set_scroll_lines, set_scroll_mode, set_scroll_speed, set_show_thinking_blocks, set_show_tips, + set_simple_mode, set_theme, set_timeline, set_timestamps, set_vim_mode, set_voice_capture_mode, + set_voice_stt_language, }; use super::settings::ui::{ dispatch_confirm_reset_setting, dispatch_open_command_palette, dispatch_open_howto_guides, @@ -116,6 +117,9 @@ use crate::app::app_view::{ActiveView, AppView, AuthState}; use crate::scrollback::types::DisplayMode; use crate::views::session_picker::CONTENT_EXPAND_OFFSET; use xai_grok_telemetry::session_ctx::log_event; +pub(super) fn auth_copy_was_confirmed(delivery: crate::clipboard::ClipboardDelivery) -> bool { + delivery == crate::clipboard::ClipboardDelivery::Confirmed +} /// Dispatch an action: mutate state, return effects to execute. /// /// The returned `Vec` may be empty (pure state mutation) or contain @@ -612,6 +616,26 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { server_name, }] } + Action::McpSetupSubmit { + server_name, + values, + } => { + let ActiveView::Agent(id) = app.active_view else { + return vec![]; + }; + let Some(agent) = app.agents.get_mut(&id) else { + return vec![]; + }; + let Some(session_id) = agent.session.session_id.clone() else { + return vec![]; + }; + vec![Effect::McpSetupSubmit { + agent_id: id, + session_id, + server_name, + values, + }] + } Action::ReloadSkills => { let ActiveView::Agent(id) = app.active_view else { return vec![]; @@ -932,6 +956,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { Action::SetContextualHintSendNow(v) => set_contextual_hint_send_now(app, v), Action::SetContextualHintSmallScreen(v) => set_contextual_hint_small_screen(app, v), Action::SetContextualHintWordSelect(v) => set_contextual_hint_word_select(app, v), + Action::SetContextualHintSshWrap(v) => set_contextual_hint_ssh_wrap(app, v), Action::SetTheme(v) => set_theme(app, v), Action::SetAutoDarkTheme(v) => set_auto_dark_theme(app, v), Action::SetAutoLightTheme(v) => set_auto_light_theme(app, v), @@ -1015,7 +1040,8 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { .. } = &app.auth_state { - app.auth_clipboard_copied = crate::clipboard::SystemClipboard::try_set(url); + app.auth_clipboard_copied = + auth_copy_was_confirmed(crate::clipboard::SystemClipboard::try_set(url)); } if app.auth_clipboard_copied { vec![Effect::ScheduleClearAuthCopied] diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs index 4706ba8..e69d829 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/session/lifecycle.rs @@ -13,7 +13,7 @@ use crate::app::dispatch::ctx::{ }; use crate::app::dispatch::modes::inherit_auto_mode; use crate::app::dispatch::prompt::{consume_chat_kind, dispatch_initial_prompt}; -use crate::app::dispatch::queue::maybe_drain_queue; +use crate::app::dispatch::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; use crate::app::dispatch::router::dispatch; use crate::app::dispatch::status::notify_session_ready; use crate::app::dispatch::task_result::unregister_session_effect; @@ -888,6 +888,7 @@ pub(in crate::app::dispatch) fn handle_session_created( cwd: agent.session.cwd.display().to_string(), }); notify_session_ready(&app.notification_service, agent); + note_peek_page_flip_after_drain(app, agent_id); return effects; } vec![] @@ -978,6 +979,7 @@ pub(in crate::app::dispatch) fn handle_worktree_session_created( cwd: agent.session.cwd.display().to_string(), }); notify_session_ready(&app.notification_service, agent); + note_peek_page_flip_after_drain(app, agent_id); return effects; } vec![] @@ -1092,6 +1094,7 @@ pub(in crate::app::dispatch) fn handle_switch_model_complete( } }; effects.extend(maybe_drain_queue(agent)); + note_peek_page_flip_after_drain(app, agent_id); effects } else { vec![] diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs index d6ee5a3..ea47a7f 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/session/load.rs @@ -15,7 +15,7 @@ use crate::app::dispatch::ctx::{ }; use crate::app::dispatch::modes::inherit_auto_mode; use crate::app::dispatch::prompt::{defer_to_open_reload_window, supersede_open_reload_window}; -use crate::app::dispatch::queue::maybe_drain_queue; +use crate::app::dispatch::queue::{maybe_drain_queue, note_peek_page_flip_after_drain}; use crate::app::dispatch::router::dispatch; use crate::app::dispatch::status::notify_session_ready; use crate::app::dispatch::transcript::extensions_modal_tab_fetches; @@ -993,6 +993,7 @@ pub(in crate::app::dispatch) fn handle_session_loaded( }); notify_session_ready(&app.notification_service, agent); crate::memory_release::release_retained_memory_with("session-load-replay"); + note_peek_page_flip_after_drain(app, agent_id); return effects; } vec![] diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs index 2179362..4d16527 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/setters.rs @@ -1104,6 +1104,21 @@ pub(in crate::app::dispatch) fn set_contextual_hint_word_select( ) } +pub(in crate::app::dispatch) fn set_contextual_hint_ssh_wrap( + app: &mut AppView, + new: bool, +) -> Vec { + let prev = app.current_ui.contextual_hints.ssh_wrap; + set_contextual_hint( + app, + "contextual_hints.ssh_wrap", + "SSH wrap hint", + prev, + |h, v| h.ssh_wrap = v, + new, + ) +} + // --------------------------------------------------------------------------- // Theme settings: `theme`, `auto_dark_theme`, `auto_light_theme`. // diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs index 008abd1..2eb2c40 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/settings/ui.rs @@ -705,6 +705,9 @@ pub(in crate::app::dispatch) fn action_for_reset( ("contextual_hints.word_select", SettingValue::Bool(b)) => { Some(Action::SetContextualHintWordSelect(*b)) } + ("contextual_hints.ssh_wrap", SettingValue::Bool(b)) => { + Some(Action::SetContextualHintSshWrap(*b)) + } ("multiline_mode", SettingValue::Bool(b)) => Some(Action::SetMultilineMode(*b)), ("render_mermaid", SettingValue::Enum(s)) => { crate::appearance::RenderMermaid::from_canonical(s).map(Action::SetRenderMermaid) @@ -893,6 +896,9 @@ pub(in crate::app::dispatch) fn apply_setting_rollback( ("contextual_hints.word_select", SettingValue::Bool(b)) => { set_contextual_hint_inner(app, |h, v| h.word_select = v, *b) } + ("contextual_hints.ssh_wrap", SettingValue::Bool(b)) => { + set_contextual_hint_inner(app, |h, v| h.ssh_wrap = v, *b) + } ("respect_manual_folds", SettingValue::Bool(b)) => set_respect_manual_folds_inner(app, *b), ("theme", SettingValue::Enum(s)) => set_theme_inner(app, s), ("default_selected_permission", SettingValue::Enum(s)) => { diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs index ba63810..dbc87cd 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/task_result.rs @@ -1,6 +1,7 @@ //! Async task-result application: routes task results into state. use super::auth::{ ensure_login_method, handle_auth_complete, handle_auth_url_ready, handle_mcp_auth_trigger_done, + handle_mcp_setup_submit_done, }; use super::billing::{ PAYWALL_AUTO_CHECK_TIMEOUT, apply_auto_topup, handle_billing_fetched, @@ -569,6 +570,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec mode, } => handle_auth_url_ready(app, request_seq, auth_url, external, mode), TaskResult::AuthCodeSubmitted { .. } => vec![], + TaskResult::AuthCancelComplete => vec![], TaskResult::McpsListLoaded { agent_id, result } => { use crate::views::extensions_modal::TabDataState; if let Some(agent) = app.agents.get_mut(&agent_id) @@ -588,6 +590,11 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec server_name, result, } => handle_mcp_auth_trigger_done(app, agent_id, server_name, result), + TaskResult::McpSetupSubmitDone { + agent_id, + server_name, + result, + } => handle_mcp_setup_submit_done(app, agent_id, server_name, result), TaskResult::HooksListLoaded { agent_id, result } => { handle_hooks_list_loaded(app, agent_id, result) } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/auth.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/auth.rs index 0d20d34..eb8789b 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/auth.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/auth.rs @@ -438,6 +438,144 @@ fn login_with_empty_auth_methods_fails_closed() { assert!(app.login_method_id.is_none()); } +/// Puts the app in `Authenticating` with a live task's abort handle installed +/// (as the event loop would), returning the task's JoinHandle and the seq. +/// Callers assert the task actually gets aborted (`unwrap_err().is_cancelled()`), +/// not merely that the handle slot was cleared. +fn install_live_auth_task( + app: &mut AppView, + rt: &tokio::runtime::Runtime, +) -> (tokio::task::JoinHandle<()>, u64) { + dispatch(Action::Login, app); + let task = rt.spawn(std::future::pending::<()>()); + match &mut app.auth_state { + AuthState::Authenticating { + handle, + request_seq, + .. + } => { + *handle = Some(task.abort_handle()); + (task, *request_seq) + } + other => panic!("expected Authenticating after Login, got {other:?}"), + } +} + +fn test_runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") +} + +/// A second `/login` while already authenticating must abort the prior auth +/// task and bump the seq (single-flight: no stacked device-code mints). +#[test] +fn login_while_authenticating_aborts_prior_task() { + let rt = test_runtime(); + let mut app = test_app_with_agent(); + let (prior_task, first_seq) = install_live_auth_task(&mut app, &rt); + + let effects = dispatch(Action::Login, &mut app); + + rt.block_on(async { + assert!( + prior_task.await.unwrap_err().is_cancelled(), + "prior auth task must be aborted" + ); + }); + match &app.auth_state { + AuthState::Authenticating { request_seq, .. } => { + assert!( + *request_seq > first_seq, + "re-login must bump request_seq for single-flight" + ); + } + other => panic!("expected Authenticating after re-Login, got {other:?}"), + } + assert!( + effects + .iter() + .any(|e| matches!(e, Effect::Authenticate { .. })), + "re-login must emit a new Authenticate" + ); +} + +/// A stale `AuthComplete` (from an attempt whose abort lost the race because +/// the task had already finished) must not complete the new attempt: the +/// request-seq guard is the only protection here. +#[test] +fn stale_auth_complete_after_relogin_is_ignored() { + let mut app = test_app_with_agent(); + dispatch(Action::Login, &mut app); + let first_seq = match &app.auth_state { + AuthState::Authenticating { request_seq, .. } => *request_seq, + other => panic!("expected Authenticating after Login, got {other:?}"), + }; + dispatch(Action::Login, &mut app); // re-login bumps to seq2 + + dispatch( + Action::TaskComplete(TaskResult::AuthComplete { + request_seq: first_seq, + meta: None, + }), + &mut app, + ); + + match &app.auth_state { + AuthState::Authenticating { request_seq, .. } => { + assert!( + *request_seq > first_seq, + "stale AuthComplete must leave the new attempt authenticating" + ); + } + other => panic!("stale AuthComplete must be ignored, got {other:?}"), + } +} + +/// Switch-account while authenticating goes through the same single-flight +/// abort as `/login` (sibling entry point). +#[test] +fn switch_account_while_authenticating_aborts_prior_task() { + let rt = test_runtime(); + let mut app = test_app_with_agent(); + let (prior_task, first_seq) = install_live_auth_task(&mut app, &rt); + + dispatch(Action::SwitchAccount, &mut app); + + rt.block_on(async { + assert!( + prior_task.await.unwrap_err().is_cancelled(), + "prior auth task must be aborted on switch-account" + ); + }); + match &app.auth_state { + AuthState::Authenticating { request_seq, .. } => { + assert!(*request_seq > first_seq, "switch must bump request_seq"); + } + other => panic!("expected Authenticating after SwitchAccount, got {other:?}"), + } +} + +/// Cancelling a mid-session login aborts the in-flight auth task (not just +/// restores the view) so a retry cannot race a still-polling prior mint. +#[test] +fn cancel_login_aborts_prior_task() { + let rt = test_runtime(); + let mut app = test_app_with_agent(); + // Login from a session view stashes `auth_return_view`, making CancelLogin live. + let (prior_task, _) = install_live_auth_task(&mut app, &rt); + + dispatch(Action::CancelLogin, &mut app); + + rt.block_on(async { + assert!( + prior_task.await.unwrap_err().is_cancelled(), + "cancel must abort the in-flight auth task" + ); + }); +} + /// Cancelling a mid-session login returns to the session rather than /// quitting the app, and clears the stashed view + auth state. #[test] @@ -445,10 +583,20 @@ fn cancel_login_restores_view() { let mut app = test_app_with_agent(); dispatch(Action::Login, &mut app); assert_eq!(app.active_view, ActiveView::Welcome); + let prior_seq = match &app.auth_state { + AuthState::Authenticating { request_seq, .. } => *request_seq, + other => panic!("expected Authenticating after Login, got {other:?}"), + }; let effects = dispatch(Action::CancelLogin, &mut app); - assert!(effects.is_empty(), "cancel is pure state, no effects"); + assert!( + matches!( + effects.as_slice(), + [Effect::CancelAuth { request_seq }] if *request_seq == prior_seq + ), + "cancel must tell the shell to stop the in-flight auth poll for this attempt" + ); assert_eq!(app.active_view, ActiveView::Agent(AgentId(0))); assert_eq!(app.auth_return_view, None); assert!(matches!(app.auth_state, AuthState::Done)); diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs index c5c3c87..f05fe90 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/dashboard.rs @@ -43,8 +43,6 @@ fn voice_final_appends_to_peek_reply_when_peek_open() { time_ago: "1m".into(), response_type: "Response".into(), last_user_message: None, - last_agent_lines: vec![], - last_response_truncated: false, question: None, options: vec![], request_id: None, @@ -92,8 +90,6 @@ fn voice_final_discarded_when_peek_row_changed_after_stop() { time_ago: "1m".into(), response_type: "Response".into(), last_user_message: None, - last_agent_lines: vec![], - last_response_truncated: false, question: None, options: vec![], request_id: None, @@ -258,8 +254,6 @@ fn voice_target_bound_at_start_dispatch_vs_peek() { time_ago: "1m".into(), response_type: "Response".into(), last_user_message: None, - last_agent_lines: vec![], - last_response_truncated: false, question: None, options: vec![], request_id: None, @@ -289,8 +283,6 @@ fn voice_auto_stops_when_peek_row_changes() { time_ago: "1m".into(), response_type: "Response".into(), last_user_message: None, - last_agent_lines: vec![], - last_response_truncated: false, question: None, options: vec![], request_id: None, @@ -3908,7 +3900,7 @@ fn dashboard_upgrade_cta_paints_arms_rect_and_ctrl_o_override() { use xai_grok_telemetry::events::AnnouncementCtaSurface; let registry = ActionRegistry::defaults(); - let agents: indexmap::IndexMap = + let mut agents: indexmap::IndexMap = indexmap::IndexMap::new(); // Wide enough that the reservation leaves room for the button + caption. let area = Rect::new(0, 0, 140, 20); @@ -3931,7 +3923,7 @@ fn dashboard_upgrade_cta_paints_arms_rect_and_ctrl_o_override() { &mut buf, area, &mut state, - &agents, + &mut agents, ®istry, None, &[], @@ -3985,7 +3977,7 @@ fn dashboard_upgrade_cta_paints_arms_rect_and_ctrl_o_override() { &mut buf, area, &mut state, - &agents, + &mut agents, ®istry, None, &[], @@ -4016,7 +4008,7 @@ fn dashboard_upgrade_cta_paints_arms_rect_and_ctrl_o_override() { &mut buf, area, &mut state, - &agents, + &mut agents, ®istry, None, &[], @@ -4053,7 +4045,7 @@ fn dashboard_upgrade_cta_paints_arms_rect_and_ctrl_o_override() { &mut buf, area, &mut state, - &agents, + &mut agents, ®istry, None, &[], @@ -4808,8 +4800,6 @@ fn dashboard_permission_select_drops_stale_request() { time_ago: String::new(), response_type: "Awaiting your input".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: Some("q?".into()), options: vec![("allow".into(), "Allow".into())], request_id: Some(123), // mismatched id @@ -4845,8 +4835,6 @@ fn dashboard_permission_select_for_missing_row_clears_peek() { time_ago: String::new(), response_type: "Idle".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -4885,8 +4873,6 @@ fn dashboard_peek_reply_to_idle_agent_sends() { time_ago: String::new(), response_type: "Idle".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -4928,8 +4914,6 @@ fn dashboard_peek_reply_to_running_agent_queues() { time_ago: String::new(), response_type: "Running\u{2026}".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -4970,8 +4954,6 @@ fn dashboard_peek_reply_with_image_sends_blocks() { time_ago: String::new(), response_type: "Idle".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -5028,8 +5010,6 @@ fn dashboard_peek_reply_image_with_whitespace_survives_rewind_restore() { time_ago: String::new(), response_type: "Idle".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -5104,8 +5084,6 @@ fn dashboard_peek_reply_with_image_queues_images() { time_ago: String::new(), response_type: "Running\u{2026}".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -5345,7 +5323,7 @@ fn dashboard_peek_auto_opens_for_selected_row() { let mut app = test_app_with_agent(); mark_agent_nonempty(&mut app, AgentId(0)); open_dashboard(&mut app); - let area = Rect::new(0, 0, 80, 24); // tall enough for the peek + let area = Rect::new(0, 0, 80, 40); // list-first: list floor 12 + peek min 8 + chrome let reg = crate::actions::ActionRegistry::defaults(); // Select a row, then render → the peek opens by default. @@ -5360,7 +5338,7 @@ fn dashboard_peek_auto_opens_for_selected_row() { &mut buf, area, app.dashboard.as_mut().unwrap(), - &app.agents, + &mut app.agents, ®, None, &[], @@ -5379,7 +5357,7 @@ fn dashboard_peek_auto_opens_for_selected_row() { &mut buf2, area, app.dashboard.as_mut().unwrap(), - &app.agents, + &mut app.agents, ®, None, &[], @@ -5402,7 +5380,7 @@ fn dashboard_peek_box_grows_for_multiline_reply() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let reg = crate::actions::ActionRegistry::defaults(); - let area = Rect::new(0, 0, 80, 24); + let area = Rect::new(0, 0, 80, 40); let box_height_for = |reply_text: &str| -> u16 { let mut app = test_app_with_agent(); @@ -5417,7 +5395,7 @@ fn dashboard_peek_box_grows_for_multiline_reply() { &mut buf, area, app.dashboard.as_mut().unwrap(), - &app.agents, + &mut app.agents, ®, None, &[], diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs index 5e436b4..7233ab3 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs @@ -126,6 +126,7 @@ fn test_app() -> AppView { tip_seen_counts: Default::default(), last_known_terminal_rows: 0, small_screen_tip_evaluated: false, + ssh_wrap_tip_evaluated: false, clipboard_focus_tip: Default::default(), new_session_worktree_mode: crate::app::app_view::WorktreeMode::Never, fork_worktree_mode: crate::app::app_view::WorktreeMode::Ask, @@ -143,6 +144,7 @@ fn test_app() -> AppView { auth_start_mode: AuthMode::Pending, auth_code_input: String::new(), next_auth_request_seq: 1, + auth_url_poll_handle: None, deferred_startup: Default::default(), auth_use_oauth: false, auth_clipboard_copied: false, @@ -423,6 +425,9 @@ fn cta_mcp_server( status, tool_count: 0, auth_required: matches!(status, McpServerDisplayStatus::NeedsAuth), + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: vec![], enabled: true, source: plugin diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs index 2aa34c4..259089d 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/prompt.rs @@ -362,6 +362,153 @@ fn small_screen_trigger_suppressed_when_user_compact_on() { assert!(app.tip_seen_counts.is_empty(), "no count burned"); } +// ── SSH wrap tip (`show_ssh_wrap_tip` + its one-shot trigger) ── + +/// `show_ssh_wrap_tip` on a drawable agent shows the tip and increments the +/// per-session seen count in memory (nothing persisted — the fn returns +/// nothing, so it cannot raise effects). +#[test] +fn show_ssh_wrap_tip_shows_and_counts_in_memory() { + use crate::tips::ssh_wrap::{SSH_WRAP_TIP_KEY, SSH_WRAP_TIP_SEEN_KEY}; + let mut app = test_app_with_agent(); + app.contextual_hints.ssh_wrap = true; + let id = AgentId(0); + app.agents.get_mut(&id).unwrap().last_terminal_size = (100, 40); + + crate::app::dispatch::show_ssh_wrap_tip(&mut app); + assert_eq!( + app.agents[&id].ephemeral_tip.current_key(), + Some(SSH_WRAP_TIP_KEY) + ); + assert_eq!(app.tip_seen_counts.get(SSH_WRAP_TIP_SEEN_KEY), Some(&1)); +} + +/// `show_ssh_wrap_tip` is a no-op when `contextual_hints.ssh_wrap` is off: +/// no tip shown, no count burned — even on a drawable agent. +#[test] +fn show_ssh_wrap_tip_no_op_when_flag_off() { + let mut app = test_app_with_agent(); + let id = AgentId(0); + app.agents.get_mut(&id).unwrap().last_terminal_size = (100, 40); + app.contextual_hints.ssh_wrap = false; + + crate::app::dispatch::show_ssh_wrap_tip(&mut app); + assert!(app.tip_seen_counts.is_empty(), "no count burned"); + assert!(!app.agents[&id].ephemeral_tip.is_active()); +} + +/// The seen cap holds at one show per session even if the show fn re-runs +/// after the first tip expired or was cleared. +#[test] +fn show_ssh_wrap_tip_respects_once_per_session_cap() { + use crate::tips::ssh_wrap::SSH_WRAP_TIP_SEEN_KEY; + let mut app = test_app_with_agent(); + let id = AgentId(0); + app.agents.get_mut(&id).unwrap().last_terminal_size = (100, 40); + + crate::app::dispatch::show_ssh_wrap_tip(&mut app); + app.agents.get_mut(&id).unwrap().ephemeral_tip.clear_all(); + crate::app::dispatch::show_ssh_wrap_tip(&mut app); + assert!( + !app.agents[&id].ephemeral_tip.is_active(), + "second show must be seen-gated" + ); + assert_eq!(app.tip_seen_counts.get(SSH_WRAP_TIP_SEEN_KEY), Some(&1)); +} + +/// The trigger defers — WITHOUT consuming the one-shot — until the active +/// view is an agent with a stable, draw-measured size; the first stable +/// measure with the environment recommending wrap then shows it exactly once. +#[test] +fn ssh_wrap_trigger_waits_for_stable_agent_measure_then_fires_once() { + let mut app = test_app_with_agent(); + let id = AgentId(0); + + // Welcome view: no evaluation, one-shot not consumed. + app.active_view = ActiveView::Welcome; + app.maybe_trigger_ssh_wrap_tip_inner(true); + assert!(!app.ssh_wrap_tip_evaluated); + + // Agent view, but never drawn (size (0,0)): still deferred. + app.active_view = ActiveView::Agent(id); + app.maybe_trigger_ssh_wrap_tip_inner(true); + assert!(!app.ssh_wrap_tip_evaluated); + + // Pending post-resize re-measure: still deferred. + { + let agent = app.agents.get_mut(&id).unwrap(); + agent.last_terminal_size = (100, 40); + agent.terminal_size_stale = true; + } + app.maybe_trigger_ssh_wrap_tip_inner(true); + assert!(!app.ssh_wrap_tip_evaluated); + + // Stable measure + recommending environment: evaluates once and shows. + app.agents.get_mut(&id).unwrap().terminal_size_stale = false; + app.maybe_trigger_ssh_wrap_tip_inner(true); + assert!(app.ssh_wrap_tip_evaluated); + assert_eq!( + app.agents[&id].ephemeral_tip.current_key(), + Some(crate::tips::ssh_wrap::SSH_WRAP_TIP_KEY) + ); + + // One-shot: later calls are inert. + app.agents.get_mut(&id).unwrap().ephemeral_tip.clear_all(); + app.maybe_trigger_ssh_wrap_tip_inner(true); + assert!(!app.agents[&id].ephemeral_tip.is_active()); +} + +/// A not-recommending environment (local session, wrap sink already active, +/// or a VS Code remote) consumes the one-shot without showing — the shape is +/// process-constant, so there is nothing to re-evaluate later. +#[test] +fn ssh_wrap_trigger_env_not_recommending_consumes_without_showing() { + let mut app = test_app_with_agent(); + let id = AgentId(0); + app.agents.get_mut(&id).unwrap().last_terminal_size = (100, 40); + + app.maybe_trigger_ssh_wrap_tip_inner(false); + assert!(app.ssh_wrap_tip_evaluated, "evaluation is consumed"); + assert!(!app.agents[&id].ephemeral_tip.is_active()); + assert!(app.tip_seen_counts.is_empty(), "no count burned"); + + // The one-shot is spent: even a recommending call stays inert. + app.maybe_trigger_ssh_wrap_tip_inner(true); + assert!(!app.agents[&id].ephemeral_tip.is_active()); +} + +/// A busy tip slot defers WITHOUT consuming — replacing would burn the other +/// session-load tip's once-per-session show; once the slot frees, the next +/// draw shows the wrap tip. +#[test] +fn ssh_wrap_trigger_defers_while_tip_slot_busy() { + let mut app = test_app_with_agent(); + let id = AgentId(0); + // In the small-screen band so the other session-load tip takes the slot + // first (mirrors the real draw order: the small-screen trigger runs + // first). + app.agents.get_mut(&id).unwrap().last_terminal_size = (100, 24); + app.maybe_trigger_small_screen_tip(); + assert!(app.agents[&id].ephemeral_tip.is_active()); + + app.maybe_trigger_ssh_wrap_tip_inner(true); + assert!(!app.ssh_wrap_tip_evaluated, "busy slot must defer"); + assert_eq!( + app.agents[&id].ephemeral_tip.current_key(), + Some(crate::tips::small_screen::SMALL_SCREEN_TIP_KEY), + "the earlier tip keeps the slot" + ); + + // Slot free (the first tip expired or cleared): the next draw shows it. + app.agents.get_mut(&id).unwrap().ephemeral_tip.clear_all(); + app.maybe_trigger_ssh_wrap_tip_inner(true); + assert!(app.ssh_wrap_tip_evaluated); + assert_eq!( + app.agents[&id].ephemeral_tip.current_key(), + Some(crate::tips::ssh_wrap::SSH_WRAP_TIP_KEY) + ); +} + #[test] fn focus_prompt_switches_pane() { let mut app = test_app_with_agent(); diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/router.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/router.rs index 46e3c60..ac0f417 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/router.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/router.rs @@ -1,5 +1,18 @@ //! Tests for the action router, model switching, slash commands, and other cross-cutting dispatch behavior. use super::*; +#[test] +fn auth_copy_success_requires_confirmed_delivery() { + use crate::clipboard::ClipboardDelivery; + assert!(crate::app::dispatch::router::auth_copy_was_confirmed( + ClipboardDelivery::Confirmed + )); + assert!(!crate::app::dispatch::router::auth_copy_was_confirmed( + ClipboardDelivery::Unverified + )); + assert!(!crate::app::dispatch::router::auth_copy_was_confirmed( + ClipboardDelivery::Failed + )); +} fn seed_foreign_resume_hint( app: &mut AppView, tool: xai_grok_workspace::foreign_sessions::ForeignSessionTool, @@ -2104,8 +2117,6 @@ fn mouse_click_on_peek_close_rect_clears_peek() { time_ago: String::new(), response_type: "Idle".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/lifecycle.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/lifecycle.rs index 1c2e041..72be456 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/lifecycle.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/session/lifecycle.rs @@ -1873,7 +1873,7 @@ fn dashboard_stop_with_peek_open_moves_selection_and_peek_down_one() { let first = order[0].clone(); let second = order[1].clone(); app.dashboard.as_mut().unwrap().focus_row(first.clone()); - let area = Rect::new(0, 0, 80, 24); + let area = Rect::new(0, 0, 80, 40); let reg = crate::actions::ActionRegistry::defaults(); let render = |app: &mut AppView| { let mut buf = Buffer::empty(area); @@ -1881,7 +1881,7 @@ fn dashboard_stop_with_peek_open_moves_selection_and_peek_down_one() { &mut buf, area, app.dashboard.as_mut().unwrap(), - &app.agents, + &mut app.agents, ®, None, &[], diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs index e7186dd..cc11b09 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/settings.rs @@ -1212,6 +1212,9 @@ fn move_setting_away_from_default(app: &mut AppView, key: crate::settings::Setti "contextual_hints.word_select" => { let _ = dispatch(Action::SetContextualHintWordSelect(false), app); } + "contextual_hints.ssh_wrap" => { + let _ = dispatch(Action::SetContextualHintSshWrap(false), app); + } "multiline_mode" => { let _ = dispatch(Action::SetMultilineMode(true), app); } diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/turn.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/turn.rs index 541e67f..1539cf7 100644 --- a/crates/codegen/xai-grok-pager/src/app/dispatch/turn.rs +++ b/crates/codegen/xai-grok-pager/src/app/dispatch/turn.rs @@ -2,7 +2,7 @@ use super::ctx::find_agent_by_session_id; use super::permissions::drain_permission_queue; -use super::queue::{apply_turn_start_shim, maybe_drain_queue}; +use super::queue::{apply_turn_start_shim, maybe_drain_queue, note_peek_page_flip_after_drain}; use crate::app::actions::Effect; use crate::app::agent::AgentId; use crate::app::agent_view::ActivePane; @@ -317,6 +317,7 @@ pub(crate) fn reconcile_overdue_turn_ends(app: &mut AppView) -> Option Option String { if i32::from(err.code) == RATE_LIMITED_ERROR_CODE { - if super::dispatch::acp_error_is_free_usage_exhausted(err) { - return super::dispatch::FREE_USAGE_USER_MESSAGE.into(); - } - return rate_limited_user_message(is_api_key_auth).into(); + let detail = err.data.as_ref().and_then(error_detail_from_data); + return format_rate_limited_user_message(detail.as_deref(), is_api_key_auth); } if err.code == acp::ErrorCode::InvalidParams && let Some(data) = &err.data - && let Some(msg) = xai_grok_shell::sampling::error::error_detail_from_data(data) - && !msg.is_empty() + && let Some(msg) = error_detail_from_data(data) && !msg.is_empty() { return msg; } @@ -339,6 +338,9 @@ pub(crate) struct EffectMeta { /// install this into `AppView.auth_state` if the current auth state /// still matches the sequence. pub auth_abort_handle: Option<(u64, tokio::task::AbortHandle)>, + /// Auth URL poll abort handle + request sequence (installed on + /// `AppView.auth_url_poll_handle` when the seq still matches). + pub auth_url_poll_handle: Option<(u64, tokio::task::AbortHandle)>, } /// Extract the first user prompt text from a session's `chat_history.jsonl`. /// @@ -639,6 +641,23 @@ pub(super) async fn send_logout(tx: &AcpAgentTx) { tracing::warn!(error = % e, "logout failed"); } } +/// Best-effort `x.ai/auth/cancel`: stops the shell's device/loopback wait so a +/// later login is single-flight. Errors are ignored — UI already left +/// `Authenticating`. `request_seq` scopes the cancel to the abandoned attempt. +pub(super) async fn send_auth_cancel(tx: &AcpAgentTx, request_seq: u64) -> TaskResult { + let req = acp::ExtRequest::new( + "x.ai/auth/cancel", + serde_json::value::to_raw_value( + &serde_json::json!({ "request_seq" : request_seq }), + ) + .expect("serialize auth/cancel params") + .into(), + ); + if let Err(e) = acp_send(req, tx).await { + tracing::debug!(error = % e, "auth cancel ext request failed (ignored)"); + } + TaskResult::AuthCancelComplete +} pub(super) async fn send_check_subscription( tx: &AcpAgentTx, verify: Option, @@ -713,7 +732,9 @@ pub(super) async fn send_authenticate( use_oauth: bool, force_interactive: bool, ) -> TaskResult { - let mut meta = serde_json::json!({ "use_oauth" : use_oauth }); + let mut meta = serde_json::json!( + { "use_oauth" : use_oauth, "request_seq" : request_seq, } + ); if force_interactive { meta["force_interactive"] = serde_json::json!(true); } @@ -839,6 +860,14 @@ pub(crate) async fn persist_setting( .await .map_err(|e| e.to_string()) } + "contextual_hints.ssh_wrap" => { + let SettingValue::Bool(b) = value else { + return Err(kind_mismatch("contextual_hints.ssh_wrap", "Bool", &value)); + }; + xai_grok_shell::util::config::set_contextual_hint_ssh_wrap(b) + .await + .map_err(|e| e.to_string()) + } "theme" => { let SettingValue::Enum(s) = value else { return Err(kind_mismatch("theme", "Enum", &value)); diff --git a/crates/codegen/xai-grok-pager/src/app/effects/mod.rs b/crates/codegen/xai-grok-pager/src/app/effects/mod.rs index ce25c93..97c5247 100644 --- a/crates/codegen/xai-grok-pager/src/app/effects/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/effects/mod.rs @@ -81,6 +81,10 @@ pub(crate) fn execute( TaskResult::LogoutComplete }); } + Effect::CancelAuth { request_seq } => { + let tx = acp_tx.clone(); + tasks.spawn(async move { send_auth_cancel(&tx, request_seq).await }); + } Effect::CheckSubscription { verify } => { let tx = acp_tx.clone(); tasks.spawn(async move { send_check_subscription(&tx, verify).await }); @@ -1900,7 +1904,7 @@ pub(crate) fn execute( } Effect::PollAuthUrl { request_seq } => { let tx = acp_tx.clone(); - tasks + let abort_handle = tasks .spawn(async move { let mut auth_url: Option = None; let mut external = false; @@ -1944,6 +1948,7 @@ pub(crate) fn execute( mode, } }); + meta.auth_url_poll_handle = Some((request_seq, abort_handle)); } Effect::SubmitAuthCode { request_seq, code } => { let tx = acp_tx.clone(); @@ -2043,7 +2048,24 @@ pub(crate) fn execute( .and_then(|s| s.as_str()) .unwrap_or("unknown"); if status == "authenticated" { - Ok(()) + Ok( + crate::app::actions::McpAuthTriggerOutcome::Authenticated, + ) + } else if status == "setup_required" { + let setup = result_obj + .and_then(|r| r.get("setup")) + .cloned() + .and_then(|value| { + serde_json::from_value::< + crate::views::mcps_modal::McpSetupConfig, + >(value) + .ok() + }) + .ok_or_else(|| "setup required".to_string()); + setup + .map( + crate::app::actions::McpAuthTriggerOutcome::SetupRequired, + ) } else { let detail = result_obj .and_then(|r| r.get("error")) @@ -2066,6 +2088,51 @@ pub(crate) fn execute( } }); } + Effect::McpSetupSubmit { agent_id, session_id, server_name, values } => { + let tx = acp_tx.clone(); + tasks + .spawn(async move { + let params = serde_json::json!( + { "sessionId" : session_id.0.to_string(), "serverName" : + server_name, "values" : values, } + ); + let req = acp::ExtRequest::new( + "x.ai/mcp/setup", + serde_json::value::to_raw_value(¶ms) + .expect("serialize mcp/setup params") + .into(), + ); + let result = match acp_send(req, &tx).await { + Ok(resp) => { + let wrapper: serde_json::Value = serde_json::from_str( + resp.0.get(), + ) + .unwrap_or_default(); + let result_obj = wrapper.get("result"); + if result_obj + .and_then(|r| r.get("ok")) + .and_then(|ok| ok.as_bool()) + .unwrap_or(false) + { + Ok(()) + } else { + let detail = result_obj + .and_then(|r| r.get("error")) + .and_then(|e| e.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "setup failed".to_string()); + Err(detail) + } + } + Err(e) => Err(sanitize_user_error(&format!("setup failed: {e}"))), + }; + TaskResult::McpSetupSubmitDone { + agent_id, + server_name, + result, + } + }); + } Effect::FetchHooksList { agent_id, session_id } => { let tx = acp_tx.clone(); tasks @@ -4180,17 +4247,9 @@ fn format_session_info( .as_deref() .map(|b| format!("\n API Backend: {b}")) .unwrap_or_default(); - let sandbox_line = match xai_grok_sandbox::profile_name() { - Some(profile) => { - let net = if xai_grok_sandbox::should_restrict_child_network() { - " (network: restricted)" - } else { - "" - }; - format!("\n Sandbox: {profile}{net}") - } - None => String::new(), - }; + let sandbox_line = xai_grok_sandbox::profile_name() + .map(|profile| format!("\n Sandbox: {profile}")) + .unwrap_or_default(); let turn_line = format!("\n Turn: {}", info.data.turn_index); let conversation_line = info .data diff --git a/crates/codegen/xai-grok-pager/src/app/effects/tests.rs b/crates/codegen/xai-grok-pager/src/app/effects/tests.rs index e0afcf7..139dd18 100644 --- a/crates/codegen/xai-grok-pager/src/app/effects/tests.rs +++ b/crates/codegen/xai-grok-pager/src/app/effects/tests.rs @@ -17,14 +17,31 @@ fn format_acp_error_reads_detail_from_wrapped_data() { assert_eq!(format_acp_error(& wrapped, false), "model does not support tools"); } #[test] -fn format_acp_error_rate_limit_is_auth_aware() { +fn format_acp_error_rate_limit_surfaces_detail_or_fallback() { use xai_grok_shell::sampling::error::{ - RATE_LIMITED_ERROR_CODE, RATE_LIMITED_USER_MESSAGE_API_KEY, - RATE_LIMITED_USER_MESSAGE_OAUTH, + FREE_USAGE_USER_MESSAGE, RATE_LIMITED_ERROR_CODE, + RATE_LIMITED_USER_MESSAGE_API_KEY, RATE_LIMITED_USER_MESSAGE_OAUTH, }; - let err = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited").data("slow down"); - assert_eq!(format_acp_error(& err, false), RATE_LIMITED_USER_MESSAGE_OAUTH); - assert_eq!(format_acp_error(& err, true), RATE_LIMITED_USER_MESSAGE_API_KEY); + let capacity = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited") + .data( + "The service is temporarily at capacity. Please retry your request shortly.", + ); + let cap = "The service is temporarily at capacity. Please retry your request shortly."; + assert_eq!(format_acp_error(& capacity, false), cap); + assert_eq!(format_acp_error(& capacity, true), cap); + let rpm = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited") + .data( + "You are sending requests too quickly. Please slow down, or upgrade to a Grok subscription for higher limits: https://grok.com/supergrok", + ); + assert!(format_acp_error(& rpm, false).contains("grok.com/supergrok")); + assert_eq!(format_acp_error(& rpm, true), RATE_LIMITED_USER_MESSAGE_API_KEY); + let empty = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited"); + assert_eq!(format_acp_error(& empty, false), RATE_LIMITED_USER_MESSAGE_OAUTH); + assert_eq!(format_acp_error(& empty, true), RATE_LIMITED_USER_MESSAGE_API_KEY); + let free = acp::Error::new(RATE_LIMITED_ERROR_CODE, "Rate limited") + .data("subscription:free-usage-exhausted: You have used all your free usage."); + assert_eq!(format_acp_error(& free, false), FREE_USAGE_USER_MESSAGE); + assert_eq!(format_acp_error(& free, true), FREE_USAGE_USER_MESSAGE); } /// Non-empty token ranges ride the wire block meta as `skillTokenRanges` /// byte pairs; the text itself is untouched. diff --git a/crates/codegen/xai-grok-pager/src/app/event_loop.rs b/crates/codegen/xai-grok-pager/src/app/event_loop.rs index e093cd6..79c7981 100644 --- a/crates/codegen/xai-grok-pager/src/app/event_loop.rs +++ b/crates/codegen/xai-grok-pager/src/app/event_loop.rs @@ -2562,14 +2562,38 @@ fn sync_appearance_watcher(watcher: &mut Option) { /// Build [`ExitInfo`] from the active agent's session (if any). /// +/// Sole construction site of [`super::ExitSummary`]: fullscreen quits only +/// (leaving the alt screen wipes the transcript; inline/minimal quits keep it +/// visible in native scrollback), and only with at least one conversation +/// line (a bare title is noise). Deliberately the root agent even when a +/// subagent view is focused — `--resume` restores the root session, and a +/// subagent's latest "prompt" is the parent's task brief, not user input. +/// /// `exit_info` is only consumed on the plain-quit path; a pending `relaunch` /// short-circuits before it is read and carries its own session id. fn make_run_result(app: &AppView) -> RunResult { - RunResult { - exit_info: app.active_session_id().map(|sid| super::ExitInfo { - session_id: sid.to_string(), + let exit_info = app.active_agent().and_then(|agent| { + let sid = agent.session.session_id.as_ref()?; + let summary = if app.screen_mode.is_fullscreen() { + use crate::views::session_title; + let last_prompt = session_title::last_user_prompt_line(agent); + let last_response = session_title::last_agent_message_line(agent); + (last_prompt.is_some() || last_response.is_some()).then(|| super::ExitSummary { + title: session_title::entry_title(agent), + last_prompt, + last_response, + }) + } else { + None + }; + Some(super::ExitInfo { + session_id: sid.0.to_string(), minimal: app.screen_mode.is_minimal(), - }), + summary, + }) + }); + RunResult { + exit_info, quit_for_update: app.quit_for_update, relaunch: app.relaunch.clone(), } @@ -3238,6 +3262,18 @@ fn process_effects( { *handle = Some(abort_handle); } + // Install URL-poll abort handle when the seq still matches (or is the + // current Authenticating attempt). Aborted in `abort_prior_auth`. + if let Some((seq, abort_handle)) = meta.auth_url_poll_handle { + let still_current = matches!( + &app.auth_state, + super::app_view::AuthState::Authenticating { request_seq, .. } + if *request_seq == seq + ); + if still_current { + app.auth_url_poll_handle = Some((seq, abort_handle)); + } + } if quit { return true; } @@ -4137,4 +4173,90 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste(r"C:\foo.png".to_string())); } + + // ── make_run_result exit info ──────────────────────────────────────── + + /// App focused on an agent (session `test-session`) with a seeded + /// prompt → prompt → response exchange in its scrollback. + fn seeded_quit_app(screen_mode: crate::app::ScreenMode) -> AppView { + use crate::scrollback::block::RenderBlock; + let mut app = crate::app::app_view::tests::test_app_with_agent(); + app.screen_mode = screen_mode; + let ActiveView::Agent(id) = app.active_view else { + panic!("test app must start on an agent"); + }; + let scrollback = &mut app.agents.get_mut(&id).unwrap().scrollback; + scrollback.push_block(RenderBlock::user_prompt("fix the flaky CI test")); + scrollback.push_block(RenderBlock::user_prompt("make the suite deterministic")); + scrollback.push_block(RenderBlock::agent_message("Pinned the seed.\nSecond line.")); + app + } + + #[test] + fn make_run_result_fullscreen_quit_builds_summary() { + let app = seeded_quit_app(crate::app::ScreenMode::Fullscreen); + let info = make_run_result(&app).exit_info.expect("agent exit info"); + assert_eq!(info.session_id, "test-session"); + assert!(!info.minimal); + let summary = info.summary.expect("summary on fullscreen quit"); + // Deliberate: title comes from the first prompt, last_prompt from the newest. + assert_eq!(summary.title, "fix the flaky CI test"); + assert_eq!( + summary.last_prompt.as_deref(), + Some("make the suite deterministic") + ); + assert_eq!(summary.last_response.as_deref(), Some("Pinned the seed.")); + } + + #[test] + fn make_run_result_unanswered_prompt_omits_stale_response() { + use crate::scrollback::block::RenderBlock; + let mut app = seeded_quit_app(crate::app::ScreenMode::Fullscreen); + let ActiveView::Agent(id) = app.active_view else { + panic!("test app must start on an agent"); + }; + app.agents + .get_mut(&id) + .unwrap() + .scrollback + .push_block(RenderBlock::user_prompt("now rerun the whole suite")); + let info = make_run_result(&app).exit_info.expect("agent exit info"); + let summary = info.summary.expect("prompt alone still summarizes"); + assert_eq!( + summary.last_prompt.as_deref(), + Some("now rerun the whole suite") + ); + // The earlier reply answered an older prompt — it must not appear here. + assert!(summary.last_response.is_none()); + } + + #[test] + fn make_run_result_inline_and_minimal_quits_omit_summary() { + let app = seeded_quit_app(crate::app::ScreenMode::Inline); + let info = make_run_result(&app).exit_info.expect("agent exit info"); + assert!(info.summary.is_none()); + assert!(!info.minimal); + + let app = seeded_quit_app(crate::app::ScreenMode::Minimal); + let info = make_run_result(&app).exit_info.expect("agent exit info"); + assert!(info.summary.is_none()); + assert!(info.minimal); + } + + #[test] + fn make_run_result_empty_session_omits_summary() { + let mut app = crate::app::app_view::tests::test_app_with_agent(); + app.screen_mode = crate::app::ScreenMode::Fullscreen; + let info = make_run_result(&app).exit_info.expect("agent exit info"); + assert!(info.summary.is_none()); + } + + #[test] + fn make_run_result_non_agent_views_have_no_exit_info() { + for view in [ActiveView::Welcome, ActiveView::AgentDashboard] { + let mut app = seeded_quit_app(crate::app::ScreenMode::Fullscreen); + app.active_view = view; + assert!(make_run_result(&app).exit_info.is_none()); + } + } } diff --git a/crates/codegen/xai-grok-pager/src/app/mermaid_worker.rs b/crates/codegen/xai-grok-pager/src/app/mermaid_worker.rs index 742f5c7..8e62168 100644 --- a/crates/codegen/xai-grok-pager/src/app/mermaid_worker.rs +++ b/crates/codegen/xai-grok-pager/src/app/mermaid_worker.rs @@ -1058,7 +1058,9 @@ impl AgentView { fn complete_mermaid_action(&mut self, action: MermaidClickAction, path: &Path) { let ok = match action { MermaidClickAction::Open => self.open_media_natively(path), - MermaidClickAction::CopyPath => self.copy_to_clipboard(&path.display().to_string()), + MermaidClickAction::CopyPath => !self + .copy_to_clipboard(&path.display().to_string()) + .is_failed(), }; if !ok { crate::unified_log::error( @@ -2216,7 +2218,9 @@ mod tests { // CopyPath ran now (clipboard toast), no render pending, no runtime built. let toast = toast_of(&agent); assert!( - toast.starts_with("Copied") || toast.starts_with("Copy failed"), + toast.starts_with("Copied") + || toast.starts_with("Copy sent") + || toast.starts_with("Copy failed"), "a disk hit runs the copy action immediately, got {toast:?}", ); assert!( diff --git a/crates/codegen/xai-grok-pager/src/app/mod.rs b/crates/codegen/xai-grok-pager/src/app/mod.rs index 9b1f500..21bf391 100644 --- a/crates/codegen/xai-grok-pager/src/app/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/mod.rs @@ -56,7 +56,6 @@ use crossterm::execute; use crossterm::terminal::{ self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, SetTitle, }; -pub(crate) use dispatch::{FREE_USAGE_USER_MESSAGE, acp_error_is_free_usage_exhausted}; pub use foreign_sessions::ForeignScanCoordinator; pub(crate) use foreign_sessions::{ badge_for_picker_source, foreign_tool_display_label, is_foreign_picker_source, @@ -346,6 +345,20 @@ fn finish_theme_after_probe(requested_minimal: bool, effective_mode: ScreenMode) pub(crate) struct ExitInfo { pub session_id: String, pub minimal: bool, + /// Glanceable session tail; `Some` exactly when it should print. The + /// presence policy lives at the sole construction site, `make_run_result`. + pub summary: Option, +} +/// Session tail printed above the resume command on fullscreen quits. +/// +/// Invariant: every field is a pre-sanitized single line (built from the +/// `views::session_title` helpers), so the printer only width-truncates. +pub(crate) struct ExitSummary { + /// Display title (rename > generated > first prompt). + pub title: String, + pub last_prompt: Option, + /// `None` when the newest prompt is still unanswered. + pub last_response: Option, } /// Resolve leader mode → `(use_leader, policy_disable_reason)`. /// @@ -732,7 +745,8 @@ pub async fn run( return Ok(false); } if let Some(info) = run_result.exit_info { - print_exit_resume_hint(&info.session_id, info.minimal, &mut io::stderr()); + let width = crossterm::terminal::size().map_or(80, |(cols, _)| cols as usize); + print_exit_resume_hint(&info, width, &mut io::stderr()); } Ok(false) } @@ -740,14 +754,33 @@ pub async fn run( } } /// Plain-quit "Resume this session with…" lines (after terminal restore). +/// +/// A summary, when present — title, last prompt, last response, one line +/// each, width-truncated — precedes the command so a glance at the pane +/// shows which session lives there and where it left off. /// Best-effort: closed-pane EIO/BrokenPipe must not panic (`panic = "abort"`). -fn print_exit_resume_hint(session_id: &str, minimal: bool, w: &mut impl Write) { +fn print_exit_resume_hint(info: &ExitInfo, max_width: usize, w: &mut impl Write) { + use crate::render::line_utils::truncate_str; let _ = writeln!(w); + if let Some(summary) = &info.summary { + let _ = writeln!(w, "{}", truncate_str(&summary.title, max_width)); + if let Some(prompt) = summary.last_prompt.as_deref() { + let _ = writeln!(w, "> {}", truncate_str(prompt, max_width.saturating_sub(2))); + } + if let Some(response) = summary.last_response.as_deref() { + let _ = writeln!( + w, + " {}", + truncate_str(response, max_width.saturating_sub(2)) + ); + } + let _ = writeln!(w); + } let _ = writeln!(w, "Resume this session with:"); - if minimal { - let _ = writeln!(w, " grok --minimal --resume {session_id}"); + if info.minimal { + let _ = writeln!(w, " grok --minimal --resume {}", info.session_id); } else { - let _ = writeln!(w, " grok --resume {session_id}"); + let _ = writeln!(w, " grok --resume {}", info.session_id); } } /// Screen-mode relaunch failure fallback (same quit tail as plain resume). @@ -1768,10 +1801,18 @@ mod tests { Err(io::Error::from_raw_os_error(5)) } } + /// [`ExitInfo`] with no summary, as built for inline/minimal quits. + fn bare_exit_info(session_id: &str, minimal: bool) -> ExitInfo { + ExitInfo { + session_id: session_id.to_string(), + minimal, + summary: None, + } + } #[test] fn print_exit_resume_hint_writes_expected_lines() { let mut buf = Vec::new(); - print_exit_resume_hint("sess-abc", false, &mut buf); + print_exit_resume_hint(&bare_exit_info("sess-abc", false), 80, &mut buf); assert_eq!( String::from_utf8(buf).unwrap(), "\nResume this session with:\n grok --resume sess-abc\n" @@ -1780,13 +1821,58 @@ mod tests { #[test] fn print_exit_resume_hint_includes_minimal_flag() { let mut buf = Vec::new(); - print_exit_resume_hint("sess-abc", true, &mut buf); + print_exit_resume_hint(&bare_exit_info("sess-abc", true), 80, &mut buf); assert_eq!( String::from_utf8(buf).unwrap(), "\nResume this session with:\n grok --minimal --resume sess-abc\n" ); } #[test] + fn print_exit_resume_hint_includes_session_summary() { + let info = ExitInfo { + session_id: "sess-abc".to_string(), + minimal: false, + summary: Some(ExitSummary { + title: "Fix flaky CI test".to_string(), + last_prompt: Some("make the suite deterministic".to_string()), + last_response: Some("Pinned the seed; 200 consecutive green runs.".to_string()), + }), + }; + let mut buf = Vec::new(); + print_exit_resume_hint(&info, 80, &mut buf); + assert_eq!( + String::from_utf8(buf).unwrap(), + concat!( + "\n", + "Fix flaky CI test\n", + "> make the suite deterministic\n", + " Pinned the seed; 200 consecutive green runs.\n", + "\n", + "Resume this session with:\n", + " grok --resume sess-abc\n", + ) + ); + } + #[test] + fn print_exit_resume_hint_truncates_summary_to_width() { + let info = ExitInfo { + session_id: "sess-abc".to_string(), + minimal: false, + summary: Some(ExitSummary { + title: "t".repeat(50), + last_prompt: Some("p".repeat(50)), + last_response: Some("r".repeat(50)), + }), + }; + let mut buf = Vec::new(); + print_exit_resume_hint(&info, 20, &mut buf); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains(&format!("\n{}…\n", "t".repeat(19)))); + assert!(out.contains(&format!("\n> {}…\n", "p".repeat(17)))); + assert!(out.contains(&format!("\n {}…\n", "r".repeat(17)))); + assert!(out.contains(" grok --resume sess-abc\n")); + } + #[test] fn print_relaunch_failure_hint_writes_expected_lines() { let mut buf = Vec::new(); print_relaunch_failure_hint(&"exec failed", "sess-xyz", false, &mut buf); @@ -1799,11 +1885,23 @@ mod tests { ) ); } + /// [`ExitInfo`] with a full summary, for the failing-writer tests. + fn full_exit_info(session_id: &str) -> ExitInfo { + ExitInfo { + summary: Some(ExitSummary { + title: "title".to_string(), + last_prompt: Some("prompt".to_string()), + last_response: Some("response".to_string()), + }), + ..bare_exit_info(session_id, false) + } + } #[test] fn print_hints_survive_eio() { let mut w = AlwaysFailWrite; - print_exit_resume_hint("sess-abc", false, &mut w); - print_exit_resume_hint("sess-abc", true, &mut w); + print_exit_resume_hint(&bare_exit_info("sess-abc", false), 80, &mut w); + print_exit_resume_hint(&bare_exit_info("sess-abc", true), 80, &mut w); + print_exit_resume_hint(&full_exit_info("sess-abc"), 80, &mut w); print_relaunch_failure_hint(&"exec failed", "sess-xyz", true, &mut w); } /// Close the *read* end so writes on the write end get EPIPE @@ -1819,8 +1917,9 @@ mod tests { libc::close(fds[0]); } let mut writer = unsafe { std::fs::File::from_raw_fd(fds[1]) }; - print_exit_resume_hint("pipe-sid", false, &mut writer); - print_exit_resume_hint("pipe-sid", true, &mut writer); + print_exit_resume_hint(&bare_exit_info("pipe-sid", false), 80, &mut writer); + print_exit_resume_hint(&bare_exit_info("pipe-sid", true), 80, &mut writer); + print_exit_resume_hint(&full_exit_info("pipe-sid"), 80, &mut writer); print_relaunch_failure_hint(&"exec failed", "pipe-sid", false, &mut writer); } } diff --git a/crates/codegen/xai-grok-pager/src/diagnostics.rs b/crates/codegen/xai-grok-pager/src/diagnostics.rs index 276452b..1fa80a5 100644 --- a/crates/codegen/xai-grok-pager/src/diagnostics.rs +++ b/crates/codegen/xai-grok-pager/src/diagnostics.rs @@ -51,6 +51,10 @@ pub enum WarningCategory { /// Below truecolor: truecolor themes hidden. `/terminal-setup` only. LimitedColorSupport, SandboxProfileConflict, + /// The session runs over SSH without `grok wrap` on the local end, so + /// clipboard forwarding and terminal-mode restore on dropped connections + /// are not guaranteed. Informational recommendation, not a breakage. + SshWithoutWrap, } /// A structured startup warning carrying category, human-readable description, @@ -356,16 +360,66 @@ fn sandbox_profile_conflict_warning_from(conflicts: Vec) -> Option` on the user's local machine, which gives a +/// remote session reliable clipboard forwarding plus terminal-mode restore +/// when the connection drops. +/// +/// Gates (all must hold): +/// - `is_ssh` — the session runs over SSH ([`TerminalContext::is_ssh`]); +/// - `!osc52_sink_active` — no wrap is already capturing our output. `grok +/// wrap` advertises its OSC 52 sink through the SSH hop via an env var +/// (see `clipboard::osc52_sink_active`), so once a user adopts wrap the +/// hint silences itself with no further bookkeeping. Env-based, so stale +/// under tmux (panes inherit the server's env at server start): a server +/// started before wrap misses the sink and the hint fires despite wrap, +/// and one started under wrap keeps suppressing after wrap is gone — +/// accepted, the same exposure the SSH env checks already live with; +/// - `!is_official_vscode_remote` — a VS Code remote integrated terminal is +/// not a plain ssh terminal the user could wrap. +/// +/// This detector describes environment shape only; the +/// `[ui.contextual_hints].ssh_wrap` policy gate is applied by the ephemeral +/// tip's trigger (`AppView::maybe_trigger_ssh_wrap_tip`), while +/// `/terminal-setup` deliberately lists the recommendation unconditionally. +/// All inputs are injected so tests never touch ambient env (pattern: +/// [`diagnose_wayland_data_control`]). +pub fn ssh_wrap_hint( + is_ssh: bool, + osc52_sink_active: bool, + is_official_vscode_remote: bool, +) -> Option { + if !is_ssh || osc52_sink_active || is_official_vscode_remote { + return None; + } + let mut warning = TerminalWarning::new( + WarningCategory::SshWithoutWrap, + "Running over SSH without `grok wrap` -- clipboard copies depend on the \ + terminal's escape-sequence support, and a dropped connection can leave \ + your local terminal in a bad state", + Some("grok wrap ssh "), + None, + ); + warning.note = Some( + "Run it on your local machine in place of plain `ssh` -- it forwards \ + clipboard copies to your local system and restores terminal modes if \ + the connection drops." + .to_string(), + ); + Some(warning) +} + /// Assemble the welcome-screen startup warning list. /// -/// The WezTerm kitty-keyboard warning (when present) goes **first**: the -/// welcome screen renders only the first entry, and a broken-local-input -/// warning outranks the SSH clipboard advisories from -/// [`summarize_warnings`]. The Wayland no-data-control warning follows the -/// same bypass (surfaced locally — [`summarize_warnings`] is SSH-gated — but -/// after WezTerm: broken input outranks focus-dependent copies). Keeping the -/// banner copy here (instead of at the call site) ties it to the warnings so -/// the surfaces can't drift. +/// The welcome screen renders a single entry — the severity-aware pick from +/// `startup::banner_warning`, whose doc owns the selection contract — so +/// assemble order decides precedence among Warnings. The WezTerm kitty-keyboard +/// warning (when present) goes **first**: a broken-local-input warning +/// outranks the SSH clipboard advisories from [`summarize_warnings`]. The +/// Wayland no-data-control warning follows the same bypass (surfaced locally +/// — [`summarize_warnings`] is SSH-gated — but after WezTerm: broken input +/// outranks focus-dependent copies). Keeping the banner copy here (instead of +/// at the call site) ties it to the warnings so the surfaces can't drift. pub fn assemble_startup_warnings( wezterm_warning: Option<&TerminalWarning>, wayland_clipboard_warning: Option<&TerminalWarning>, @@ -597,6 +651,144 @@ pub fn diagnose_wayland_data_control_live() -> Option { ) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Osc52Capability { + Supported, + Unsupported, + Unknown, +} + +impl Osc52Capability { + fn from_brand(brand: TerminalName) -> Self { + if brand.supports_osc52_clipboard() { + Self::Supported + } else if brand == TerminalName::Unknown { + Self::Unknown + } else { + Self::Unsupported + } + } + + fn label(self) -> &'static str { + match self { + Self::Supported => "supported", + Self::Unsupported => "unsupported", + Self::Unknown => "unknown", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct ClipboardDiagnosticsInput<'a> { + pub route_native: bool, + pub route_tmux: bool, + pub route_osc52: bool, + pub native_tool: &'a str, + pub brand: TerminalName, + pub host_os: crate::host::HostOs, + pub display_server: crate::host::DisplayServer, + pub is_ssh: bool, + pub container_no_display: bool, + pub osc52_sink: bool, + pub wayland_data_control: bool, + pub wl_copy_available: bool, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct ClipboardDiagnostics { + pub text: String, + pub has_issue: bool, +} + +/// Format preflight clipboard routes without claiming that a copy already happened. +pub fn format_clipboard_diagnostics(input: ClipboardDiagnosticsInput<'_>) -> ClipboardDiagnostics { + use crate::clipboard::{ + ClipboardDelivery, NativeClipboardPreflight, expected_delivery, native_clipboard_preflight, + }; + + let capability = Osc52Capability::from_brand(input.brand); + let native_preflight = native_clipboard_preflight( + input.route_native, + input.host_os, + input.display_server, + input.is_ssh, + input.container_no_display, + input.wayland_data_control, + input.wl_copy_available, + ); + let delivery = expected_delivery( + native_preflight, + input.route_tmux, + input.route_osc52, + input.brand, + input.is_ssh, + input.container_no_display, + input.osc52_sink, + ); + let native = match native_preflight { + NativeClipboardPreflight::LocalAvailable => format!("local ({})", input.native_tool), + NativeClipboardPreflight::RemoteOnly if input.container_no_display => { + format!("container ({})", input.native_tool) + } + NativeClipboardPreflight::RemoteOnly => format!("remote ({})", input.native_tool), + NativeClipboardPreflight::Unavailable => "unavailable".to_owned(), + NativeClipboardPreflight::Disabled => "off".to_owned(), + }; + let tmux = if input.route_tmux { "on" } else { "off" }; + let osc52 = if !input.route_osc52 { + "off" + } else if input.osc52_sink || capability == Osc52Capability::Supported { + "supported" + } else { + capability.label() + }; + let wrap = if input.osc52_sink { "on" } else { "off" }; + let status = match delivery { + ClipboardDelivery::Confirmed => "confirmed", + ClipboardDelivery::Unverified => "unverified", + ClipboardDelivery::Failed => "unavailable", + }; + let fix = match delivery { + ClipboardDelivery::Confirmed => None, + ClipboardDelivery::Unverified if input.is_ssh => { + Some("grok wrap or /minimal") + } + ClipboardDelivery::Unverified if input.container_no_display => { + Some("grok wrap or /minimal") + } + ClipboardDelivery::Unverified => Some("grok wrap or /minimal"), + ClipboardDelivery::Failed if input.is_ssh => Some("grok wrap or /minimal"), + ClipboardDelivery::Failed if input.container_no_display => { + Some("grok wrap or /minimal") + } + ClipboardDelivery::Failed => Some("/minimal"), + }; + + let mut out = String::from("Clipboard\n"); + out.push_str(&format!(" native {native}\n")); + out.push_str(&format!(" tmux {tmux}\n")); + out.push_str(&format!(" osc 52 {osc52}\n")); + out.push_str(&format!(" wrap {wrap}\n")); + if input.display_server == crate::host::DisplayServer::Wayland { + out.push_str(&format!( + " data-control {}\n", + if input.wayland_data_control { + "on" + } else { + "off" + } + )); + } + out.push_str(&format!(" status {status}\n")); + if let Some(fix) = fix { + out.push_str(&format!(" fix {fix}\n")); + } + ClipboardDiagnostics { + text: out, + has_issue: delivery != ClipboardDelivery::Confirmed, + } +} + /// `/terminal-setup` Environment `color` row. pub fn format_color_env_line(level: ColorLevel) -> String { format!(" color {}\n", level.as_str()) @@ -830,6 +1022,119 @@ mod tests { // diagnose_clipboard_from_values: pure clipboard logic // ===================================================================== + fn clipboard_input(brand: TerminalName) -> ClipboardDiagnosticsInput<'static> { + ClipboardDiagnosticsInput { + route_native: true, + route_tmux: false, + route_osc52: true, + native_tool: "arboard", + brand, + host_os: crate::host::HostOs::Linux, + display_server: crate::host::DisplayServer::Unknown, + is_ssh: true, + container_no_display: false, + osc52_sink: false, + wayland_data_control: false, + wl_copy_available: false, + } + } + + #[test] + fn clipboard_diagnostics_unknown_ssh_is_unverified() { + let diagnostics = format_clipboard_diagnostics(clipboard_input(TerminalName::Unknown)); + for expected in [ + "Clipboard", + "native remote (arboard)", + "tmux off", + "osc 52 unknown", + "wrap off", + "status unverified", + "fix grok wrap or /minimal", + ] { + assert!( + diagnostics.text.contains(expected), + "missing {expected:?}:\n{}", + diagnostics.text + ); + } + assert!(diagnostics.has_issue); + } + + #[test] + fn clipboard_diagnostics_known_terminal_status() { + let supported = format_clipboard_diagnostics(clipboard_input(TerminalName::Ghostty)); + assert!(supported.text.contains("osc 52 supported")); + assert!(supported.text.contains("status confirmed")); + assert!(!supported.has_issue); + + let unsupported = format_clipboard_diagnostics(clipboard_input(TerminalName::Vte)); + assert!(unsupported.text.contains("osc 52 unsupported")); + assert!(unsupported.text.contains("status unavailable")); + assert!( + unsupported + .text + .contains("fix grok wrap or /minimal") + ); + assert!(unsupported.has_issue); + } + + #[test] + fn clipboard_diagnostics_local_wayland_native_matrix() { + for (data_control, wl_copy, expected) in [ + (false, false, crate::clipboard::ClipboardDelivery::Failed), + (false, true, crate::clipboard::ClipboardDelivery::Confirmed), + (true, false, crate::clipboard::ClipboardDelivery::Confirmed), + ] { + let diagnostics = format_clipboard_diagnostics(ClipboardDiagnosticsInput { + route_osc52: false, + native_tool: if wl_copy { "wl-copy" } else { "arboard" }, + brand: TerminalName::Vte, + display_server: crate::host::DisplayServer::Wayland, + is_ssh: false, + wayland_data_control: data_control, + wl_copy_available: wl_copy, + ..clipboard_input(TerminalName::Vte) + }); + assert_eq!(diagnostics.has_issue, expected.is_failed()); + assert!(diagnostics.text.contains(if data_control { + "data-control on" + } else { + "data-control off" + })); + } + } + + #[test] + fn clipboard_diagnostics_tmux_wrap_and_container() { + let tmux = format_clipboard_diagnostics(ClipboardDiagnosticsInput { + route_tmux: true, + route_osc52: false, + ..clipboard_input(TerminalName::Unknown) + }); + assert!(tmux.text.contains("tmux on")); + assert!(tmux.text.contains("status confirmed")); + + let wrapped = format_clipboard_diagnostics(ClipboardDiagnosticsInput { + osc52_sink: true, + ..clipboard_input(TerminalName::Unknown) + }); + assert!(wrapped.text.contains("wrap on")); + assert!(wrapped.text.contains("status confirmed")); + + let container = format_clipboard_diagnostics(ClipboardDiagnosticsInput { + is_ssh: false, + container_no_display: true, + ..clipboard_input(TerminalName::Unknown) + }); + assert!(container.text.contains("native container (arboard)")); + assert!(container.text.contains("status unverified")); + assert!( + container + .text + .contains("fix grok wrap or /minimal") + ); + } + #[test] fn clipboard_all_good_modern_tmux() { let w = diagnose_clipboard_from_values(Some("on"), true, Some("on"), "~/.tmux.conf"); @@ -1572,6 +1877,46 @@ mod tests { assert!(out[1].message.contains("sandbox profile")); } + // -- ssh_wrap_hint: `grok wrap ssh` recommendation -------------------------- + + #[test] + fn ssh_wrap_hint_fires_over_plain_ssh() { + // is_ssh, no sink, not VS Code remote → recommend wrap. + let w = ssh_wrap_hint(true, false, false).expect("hint must fire"); + assert_eq!(w.category, WarningCategory::SshWithoutWrap); + assert_eq!(w.fix.as_deref(), Some("grok wrap ssh ")); + assert!( + w.config_path.is_none(), + "fix is a command, not a config line" + ); + assert!( + w.note + .as_deref() + .is_some_and(|n| n.contains("local machine")), + "note must say where to run the command, got: {:?}", + w.note + ); + } + + #[test] + fn ssh_wrap_hint_suppressed_without_ssh() { + assert!(ssh_wrap_hint(false, false, false).is_none()); + } + + #[test] + fn ssh_wrap_hint_suppressed_when_sink_active() { + // An active OSC 52 sink means the session already runs under + // `grok wrap` — adoption silences the hint by itself. + assert!(ssh_wrap_hint(true, true, false).is_none()); + } + + #[test] + fn ssh_wrap_hint_suppressed_in_vscode_remote() { + // VS Code remote's integrated terminal is not a plain ssh terminal + // the user could wrap. + assert!(ssh_wrap_hint(true, false, true).is_none()); + } + // -- Warning ordering: clipboard before DCS -------------------------------- #[test] diff --git a/crates/codegen/xai-grok-pager/src/headless.rs b/crates/codegen/xai-grok-pager/src/headless.rs index 4af6912..103d028 100644 --- a/crates/codegen/xai-grok-pager/src/headless.rs +++ b/crates/codegen/xai-grok-pager/src/headless.rs @@ -17,7 +17,9 @@ use xai_acp_lib::{AcpAgentTx, AcpClientMessageBox, AcpClientRx, acp_send}; use xai_grok_shell::agent::auth_method::AuthMethodKind; use xai_grok_shell::agent::config::Config as AgentConfig; use xai_grok_shell::extensions::task::{CancelSubagentRequest, KillTaskRequest}; -use xai_grok_shell::sampling::error::{RATE_LIMITED_ERROR_CODE, rate_limited_user_message}; +use xai_grok_shell::sampling::error::{ + RATE_LIMITED_ERROR_CODE, error_detail_from_data, format_rate_limited_user_message, +}; use xai_grok_shell::sampling::types::{ REASONING_EFFORT_META_KEY, parse_canonical_effort_token, reasoning_effort_meta_value, }; @@ -1321,13 +1323,8 @@ pub async fn run_single_turn( } Some(Err(err)) => { let msg = if i32::from(err.code) == RATE_LIMITED_ERROR_CODE { - // The -32003 data is the flattened server message; a - // free-usage 429 carries the well-known code inline there. - if crate::app::acp_error_is_free_usage_exhausted(&err) { - crate::app::FREE_USAGE_USER_MESSAGE.to_string() - } else { - rate_limited_user_message(is_api_key_auth).to_string() - } + let detail = err.data.as_ref().and_then(error_detail_from_data); + format_rate_limited_user_message(detail.as_deref(), is_api_key_auth) } else { err.to_string() }; diff --git a/crates/codegen/xai-grok-pager/src/lib.rs b/crates/codegen/xai-grok-pager/src/lib.rs index 826f407..86778ff 100644 --- a/crates/codegen/xai-grok-pager/src/lib.rs +++ b/crates/codegen/xai-grok-pager/src/lib.rs @@ -53,6 +53,8 @@ pub mod startup; pub mod tips; pub mod wrap_clipboard_image; pub mod wrap_cmd; +pub(crate) mod wrap_filter; +pub(crate) mod wrap_restore; pub mod tool_usage; diff --git a/crates/codegen/xai-grok-pager/src/mcp_cmd.rs b/crates/codegen/xai-grok-pager/src/mcp_cmd.rs index a796569..a25fef4 100644 --- a/crates/codegen/xai-grok-pager/src/mcp_cmd.rs +++ b/crates/codegen/xai-grok-pager/src/mcp_cmd.rs @@ -233,6 +233,7 @@ async fn run_add(args: AddArgs) -> Result<()> { transport: resolved.transport, enabled: true, oauth: None, + setup: None, startup_timeout_sec: None, tool_timeout_sec: None, tool_timeouts: None, diff --git a/crates/codegen/xai-grok-pager/src/pty_wrap.rs b/crates/codegen/xai-grok-pager/src/pty_wrap.rs index 9e57b7c..3f10d08 100644 --- a/crates/codegen/xai-grok-pager/src/pty_wrap.rs +++ b/crates/codegen/xai-grok-pager/src/pty_wrap.rs @@ -1,60 +1,31 @@ -//! Local PTY wrapper with OSC 52 clipboard interception and host image paste. +//! Local PTY wrapper: the engine behind `grok wrap` (see [`crate::wrap_cmd`]). //! -//! Spawns a command inside a local pseudo-terminal, intercepts the OSC 52 -//! clipboard escape sequences it emits, and writes their payload to the local -//! system clipboard. This is the engine behind `grok wrap` (see -//! [`crate::wrap_cmd`]); it makes clipboard "copy" work for programs running -//! somewhere that cannot reach the user's clipboard (containers, SSH) even when -//! the outer terminal does not handle OSC 52 itself (for example Apple -//! Terminal). +//! Spawns a command inside a local pseudo-terminal and pipes its output +//! through `crate::wrap_filter::Osc52Filter`, which intercepts OSC 52 +//! clipboard sequences (making "copy" work for programs that cannot reach the +//! user's clipboard — containers, SSH — even under terminals without OSC 52 +//! support), answers the private host clipboard image request OSC (see +//! [`crate::wrap_clipboard_image`]), and reports DEC private mode changes to +//! `crate::wrap_restore::ModeTracker`. //! -//! Also consumes a private remote request OSC for host clipboard images and -//! injects a bracketed-paste response on PTY stdin (see -//! [`crate::wrap_clipboard_image`]). Trust model for auto-answering that OSC -//! (any PTY emitter can solicit the host pasteboard) is documented there. +//! This module owns the process plumbing: PTY setup, the writer/stdin/resize +//! threads, and — via the tracker — the exit paths that restore the outer +//! terminal (drop guard, termination-signal thread) when the wrapped command +//! dies with modes still latched. use anyhow::Result; -use base64::Engine as _; use std::io::Write; +use std::sync::Arc; -/// Maximum size for a buffered escape sequence candidate (1 MiB). -/// -/// This bounds the memory used while accumulating a candidate OSC 52 or DCS -/// sequence. Must be large enough to hold the base64-encoded form of -/// `MAX_CLIPBOARD_PAYLOAD` (~1.33x expansion) plus the escape envelope. -const MAX_ESC_BUFFER: usize = 1024 * 1024; - -/// Maximum decoded clipboard payload size (768 KiB). -/// -/// Aligned with `MAX_ESC_BUFFER`: a 768 KiB payload encodes to ~1 MiB of -/// base64, fitting within the buffer limit. Payloads larger than this are -/// unrealistic for clipboard content over SSH. -const MAX_CLIPBOARD_PAYLOAD: usize = 768 * 1024; - -/// The prefix that identifies an OSC 52 sequence after the `ESC ]`. -const OSC52_PREFIX: &[u8] = b"52;"; - -/// The tmux DCS passthrough prefix after `ESC P`: `tmux;\x1b\x1b]`. -const TMUX_DCS_PREFIX: &[u8] = b"tmux;\x1b\x1b]"; - -/// Base64 engine that accepts both padded and unpadded input. -/// -/// OSC 52 emitters in the wild (including some Go-based tools and terminals) -/// may omit `=` padding. Using `Indifferent` mode avoids silent decode -/// failures from legitimate clipboard sequences. -const BASE64_STANDARD_INDIFFERENT: base64::engine::GeneralPurpose = - base64::engine::GeneralPurpose::new( - &base64::alphabet::STANDARD, - base64::engine::GeneralPurposeConfig::new() - .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), - ); +use crate::wrap_filter::Osc52Filter; +use crate::wrap_restore::ModeTracker; /// Run an arbitrary command inside a local PTY with OSC 52 output filtering. /// /// This is the engine behind `grok wrap`: it spawns /// `program` (with `args`) attached to a local pseudo-terminal, forwards the /// outer terminal's size changes to it, and filters its output through -/// [`Osc52Filter`], which intercepts OSC 52 clipboard sequences and writes +/// `Osc52Filter`, which intercepts OSC 52 clipboard sequences and writes /// their payload to the local system clipboard. All other output passes /// through unchanged. /// @@ -84,9 +55,8 @@ pub(crate) fn run_wrapped_command(program: &str, args: &[String]) -> Result // intercepted here and copied to the real local clipboard. The inner grok // reads this (see `xai_grok_pager_render::clipboard::osc52_sink_active`) to // *trust* OSC 52 even when it can't detect an OSC-52-capable terminal, - // which is the usual SSH case (only `TERM` propagates, so Apple Terminal / - // unknown brands look incapable and the inner grok would otherwise report - // "Copy failed" despite the copy actually working). + // which is the usual SSH case (only `TERM` propagates, so the inner grok + // cannot otherwise verify that the local clipboard received the write). // // `CommandBuilder::new` inherits the full parent environment; `env` overlays // these two without clearing it. The canonical `GROK_OSC52_SINK` is @@ -115,9 +85,38 @@ pub(crate) fn run_wrapped_command(program: &str, args: &[String]) -> Result // waiting via `sigwait` looks correct but silently fails on macOS (see // `sigwinch_loop`). + // Tracks the DEC private modes / kitty pushes flowing through the output + // filter so every exit path can reset exactly what the child left latched + // (a connection drop kills the child before its reset bytes arrive). + let tracker = Arc::new(ModeTracker::new()); + // Switch to raw mode so keystrokes pass through unchanged. crossterm::terminal::enable_raw_mode()?; - let _raw_guard = RawModeGuard; + let _restore_guard = TerminalRestoreGuard { + tracker: Arc::clone(&tracker), + }; + + // Terminating signals (external kill, terminal-close HUP) bypass Drop, so + // handle them explicitly: forward to the child, restore, exit 128+N. + // Handlers are installed here on the main thread so no signal can slip + // through before the loop thread gets scheduled. + #[cfg(unix)] + let child_reaped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + #[cfg(unix)] + { + use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM}; + let child_pid = child.process_id(); + let tracker = Arc::clone(&tracker); + let child_reaped = Arc::clone(&child_reaped); + match signal_hook::iterator::Signals::new([SIGHUP, SIGINT, SIGTERM]) { + Ok(signals) => { + std::thread::spawn(move || { + terminate_signal_loop(signals, tracker, child_pid, child_reaped) + }); + } + Err(e) => tracing::debug!("failed to install wrap termination handler: {e}"), + } + } let (write_tx, write_rx) = std::sync::mpsc::channel::>(); { @@ -176,14 +175,16 @@ pub(crate) fn run_wrapped_command(program: &str, args: &[String]) -> Result // mashing can spawn multiple workers; fine for a short-lived wrap process. { let mut stdout = std::io::stdout().lock(); - let mut filter = Osc52Filter::new().with_wrap_image_handler(move || { - let tx = write_tx.clone(); - std::thread::spawn(move || { - let mut bytes = host_clipboard_image_frame(); - bytes.push(b'\n'); - let _ = tx.send(bytes); - }); - }); + let mut filter = Osc52Filter::new() + .with_wrap_image_handler(move || { + let tx = write_tx.clone(); + std::thread::spawn(move || { + let mut bytes = crate::wrap_filter::host_clipboard_image_frame(); + bytes.push(b'\n'); + let _ = tx.send(bytes); + }); + }) + .with_mode_tracker(Arc::clone(&tracker)); let mut buf = [0u8; 8192]; loop { match pty_reader.read(&mut buf) { @@ -203,6 +204,10 @@ pub(crate) fn run_wrapped_command(program: &str, args: &[String]) -> Result // Wait for child and extract exit code. let status = child.wait()?; + // Reaped: the pid is recyclable from here on, so the signal thread must + // no longer forward to it. + #[cfg(unix)] + child_reaped.store(true, std::sync::atomic::Ordering::SeqCst); let code = status.exit_code() as i32; Ok(code) @@ -245,606 +250,177 @@ fn sigwinch_loop(master: Box) { } /// Guard that restores terminal state when dropped (including on panic). -struct RawModeGuard; - -impl Drop for RawModeGuard { - fn drop(&mut self) { - let _ = crossterm::terminal::disable_raw_mode(); - } -} - -/// State machine states for the OSC 52 streaming parser. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FilterState { - /// Normal output passthrough. - Normal, - /// Saw ESC (0x1b), waiting for next byte to determine sequence type. - Esc, - /// Inside OSC: saw `ESC ]` -- accumulating until BEL or ST. - Osc, - /// Inside DCS: saw `ESC P` -- checking for tmux passthrough prefix. - Dcs, - /// Inside DCS tmux passthrough, accumulating inner OSC 52. - DcsTmuxOsc, - /// Saw ESC inside an OSC, could be ST terminator (`ESC \`). - OscEsc, - /// Saw ESC inside a DCS tmux OSC, could be inner ST or DCS ST. - DcsTmuxOscEsc, -} - -/// Streaming filter that intercepts OSC 52 clipboard sequences from PTY -/// output and sends their decoded payload to the local clipboard. /// -/// All non-OSC-52 bytes pass through unchanged. The parser handles sequences -/// split across arbitrary byte boundaries. -/// Clipboard sink type: a boxed closure that receives decoded clipboard data. -type ClipboardSink = Box; - -type WrapImageRequestHandler = Box; - -struct Osc52Filter { - state: FilterState, - buf: Vec, - clipboard_sink: ClipboardSink, - wrap_image_handler: Option, +/// Covers child EOF (the connection-drop path), a `wait()` error, and panics: +/// emits resets for whatever the child left latched, then leaves raw mode. +/// Shares the tracker's one-shot gate with the termination-signal thread so +/// the restore never runs twice. +struct TerminalRestoreGuard { + tracker: Arc, } -impl Osc52Filter { - /// Create a new filter that sends clipboard data to the system clipboard. - fn new() -> Self { - Self { - state: FilterState::Normal, - buf: Vec::new(), - clipboard_sink: Box::new(set_local_clipboard), - wrap_image_handler: None, - } +impl Drop for TerminalRestoreGuard { + fn drop(&mut self) { + restore_terminal(&self.tracker); } +} - fn with_wrap_image_handler(mut self, handler: impl FnMut() + 'static) -> Self { - self.wrap_image_handler = Some(Box::new(handler)); - self +/// Idempotently restore the outer terminal: emit resets for the latched +/// modes (only while stdout is still a TTY), then leave raw mode. +/// +/// Exactly one caller wins the tracker's claim and emits; every other exit +/// path blocks (bounded) until the winner finishes, because both callers sit +/// directly in front of a `process::exit` (`wrap_cmd::run` after the drop +/// guard, `terminate_signal_loop` after this call) and an exit racing the +/// winner would kill the process mid-restore — raw mode kept, resets partial. +fn restore_terminal(tracker: &ModeTracker) { + use std::io::IsTerminal; + + if !tracker.begin_restore() { + wait_restore_done(tracker, std::time::Duration::from_millis(100)); + return; } - - /// Create a filter with a custom clipboard sink (for testing). - #[cfg(test)] - fn with_sink(sink: impl FnMut(&[u8]) + 'static) -> Self { - Self { - state: FilterState::Normal, - buf: Vec::new(), - clipboard_sink: Box::new(sink), - wrap_image_handler: None, - } + let bytes = crate::wrap_restore::restore_bytes(tracker.snapshot()); + if !bytes.is_empty() && std::io::stdout().is_terminal() { + write_stdout_unlocked(&bytes); } + let _ = crossterm::terminal::disable_raw_mode(); + tracker.finish_restore(); +} - /// Process a chunk of bytes from PTY output. - /// - /// Returns bytes that should be written to stdout. OSC 52 clipboard - /// sequences are consumed (not included in the output) and their decoded - /// payload is sent to the clipboard sink. - fn feed(&mut self, data: &[u8]) -> Vec { - let mut output = Vec::with_capacity(data.len()); - for &byte in data { - match self.state { - FilterState::Normal => { - if byte == 0x1b { - self.state = FilterState::Esc; - self.buf.clear(); - self.buf.push(byte); - } else { - output.push(byte); - } - } - FilterState::Esc => { - self.buf.push(byte); - match byte { - b']' => self.state = FilterState::Osc, - b'P' => self.state = FilterState::Dcs, - _ => { - // Not an OSC or DCS -- flush buffer and continue. - output.extend_from_slice(&self.buf); - self.buf.clear(); - self.state = FilterState::Normal; - } - } - } - FilterState::Osc => { - self.buf.push(byte); - match byte { - // BEL terminates the OSC sequence. - 0x07 => { - if !self.try_handle_consumed_osc() { - output.extend_from_slice(&self.buf); - } - self.buf.clear(); - self.state = FilterState::Normal; - } - // ESC could be the start of ST (ESC \). - 0x1b => { - self.state = FilterState::OscEsc; - } - _ => {} - } - } - FilterState::OscEsc => { - self.buf.push(byte); - if byte == b'\\' { - // ST terminator: ESC \. - if !self.try_handle_consumed_osc() { - output.extend_from_slice(&self.buf); - } - self.buf.clear(); - self.state = FilterState::Normal; - } else { - // Not ST -- continue accumulating in Osc state. - // The ESC we saw might be part of the payload in some - // broken sequence; just keep buffering. - self.state = FilterState::Osc; - } - } - FilterState::Dcs => { - self.buf.push(byte); - // buf starts with \x1bP so tmux prefix bytes start at offset 2. - let prefix_pos = self.buf.len() - 2; - if prefix_pos <= TMUX_DCS_PREFIX.len() { - if TMUX_DCS_PREFIX[prefix_pos - 1] == byte { - if prefix_pos == TMUX_DCS_PREFIX.len() { - // Full tmux prefix matched: \x1bPtmux;\x1b\x1b] - self.state = FilterState::DcsTmuxOsc; - } - // else keep matching prefix - } else { - // Prefix mismatch: not a tmux passthrough, flush. - output.extend_from_slice(&self.buf); - self.buf.clear(); - self.state = FilterState::Normal; - } - } else { - // Exceeded prefix length without matching; flush. - output.extend_from_slice(&self.buf); - self.buf.clear(); - self.state = FilterState::Normal; - } - } - FilterState::DcsTmuxOsc => { - self.buf.push(byte); - match byte { - // BEL terminates the inner OSC. - 0x07 => { - // Inner OSC is done but we still need DCS ST - // (ESC \) to close the tmux wrapper. - // Remain in this state to catch the ESC. - } - 0x1b => { - self.state = FilterState::DcsTmuxOscEsc; - } - _ => {} - } - } - FilterState::DcsTmuxOscEsc => { - self.buf.push(byte); - if byte == b'\\' { - // DCS ST: ESC \. The full tmux-wrapped sequence is done. - if !self.try_handle_tmux_osc52() { - output.extend_from_slice(&self.buf); - } - self.buf.clear(); - self.state = FilterState::Normal; - } else { - // Not ST. Continue accumulating in DcsTmuxOsc. - self.state = FilterState::DcsTmuxOsc; - } - } - } - - // Guard: if the buffer grows beyond the limit, flush and reset. - if self.buf.len() > MAX_ESC_BUFFER { - output.extend_from_slice(&self.buf); - self.buf.clear(); - self.state = FilterState::Normal; - } - } - output - } - - /// Handle OSC 52 clipboard or wrap image request; `true` if consumed. - fn try_handle_consumed_osc(&mut self) -> bool { - let body = self.buf[2..].to_vec(); - let body = strip_osc_terminator(&body); - if self.try_handle_wrap_image_request(body) { +/// Bounded wait for a claimed restore to complete; `true` when it did. +/// +/// The bound is load-bearing: the claim winner's `write(2)` can block +/// indefinitely on a flow-controlled TTY, and an unbounded wait here would +/// reintroduce the never-exits failure mode the unlocked write exists to +/// avoid. On timeout the caller proceeds to exit with a possibly-partial +/// restore — no worse than losing the race outright. +fn wait_restore_done(tracker: &ModeTracker, timeout: std::time::Duration) -> bool { + let deadline = std::time::Instant::now() + timeout; + loop { + if tracker.restore_done() { return true; } - self.extract_and_set_clipboard(body) - } - - fn try_handle_wrap_image_request(&mut self, body: &[u8]) -> bool { - if body != crate::wrap_clipboard_image::REQUEST_BODY { + if std::time::Instant::now() >= deadline { return false; } - if let Some(handler) = self.wrap_image_handler.as_mut() { - handler(); - } - true - } - - /// Try to handle the buffered bytes as a tmux-wrapped OSC 52 sequence. - /// - /// Expected buffer format: - /// `\x1bPtmux;\x1b\x1b]52;;\x07\x1b\\` - /// - /// Returns `true` if the sequence was a valid OSC 52 and was consumed. - fn try_handle_tmux_osc52(&mut self) -> bool { - // Strip the DCS tmux prefix: \x1bPtmux;\x1b\x1b] (total 9 bytes) - // and the DCS ST terminator: \x1b\ (2 bytes at the end). - // Copy the body to avoid borrowing self.buf while calling &mut self. - let prefix_len = 2 + TMUX_DCS_PREFIX.len(); // \x1bP + tmux;\x1b\x1b] - if self.buf.len() < prefix_len + 2 { - return false; - } - let body = self.buf[prefix_len..self.buf.len() - 2].to_vec(); // strip DCS ST - let body = strip_osc_terminator(&body); // strip inner BEL if present - self.extract_and_set_clipboard(body) - } - - /// Parse OSC 52 body (`52;;`), decode, and set clipboard. - /// - /// Returns `true` if successfully handled. - fn extract_and_set_clipboard(&mut self, body: &[u8]) -> bool { - // Must start with "52;" - if !body.starts_with(OSC52_PREFIX) { - return false; - } - let after_52 = &body[OSC52_PREFIX.len()..]; - - // Find the selection parameter separator (next ';'). - let payload_start = match after_52.iter().position(|&b| b == b';') { - Some(pos) => pos + 1, - None => return false, - }; - let b64_payload = &after_52[payload_start..]; - - // Decode base64. - let decoded = match BASE64_STANDARD_INDIFFERENT.decode(b64_payload) { - Ok(data) => data, - Err(_) => return false, - }; - - // Check payload size limit. - if decoded.len() > MAX_CLIPBOARD_PAYLOAD { - tracing::warn!( - "OSC 52 payload too large ({} bytes), ignoring", - decoded.len() - ); - return false; - } - - (self.clipboard_sink)(&decoded); - true + std::thread::sleep(std::time::Duration::from_millis(1)); } } -/// Strip the OSC terminator from the end of a body slice. +/// Best-effort write of restore bytes to stdout, bypassing Rust's stdout lock. /// -/// Removes trailing BEL (`\x07`) or ST (`\x1b\x5c`) if present. -fn strip_osc_terminator(body: &[u8]) -> &[u8] { - if body.ends_with(&[0x1b, b'\\']) { - &body[..body.len() - 2] - } else if body.ends_with(&[0x07]) { - &body[..body.len() - 1] - } else { - body - } -} - -/// Write decoded clipboard payload to the local system clipboard. -/// -/// Delegates to [`xai_grok_shell::util::clipboard::set_text`] which uses -/// `pbcopy` on macOS and `arboard` elsewhere. Failures are logged but do -/// not propagate -- clipboard access is best-effort. -fn set_local_clipboard(data: &[u8]) { - let text = match std::str::from_utf8(data) { - Ok(s) => s, - Err(e) => { - tracing::warn!("OSC 52 payload is not valid UTF-8: {e}"); - return; +/// The signal path can fire while the read loop holds the locked stdout +/// (blocked on a PTY read); taking the lock there would trade a broken +/// terminal for a wrap process that never exits. The cost of not locking: +/// this write can interleave with a concurrent read-loop chunk — including +/// landing mid-escape-sequence after a short `write(2)`, or ahead of bytes +/// still buffered between the loop's `write_all` and `flush` — garbling part +/// of the restore. Accepted: it only arises on the signal path of a process +/// that exits immediately after, and a partially-garbled restore attempt +/// still beats the deadlock. +#[cfg(unix)] +fn write_stdout_unlocked(bytes: &[u8]) { + let mut written = 0; + while written < bytes.len() { + // SAFETY: plain write(2) on fd 1 with an in-bounds slice. + let rc = unsafe { + libc::write( + 1, + bytes[written..].as_ptr() as *const libc::c_void, + bytes.len() - written, + ) + }; + if rc > 0 { + written += rc as usize; + } else if rc < 0 + && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted + { + continue; + } else { + break; } - }; - if let Err(e) = xai_grok_shell::util::clipboard::set_text(text) { - tracing::warn!("clipboard copy failed: {e}"); } } -/// Encode a host clipboard image (or NONE) as a bracketed-paste frame. -fn host_clipboard_image_frame() -> Vec { - let image = xai_grok_pager_render::clipboard::system_clipboard_get_image(); - crate::wrap_clipboard_image::encode_wrap_image_response(image.as_ref()) +/// Without a signal path (no Unix signals), the restore only runs on the +/// main-thread drop path after the read loop released the lock, so the +/// ordinary locked stdout is safe here. +#[cfg(not(unix))] +fn write_stdout_unlocked(bytes: &[u8]) { + let mut stdout = std::io::stdout().lock(); + let _ = stdout.write_all(bytes); + let _ = stdout.flush(); +} + +/// Handle a terminating signal delivered to wrap itself (external kill, +/// terminal-close HUP): forward the same signal to the child, restore the +/// terminal from the latched-mode state, and exit `128 + N`. +/// +/// Keyboard Ctrl-C never lands here — raw mode delivers it to wrap as a +/// `0x03` byte that is forwarded to the child. Without this thread a signal +/// death would skip `Drop` entirely, leaking raw mode and every latched mode. +/// Runs on a normal thread via `signal_hook::iterator` (same pattern as +/// `sigwinch_loop`), so no async-signal-safety constraints apply. +/// +/// Accepted race with the read loop: the filter reports a mode to the +/// tracker before the loop writes that chunk to stdout, so the snapshot +/// taken here can include an enable the terminal never received — for kitty +/// that direction means emitting a pop the terminal never saw pushed, which +/// can pop an enclosing context's entry. The window is the microseconds +/// between report and write inside a process being externally killed; +/// deferring reporting until after the write would flip the race to +/// under-restore but puts a per-CSI buffer on the hot output path. +#[cfg(unix)] +fn terminate_signal_loop( + mut signals: signal_hook::iterator::Signals, + tracker: Arc, + child_pid: Option, + child_reaped: Arc, +) { + if let Some(signal) = signals.forever().next() { + // Skip the forward once the child is reaped: its pid is recyclable + // and the kill could hit a bystander. The check narrows — but cannot + // close — the reuse window (a reap can land between it and the + // kill); that residual window is the same one every signal-forwarding + // wrapper accepts. + if let Some(pid) = child_pid + && !child_reaped.load(std::sync::atomic::Ordering::SeqCst) + { + // Forward first so the child can run its own teardown while we + // restore. Its late output goes to a PTY we are abandoning. + // SAFETY: kill(2) has no memory-safety preconditions; pid is + // positive (never the 0/-1 broadcast forms). + unsafe { libc::kill(pid as libc::pid_t, signal) }; + } + restore_terminal(&tracker); + std::process::exit(128 + signal); + } } #[cfg(test)] mod tests { use super::*; - use std::cell::RefCell; - use std::rc::Rc; - /// Helper: run data through the filter with a capturing clipboard sink. - /// Returns (stdout_output, captured_clipboard_payloads). - fn filter_output(input: &[u8]) -> (Vec, Vec>) { - let clips = Rc::new(RefCell::new(Vec::new())); - let clips_clone = Rc::clone(&clips); - let mut filter = Osc52Filter::with_sink(move |data: &[u8]| { - clips_clone.borrow_mut().push(data.to_vec()); - }); - let output = filter.feed(input); - let captured = clips.borrow().clone(); - (output, captured) - } - - /// Helper: run data through the filter in multiple small chunks. - fn filter_output_chunked(input: &[u8], chunk_size: usize) -> (Vec, Vec>) { - let clips = Rc::new(RefCell::new(Vec::new())); - let clips_clone = Rc::clone(&clips); - let mut filter = Osc52Filter::with_sink(move |data: &[u8]| { - clips_clone.borrow_mut().push(data.to_vec()); - }); - let mut output = Vec::new(); - for chunk in input.chunks(chunk_size) { - output.extend_from_slice(&filter.feed(chunk)); - } - let captured = clips.borrow().clone(); - (output, captured) - } - - /// Encode text as a plain OSC 52 sequence with BEL terminator. - fn make_osc52_bel(text: &str) -> Vec { - let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - format!("\x1b]52;c;{b64}\x07").into_bytes() - } - - /// Encode text as a plain OSC 52 sequence with ST terminator. - fn make_osc52_st(text: &str) -> Vec { - let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - format!("\x1b]52;c;{b64}\x1b\\").into_bytes() - } - - /// Encode text as a tmux-wrapped OSC 52 sequence. - fn make_osc52_tmux(text: &str) -> Vec { - let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - format!("\x1bPtmux;\x1b\x1b]52;c;{b64}\x07\x1b\\").into_bytes() + #[test] + fn wait_restore_done_returns_immediately_when_finished() { + let tracker = ModeTracker::new(); + assert!(tracker.begin_restore()); + tracker.finish_restore(); + assert!(wait_restore_done( + &tracker, + std::time::Duration::from_millis(100) + )); } #[test] - fn osc52_normal_text_unchanged() { - let input = b"Hello, world!\r\n"; - let (output, clips) = filter_output(input); - assert_eq!(output, input); - assert!(clips.is_empty()); - } - - #[test] - fn osc52_ansi_escapes_pass_through() { - // SGR color: ESC [ 31 m - let input = b"\x1b[31mred text\x1b[0m"; - let (output, clips) = filter_output(input); - assert_eq!(output, input.as_slice()); - assert!(clips.is_empty()); - } - - #[test] - fn osc52_plain_bel_terminated() { - let seq = make_osc52_bel("hello"); - let (output, clips) = filter_output(&seq); - assert!( - output.is_empty(), - "OSC 52 should be consumed, got: {output:?}" - ); - assert_eq!(clips.len(), 1); - assert_eq!(clips[0], b"hello"); - } - - #[test] - fn osc52_plain_st_terminated() { - let seq = make_osc52_st("hello"); - let (output, clips) = filter_output(&seq); - assert!( - output.is_empty(), - "OSC 52 should be consumed, got: {output:?}" - ); - assert_eq!(clips.len(), 1); - assert_eq!(clips[0], b"hello"); - } - - #[test] - fn osc52_with_s0_selection() { - // Selection parameter "s0" instead of "c". - let b64 = base64::engine::general_purpose::STANDARD.encode(b"clipboard data"); - let seq = format!("\x1b]52;s0;{b64}\x07").into_bytes(); - let (output, clips) = filter_output(&seq); - assert!(output.is_empty()); - assert_eq!(clips.len(), 1); - assert_eq!(clips[0], b"clipboard data"); - } - - #[test] - fn osc52_tmux_wrapped() { - let seq = make_osc52_tmux("hello from tmux"); - let (output, clips) = filter_output(&seq); - assert!( - output.is_empty(), - "tmux OSC 52 should be consumed, got: {output:?}" - ); - assert_eq!(clips.len(), 1); - assert_eq!(clips[0], b"hello from tmux"); - } - - #[test] - fn osc52_surrounded_by_text() { - let mut input = b"before ".to_vec(); - input.extend_from_slice(&make_osc52_bel("copied")); - input.extend_from_slice(b" after"); - let (output, clips) = filter_output(&input); - assert_eq!(output, b"before after"); - assert_eq!(clips.len(), 1); - assert_eq!(clips[0], b"copied"); - } - - #[test] - fn osc52_multiple_sequences() { - let mut input = make_osc52_bel("first"); - input.extend_from_slice(b"gap"); - input.extend_from_slice(&make_osc52_st("second")); - let (output, clips) = filter_output(&input); - assert_eq!(output, b"gap"); - assert_eq!(clips.len(), 2); - assert_eq!(clips[0], b"first"); - assert_eq!(clips[1], b"second"); - } - - #[test] - fn osc52_split_across_chunks() { - let seq = make_osc52_bel("split test"); - // Feed one byte at a time. - let (output, clips) = filter_output_chunked(&seq, 1); - assert!(output.is_empty(), "should be consumed even byte-by-byte"); - assert_eq!(clips.len(), 1); - assert_eq!(clips[0], b"split test"); - } - - #[test] - fn osc52_split_at_various_sizes() { - let seq = make_osc52_st("chunk test"); - for chunk_size in 2..=seq.len() { - let (output, clips) = filter_output_chunked(&seq, chunk_size); - assert!( - output.is_empty(), - "chunk_size={chunk_size}: should be consumed" - ); - assert_eq!(clips.len(), 1, "chunk_size={chunk_size}: expected 1 clip"); - assert_eq!(clips[0], b"chunk test"); - } - } - - #[test] - fn osc52_tmux_split_across_chunks() { - let seq = make_osc52_tmux("tmux split"); - let (output, clips) = filter_output_chunked(&seq, 3); - assert!(output.is_empty()); - assert_eq!(clips.len(), 1); - assert_eq!(clips[0], b"tmux split"); - } - - #[test] - fn osc52_invalid_base64_passes_through() { - // Invalid base64 payload: "!!!" is not valid base64. - let seq = b"\x1b]52;c;!!!\x07"; - let (output, clips) = filter_output(seq); - assert_eq!(output, seq.as_slice(), "invalid base64 should pass through"); - assert!(clips.is_empty()); - } - - #[test] - fn osc52_non_52_osc_passes_through() { - // OSC 0 (window title) should pass through. - let seq = b"\x1b]0;my title\x07"; - let (output, clips) = filter_output(seq); - assert_eq!(output, seq.as_slice()); - assert!(clips.is_empty()); - } - - #[test] - fn osc52_non_52_osc_st_passes_through() { - // OSC 0 with ST terminator. - let seq = b"\x1b]0;my title\x1b\\"; - let (output, clips) = filter_output(seq); - assert_eq!(output, seq.as_slice()); - assert!(clips.is_empty()); - } - - #[test] - fn osc52_oversized_buffer_flushes() { - // Build a sequence that exceeds MAX_ESC_BUFFER. - let mut seq = b"\x1b]52;c;".to_vec(); - // Fill with valid base64 chars until we exceed the limit. - seq.resize(MAX_ESC_BUFFER + 100, b'A'); - seq.push(0x07); - - let (output, clips) = filter_output(&seq); - // The oversized sequence should have been flushed through. - assert!( - !output.is_empty(), - "oversized sequence should flush through" - ); - assert!( - clips.is_empty(), - "oversized sequence should not set clipboard" - ); - } - - #[test] - fn osc52_empty_payload() { - // Empty base64 payload should still work (copies empty string). - let seq = b"\x1b]52;c;\x07"; - let (output, clips) = filter_output(seq); - assert!(output.is_empty()); - assert_eq!(clips.len(), 1); - assert_eq!(clips[0], b""); - } - - #[test] - fn osc52_non_tmux_dcs_passes_through() { - // A DCS that doesn't start with the tmux prefix should flush. - let seq = b"\x1bPother;stuff\x1b\\"; - let (output, clips) = filter_output(seq); - // The flush happens when the prefix mismatch is detected. - assert!(!output.is_empty(), "non-tmux DCS should pass through"); - assert!(clips.is_empty()); - } - - #[test] - fn osc52_missing_selection_separator() { - // No second ';' after "52;" -- missing selection param separator. - let b64 = base64::engine::general_purpose::STANDARD.encode(b"data"); - let seq = format!("\x1b]52;{b64}\x07").into_bytes(); - // This has "52;" followed by base64 with no second ';'. The parser - // will treat everything after "52;" up to the next ';' as the - // selection param. If there's no ';', it returns false. - let (output, clips) = filter_output(&seq); - assert_eq!(output, seq, "should pass through without second ';'"); - assert!(clips.is_empty()); - } - - #[test] - fn wrap_image_request_consumed_and_handler_runs() { - let calls = Rc::new(RefCell::new(0usize)); - let calls_clone = Rc::clone(&calls); - let clips = Rc::new(RefCell::new(Vec::new())); - let clips_clone = Rc::clone(&clips); - let mut filter = Osc52Filter::with_sink(move |data: &[u8]| { - clips_clone.borrow_mut().push(data.to_vec()); - }) - .with_wrap_image_handler(move || { - *calls_clone.borrow_mut() += 1; - }); - let mut input = b"before".to_vec(); - input.extend_from_slice(&crate::wrap_clipboard_image::request_osc_bytes()); - input.extend_from_slice(b"after"); - let output = filter.feed(&input); - assert_eq!(output, b"beforeafter"); - assert_eq!(*calls.borrow(), 1); - assert!(clips.borrow().is_empty()); - } - - #[test] - fn wrap_image_request_split_across_chunks() { - let calls = Rc::new(RefCell::new(0usize)); - let calls_clone = Rc::clone(&calls); - let mut filter = Osc52Filter::with_sink(|_| {}).with_wrap_image_handler(move || { - *calls_clone.borrow_mut() += 1; - }); - let seq = crate::wrap_clipboard_image::request_osc_bytes(); - let mut output = Vec::new(); - for chunk in seq.chunks(3) { - output.extend_from_slice(&filter.feed(chunk)); - } - assert!(output.is_empty(), "request OSC must be fully consumed"); - assert_eq!(*calls.borrow(), 1); + fn wait_restore_done_times_out_when_winner_never_finishes() { + let tracker = ModeTracker::new(); + assert!(tracker.begin_restore()); + // No finish_restore: the bounded wait must give up, not hang. + assert!(!wait_restore_done( + &tracker, + std::time::Duration::from_millis(5) + )); } } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs b/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs index b497a81..3d97011 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/state/mod.rs @@ -1691,6 +1691,32 @@ impl ScrollbackState { self.scroll_offset } + pub fn capture_viewport_snapshot(&self) -> ViewportSnapshot { + ViewportSnapshot { + scroll_offset: self.scroll_offset, + follow_mode: self.follow_mode, + follow_preserve_scroll: self.follow_preserve_scroll, + viewport_height: self.viewport_height, + last_width: self.last_width, + selected: self.selected, + current_turn: self.current_turn, + view_mode: self.view_mode, + total_height: self.total_height, + } + } + + pub fn restore_viewport_snapshot(&mut self, snap: ViewportSnapshot) { + self.scroll_offset = snap.scroll_offset; + self.follow_mode = snap.follow_mode; + self.follow_preserve_scroll = snap.follow_preserve_scroll; + self.viewport_height = snap.viewport_height; + self.last_width = snap.last_width; + self.selected = snap.selected; + self.current_turn = snap.current_turn; + self.view_mode = snap.view_mode; + self.invalidate_layout_cache(); + } + /// Set viewport height. pub fn set_viewport_height(&mut self, height: u16) { self.viewport_height = height; @@ -3303,4 +3329,149 @@ mod tests { DisplayMode::Expanded ); } + + fn long_wrap_text() -> String { + "word ".repeat(80) + } + + fn snapshot_fixture() -> ScrollbackState { + let mut state = ScrollbackState::new(); + state.push_block(user_block("Q1")); + state.push_block(agent_block(&long_wrap_text())); + state.push_block(user_block("Q2")); + state.push_block(agent_block(&long_wrap_text())); + state + } + + #[test] + fn viewport_snapshot_restore_roundtrip_after_guest_mutate() { + let mut state = snapshot_fixture(); + const W0: u16 = 80; + const H0: u16 = 20; + state.prepare_layout(W0, H0); + state.follow_mode = false; + state.follow_preserve_scroll = true; + state.set_selected(Some(0)); + state.set_scroll_offset(3); + state.view_mode = ViewMode::SingleTurn; + state.current_turn = Some(0); + state.prepare_layout(W0, H0); + + let snap = state.capture_viewport_snapshot(); + let expected_offset = snap.scroll_offset; + let expected_follow = snap.follow_mode; + let expected_preserve = snap.follow_preserve_scroll; + let expected_vh = snap.viewport_height; + let expected_lw = snap.last_width; + let expected_sel = snap.selected; + let expected_turn = snap.current_turn; + let expected_mode = snap.view_mode; + + state.enable_follow_mode(); + state.view_mode = ViewMode::AllTurns; + assert!(state.prepare_layout(40, 8)); + assert!(state.layout_cache.is_some()); + assert_eq!(state.layout_cache.as_ref().unwrap().width, 40); + + state.restore_viewport_snapshot(snap); + + assert_eq!(state.scroll_offset, expected_offset); + assert_eq!(state.follow_mode, expected_follow); + assert_eq!(state.follow_preserve_scroll, expected_preserve); + assert_eq!(state.viewport_height, expected_vh); + assert_eq!(state.last_width, expected_lw); + assert_eq!(state.selected, expected_sel); + assert_eq!(state.current_turn, expected_turn); + assert_eq!(state.view_mode, expected_mode); + assert!(state.layout_cache.is_none()); + + assert!(state.prepare_layout(W0, H0)); + assert_eq!(state.layout_cache.as_ref().unwrap().width, W0); + } + + #[test] + fn restore_invalidates_stale_peek_width_cache_before_full_prepare() { + let mut state = snapshot_fixture(); + const W0: u16 = 80; + const W1: u16 = 40; + const H: u16 = 20; + + assert!(state.prepare_layout(W0, H)); + assert_eq!(state.last_width, W0); + let snap = state.capture_viewport_snapshot(); + assert_eq!(snap.last_width, W0); + + assert!(state.prepare_layout(W1, H)); + assert_eq!(state.last_width, W1); + assert_eq!(state.layout_cache.as_ref().unwrap().width, W1); + let peek_height = state.layout_cache.as_ref().unwrap().entries[1].height; + + state.restore_viewport_snapshot(snap); + assert_eq!(state.last_width, W0); + assert!(state.layout_cache.is_none()); + + assert!( + state.prepare_layout(W0, H), + "restore must force Case 1 full rebuild at restored width" + ); + let cache = state.layout_cache.as_ref().unwrap(); + assert_eq!(cache.width, W0); + assert_ne!( + cache.entries[1].height, peek_height, + "heights must be recomputed for W0, not left at W1 wrap" + ); + } + + #[test] + fn prepare_layout_width_change_is_case1_height_only_is_not() { + let mut state = snapshot_fixture(); + assert!(state.prepare_layout(80, 20)); + assert!( + !state.prepare_layout(80, 20), + "stable WxH with clean cache is Case 3" + ); + assert!( + !state.prepare_layout(80, 12), + "height-only change is not Case 1" + ); + assert_eq!(state.last_width, 80); + assert_eq!(state.layout_cache.as_ref().unwrap().width, 80); + assert!( + !state.prepare_layout(80, 12), + "stable width after height-only stays Case 3" + ); + assert!(state.prepare_layout(50, 12), "width change is Case 1"); + assert_eq!(state.layout_cache.as_ref().unwrap().width, 50); + assert!( + !state.prepare_layout(50, 12), + "stable width after Case 1 is Case 3" + ); + } + + #[test] + fn restore_reverts_follow_autoselect_and_current_turn() { + let mut state = snapshot_fixture(); + state.prepare_layout(80, 20); + state.follow_mode = false; + state.set_selected(Some(0)); + assert_eq!(state.current_turn(), Some(0)); + state.set_scroll_offset(2); + + let snap = state.capture_viewport_snapshot(); + assert_eq!(snap.selected, Some(0)); + assert_eq!(snap.current_turn, Some(0)); + assert!(!snap.follow_mode); + + state.enable_follow_mode(); + state.prepare_layout(80, 20); + assert!(state.is_follow_mode()); + assert_ne!(state.selected(), Some(0)); + assert_eq!(state.current_turn(), Some(1)); + + state.restore_viewport_snapshot(snap); + assert!(!state.is_follow_mode()); + assert_eq!(state.selected(), Some(0)); + assert_eq!(state.current_turn(), Some(0)); + assert_eq!(state.scroll_offset(), 2); + } } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs b/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs index 5473deb..9e181f9 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/state/nav.rs @@ -392,15 +392,51 @@ impl ScrollbackState { self.bump_generation(); } + /// Rows to advance for a full-page scroll. + /// + /// A page is the *content* area (viewport minus any sticky prompt header + /// pinned at the top) less a 2-row overlap for continuity. Subtracting the + /// header is what keeps a page-flip from skipping the lines that sit behind + /// the pinned prompt: without it, a page moves `viewport_height - 2` rows + /// but only `viewport_height - header` rows are actually on screen, so + /// `header - 2` lines are silently jumped over at the top border. Always + /// moves at least 1 row so paging never stalls on a tiny viewport. + fn page_scroll_rows(&self) -> u16 { + let header = self.current_header_screen_rows(); + self.viewport_height + .saturating_sub(header) + .saturating_sub(2) + .max(1) + } + + /// Current sticky header height in screen rows (0 when no header/cache). + fn current_header_screen_rows(&self) -> u16 { + // Mirror render_with_sticky_headers: no sticky header is drawn when disabled + // or in compact prompt mode, so the whole viewport is content and paging + // must not subtract a header height. + if !self.appearance.scrollback.display.sticky_headers || self.appearance.prompt.compact { + return 0; + } + let Some(cache) = self.layout_cache.as_ref() else { + return 0; + }; + let range = self.visible_entry_range(); + if range.is_empty() { + return 0; + } + self.current_sticky_layout(cache, &range) + .header_screen_rows() + } + /// Page up: scroll viewport, then select the topmost selectable on-screen entry. pub fn page_up(&mut self) { - self.scroll_up(self.viewport_height.saturating_sub(2)); + self.scroll_up(self.page_scroll_rows()); self.select_viewport_edge(/* prefer_top */ true); } /// Page down: scroll viewport, then select the bottommost selectable on-screen entry. pub fn page_down(&mut self) { - self.scroll_down(self.viewport_height.saturating_sub(2)); + self.scroll_down(self.page_scroll_rows()); self.select_viewport_edge(/* prefer_top */ false); } @@ -539,6 +575,10 @@ impl ScrollbackState { self.follow_mode } + pub(crate) fn is_follow_preserve_scroll(&self) -> bool { + self.follow_preserve_scroll + } + /// Check if there's content below the viewport (not at the bottom). pub fn has_content_below(&self) -> bool { let max_offset = self @@ -2156,4 +2196,151 @@ mod tests { "viewport jumped {delta} rows after select_next (page offset was {offset_after_page})" ); } + + /// Regression: a full page-down must not skip the lines that sit behind a + /// sticky prompt header pinned at the viewport top. The content area is + /// `viewport - header` rows, so a page that advances `viewport - 2` rows + /// jumps `header - 2` lines over the top border. The page delta has to + /// subtract the header height so the intended 2-row overlap is preserved. + #[test] + fn page_down_does_not_skip_lines_behind_sticky_header() { + let mut h = ScrollTestHarness::new(80, 20); + // A multi-line prompt so the pinned header is taller than the 2-row + // overlap (single-line prompts render as exactly 1 row + 1 gap = 2, + // which happens to match the overlap and would hide the bug), followed + // by one very tall response so there is plenty of room to page through + // the middle without clamping at the bottom. + h.push_prompt("Q1 line A\nQ1 line B\nQ1 line C"); + let giant: String = (1..=300) + .map(|i| format!("answer line {i}")) + .collect::>() + .join("\n"); + h.push_agent(&giant); + h.frame(); + + // Start at the top, then page down until the prompt scrolls above the + // viewport and pins as a sticky header. One extra page lands on the + // stable (fully collapsed) header height. + h.state.goto_top(); + h.frame(); + let mut guard = 0; + while h.state.current_header_screen_rows() == 0 { + h.state.page_down(); + h.frame(); + guard += 1; + assert!( + guard < 100, + "expected a sticky header to appear while paging" + ); + } + h.state.page_down(); + h.frame(); + + // The header must be taller than the 2-row overlap, otherwise the old + // `viewport - 2` delta would not have skipped anything and the test + // would not exercise the bug. + let header = h.state.current_header_screen_rows(); + assert!( + header > 2, + "test needs a header taller than the overlap to be meaningful, got {header}" + ); + // There must be a full page of room left below, so the next page-down + // advances a whole page instead of clamping at the bottom. + assert!( + h.state.scroll_offset + h.state.viewport_height as usize <= h.max_offset(), + "test needs a full page of room to page down without clamping" + ); + + let old_bottom_line = h.state.scroll_offset + h.state.viewport_height as usize - 1; + h.state.page_down(); + h.frame(); + let new_top_content = h.state.scroll_offset + h.state.current_header_screen_rows() as usize; + + assert!( + new_top_content <= old_bottom_line + 1, + "page-down skipped lines behind the sticky header: new top content line \ + {new_top_content} > old bottom line {old_bottom_line} + 1" + ); + } + + /// Regression: when sticky headers are disabled the renderer draws no + /// header (`render_with_sticky_headers` falls back to a zero-height layout + /// because `use_sticky` is false), so the whole viewport is content and a + /// page must advance `viewport - 2`. `current_header_screen_rows()` used to + /// measure the header unconditionally, so `page_scroll_rows()` subtracted a + /// header that was never on screen and PageUp/PageDown advanced short of a + /// full page. The header height must be gated on the same flag as the + /// renderer. + #[test] + fn page_delta_ignores_header_when_sticky_headers_disabled() { + let mut h = ScrollTestHarness::new(80, 20); + // Same setup as the sticky-header test: a multi-line prompt that would + // pin a >2-row header when enabled, plus a long response with room to + // page through the middle without clamping at the bottom. + h.push_prompt("Q1 line A\nQ1 line B\nQ1 line C"); + let giant: String = (1..=300) + .map(|i| format!("answer line {i}")) + .collect::>() + .join("\n"); + h.push_agent(&giant); + h.frame(); + + // Page down (with sticky headers on, the harness default) until the + // prompt pins as a header, so we land on a scroll position where a + // header genuinely exists. + h.state.goto_top(); + h.frame(); + let mut guard = 0; + while h.state.current_header_screen_rows() == 0 { + h.state.page_down(); + h.frame(); + guard += 1; + assert!( + guard < 100, + "expected a sticky header to appear while paging" + ); + } + h.state.page_down(); + h.frame(); + + // Sanity: with sticky headers on, a real (>2-row) header is measured + // here, so gating on the flag actually changes the result below. + let header_on = h.state.current_header_screen_rows(); + assert!( + header_on > 2, + "test needs a real header with sticky headers on, got {header_on}" + ); + // A full page of room remains below, so a page-down will not clamp. + assert!( + h.state.scroll_offset + h.state.viewport_height as usize <= h.max_offset(), + "test needs a full page of room to page down without clamping" + ); + + // Disable sticky headers, mirroring the renderer's `use_sticky` gate. + // No header is drawn now, so none must be subtracted from the page. + h.state.appearance.scrollback.display.sticky_headers = false; + h.frame(); + + assert_eq!( + h.state.current_header_screen_rows(), + 0, + "no header must be measured when sticky headers are disabled" + ); + let expected = h.state.viewport_height - 2; + assert_eq!( + h.state.page_scroll_rows(), + expected, + "page delta must be viewport - 2 (no header subtracted) when sticky headers are off" + ); + + // The observable scroll delta of a page-down is a full viewport - 2. + let before = h.state.scroll_offset; + h.state.page_down(); + h.frame(); + assert_eq!( + h.state.scroll_offset - before, + expected as usize, + "page-down should advance a full viewport - 2 with sticky headers off" + ); + } } diff --git a/crates/codegen/xai-grok-pager/src/scrollback/state/selection.rs b/crates/codegen/xai-grok-pager/src/scrollback/state/selection.rs index 6519a18..0e44356 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/state/selection.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/state/selection.rs @@ -22,6 +22,10 @@ impl ScrollbackState { self.view_mode } + pub(crate) fn set_view_mode(&mut self, mode: ViewMode) { + self.view_mode = mode; + } + /// Get the range of entry indices visible in the current view mode. pub fn visible_entry_range(&self) -> Range { match self.view_mode { diff --git a/crates/codegen/xai-grok-pager/src/scrollback/state/types.rs b/crates/codegen/xai-grok-pager/src/scrollback/state/types.rs index a396307..802f477 100644 --- a/crates/codegen/xai-grok-pager/src/scrollback/state/types.rs +++ b/crates/codegen/xai-grok-pager/src/scrollback/state/types.rs @@ -62,6 +62,19 @@ pub enum ViewMode { SingleTurn, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ViewportSnapshot { + pub(crate) scroll_offset: usize, + pub(crate) follow_mode: bool, + pub(crate) follow_preserve_scroll: bool, + pub(crate) viewport_height: u16, + pub(crate) last_width: u16, + pub(crate) selected: Option, + pub(crate) current_turn: Option, + pub(crate) view_mode: ViewMode, + pub(crate) total_height: usize, +} + /// Maximum truncated header height for AllTurns sticky headers. /// (vpad + 3 content lines + ellipsis if needed + vpad) pub(super) const MAX_TRUNCATED_HEADER_HEIGHT: u16 = 6; diff --git a/crates/codegen/xai-grok-pager/src/settings/defs.rs b/crates/codegen/xai-grok-pager/src/settings/defs.rs index 92fd2bd..daca12f 100644 --- a/crates/codegen/xai-grok-pager/src/settings/defs.rs +++ b/crates/codegen/xai-grok-pager/src/settings/defs.rs @@ -513,6 +513,7 @@ const CONTEXTUAL_HINTS_CHILDREN: &[&str] = &[ "contextual_hints.send_now", "contextual_hints.small_screen", "contextual_hints.word_select", + "contextual_hints.ssh_wrap", ]; /// Build the catalog. Called once at process start via @@ -1268,6 +1269,9 @@ pub fn default_settings() -> Vec { "small", "screen", "compact", + "ssh", + "wrap", + "remote", ], kind: SettingKind::Group { children: CONTEXTUAL_HINTS_CHILDREN, @@ -1468,6 +1472,28 @@ pub fn default_settings() -> Vec { restart_required: false, hidden_in_minimal: false, }, + SettingMeta { + key: "contextual_hints.ssh_wrap", + category: SettingCategory::Advanced, + owner: SettingOwner::Shell, + label: "SSH wrap", + description: "At session load over SSH, recommend `grok wrap ssh` for \ + clipboard forwarding and terminal restore.", + keywords: &[ + "ssh", + "wrap", + "remote", + "clipboard", + "restore", + "startup", + "hint", + ], + kind: SettingKind::Bool { + default: ui_default.contextual_hints.ssh_wrap.unwrap_or(true), + }, + restart_required: false, + hidden_in_minimal: false, + }, // ── TodoGate (runtime turn-end backstop) ────────────────────── // // Only the CLI flag (`--todo-gate`) is wired. Settings-modal diff --git a/crates/codegen/xai-grok-pager/src/settings/registry.rs b/crates/codegen/xai-grok-pager/src/settings/registry.rs index 7e95ee9..8a993c9 100644 --- a/crates/codegen/xai-grok-pager/src/settings/registry.rs +++ b/crates/codegen/xai-grok-pager/src/settings/registry.rs @@ -504,6 +504,9 @@ pub fn current_value_for( "contextual_hints.word_select" => Some(SettingValue::Bool( ui.contextual_hints.word_select.unwrap_or(true), )), + "contextual_hints.ssh_wrap" => Some(SettingValue::Bool( + ui.contextual_hints.ssh_wrap.unwrap_or(true), + )), "keep_text_selection" => Some(SettingValue::Enum( crate::appearance::cache::load_keep_text_selection().as_canonical(), )), @@ -754,6 +757,13 @@ mod tests { "contextual_hints.word_select default drifts from UiConfig::default()" ); } + ("contextual_hints.ssh_wrap", SettingKind::Bool { default }) => { + assert_eq!( + *default, + ui.contextual_hints.ssh_wrap.unwrap_or(true), + "contextual_hints.ssh_wrap default drifts from UiConfig::default()" + ); + } ("show_timestamps", SettingKind::Bool { default }) => { assert_eq!( *default, @@ -1506,6 +1516,7 @@ mod tests { "contextual_hints.send_now", "contextual_hints.small_screen", "contextual_hints.word_select", + "contextual_hints.ssh_wrap", ], ); for &key in *children { diff --git a/crates/codegen/xai-grok-pager/src/slash/command.rs b/crates/codegen/xai-grok-pager/src/slash/command.rs index 8bee3d2..28fef56 100644 --- a/crates/codegen/xai-grok-pager/src/slash/command.rs +++ b/crates/codegen/xai-grok-pager/src/slash/command.rs @@ -226,12 +226,13 @@ pub trait SlashCommand: Send + Sync { /// mode (`grok --minimal`). /// /// Minimal mode deletes the interactive fullscreen scrollback pane, the - /// in-app mouse selection/copy path, and the agent dashboard, handing - /// scroll / search / selection back to the terminal (K7). Commands that - /// drive those deleted surfaces — `/find`, `/copy`, `/dashboard` — have - /// nothing to act on, so the central dispatch gate refuses them with a - /// "/ is not available in minimal mode" message (committed as a system - /// block). + /// in-app mouse selection path, and the agent dashboard, handing scroll / + /// search / selection back to the terminal (K7). Commands that drive those + /// deleted surfaces — `/find`, `/dashboard` — have nothing to act on, so + /// the central dispatch gate refuses them with a "/ is not available in + /// minimal mode" message (committed as a system block). Clipboard helpers + /// like `/copy` stay available: they read scrollback state and do not need + /// the fullscreen pane. /// /// Defaults to `true` — a **denylist, not an allowlist**: the many /// mode-agnostic commands keep working and new commands are available in diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs b/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs index 58e81ad..1b7b71a 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/copy.rs @@ -31,12 +31,6 @@ impl SlashCommand for CopyCommand { Some("[N]") } - /// Minimal mode has no in-app copy path — native terminal selection - /// replaces it (K7/§6.13). Gated off with a message. - fn available_in_minimal(&self) -> bool { - false - } - fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult { let trimmed = args.trim(); let n = if trimmed.is_empty() { @@ -152,8 +146,10 @@ mod tests { } #[test] - fn not_available_in_minimal() { - // Native terminal selection replaces the in-app copy path in minimal. - assert!(!CopyCommand.available_in_minimal()); + fn available_in_minimal_by_default() { + // Clipboard copy from scrollback does not need the fullscreen pane — + // same path as `/export` and useful when native selection is awkward + // for multi-page assistant messages. + assert!(CopyCommand.available_in_minimal()); } } diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/terminal_setup.rs b/crates/codegen/xai-grok-pager/src/slash/commands/terminal_setup.rs index 205505f..4714613 100644 --- a/crates/codegen/xai-grok-pager/src/slash/commands/terminal_setup.rs +++ b/crates/codegen/xai-grok-pager/src/slash/commands/terminal_setup.rs @@ -59,8 +59,23 @@ impl SlashCommand for TerminalSetupCommand { ctx.is_tmux_backed(), &ctx.tmux_config_path(), )); + // SSH wrap recommendation — rendered as its own section below, NOT an + // issue row: nothing is misconfigured, so it must not put "N issue(s)" + // on every healthy SSH session. On-demand diagnostics also ignore the + // `[ui.contextual_hints].ssh_wrap` tip opt-out: that gate (both its + // user and remote tiers) governs the unprompted session-load tip, + // while here the user explicitly asked for setup guidance, and an + // environment report that omits a known improvement would be + // incomplete. + let ssh_wrap_recommendation = crate::diagnostics::ssh_wrap_hint( + ctx.is_ssh, + crate::clipboard::osc52_sink_active(), + ctx.is_official_vscode_remote, + ); let route = crate::clipboard::clipboard_route(); let is_ssh = xai_grok_shell::util::clipboard::is_remote_session(); + let container_no_display = + xai_grok_shell::util::clipboard::is_containerized_without_display(); let mut out = String::new(); @@ -123,30 +138,34 @@ impl SlashCommand for TerminalSetupCommand { } // -- Clipboard -- - out.push_str("\nClipboard routes\n"); - out.push_str(&format!( - " native {} (tool: {})\n", - if route.native { "active" } else { "off" }, - xai_grok_shell::util::clipboard::native_tool_name(), - )); - out.push_str(&format!( - " tmux buffer {}\n", - if route.tmux_buffer { "active" } else { "off" } - )); - out.push_str(&format!( - " osc 52 {}\n", - if route.osc52 { "active" } else { "off" } - )); - out.push_str(&format!( - " data-control {}\n", - crate::clipboard::wayland_data_control_label() - )); + let display_server = crate::host::DisplayServer::current(); + let is_wayland = display_server == crate::host::DisplayServer::Wayland; + let clipboard_diagnostics = crate::diagnostics::format_clipboard_diagnostics( + crate::diagnostics::ClipboardDiagnosticsInput { + route_native: route.native, + route_tmux: route.tmux_buffer, + route_osc52: route.osc52, + native_tool: xai_grok_shell::util::clipboard::native_tool_name(), + brand: ctx.brand, + host_os: crate::host::HostOs::current(), + display_server, + is_ssh, + container_no_display, + osc52_sink: crate::clipboard::osc52_sink_active(), + wayland_data_control: is_wayland + && xai_grok_shell::util::clipboard::wayland_data_control_supported(), + wl_copy_available: is_wayland + && xai_grok_shell::util::clipboard::native_tool_name() == "wl-copy", + }, + ); + out.push('\n'); + out.push_str(&clipboard_diagnostics.text); // -- Diagnostics -- - if warnings.is_empty() { + if warnings.is_empty() && !clipboard_diagnostics.has_issue { out.push_str("\nNo issues found.\n"); - } else { - out.push_str(&format!("\n{} issue(s)\n", warnings.len())); + } else if !warnings.is_empty() { + out.push_str(&format!("\n{} additional issue(s)\n", warnings.len())); for w in &warnings { out.push_str(&format!("\n [!] {}\n", w.message)); match (w.fix.as_deref(), w.config_path.as_deref()) { @@ -164,6 +183,17 @@ impl SlashCommand for TerminalSetupCommand { } } + // -- Recommendation -- + if let Some(rec) = ssh_wrap_recommendation { + out.push_str(&format!("\nRecommendation\n\n {}\n", rec.message)); + if let Some(fix) = rec.fix.as_deref() { + out.push_str(&format!(" Run: `{}`\n", fix)); + } + if let Some(note) = rec.note.as_deref() { + out.push_str(&format!(" Note: {}\n", note)); + } + } + CommandResult::Message(out) } } diff --git a/crates/codegen/xai-grok-pager/src/startup.rs b/crates/codegen/xai-grok-pager/src/startup.rs index c02a3f1..74d794a 100644 --- a/crates/codegen/xai-grok-pager/src/startup.rs +++ b/crates/codegen/xai-grok-pager/src/startup.rs @@ -26,3 +26,80 @@ pub enum WarningSeverity { /// Rendered in dim/gray. Informational, not actionable. Info, } + +/// Pick the warning the single-slot welcome banner shows: the first +/// `Warning`-severity entry, else the last entry. +/// +/// `startup_warnings` is appended to at runtime while the user sits on the +/// welcome screen (session-start failures, Claude import results), so a plain +/// `first()` lets an early entry mask that later feedback — e.g. an import +/// Info result at index 0 hides a session-start Warning pushed behind it. +/// Severity decides first (a real Warning always beats an Info; among +/// Warnings, assemble order stays authoritative); a Warning-less list falls +/// back to the **last** entry because later Info pushes are direct +/// user-action feedback that must not be masked by an older Info. Every +/// banner surface (height calc + render) must pick through here so they +/// cannot disagree. +pub fn banner_warning(warnings: &[StartupWarning]) -> Option<&StartupWarning> { + warnings + .iter() + .find(|w| w.severity == WarningSeverity::Warning) + .or_else(|| warnings.last()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(severity: WarningSeverity, message: &str) -> StartupWarning { + StartupWarning { + severity, + message: message.to_string(), + action: None, + } + } + + #[test] + fn banner_warning_empty_is_none() { + assert!(banner_warning(&[]).is_none()); + } + + #[test] + fn banner_warning_lone_info_shows() { + let list = [entry(WarningSeverity::Info, "info note")]; + assert_eq!(banner_warning(&list).unwrap().message, "info note"); + } + + #[test] + fn banner_warning_runtime_pushed_warning_displaces_info() { + // An Info entry holds index 0 (e.g. a Claude import result). A + // Warning pushed later (e.g. "Not inside a git repository") must + // still win the single banner slot. + let list = [ + entry(WarningSeverity::Info, "info note"), + entry(WarningSeverity::Warning, "real problem"), + ]; + assert_eq!(banner_warning(&list).unwrap().message, "real problem"); + } + + #[test] + fn banner_warning_runtime_pushed_info_displaces_earlier_info() { + // Warning-less list: a later Info push is direct user-action + // feedback (e.g. a Claude import result) and wins over an older Info. + let list = [ + entry(WarningSeverity::Info, "info note"), + entry(WarningSeverity::Info, "import result"), + ]; + assert_eq!(banner_warning(&list).unwrap().message, "import result"); + } + + #[test] + fn banner_warning_first_warning_wins_among_warnings() { + let list = [ + entry(WarningSeverity::Warning, "first"), + entry(WarningSeverity::Warning, "second"), + entry(WarningSeverity::Info, "info note"), + ]; + assert_eq!(banner_warning(&list).unwrap().message, "first"); + } +} diff --git a/crates/codegen/xai-grok-pager/src/tips/mod.rs b/crates/codegen/xai-grok-pager/src/tips/mod.rs index 246a710..155d2b4 100644 --- a/crates/codegen/xai-grok-pager/src/tips/mod.rs +++ b/crates/codegen/xai-grok-pager/src/tips/mod.rs @@ -14,6 +14,7 @@ pub mod plan_nudge; pub mod render; pub mod send_now; pub mod small_screen; +pub mod ssh_wrap; pub mod word_select; pub use ephemeral::{DEFAULT_TIP_TICKS, EphemeralTip, EphemeralTipState, tip_row_renderable}; diff --git a/crates/codegen/xai-grok-pager/src/tips/ssh_wrap.rs b/crates/codegen/xai-grok-pager/src/tips/ssh_wrap.rs new file mode 100644 index 0000000..cacb7dd --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/tips/ssh_wrap.rs @@ -0,0 +1,101 @@ +//! SSH wrap tip: over SSH without `grok wrap`, advertise that wrapping the +//! ssh command on the local machine forwards clipboard copies and restores +//! the terminal when the connection drops. +//! +//! Shown once per run, at the first stable agent-view draw — the welcome +//! screen has no ephemeral-tip row, so the first agent render is the +//! earliest surface that can paint it (see +//! `AppView::maybe_trigger_ssh_wrap_tip`). Environment shape comes from +//! `diagnostics::ssh_wrap_hint`; the per-tip config gate is +//! `[ui.contextual_hints].ssh_wrap`. + +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; + +use super::EphemeralTip; +use crate::theme::Theme; + +/// Ephemeral-tip dedup key for the SSH `grok wrap` hint. +pub(crate) const SSH_WRAP_TIP_KEY: &str = "ssh_wrap_tip"; + +/// Key into the per-session in-memory seen-count map for this tip. +pub(crate) const SSH_WRAP_TIP_SEEN_KEY: &str = "ssh_wrap_tip_shown_count"; + +/// Stop showing after this many shows within a single session. +const SSH_WRAP_TIP_SEEN_CAP: u32 = 1; + +/// Tip lifetime (~10 s at the 30 fps animation cadence). The default ~3 s +/// window suits glanceable notices; this one carries a command the user is +/// meant to read and act on, so it gets a longer window. Ambient bounds it: +/// the TTL pauses while occluded instead of burning off-screen. +pub(crate) const SSH_WRAP_TIP_TICKS: u16 = 300; + +/// Build "Over SSH? Run `grok wrap ssh ` locally for clipboard + +/// terminal restore", seen-gated to [`SSH_WRAP_TIP_SEEN_CAP`] show per +/// session (in-memory). Ambient: it is about the session's transport, not +/// the draft, so submitting a prompt right after session load must not +/// retire it, and occlusion pauses (not burns) its TTL. +pub fn ssh_wrap_tip() -> EphemeralTip { + let theme = Theme::current(); + let dim = Style::default().fg(theme.gray); + // Command token styled like the other tips style their chord/key tokens. + let command = Style::default() + .fg(theme.text_secondary) + .add_modifier(Modifier::BOLD); + EphemeralTip { + ticks_remaining: SSH_WRAP_TIP_TICKS, + ..EphemeralTip::new( + SSH_WRAP_TIP_KEY, + Line::from(vec![ + Span::styled("Over SSH? Run ", dim), + Span::styled("grok wrap ssh ", command), + Span::styled(" locally for clipboard + terminal restore", dim), + ]), + ) + .with_session_seen_cap(SSH_WRAP_TIP_SEEN_KEY, SSH_WRAP_TIP_SEEN_CAP) + .ambient() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssh_wrap_tip_builder_applies_seen_gating() { + assert_eq!( + ssh_wrap_tip().session_seen, + Some((SSH_WRAP_TIP_SEEN_KEY, SSH_WRAP_TIP_SEEN_CAP)) + ); + } + + #[test] + fn ssh_wrap_tip_advertises_local_wrap() { + let tip = ssh_wrap_tip(); + assert_eq!(tip.key, SSH_WRAP_TIP_KEY); + let text: String = tip.line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!( + text, + "Over SSH? Run grok wrap ssh locally for clipboard + terminal restore" + ); + } + + #[test] + fn ssh_wrap_tip_outlives_default_ttl() { + let tip = ssh_wrap_tip(); + assert_eq!(tip.ticks_remaining, SSH_WRAP_TIP_TICKS); + // Read-and-act copy needs more than the glanceable default window. + assert!( + tip.ticks_remaining > super::super::DEFAULT_TIP_TICKS, + "ssh wrap tip must outlive the default TTL" + ); + } + + #[test] + fn ssh_wrap_tip_is_ambient() { + // Must survive prompt submission and pause TTL under occlusion — + // a session-load tip would otherwise blink away under the first + // submit or permission ask. + assert!(ssh_wrap_tip().ambient); + } +} diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/layout.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/layout.rs index 8387556..d91761a 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/layout.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/layout.rs @@ -1,4 +1,7 @@ //! Pure layout computation for the dashboard view. +//! +//! Peek vs roster vertical policy: +//! [`docs/internal/33-dashboard-peek-responsive-layout.md`](../../../../docs/internal/33-dashboard-peek-responsive-layout.md). use ratatui::layout::Rect; @@ -7,10 +10,160 @@ use ratatui::layout::Rect; /// view; row labels are middle-truncated. pub const MIN_DASHBOARD_WIDTH: u16 = 40; -/// Minimum total height at which the peek panel is allowed to render. -/// Below this we drop the peek section even when toggled on so the -/// row list still has room to breathe. -pub const MIN_PEEK_HEIGHT: u16 = 12; +/// Min list-band height (terminal rows) while evaluating/opening peek. +pub const LIST_FLOOR_ROWS: u16 = 12; + +/// Min whole peek box (borders + status + body + reply) for live-tail. +pub const PEEK_MIN_BOX_LIVE_TAIL: u16 = 8; + +/// Min whole peek box for question/permission peeks (options need room). +pub const PEEK_MIN_BOX_QUESTION: u16 = 10; + +/// Peek max = ⌊H × PEEK_MAX_FRAC_NUM / PEEK_MAX_FRAC_DEN⌋ (whole box). +pub const PEEK_MAX_FRAC_NUM: u16 = 3; +pub const PEEK_MAX_FRAC_DEN: u16 = 8; + +/// Secondary cap on live-tail body rows inside an allocated peek box. +pub const MAX_LIVE_TAIL_ROWS: u16 = 28; + +/// Live-tail height budget for a no-question peek (status + optional blank + reply). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PeekLiveTailBudget { + pub live_tail: u16, + pub blank_row: bool, + pub content_rows: u16, +} + +/// Result of list-first peek allocation for height `H`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PeekAllocation { + pub show_peek: bool, + /// Whole peek box height including borders; 0 if `!show_peek`. + pub peek_box_h: u16, + /// Max inner content rows for a peek at full allowed size (`peek_box_h - 2`). + pub max_content_rows: u16, +} + +/// Shrink-to-content desired inner rows for a live-tail peek. +/// +/// Recipe (matches dense paint): `status + [pin?] + body + [blank?] + reply`. +/// - `body_measured`: densified **current-turn** lines (after last user). +/// - `pin_user`: last user exists → budget one pin row (paint charges it too). +/// - Blank when body > 0 and room remains after pin+body (paint blanks only +/// when middle still has ≥2 rows after the blank so pin + body share). +/// Empty body reserves 1 row for the empty/hint line. Never exceeds +/// `max_content`; body is also capped by [`MAX_LIVE_TAIL_ROWS`]. +pub fn peek_live_tail_desired_content( + max_content: u16, + reply_rows: u16, + body_measured: u16, + pin_user: bool, +) -> PeekLiveTailBudget { + let reply_rows = reply_rows.max(1); + let pin = u16::from(pin_user); + let fixed = 1u16 + reply_rows + pin; // status + reply + optional pin + + if max_content < fixed { + return PeekLiveTailBudget { + live_tail: 0, + blank_row: false, + content_rows: max_content, + }; + } + + let room_no_blank = max_content.saturating_sub(fixed).min(MAX_LIVE_TAIL_ROWS); + if room_no_blank == 0 { + return PeekLiveTailBudget { + live_tail: 0, + blank_row: false, + content_rows: fixed, + }; + } + + // Prefer a breathing blank whenever body is non-empty and room remains. + let room_with_blank = max_content + .saturating_sub(fixed + 1) + .min(MAX_LIVE_TAIL_ROWS); + let blank = room_with_blank > 0; + let body_cap = if blank { + room_with_blank + } else { + room_no_blank + }; + + let body = if body_measured == 0 { + 1u16.min(body_cap) + } else { + body_measured.min(body_cap) + }; + // If body collapsed to 0, no blank either. + let blank = blank && body > 0; + let content_rows = fixed + u16::from(blank) + body; + PeekLiveTailBudget { + live_tail: body, + blank_row: blank, + content_rows: content_rows.min(max_content), + } +} + +/// ⌊H × 3/8⌋ whole-box peek max. +pub fn peek_max_box_rows(h: u16) -> u16 { + ((u32::from(h) * u32::from(PEEK_MAX_FRAC_NUM)) / u32::from(PEEK_MAX_FRAC_DEN)) as u16 +} + +/// Chrome rows (header/gaps/footer/margins) — not list, not peek. +pub fn chrome_overhead(area: Rect) -> u16 { + dashboard_fixed_overhead(area).0 +} + +/// List-first peek allocation. +/// +/// 1. Reserve [`LIST_FLOOR_ROWS`] for the list band (clamped to space after chrome). +/// 2. Remainder → candidate peek, capped by [`peek_max_box_rows`]. +/// 3. If candidate < `peek_min_box` → no peek. +/// 4. Else peek height = `min(desired_content+2, max_candidate)`, at least +/// `peek_min_box` when showing. +/// +/// `desired_content_rows` is inner content (no borders). Reply growth should +/// increase this; list may shrink only down to the floor (enforced by max +/// candidate). +pub fn allocate_peek( + area_h: u16, + fixed_overhead: u16, + desired_content_rows: u16, + peek_min_box: u16, +) -> PeekAllocation { + let after = area_h.saturating_sub(fixed_overhead); + if after == 0 { + return PeekAllocation { + show_peek: false, + peek_box_h: 0, + max_content_rows: 0, + }; + } + let list_floor = LIST_FLOOR_ROWS.min(after); + let remainder = after.saturating_sub(list_floor); + let peek_max = peek_max_box_rows(area_h); + let max_peek = remainder.min(peek_max); + let max_content_rows = max_peek.saturating_sub(2); + + if max_peek < peek_min_box { + return PeekAllocation { + show_peek: false, + peek_box_h: 0, + max_content_rows, + }; + } + + let desired_box = desired_content_rows.saturating_add(2); + let peek_box_h = desired_box.max(peek_min_box).min(max_peek); + + PeekAllocation { + show_peek: true, + peek_box_h, + max_content_rows, + } +} /// Outer horizontal padding for the dispatch box (cols on each side). /// @@ -19,9 +172,8 @@ pub const MIN_PEEK_HEIGHT: u16 = 12; pub const DISPATCH_OUTER_HPAD: u16 = 2; /// Outer horizontal padding for the top page header (cols on each side). -/// Slightly less than the list to give the title and status chips a bit -/// more horizontal real estate. -pub const HEADER_OUTER_HPAD: u16 = 1; +/// Matches list/dispatch so the title aligns with content below. +pub const HEADER_OUTER_HPAD: u16 = 2; /// Outer horizontal padding for the row list (cols on each side). /// @@ -80,18 +232,97 @@ pub fn compute_layout(area: Rect, peek_visible: bool) -> DashboardLayout { compute_layout_with_dispatch(area, peek_visible, 1) } +fn dashboard_chrome_heights(area: Rect) -> (u16, u16, u16, u16, u16, u16, u16, bool) { + // Match welcome/agent top margin; drop on short terminals. + let top_margin_h: u16 = if area.height > 6 { 1 } else { 0 }; + let header_h: u16 = if area.height > 4 { 1 } else { 0 }; + // Header↔list gap; collapses with dispatch/shortcuts gaps on short terms. + let header_gap_h: u16 = if area.height > 10 { 1 } else { 0 }; + let footer_h: u16 = if area.height >= 2 { 1 } else { 0 }; + // Match agent prompt/shortcuts gaps; drop on short terminals. + let dispatch_gap_h: u16 = if area.height > 10 { 1 } else { 0 }; + let shortcuts_gap_h: u16 = if area.height > 10 { 1 } else { 0 }; + // Match agent bottom_vpad; drop when height <= 16. + let bottom_margin_h: u16 = if area.height > 16 { 1 } else { 0 }; + let short_terminal = area.height <= 8; + ( + top_margin_h, + header_h, + header_gap_h, + footer_h, + dispatch_gap_h, + shortcuts_gap_h, + bottom_margin_h, + short_terminal, + ) +} + +fn dashboard_fixed_overhead(area: Rect) -> (u16, bool) { + let ( + top_margin_h, + header_h, + header_gap_h, + footer_h, + dispatch_gap_h, + shortcuts_gap_h, + bottom_margin_h, + short_terminal, + ) = dashboard_chrome_heights(area); + let fixed_overhead = top_margin_h + + header_h + + header_gap_h + + footer_h + + dispatch_gap_h + + shortcuts_gap_h + + bottom_margin_h; + (fixed_overhead, short_terminal) +} + +/// Max inner content rows available for a peek under list-first allocation +/// (list floor + peek max fraction). 0 when a peek cannot open. +pub fn max_peek_content_rows(area: Rect) -> u16 { + if area.height <= 8 { + return 0; + } + let fixed = chrome_overhead(area); + let probe = allocate_peek( + area.height, + fixed, + // Probe with enough content that allocation uses full max candidate. + 255, + PEEK_MIN_BOX_LIVE_TAIL, + ); + probe.max_content_rows +} + +/// Like [`compute_layout`] but with a fixed whole peek-box height +/// (from [`allocate_peek`]). List band receives the rest after chrome. +pub fn compute_layout_with_peek_box(area: Rect, peek_box_h: u16) -> DashboardLayout { + compute_layout_with_dispatch_inner(area, true, 0, Some(peek_box_h.max(3))) +} + /// Like [`compute_layout`] but lets the caller request a taller /// dispatch box. `dispatch_text_rows` is the number of *text* rows the /// dispatch input wants (≥1); the box adds 2 more for its top/bottom /// border chrome. Used to grow the box as the user inserts newlines /// (Shift+Enter) so multiline dispatch prompts are fully visible. /// -/// The caller is responsible for clamping `dispatch_text_rows` so the -/// row list keeps usable space; this function only enforces a ≥1 floor. +/// When `peek_visible`, uses list-first [`allocate_peek`] with +/// [`PEEK_MIN_BOX_LIVE_TAIL`]. Prefer [`compute_layout_with_peek_box`] +/// when the caller already allocated. pub fn compute_layout_with_dispatch( area: Rect, peek_visible: bool, dispatch_text_rows: u16, +) -> DashboardLayout { + compute_layout_with_dispatch_inner(area, peek_visible, dispatch_text_rows, None) +} + +fn compute_layout_with_dispatch_inner( + area: Rect, + peek_visible: bool, + dispatch_text_rows: u16, + forced_peek_box_h: Option, ) -> DashboardLayout { // When `area.height == 0`, every subrect collapses // to zero. A footer_h = 1 default would produce a non-zero @@ -114,63 +345,41 @@ pub fn compute_layout_with_dispatch( bottom_margin: z, }; } - // Match the welcome / agent view's top margin so - // the dashboard's header doesn't sit flush against the alt-screen's - // top edge. The welcome view uses `v_margin = 1` (see - // `views::welcome::render_welcome`). Dropped to 0 on very short - // terminals so we don't starve the row list. - let top_margin_h: u16 = if area.height > 6 { 1 } else { 0 }; - let header_h: u16 = if area.height > 4 { 1 } else { 0 }; - // 1-row gap between the header and the row list so the title / - // status chips don't sit flush against the first row (or the - // first group header). Collapses on short terminals so the row - // list isn't starved (same threshold as the dispatch/shortcuts - // gaps). - let header_gap_h: u16 = if area.height > 10 { 1 } else { 0 }; - let footer_h: u16 = if area.height >= 2 { 1 } else { 0 }; - // Vertical gaps around the dispatch box, matching - // the agent view's `prompt_gap` and `shortcuts_gap` (both = 1) so - // the dispatch chrome doesn't sit flush against the list above or - // the footer below. Gaps drop to 0 on short terminals so the row - // list still gets visible space. Computed BEFORE `dispatch_h` so the - // content-sized peek box can leave the row list at least one row. - let dispatch_gap_h: u16 = if area.height > 10 { 1 } else { 0 }; - let shortcuts_gap_h: u16 = if area.height > 10 { 1 } else { 0 }; - // Bottom margin below the shortcuts bar, matching - // the agent view's `bottom_vpad` (`outer_vpad = 1` from - // `LayoutConfig::default` dropped to 0 when `area.height <= 16`). - let bottom_margin_h: u16 = if area.height > 16 { 1 } else { 0 }; + let ( + top_margin_h, + header_h, + header_gap_h, + footer_h, + dispatch_gap_h, + shortcuts_gap_h, + bottom_margin_h, + short_terminal, + ) = dashboard_chrome_heights(area); + let (fixed_overhead, _) = dashboard_fixed_overhead(area); - // The peek panel sizes to its CONTENT instead of a fixed - // height. Its inner rows are: status (1) + wrapped response (N) + - // one blank breathing row (1) + `❯ reply` (1); the caller passes - // that inner content count via `dispatch_text_rows` (floored at - // status + blank + reply = 3 when there's no response yet). Adding - // the 2 borders gives the box height, clamped so the row list keeps - // at least one visible row. - // - // Otherwise (no peek) the dispatch reserves 2 borders + N text rows - // so the rounded box reads as a real input field and grows for - // multiline (Alt+Enter) prompts. Very short terminals (height ≤ 8) - // fall back to a single line so the row list isn't starved. - let dispatch_h: u16 = if peek_visible { - if area.height <= 8 { + // Peek: list-first allocation (see `allocate_peek`). No peek → normal + // dispatch chrome. `forced_peek_box_h` skips re-allocation when the + // caller already chose a height (and peek min for question vs live-tail). + let dispatch_h: u16 = if let Some(h) = forced_peek_box_h { + let after = area.height.saturating_sub(fixed_overhead); + let list_floor = LIST_FLOOR_ROWS.min(after); + let max_peek = after + .saturating_sub(list_floor) + .min(peek_max_box_rows(area.height)); + h.min(max_peek).max(3) + } else if peek_visible { + if short_terminal { 1 } else { - let fixed_overhead = top_margin_h - + header_h - + header_gap_h - + footer_h - + dispatch_gap_h - + shortcuts_gap_h - + bottom_margin_h; - let content = dispatch_text_rows.max(3); - let desired = content + 2; - // Keep ≥1 row for the list; never collapse below a 3-row box. - let max_box = area.height.saturating_sub(fixed_overhead + 1).max(3); - desired.min(max_box) + let alloc = allocate_peek( + area.height, + fixed_overhead, + dispatch_text_rows, + PEEK_MIN_BOX_LIVE_TAIL, + ); + if alloc.show_peek { alloc.peek_box_h } else { 3 } } - } else if area.height > 8 { + } else if !short_terminal { 2 + dispatch_text_rows.max(1) } else { 1 @@ -202,9 +411,7 @@ pub fn compute_layout_with_dispatch( height: top_margin_h, }; y += top_margin_h; - // Inset the top page header using its own (slightly smaller) padding - // so the title and status chips have breathing room without losing - // as much width as the list content. + // Inset the top page header to match list/dispatch content columns. let header_inner_pad = HEADER_OUTER_HPAD.saturating_mul(2); let header_width = area.width.saturating_sub(header_inner_pad); let header_x = if header_width > 0 { @@ -490,12 +697,12 @@ mod tests { ); } - /// The header rect is inset by HEADER_OUTER_HPAD (slightly less - /// than the list) for side breathing room on the title and status chips. + /// Header h-pad matches list so title aligns with content columns. #[test] fn layout_applies_outer_hpad_to_header() { let area = Rect::new(0, 0, 80, 30); let layout = compute_layout(area, false); + assert_eq!(HEADER_OUTER_HPAD, LIST_OUTER_HPAD); assert_eq!( layout.header.x, area.x + HEADER_OUTER_HPAD, @@ -506,6 +713,8 @@ mod tests { area.width - HEADER_OUTER_HPAD * 2, "header width must lose HEADER_OUTER_HPAD on each side", ); + assert_eq!(layout.header.x, layout.list.x); + assert_eq!(layout.header.width, layout.list.width); } /// A 1-row gap separates the list/peek from the @@ -666,24 +875,25 @@ mod tests { assert_eq!(layout.footer.height, 0); } - /// One row below the peek minimum hides the peek. + /// List-first: short heights cannot open peek (remainder < peek min). #[test] - fn layout_just_below_min_peek_height_hides_peek() { - let area = Rect::new(0, 0, 80, MIN_PEEK_HEIGHT - 1); - let layout = compute_layout(area, true); - assert_eq!(layout.peek.height, 0); + fn allocate_peek_refuses_when_remainder_below_min() { + let area = Rect::new(0, 0, 80, 24); + let fixed = chrome_overhead(area); + let alloc = allocate_peek(area.height, fixed, 20, PEEK_MIN_BOX_LIVE_TAIL); + // chrome≈7, after≈17, floor=12, rem≈5 < 8 → no peek + assert!( + !alloc.show_peek, + "h=24 should not fit list floor + peek min" + ); } - /// The standalone peek rect was retired (peek now - /// renders INSIDE the dispatch box). The peek rect is always - /// zero-height; what changes when `peek_visible == true` is - /// the dispatch rect, which grows from 3 to 5 rows to host - /// the peek's status + reply input. + /// Standalone peek rect is always zero; peek uses dispatch. #[test] fn layout_grows_dispatch_when_peek_visible() { - let area = Rect::new(0, 0, 80, 30); + let area = Rect::new(0, 0, 80, 40); let no_peek = compute_layout(area, false); - let with_peek = compute_layout(area, true); + let with_peek = compute_layout_with_dispatch(area, true, 12); assert_eq!(no_peek.peek.height, 0); assert_eq!(with_peek.peek.height, 0); assert!( @@ -692,23 +902,19 @@ mod tests { no_peek.dispatch.height, with_peek.dispatch.height, ); + assert!(with_peek.list.height >= LIST_FLOOR_ROWS); } - /// The peek box sizes to its content: 2 borders + the - /// inner content rows (status + response + blank + reply) the caller - /// passes via `dispatch_text_rows`. A bigger response → taller box. + /// Larger desired content → taller peek box until max fraction. #[test] fn peek_box_sizes_to_content_rows() { let area = Rect::new(0, 0, 80, 40); - // content = status(1) + blank(1) + reply(1) = 3 → box 5 (no response). - let empty = compute_layout_with_dispatch(area, true, 3); - // content = status + 3 response + blank + reply = 6 → box 8. - let full = compute_layout_with_dispatch(area, true, 6); - assert_eq!(empty.dispatch.height, 5); - assert_eq!(full.dispatch.height, 8); - assert!(full.dispatch.height > empty.dispatch.height); - // The list reclaims the rows the smaller box doesn't use. - assert!(empty.list.height > full.list.height); + let small = compute_layout_with_dispatch(area, true, 6); + let large = compute_layout_with_dispatch(area, true, 20); + assert!(large.dispatch.height >= small.dispatch.height); + assert!(large.list.height <= small.list.height); + assert!(large.list.height >= LIST_FLOOR_ROWS); + assert!(large.dispatch.height <= peek_max_box_rows(40)); } /// Zero-width area returns valid zero-width rects. @@ -733,4 +939,136 @@ mod tests { ); assert!(layout.list.height > 0); } + + #[test] + fn max_peek_content_rows_zero_on_short_terminal() { + assert_eq!(max_peek_content_rows(Rect::new(0, 0, 80, 8)), 0); + assert_eq!(max_peek_content_rows(Rect::new(0, 0, 80, 1)), 0); + } + + #[test] + fn allocate_peek_list_floor_and_max_fraction() { + for h in [28u16, 32, 40, 60, 80] { + let area = Rect::new(0, 0, 80, h); + let fixed = chrome_overhead(area); + let alloc = allocate_peek(h, fixed, 255, PEEK_MIN_BOX_LIVE_TAIL); + assert!(alloc.show_peek, "h={h} should open peek"); + assert!( + alloc.peek_box_h <= peek_max_box_rows(h), + "h={h} peek {} > max {}", + alloc.peek_box_h, + peek_max_box_rows(h) + ); + assert!(alloc.peek_box_h >= PEEK_MIN_BOX_LIVE_TAIL); + let layout = compute_layout_with_peek_box(area, alloc.peek_box_h); + assert!( + layout.list.height >= LIST_FLOOR_ROWS, + "h={h} list {} < floor", + layout.list.height + ); + assert_eq!(layout.dispatch.height, alloc.peek_box_h); + } + } + + #[test] + fn allocate_peek_respects_three_eighths_cap() { + assert_eq!(peek_max_box_rows(40), 15); // floor(40*3/8) + assert_eq!(peek_max_box_rows(60), 22); + assert_eq!(peek_max_box_rows(8), 3); + } + + #[test] + fn reply_growth_steals_from_list_down_to_floor_then_body() { + let area = Rect::new(0, 0, 80, 40); + let fixed = chrome_overhead(area); + let one = allocate_peek(40, fixed, 6, PEEK_MIN_BOX_LIVE_TAIL); + let multi = allocate_peek(40, fixed, 14, PEEK_MIN_BOX_LIVE_TAIL); + assert!(one.show_peek && multi.show_peek); + assert!(multi.peek_box_h >= one.peek_box_h); + let layout_multi = compute_layout_with_peek_box(area, multi.peek_box_h); + assert!(layout_multi.list.height >= LIST_FLOOR_ROWS); + assert!(multi.peek_box_h <= peek_max_box_rows(40)); + } + + #[test] + fn layout_header_aligns_with_list_and_dispatch() { + let area = Rect::new(0, 0, 80, 30); + let layout = compute_layout(area, false); + assert_eq!(layout.header.x, layout.list.x); + assert_eq!(layout.header.x, layout.dispatch.x); + assert_eq!(layout.header.width, layout.list.width); + assert_eq!(layout.header.width, layout.dispatch.width); + } + + #[test] + fn peek_live_tail_desired_empty_uses_one_body_row() { + let d = peek_live_tail_desired_content(20, 1, 0, false); + assert_eq!(d.live_tail, 1); + assert!(d.blank_row, "empty/hint body still budgets blank when room"); + assert_eq!(d.content_rows, 1 + 1 + 1 + 1); // status+reply+blank+body + } + + #[test] + fn peek_live_tail_desired_tight_pin_skips_blank() { + // fixed = status + reply3 + pin = 5; max_content = fixed+1 → body 1, no blank. + let d = peek_live_tail_desired_content(6, 3, 1, true); + assert!(!d.blank_row); + assert_eq!(d.live_tail, 1); + assert_eq!(d.content_rows, 1 + 3 + 1 + 1); // status+reply+pin+body + } + + #[test] + fn peek_live_tail_desired_short_body_budgets_blank_and_pin() { + let d = peek_live_tail_desired_content(40, 1, 2, false); + assert_eq!(d.live_tail, 2); + assert!(d.blank_row); + assert_eq!(d.content_rows, 1 + 1 + 1 + 2); + + let with_pin = peek_live_tail_desired_content(40, 1, 2, true); + assert_eq!(with_pin.live_tail, 2); + assert!(with_pin.blank_row); + assert_eq!(with_pin.content_rows, 1 + 1 + 1 + 1 + 2); // +pin + assert!(with_pin.content_rows > d.content_rows); + } + + #[test] + fn peek_live_tail_desired_long_body_hits_live_tail_cap() { + let d = peek_live_tail_desired_content(80, 1, 200, false); + assert_eq!(d.live_tail, MAX_LIVE_TAIL_ROWS); + assert!(d.blank_row); + assert_eq!(d.content_rows, 1 + 1 + 1 + MAX_LIVE_TAIL_ROWS); + } + + #[test] + fn peek_live_tail_desired_pin_fits_in_measured_body_budget() { + // body that fits without pin must not force ellipsis solely due to pin: + // desired grows by the pin row so paint body_budget still covers body. + let body = 4u16; + let d = peek_live_tail_desired_content(40, 1, body, true); + assert_eq!(d.live_tail, body); + assert_eq!( + d.content_rows, + 1 + 1 + 1 + 1 + body, + "status+reply+pin+blank+body" + ); + } + + #[test] + fn peek_live_tail_desired_never_exceeds_max_content() { + for max_content in 0..=40u16 { + for reply in 1..=6u16 { + for body in [0u16, 1, 3, 10, 50, 200] { + for pin in [false, true] { + let d = peek_live_tail_desired_content(max_content, reply, body, pin); + assert!( + d.content_rows <= max_content, + "content_rows={} > max={max_content} reply={reply} body={body} pin={pin}", + d.content_rows + ); + assert!(d.live_tail <= MAX_LIVE_TAIL_ROWS); + } + } + } + } + } } diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/mod.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/mod.rs index 526d38e..c9e31b3 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/mod.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/mod.rs @@ -26,6 +26,7 @@ pub mod layout; pub mod peek; +pub mod peek_tail; pub mod render; pub mod row; pub mod state; diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/peek.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/peek.rs index 19fd6ef..6542f24 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/peek.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/peek.rs @@ -14,11 +14,29 @@ use crate::app::agent_view::AgentView; use crate::render::line_utils::truncate_str; use crate::theme::Theme; -/// Maximum number of wrapped response rows the peek panel shows. The -/// layout sizes the peek box to `status + this-many-response-rows + -/// blank + reply`, and the renderer caps the response at the same value -/// so a long response can't crowd out the reply input. -pub const MAX_RESPONSE_ROWS: usize = 3; +/// Args for painting a dense bottom-pinned live tail in the peek middle. +pub struct PeekLiveTailArgs<'a> { + pub scrollback: &'a crate::scrollback::state::ScrollbackState, +} + +/// Exclusive bottom y of the live-tail middle band (above the reply). +/// +/// Reserves a 1-row breathing blank above the reply only when middle still +/// has ≥2 rows after that blank so pin + body can share. When only one row +/// would remain (`middle_h_with_blank == 1`), expand into the blank so the +/// current-turn body is not starved — matches measure when +/// `blank_row=false` (e.g. max_content = fixed+1 with pin). +fn live_tail_middle_bottom(middle_top: u16, reply_top_y: u16) -> u16 { + let with_blank = reply_top_y.saturating_sub(1); + let h_with_blank = with_blank.saturating_sub(middle_top); + if h_with_blank > 1 { + with_blank + } else if reply_top_y > middle_top { + reply_top_y + } else { + with_blank + } +} /// Maximum number of rows the `❯ reply` input grows to as the user /// inserts newlines (Shift+Enter / Alt+Enter). Past this the reply @@ -54,13 +72,6 @@ pub struct PeekFields { /// `"Response"`, `"Edit"`), shown as the header's left label. pub response_type: String, pub last_user_message: Option, - /// Up to the last 3 lines of the most recent agent message, - /// top-to-bottom. Empty when the agent hasn't streamed text. - pub last_agent_lines: Vec, - /// Whether the most recent agent message had MORE than 3 lines, so - /// the renderer appends a `…` continuation marker. (Truncation is - /// shown only when there are genuinely more lines than fit.) - pub last_response_truncated: bool, pub question: Option, pub options: Vec<(String, String)>, pub request_id: Option, @@ -72,22 +83,17 @@ pub struct PeekFields { /// Per-row peek panel state. /// -/// The panel carries explicit slots for the things worth surfacing -/// (rather than a raw dump of the last few scrollback lines): +/// Peek panel display state for a selected dashboard row. /// -/// - The agent's status (running tool name, awaiting, idle). -/// - The most recent **user** message (first line). -/// - The most recent **agent** message (up to 3 lines). -/// - The pending permission question + options (if any). +/// - Status (`response_type` + `time_ago`) on the header row. +/// - Middle body: live-tail scrollback (see `PeekLiveTailArgs`) or a +/// pending permission / ask-question UI. +/// - `❯ reply` input backed by the dashboard-owned `peek_reply` +/// [`PromptWidget`](crate::views::prompt_widget::PromptWidget). /// -/// Reply support — the `❯ reply` line is a live input backed by the -/// dashboard-owned `peek_reply` [`PromptWidget`](crate::views::prompt_widget::PromptWidget) -/// (see the module note above). The display fields are refreshed -/// every frame from the selected agent (so the panel follows the -/// selection cursor and shows live status); the reply draft is -/// preserved across refreshes and only cleared when the peeked row -/// changes (handled by the render-time refresh) or the panel closes -/// (`DashboardState::set_peek`). +/// Display fields refresh every frame from the selected agent; the reply +/// draft is preserved across refreshes and only cleared when the peeked +/// row changes or the panel closes (`DashboardState::set_peek`). #[derive(Debug, Clone)] pub struct PeekPanelState { /// Which row is being peeked. Tracks the selection cursor; the @@ -105,12 +111,6 @@ pub struct PeekPanelState { pub response_type: String, /// First line of the most recent user prompt, or `None`. pub last_user_message: Option, - /// Up to the last 3 lines of the most recent agent message, - /// top-to-bottom. Empty when the agent hasn't streamed text. - pub last_agent_lines: Vec, - /// Whether the most recent agent message had more than 3 lines (so - /// the renderer appends a `…` continuation marker). - pub last_response_truncated: bool, /// Question text from a pending `PermissionView`, when applicable. pub question: Option, /// Multiple-choice options for a pending permission request. Each @@ -167,8 +167,6 @@ impl PeekPanelState { time_ago: fields.time_ago, response_type: fields.response_type, last_user_message: fields.last_user_message, - last_agent_lines: fields.last_agent_lines, - last_response_truncated: fields.last_response_truncated, question: fields.question, options: fields.options, request_id: fields.request_id, @@ -203,8 +201,6 @@ impl PeekPanelState { self.time_ago = fields.time_ago; self.response_type = fields.response_type; self.last_user_message = fields.last_user_message; - self.last_agent_lines = fields.last_agent_lines; - self.last_response_truncated = fields.last_response_truncated; self.question = fields.question; // Reset the selected option when the pending request changes // (a new/rotated permission); otherwise keep the user's selection. @@ -250,7 +246,6 @@ pub fn compute_peek_fields( let label = sanitize_display_text(&entry_title(agent)).into_owned(); let response_type = extract_last_response_type(agent); let last_user_message = extract_last_user_message(agent); - let (last_agent_lines, last_response_truncated) = extract_last_agent_lines(agent, 3); let time_ago = agent .last_active_at .map(|t| crate::util::format_time_ago(t.elapsed())) @@ -338,8 +333,6 @@ pub fn compute_peek_fields( time_ago, response_type, last_user_message, - last_agent_lines, - last_response_truncated, question, options, request_id, @@ -363,17 +356,12 @@ pub fn compute_peek_fields( .map(|c| extract_last_response_type(c)) .unwrap_or_else(|| "Subagent".to_string()); let last_user_message = child.and_then(|c| extract_last_user_message(c)); - let (last_agent_lines, last_response_truncated) = child - .map(|c| extract_last_agent_lines(c, 3)) - .unwrap_or((Vec::new(), false)); let time_ago = crate::util::format_time_ago(info.last_progress_at.elapsed()); Some(PeekFields { label, time_ago, response_type, last_user_message, - last_agent_lines, - last_response_truncated, // Subagents are driven by their parent — no direct // permission prompts surface here. question: None, @@ -580,6 +568,8 @@ pub fn render_peek_panel( voice_interim: Option<&str>, multiline: bool, overlay_area: Option, + live_tail: Option>, + empty_hint: Option<&str>, ) -> PeekRenderResult { use crate::views::prompt_widget::PromptStyle; use ratatui::widgets::{Block, BorderType, Borders, Widget}; @@ -779,10 +769,8 @@ pub fn render_peek_panel( } else { inner.width as usize }; - // While the agent is working the "Working" status reads as - // secondary (a touch brighter than the dim chrome) and the previous - // response is hidden entirely (below), so the panel signals "still - // running" without dwelling on a now-stale answer. + // While Working, the status label is secondary (a touch brighter than + // dim chrome). Live-tail keeps painting the middle regardless. let working = panel.response_type == "Working"; let label_fg = if working { theme.text_secondary @@ -806,50 +794,43 @@ pub fn render_peek_panel( ); } - // The most recent agent response on the rows between the status - // and the reply input. Long lines WRAP (word-wrapped to the inner - // width) instead of being truncated. The box is sized (by the - // layout) to status + response + one blank breathing row + reply, - // so capping the response at `MAX_RESPONSE_ROWS` leaves that row - // above the reply blank. A `…` continuation marker is appended to - // the last visible row only when there's more content than fits. - // Suppress the previous response while working — it's stale and the - // "Working" status already conveys the state (the box is sized without - // these rows, see `response_row_count`). - let middle = (reply_top_y.saturating_sub(inner.y + 1)) as usize; - let capacity = middle.min(MAX_RESPONSE_ROWS); - if capacity > 0 && !working { - // Flatten the response lines into wrapped visual rows, taking - // one more than `capacity` so we can detect overflow without - // wrapping an arbitrarily long response in full. - let mut rows: Vec = Vec::new(); - 'outer: for line in &panel.last_agent_lines { - for vis in wrap_to_width(line, inner.width as usize) { - rows.push(vis); - if rows.len() > capacity { - break 'outer; + let middle_top = inner.y + 1; + // Match `peek_live_tail_desired_content`: blank only when middle still + // has ≥2 rows after it (pin + body). When only 1 row remains, keep it. + let middle_bottom = live_tail_middle_bottom(middle_top, reply_top_y); + let middle_h = middle_bottom.saturating_sub(middle_top); + let middle_area = Rect { + x: inner.x, + y: middle_top, + width: inner.width, + height: middle_h, + }; + if let Some(PeekLiveTailArgs { scrollback }) = live_tail { + if middle_h > 0 { + if scrollback.is_empty() { + if let Some(hint) = empty_hint.or(Some("No activity yet")) { + let trunc = truncate_str(hint, inner.width as usize); + buf.set_string( + inner.x, + middle_top, + trunc, + Style::default().fg(theme.gray_dim).bg(theme.bg_base), + ); } + } else { + super::peek_tail::paint_peek_live_tail(scrollback, middle_area, buf); } } - let overflow = rows.len() > capacity || panel.last_response_truncated; - let n = capacity.min(rows.len()); - for (i, row) in rows.iter().take(n).enumerate() { - let y = inner.y + 1 + i as u16; - let is_last_shown = i + 1 == n; - let text = if is_last_shown && overflow { - // Reserve 2 cols for " …" so the marker stays visible. - let body = truncate_str(row, (inner.width as usize).saturating_sub(2)); - format!("{body} \u{2026}") - } else { - row.clone() - }; - buf.set_string( - inner.x, - y, - text, - Style::default().fg(theme.text_primary).bg(theme.bg_base), - ); - } + } else if let Some(hint) = empty_hint + && middle_h > 0 + { + let trunc = truncate_str(hint, inner.width as usize); + buf.set_string( + inner.x, + middle_top, + trunc, + Style::default().fg(theme.gray_dim).bg(theme.bg_base), + ); } } @@ -928,28 +909,6 @@ pub fn render_peek_panel( PeekRenderResult { caret, reply_rect } } -/// Number of visual rows the response wraps to at `width` columns, -/// capped at `cap`. The dashboard layout uses this to size the peek box -/// to its content (status + response + blank + reply) rather than a -/// fixed height. Returns 0 when there's no response or `width` is 0. -/// -/// Returns 0 while the agent is working: the previous response is hidden in -/// that state (see `render_peek_panel`), so the box shouldn't reserve rows -/// for it. -pub fn response_row_count(panel: &PeekPanelState, width: usize, cap: usize) -> usize { - if width == 0 || cap == 0 || panel.response_type == "Working" { - return 0; - } - let mut rows = 0usize; - for line in &panel.last_agent_lines { - rows += wrap_to_width(line, width).len(); - if rows >= cap { - return cap; - } - } - rows.min(cap) -} - /// Number of rows the `❯ reply` input wants at the given reply TEXT /// width (the inner box width minus the `❯ ` prefix), capped at `cap`. /// Used both by the dashboard layout to size the peek box and by @@ -977,76 +936,6 @@ pub fn reply_row_count( .max(1) } -/// Word-wrap a single logical line into visual rows of at most `width` -/// display columns, so the peek's response WRAPS instead of truncating. -/// -/// Width is measured in terminal columns (`UnicodeWidthStr` / -/// `UnicodeWidthChar`) so CJK / wide chars and emoji don't overflow the -/// box. Words longer than the row are hard-split. A `width` of 0/1 -/// returns the line unsplit (degenerate widths). Empty input yields a -/// single empty row. -fn wrap_to_width(line: &str, width: usize) -> Vec { - use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - if width <= 1 { - return vec![line.to_owned()]; - } - let mut out: Vec = Vec::new(); - let mut current = String::new(); - for word in line.split_whitespace() { - if UnicodeWidthStr::width(word) > width { - // Hard-split an overlong token (path/URL) so no row exceeds - // `width`, accumulating chars until the next would overflow. - if !current.is_empty() { - out.push(std::mem::take(&mut current)); - } - let mut chunk = String::new(); - let mut chunk_w = 0usize; - for ch in word.chars() { - let cw = UnicodeWidthChar::width(ch).unwrap_or(0); - if cw > width { - if !chunk.is_empty() { - out.push(std::mem::take(&mut chunk)); - chunk_w = 0; - } - out.push(ch.to_string()); - continue; - } - if chunk_w + cw > width { - out.push(std::mem::take(&mut chunk)); - chunk_w = 0; - } - chunk.push(ch); - chunk_w += cw; - } - if !chunk.is_empty() { - out.push(chunk); - } - continue; - } - let need = if current.is_empty() { - UnicodeWidthStr::width(word) - } else { - UnicodeWidthStr::width(current.as_str()) + 1 + UnicodeWidthStr::width(word) - }; - if need > width { - out.push(std::mem::take(&mut current)); - current.push_str(word); - } else { - if !current.is_empty() { - current.push(' '); - } - current.push_str(word); - } - } - if !current.is_empty() { - out.push(current); - } - if out.is_empty() { - out.push(String::new()); - } - out -} - /// The header label for the peek panel, e.g. `"Thinking"` / `"Thought"`, /// `"Response"`, `"Edit"`, `"Read"`, `"Bash"`, `"Working"`, … /// @@ -1155,22 +1044,7 @@ pub fn extract_last_response_type(agent: &AgentView) -> String { /// agent's scrollback. Sanitised + ANSI-stripped. Returns /// `None` when the user hasn't sent any prompts yet. pub fn extract_last_user_message(agent: &AgentView) -> Option { - use crate::scrollback::block::RenderBlock; - use crate::views::session_title::sanitize_display_text; - let len = agent.scrollback.len(); - for idx in (0..len).rev() { - let entry = agent.scrollback.entry(idx)?; - if let RenderBlock::UserPrompt(b) = &entry.block { - let first = b.text.lines().next().unwrap_or("").trim(); - if first.is_empty() { - continue; - } - let stripped = strip_ansi_escapes::strip_str(first); - let safe = sanitize_display_text(&stripped).into_owned(); - return Some(safe.trim().to_string()); - } - } - None + crate::views::session_title::last_user_prompt_line(agent) } /// Pull the first line of the FIRST user prompt (`RenderBlock::UserPrompt`) @@ -1200,62 +1074,6 @@ pub fn extract_first_user_message(agent: &AgentView) -> Option { None } -/// Pull up to `count` leading non-empty lines of the most recent -/// agent message (`RenderBlock::AgentMessage`) from the agent's -/// scrollback, in natural top-to-bottom order. Each line is -/// ANSI-stripped + sanitised. -/// -/// Returns `(lines, truncated)` where `truncated` is `true` when the -/// message had MORE than `count` non-empty lines — so the renderer can -/// show a `…` continuation marker only when there's genuinely more. -/// Returns `(vec![], false)` when the agent hasn't streamed any text. -/// -/// This is the multi-line successor to the old single-line -/// `extract_last_agent_message`: the peek panel now surfaces up to 3 -/// lines of the last response instead of just the first. -pub fn extract_last_agent_lines(agent: &AgentView, count: usize) -> (Vec, bool) { - use crate::scrollback::block::RenderBlock; - use crate::views::session_title::sanitize_display_text; - if count == 0 { - return (Vec::new(), false); - } - let len = agent.scrollback.len(); - for idx in (0..len).rev() { - let Some(entry) = agent.scrollback.entry(idx) else { - return (Vec::new(), false); - }; - if let RenderBlock::AgentMessage(msg) = &entry.block { - let text = msg.text(); - let mut out = Vec::new(); - let mut truncated = false; - for line in text.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let stripped = strip_ansi_escapes::strip_str(trimmed); - let safe = sanitize_display_text(&stripped).into_owned(); - let safe = safe.trim().to_string(); - if safe.is_empty() { - continue; - } - if out.len() >= count { - // There's at least one more renderable line beyond - // `count` — mark truncated and stop. - truncated = true; - break; - } - out.push(safe); - } - // Only the most recent agent message is considered; if it - // has no renderable lines, fall through to an empty result - // rather than scanning older messages. - return (out, truncated); - } - } - (Vec::new(), false) -} - /// Extract the last `count` short text descriptions from the given /// agent view's scrollback. /// @@ -1399,14 +1217,12 @@ mod tests { use crate::views::dashboard::state::DashboardState; /// Build a `PeekFields` for tests with sensible defaults. - fn fields(response_type: &str, agent_lines: &[&str]) -> PeekFields { + fn fields(response_type: &str) -> PeekFields { PeekFields { label: "label".to_string(), time_ago: "2m".to_string(), response_type: response_type.to_string(), last_user_message: None, - last_agent_lines: agent_lines.iter().map(|s| s.to_string()).collect(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -1420,21 +1236,77 @@ mod tests { crate::views::prompt_widget::PromptWidget::new() } - /// While the agent is working the "Working" status label renders in the - /// secondary colour and the previous response is HIDDEN entirely (so the - /// peek doesn't dwell on a stale answer); a non-working panel shows the - /// bright `text_primary` response body. #[test] - fn render_peek_working_hides_response_and_secondary_status() { + fn live_tail_middle_bottom_skips_blank_when_only_one_content_row() { + // status@0, middle from 1, 3-line reply starts at 3 → span 2. + // blank would leave middle_h=1 (pin-only); expand so pin+body fit. + assert_eq!(live_tail_middle_bottom(1, 3), 3); + // Generous middle (span 4) keeps blank above reply. + assert_eq!(live_tail_middle_bottom(1, 5), 4); + // Zero middle span stays empty. + assert_eq!(live_tail_middle_bottom(3, 3), 2); + } + + /// Tight box: status + pin + body + 3-line reply, no blank budget. + /// Paint must not steal the body row for a breathing blank. + #[test] + fn render_peek_tight_pin_shows_current_turn_body() { + use crate::scrollback::block::RenderBlock; + use crate::scrollback::entry::ScrollbackEntry; + use crate::scrollback::state::ScrollbackState; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + // borders(2) + inner content_rows(6) = 8. + let area = Rect::new(0, 0, 80, 8); + let mut buf = Buffer::empty(area); + let theme = Theme::current(); + let panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Response")); + let mut reply = test_reply(); + reply.set_text("r1\nr2\nr3"); + let mut sb = ScrollbackState::new(); + sb.push(ScrollbackEntry::new(RenderBlock::user_prompt("user pin"))); + sb.push(ScrollbackEntry::new(RenderBlock::agent_message( + "current turn line", + ))); + let _ = render_peek_panel( + &mut buf, + area, + &panel, + &mut reply, + &theme, + false, + None, + false, + None, + Some(PeekLiveTailArgs { scrollback: &sb }), + None, + ); + let mut content = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + content.push_str(buf[(x, y)].symbol()); + } + content.push('\n'); + } + assert!(content.contains("user pin"), "pin must paint: {content:?}"); + assert!( + content.contains("current turn line"), + "body must not be eaten by blank: {content:?}" + ); + } + + /// While the agent is working the "Working" status label renders in the + /// secondary colour; other status labels stay dim chrome. + #[test] + fn render_peek_working_status_uses_secondary_colour() { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let theme = Theme::current(); let render = |response_type: &str| { let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6)); - let panel = PeekPanelState::new( - DashboardRowId::TopLevel(AgentId(0)), - fields(response_type, &["hello world"]), - ); + let panel = + PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields(response_type)); let mut reply = test_reply(); let _ = render_peek_panel( &mut buf, @@ -1446,11 +1318,12 @@ mod tests { None, false, None, + None, + None, ); buf }; - // Inner content sits two cells in (1 border + 1 pad inset): status - // at (2,1), response body (when shown) at (2,2). + // Inner content sits two cells in (1 border + 1 pad inset): status at (2,1). let working = render("Working"); assert_eq!(working[(2, 1)].symbol(), "W", "status label is `Working`"); assert_eq!( @@ -1458,20 +1331,13 @@ mod tests { theme.text_secondary, "the `Working` status must render in the secondary colour", ); - // The response row must be blank — the previous response is hidden. - let working_body: String = (0..80).map(|x| working[(x, 2)].symbol()).collect(); - assert!( - !working_body.contains("hello"), - "the previous response must be hidden while working, got: {working_body:?}", - ); - // A non-working panel shows the bright response body. let idle = render("Response"); - assert_eq!(idle[(2, 2)].symbol(), "h", "response body starts with `h`"); + assert_eq!(idle[(2, 1)].symbol(), "R", "status label is `Response`"); assert_eq!( - idle[(2, 2)].fg, - theme.text_primary, - "a non-working response keeps the bright primary colour", + idle[(2, 1)].fg, + theme.gray_dim, + "a non-working status stays dim chrome", ); } @@ -1497,6 +1363,8 @@ mod tests { None, false, None, + None, + None, ); (0..80) .map(|x| buf[(x, h - 1)].symbol().to_string()) @@ -1504,10 +1372,8 @@ mod tests { }; // Summary mode → model + always-approve on the bottom border. - let mut panel = PeekPanelState::new( - DashboardRowId::TopLevel(AgentId(0)), - fields("Response", &["hello"]), - ); + let mut panel = + PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Response")); panel.model_name = Some("Grok 4 Fast".to_string()); panel.auto_approve = true; let bottom = badge_row(&panel, 6); @@ -1521,7 +1387,7 @@ mod tests { ); // Pending-question (approval) mode → badge still painted. - let mut q = fields("Response", &[]); + let mut q = fields("Response"); q.question = Some("Allow write?".to_string()); q.options = vec![ ("allow".into(), "Allow".into()), @@ -1537,10 +1403,8 @@ mod tests { ); // No always-approve flag when the agent isn't in yolo mode. - let mut plain = PeekPanelState::new( - DashboardRowId::TopLevel(AgentId(0)), - fields("Response", &["hi"]), - ); + let mut plain = + PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Response")); plain.model_name = Some("Grok 4 Fast".to_string()); plain.auto_approve = false; let plain_bottom = badge_row(&plain, 6); @@ -1551,10 +1415,8 @@ mod tests { // Plan mode → a `plan` flag (so all three Shift+Tab cycle states // are visible on the badge). - let mut planp = PeekPanelState::new( - DashboardRowId::TopLevel(AgentId(0)), - fields("Response", &["hi"]), - ); + let mut planp = + PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Response")); planp.model_name = Some("Grok 4 Fast".to_string()); planp.plan_mode = true; let plan_bottom = badge_row(&planp, 6); @@ -1596,10 +1458,7 @@ mod tests { use ratatui::buffer::Buffer; use ratatui::layout::Rect; let theme = Theme::current(); - let panel = PeekPanelState::new( - DashboardRowId::TopLevel(AgentId(0)), - fields("Response", &["hi"]), - ); + let panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Response")); let mut reply = test_reply(); let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6)); let _ = render_peek_panel( @@ -1612,6 +1471,8 @@ mod tests { Some("hello there"), false, None, + None, + None, ); // Badge `" ● rec "` starts at x = area.x + 2, so the dot is at x = 3. assert_eq!( @@ -1632,15 +1493,11 @@ mod tests { #[test] fn peek_handles_missing_question() { - let mut f = fields("Idle", &["Working on it", "second line"]); + let mut f = fields("Idle"); f.last_user_message = Some("hello?".to_string()); let state = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), f); assert!(state.question.is_none()); assert_eq!(state.last_user_message.as_deref(), Some("hello?")); - assert_eq!( - state.last_agent_lines, - vec!["Working on it".to_string(), "second line".to_string()] - ); } /// `apply_fields` reports whether the peeked row CHANGED so the @@ -1649,17 +1506,16 @@ mod tests { /// wrong agent after the selection cursor moves. #[test] fn apply_fields_reports_row_change() { - let mut state = - PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle", &[])); + let mut state = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle")); // Same row → no change reported (caller preserves the draft). let changed = state.apply_fields( DashboardRowId::TopLevel(AgentId(0)), - fields("Running\u{2026}", &["new line"]), + fields("Running\u{2026}"), ); assert!(!changed, "same row must not report a change"); - assert_eq!(state.last_agent_lines, vec!["new line".to_string()]); + assert_eq!(state.response_type, "Running\u{2026}"); // Different row → change reported (caller clears the draft). - let changed = state.apply_fields(DashboardRowId::TopLevel(AgentId(1)), fields("Idle", &[])); + let changed = state.apply_fields(DashboardRowId::TopLevel(AgentId(1)), fields("Idle")); assert!(changed, "row change must be reported"); assert_eq!(state.row, DashboardRowId::TopLevel(AgentId(1))); } @@ -1677,10 +1533,11 @@ mod tests { use ratatui::layout::Rect; let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5)); let theme = Theme::current(); - let mut f = fields("Edit", &["working on the fix now"]); + let mut f = fields("Edit"); f.label = "Add responsiveness to /context".to_string(); f.last_user_message = Some("hello, can you help?".to_string()); - let panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), f); + let mut panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), f); + panel.focused = true; let mut reply = test_reply(); let _ = render_peek_panel( &mut buf, @@ -1692,6 +1549,8 @@ mod tests { None, false, None, + None, + None, ); let mut content = String::new(); for y in 0..buf.area.height { @@ -1734,45 +1593,6 @@ mod tests { ); } - /// The peek surfaces up to 3 lines of the last - /// response (was a single line). A taller box renders the first - /// three lines and drops the rest. - #[test] - fn render_peek_shows_up_to_three_response_lines() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - let mut buf = Buffer::empty(Rect::new(0, 0, 80, 7)); - let theme = Theme::current(); - let panel = PeekPanelState::new( - DashboardRowId::TopLevel(AgentId(0)), - fields("Idle", &["line one", "line two", "line three", "line four"]), - ); - let mut reply = test_reply(); - let _ = render_peek_panel( - &mut buf, - Rect::new(0, 0, 80, 7), - &panel, - &mut reply, - &theme, - false, - None, - false, - None, - ); - let mut content = String::new(); - for y in 0..buf.area.height { - for x in 0..buf.area.width { - content.push_str(buf[(x, y)].symbol()); - } - content.push('\n'); - } - assert!(content.contains("line one"), "got: {content:?}"); - assert!(content.contains("line two"), "got: {content:?}"); - assert!(content.contains("line three"), "got: {content:?}"); - // Capped at 3 — the 4th line is dropped. - assert!(!content.contains("line four"), "got: {content:?}"); - } - /// The reply input renders the typed draft (not the dim /// placeholder) and reports a caret position. #[test] @@ -1781,7 +1601,8 @@ mod tests { use ratatui::layout::Rect; let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5)); let theme = Theme::current(); - let panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle", &[])); + let mut panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle")); + panel.focused = true; let mut reply = test_reply(); reply.set_text("ship it"); let res = render_peek_panel( @@ -1794,6 +1615,8 @@ mod tests { None, false, None, + None, + None, ); let mut content = String::new(); for y in 0..buf.area.height { @@ -1817,8 +1640,7 @@ mod tests { use ratatui::layout::Rect; let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5)); let theme = Theme::current(); - let mut panel = - PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle", &[])); + let mut panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle")); let mut reply = test_reply(); reply.set_text("draft"); panel.focused = false; @@ -1832,6 +1654,8 @@ mod tests { None, false, None, + None, + None, ); assert!( res.caret.is_none(), @@ -1857,8 +1681,7 @@ mod tests { use ratatui::layout::Rect; let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5)); let theme = Theme::current(); - let mut panel = - PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle", &[])); + let mut panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle")); panel.focused = false; let mut reply = test_reply(); let res = render_peek_panel( @@ -1871,6 +1694,8 @@ mod tests { None, false, None, + None, + None, ); assert!( res.caret.is_none(), @@ -1899,10 +1724,8 @@ mod tests { let theme = Theme::current(); let render = |focused: bool| { let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6)); - let mut panel = PeekPanelState::new( - DashboardRowId::TopLevel(AgentId(0)), - fields("Idle", &["hello world"]), - ); + let mut panel = + PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle")); let mut reply = test_reply(); reply.set_text("draft"); panel.focused = focused; @@ -1916,6 +1739,8 @@ mod tests { None, false, None, + None, + None, ); buf }; @@ -1945,119 +1770,6 @@ mod tests { } } - /// The `…` continuation marker appears only when the response has - /// more lines than are shown — `last_response_truncated` drives it. - #[test] - fn render_peek_marks_truncated_response() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - let theme = Theme::current(); - let render = |truncated: bool| -> String { - let mut buf = Buffer::empty(Rect::new(0, 0, 80, 7)); - let mut f = fields("Idle", &["alpha", "bravo", "charlie"]); - f.last_response_truncated = truncated; - let panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), f); - // Non-empty reply so the dim `reply…` placeholder (which also - // contains `…`) doesn't pollute the marker assertion. - let mut reply = test_reply(); - reply.set_text("x"); - let _ = render_peek_panel( - &mut buf, - Rect::new(0, 0, 80, 7), - &panel, - &mut reply, - &theme, - false, - None, - false, - None, - ); - let mut content = String::new(); - for y in 0..buf.area.height { - for x in 0..buf.area.width { - content.push_str(buf[(x, y)].symbol()); - } - content.push('\n'); - } - content - }; - // 3 lines, NOT truncated → all three shown, no marker. - let exact = render(false); - assert!(exact.contains("charlie"), "got: {exact:?}"); - assert!( - !exact.contains('\u{2026}'), - "no marker expected, got: {exact:?}" - ); - // 3 lines shown but the response had more → marker appended. - let more = render(true); - assert!( - more.contains('\u{2026}'), - "expected `…` marker, got: {more:?}" - ); - } - - /// `wrap_to_width` word-wraps to display columns and hard-splits - /// overlong tokens. - #[test] - fn wrap_to_width_wraps_words_and_splits_long_tokens() { - let rows = wrap_to_width("the quick brown fox", 9); - assert!(rows.len() >= 2, "should wrap, got: {rows:?}"); - for r in &rows { - assert!( - unicode_width::UnicodeWidthStr::width(r.as_str()) <= 9, - "row exceeds width: {r:?}" - ); - } - // An overlong token is hard-split rather than dropped. - let split = wrap_to_width("abcdefghijklmnop", 5); - assert!( - split.len() >= 4, - "long token must hard-split, got: {split:?}" - ); - assert_eq!(split.concat(), "abcdefghijklmnop"); - } - - /// A long response line WRAPS across rows instead of being - /// truncated — words past the first row's width still appear. - #[test] - fn render_peek_wraps_long_response_line() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - let mut buf = Buffer::empty(Rect::new(0, 0, 32, 7)); - let theme = Theme::current(); - // One logical line, long enough to need multiple ~26-col rows. - let panel = PeekPanelState::new( - DashboardRowId::TopLevel(AgentId(0)), - fields("Idle", &["the quick brown fox jumps over the lazy dog"]), - ); - let mut reply = test_reply(); - let _ = render_peek_panel( - &mut buf, - Rect::new(0, 0, 32, 7), - &panel, - &mut reply, - &theme, - false, - None, - false, - None, - ); - let mut content = String::new(); - for y in 0..buf.area.height { - for x in 0..buf.area.width { - content.push_str(buf[(x, y)].symbol()); - } - content.push('\n'); - } - // An early AND a late word both appear — impossible on a single - // ~26-col row, so the line must have wrapped. - assert!(content.contains("quick"), "got: {content:?}"); - assert!( - content.contains("lazy"), - "wrap must surface later words, got: {content:?}" - ); - } - /// When a permission is pending, the peek paints /// the question + numbered options at the top and still /// shows the reply slot on the last inner row. The 1-9 @@ -2075,8 +1787,6 @@ mod tests { time_ago: String::new(), response_type: "Awaiting your input".to_string(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: Some("Allow Edit?".to_string()), options: vec![ ("allow_once".into(), "Allow once".into()), @@ -2098,6 +1808,8 @@ mod tests { None, false, None, + None, + None, ); let mut content = String::new(); for y in 0..buf.area.height { @@ -2129,8 +1841,6 @@ mod tests { time_ago: String::new(), response_type: "Awaiting your input".to_string(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: Some("Allow Edit?".to_string()), options: vec![ ("allow".into(), "Allow".into()), @@ -2141,6 +1851,7 @@ mod tests { }, ); panel.selected_option = Some(1); // highlight the 2nd option + panel.focused = true; let mut reply = test_reply(); let _ = render_peek_panel( &mut buf, @@ -2152,6 +1863,8 @@ mod tests { None, false, None, + None, + None, ); let mut content = String::new(); for y in 0..buf.area.height { @@ -2181,8 +1894,6 @@ mod tests { time_ago: String::new(), response_type: "Awaiting your input".to_string(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: Some("Allow Edit?".to_string()), options: vec![ ("allow".into(), "Allow".into()), @@ -2205,6 +1916,8 @@ mod tests { None, false, None, + None, + None, ); let mut content = String::new(); for y in 0..buf.area.height { @@ -2238,8 +1951,6 @@ mod tests { time_ago: String::new(), response_type: "Awaiting your input".to_string(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: Some("Allow Edit?".to_string()), options: vec![ ("allow".into(), "Allow".into()), @@ -2250,6 +1961,7 @@ mod tests { }, ); panel.selected_option = Some(1); // highlight the reject option + panel.focused = true; let mut reply = test_reply(); reply.set_text("do it differently"); let res = render_peek_panel( @@ -2262,6 +1974,8 @@ mod tests { None, false, None, + None, + None, ); let mut content = String::new(); for y in 0..buf.area.height { @@ -2301,8 +2015,6 @@ mod tests { time_ago: String::new(), response_type: "Awaiting your input".to_string(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: Some("Which approach?".to_string()), options: vec![ ("Redis".into(), "Redis".into()), @@ -2325,6 +2037,8 @@ mod tests { None, false, None, + None, + None, ); let mut content = String::new(); for y in 0..buf.area.height { @@ -2357,8 +2071,6 @@ mod tests { time_ago: String::new(), response_type: "Awaiting your input".to_string(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: Some("Allow Edit?".to_string()), options: vec![ ("allow".into(), "Allow".into()), @@ -2380,6 +2092,8 @@ mod tests { None, false, None, + None, + None, ); let mut content = String::new(); for y in 0..buf.area.height { @@ -2404,7 +2118,7 @@ mod tests { use ratatui::layout::Rect; let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5)); let theme = Theme::current(); - let panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle", &[])); + let panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle")); let mut reply = test_reply(); reply.set_compact(true); let pasted = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven"; @@ -2420,6 +2134,8 @@ mod tests { None, false, None, + None, + None, ); let mut content = String::new(); for y in 0..buf.area.height { @@ -2443,8 +2159,7 @@ mod tests { use ratatui::layout::Rect; crate::appearance::cache::set_vim_mode(false); let theme = Theme::current(); - let mut panel = - PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle", &[])); + let mut panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle")); panel.focused = true; let mut reply = test_reply(); reply.set_compact(true); @@ -2465,6 +2180,8 @@ mod tests { None, false, Some(overlay), + None, + None, ); let mut content = String::new(); for y in 0..buf.area.height { @@ -2520,11 +2237,11 @@ mod tests { let area = Rect::new(0, 0, 80, 14); let mut buf = Buffer::empty(area); let theme = Theme::current(); - let panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle", &[])); + let panel = PeekPanelState::new(DashboardRowId::TopLevel(AgentId(0)), fields("Idle")); let mut reply = test_reply(); reply.set_text("alpha\nbravo\ncharlie"); let res = render_peek_panel( - &mut buf, area, &panel, &mut reply, &theme, false, None, false, None, + &mut buf, area, &panel, &mut reply, &theme, false, None, false, None, None, None, ); let rect = res.reply_rect.expect("reply rect must be reported"); assert!( @@ -2560,8 +2277,6 @@ mod tests { time_ago: String::new(), response_type: "Idle".to_string(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question, options, request_id, diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/peek_tail.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/peek_tail.rs new file mode 100644 index 0000000..a9236ff --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/peek_tail.rs @@ -0,0 +1,464 @@ +//! Dense live-tail paint for the dashboard peek middle. +//! +//! Reads the agent's leased [`ScrollbackState`] without mutating fold state, +//! layout cache, follow mode, or view mode. Dashboard lease +//! (`begin_peek_viewport`) already forced follow + AllTurns for attach restore. +//! +//! Density vs full [`ScrollbackPane`]: no sticky headers, no vpad, no gap rows, +//! no horizontal accent/pad chrome. Foldable entries project Collapsed; messages +//! keep full expanded body. +//! +//! Layout (top → bottom): +//! 1. Last user prompt pinned (1 line), when present and height allows +//! 2. Top `…` when the current-turn body is truncated from above +//! 3. Pure tail of content **after** the last user (current turn only) +//! +//! Body is **always** current-turn when a last user exists (including the +//! list-first min-box ~3-row middle). Pin is dropped only when the middle +//! has no rows left for it. After a fresh user send with no agent lines, +//! the middle is pin + empty — prior turns are not pulled up. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; + +use crate::appearance::cache::load_show_thinking_blocks; +use crate::render::SafeBuf; +use crate::scrollback::block::BlockContent; +use crate::scrollback::entry::ScrollbackEntry; +use crate::scrollback::state::ScrollbackState; +use crate::scrollback::types::{BlockLine, DisplayMode}; +use crate::theme::Theme; + +/// Densified body line count for shrink-to-content (v1: current-turn body). +/// +/// Content **after** the last user prompt only. Pin / ellipsis are layout +/// chrome and are budgeted separately in desired peek content. +/// `width` is the middle content width (same as the paint area width). +pub fn densified_body_line_count(scrollback: &ScrollbackState, width: u16) -> u16 { + if width == 0 || scrollback.is_empty() { + return 0; + } + let after = find_last_user_idx(scrollback).map(|i| i + 1).unwrap_or(0); + densified_lines_from(scrollback, width, after).len() as u16 +} + +/// Whether scrollback has a user prompt that dense paint will pin when height +/// allows (drives the pin row in shrink desired content). +pub fn scrollback_has_last_user(scrollback: &ScrollbackState) -> bool { + find_last_user_idx(scrollback).is_some() +} + +/// Paint a dense live tail into `area`. +/// +/// Does not call `prepare_layout` / `enable_follow` / `set_view_mode` — those +/// either belong to the viewport lease or would dirty attach-path state. +pub fn paint_peek_live_tail(scrollback: &ScrollbackState, area: Rect, buf: &mut Buffer) { + if area.width < 1 || area.height == 0 || scrollback.is_empty() { + return; + } + + let theme = Theme::current(); + let appearance = scrollback.appearance(); + let cwd = scrollback.cwd(); + let content_w = area.width; + let height = area.height as usize; + + let last_user = find_last_user_idx(scrollback); + // Always current-turn when a last user exists; full stream otherwise. + let body_start = last_user.map(|i| i + 1).unwrap_or(0); + let flat = densified_lines_from(scrollback, content_w, body_start); + + // Pin when we have a last user and at least one middle row. + let pin = last_user.and_then(|idx| { + let entry = scrollback.entry(idx)?; + let lines = dense_entry_lines(entry, content_w, appearance, cwd); + lines.into_iter().next() + }); + let pin_rows = usize::from(pin.is_some()); + let body_budget = height.saturating_sub(pin_rows); + let (ellipsis, body) = pure_tail_with_ellipsis(flat, body_budget); + + let bg = Style::default().bg(theme.bg_base); + for row in 0..area.height { + let y = area.y + row; + for x in area.x..area.x.saturating_add(area.width) { + if let Some(cell) = buf.cell_mut((x, y)) { + cell.set_style(bg); + cell.set_symbol(" "); + } + } + } + + let mut y = area.y; + if let Some(line) = pin { + buf.set_line_safe(area.x, y, &line, content_w); + y = y.saturating_add(1); + } + if ellipsis { + let style = Style::default().fg(theme.gray_dim).bg(theme.bg_base); + let ell = Line::from(Span::styled("…", style)); + buf.set_line_safe(area.x, y, &ell, content_w); + y = y.saturating_add(1); + } + for line in &body { + if y >= area.y.saturating_add(area.height) { + break; + } + buf.set_line_safe(area.x, y, line, content_w); + y = y.saturating_add(1); + } +} + +/// Take a pure tail of `flat` into `budget` rows, reserving one row for a top +/// `…` when content is omitted above. +fn pure_tail_with_ellipsis(flat: Vec>, budget: usize) -> (bool, Vec>) { + if budget == 0 { + return (false, Vec::new()); + } + if flat.len() <= budget { + return (false, flat); + } + if budget == 1 { + // No room for both marker and content — keep the live tail line. + return (false, flat[flat.len() - 1..].to_vec()); + } + let take = budget - 1; + (true, flat[flat.len() - take..].to_vec()) +} + +fn find_last_user_idx(scrollback: &ScrollbackState) -> Option { + (0..scrollback.len()).rev().find(|&idx| { + scrollback + .entry(idx) + .is_some_and(|e| e.block.is_user_prompt()) + }) +} + +/// Densified lines from entry index `start` (inclusive) through the end. +fn densified_lines_from( + scrollback: &ScrollbackState, + width: u16, + start: usize, +) -> Vec> { + let appearance = scrollback.appearance(); + let cwd = scrollback.cwd(); + let show_thinking = load_show_thinking_blocks(); + let mut flat = Vec::new(); + for idx in start..scrollback.len() { + let Some(entry) = scrollback.entry(idx) else { + continue; + }; + if entry.is_hidden_thinking(show_thinking) { + continue; + } + flat.extend(dense_entry_lines(entry, width, appearance, cwd)); + } + flat +} + +fn dense_mode(entry: &ScrollbackEntry) -> DisplayMode { + if entry.is_foldable() { + entry.block.collapse_mode(entry.is_running) + } else if entry.block.is_user_prompt() { + DisplayMode::Collapsed + } else { + DisplayMode::Expanded + } +} + +fn dense_entry_lines( + entry: &ScrollbackEntry, + width: u16, + appearance: &crate::appearance::AppearanceConfig, + cwd: Option<&std::path::Path>, +) -> Vec> { + let mode = dense_mode(entry); + let ctx = entry.context_with_mode(width, mode, appearance, cwd); + let output = entry.output_with_hooks(&ctx); + let keep_blanks = !entry.is_foldable(); + output + .lines + .into_iter() + .map(|bl: BlockLine| bl.content) + .filter(|line| keep_blanks || !line_is_blank(line)) + .collect() +} + +fn line_is_blank(line: &Line<'_>) -> bool { + line.spans + .iter() + .all(|s| s.content.chars().all(|c| c.is_whitespace())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scrollback::block::RenderBlock; + use crate::scrollback::entry::ScrollbackEntry; + use crate::scrollback::state::ScrollbackState; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + fn filled_buf(area: Rect) -> Buffer { + Buffer::empty(area) + } + + fn plain_cells(buf: &Buffer, area: Rect) -> Vec { + (area.y..area.y + area.height) + .map(|y| { + let mut s = String::new(); + for x in area.x..area.x + area.width { + if let Some(cell) = buf.cell((x, y)) { + s.push_str(cell.symbol()); + } + } + s.trim_end().to_string() + }) + .collect() + } + + #[test] + fn pure_tail_with_ellipsis_fits_without_marker() { + let lines: Vec> = (0..3).map(|i| Line::raw(format!("L{i}"))).collect(); + let (ell, body) = pure_tail_with_ellipsis(lines, 5); + assert!(!ell); + assert_eq!(body.len(), 3); + } + + #[test] + fn pure_tail_with_ellipsis_takes_tail_and_marks() { + let lines: Vec> = (0..10).map(|i| Line::raw(format!("L{i}"))).collect(); + let (ell, body) = pure_tail_with_ellipsis(lines, 4); + assert!(ell); + assert_eq!(body.len(), 3); + assert_eq!(body[0].spans[0].content.as_ref(), "L7"); + assert_eq!(body[2].spans[0].content.as_ref(), "L9"); + } + + #[test] + fn dense_tail_pins_last_user_at_top() { + let mut sb = ScrollbackState::new(); + sb.push(ScrollbackEntry::new(RenderBlock::user_prompt( + "first prompt", + ))); + sb.push(ScrollbackEntry::new(RenderBlock::agent_message( + "old answer", + ))); + sb.push(ScrollbackEntry::new(RenderBlock::user_prompt( + "latest prompt", + ))); + sb.push(ScrollbackEntry::new(RenderBlock::tool_call( + "bash", "tool-a", true, + ))); + sb.push(ScrollbackEntry::new(RenderBlock::agent_message( + "new answer", + ))); + + let area = Rect::new(0, 0, 48, 8); + let mut buf = filled_buf(area); + paint_peek_live_tail(&sb, area, &mut buf); + let rows = plain_cells(&buf, area); + assert!( + rows[0].contains("latest prompt"), + "pinned user on first row: {rows:?}" + ); + assert!( + !rows[0].contains("first prompt"), + "older user must not pin: {rows:?}" + ); + let joined = rows.join("\n"); + assert!( + joined.contains("new answer") || joined.contains("tool-a"), + "current turn body under pin: {joined:?}" + ); + assert!( + !joined.contains("old answer"), + "prior-turn body must not fill under pin: {joined:?}" + ); + assert!( + !joined.contains("first prompt"), + "prior user must not appear under pin: {joined:?}" + ); + } + + #[test] + fn dense_tail_after_fresh_user_send_body_is_empty() { + let mut sb = ScrollbackState::new(); + sb.push(ScrollbackEntry::new(RenderBlock::user_prompt("old ask"))); + sb.push(ScrollbackEntry::new(RenderBlock::agent_message( + "long prior answer with a table and status", + ))); + sb.push(ScrollbackEntry::new(RenderBlock::user_prompt( + "say hi to me and nothing else", + ))); + + for h in [3u16, 8] { + let area = Rect::new(0, 0, 48, h); + let mut buf = filled_buf(area); + paint_peek_live_tail(&sb, area, &mut buf); + let rows = plain_cells(&buf, area); + assert!( + rows[0].contains("say hi to me"), + "h={h}: new user pinned: {rows:?}" + ); + let body = rows[1..].join("\n"); + assert!( + !body.contains("prior answer") + && !body.contains("old ask") + && !body.contains("table"), + "h={h}: fresh send must not re-show prior turn: {body:?}" + ); + assert!( + body.chars().all(|c| c.is_whitespace()) || body.is_empty(), + "h={h}: body under pin empty until agent streams: {body:?}" + ); + } + } + + #[test] + fn dense_tail_shows_top_ellipsis_when_body_truncated() { + let mut sb = ScrollbackState::new(); + sb.push(ScrollbackEntry::new(RenderBlock::user_prompt("ask"))); + for i in 0..20 { + sb.push(ScrollbackEntry::new(RenderBlock::tool_call( + "bash", + format!("cmd-{i}"), + true, + ))); + } + + let area = Rect::new(0, 0, 40, 6); + let mut buf = filled_buf(area); + paint_peek_live_tail(&sb, area, &mut buf); + let rows = plain_cells(&buf, area); + assert!(rows[0].contains("ask"), "user pin first: {rows:?}"); + assert!( + rows[1].contains('…') || rows[1].contains("..."), + "top ellipsis under pin when truncated: {rows:?}" + ); + let joined = rows.join("\n"); + assert!( + joined.contains("cmd-19"), + "pure tail keeps latest: {joined:?}" + ); + assert!( + !joined.contains("cmd-0"), + "oldest dropped under ellipsis: {joined:?}" + ); + } + + #[test] + fn dense_tail_min_box_middle_is_current_turn_with_pin() { + // List-first min box leaves ~3 middle rows after status/reply/blank. + let mut sb = ScrollbackState::new(); + sb.push(ScrollbackEntry::new(RenderBlock::user_prompt("old"))); + sb.push(ScrollbackEntry::new(RenderBlock::agent_message( + "prior turn bulk answer", + ))); + sb.push(ScrollbackEntry::new(RenderBlock::user_prompt("ask"))); + for i in 0..12 { + sb.push(ScrollbackEntry::new(RenderBlock::tool_call( + "bash", + format!("cmd-{i}"), + true, + ))); + } + + let area = Rect::new(0, 0, 40, 3); + let mut buf = filled_buf(area); + paint_peek_live_tail(&sb, area, &mut buf); + let rows = plain_cells(&buf, area); + let joined = rows.join("\n"); + assert!(rows[0].contains("ask"), "pin on min-box middle: {rows:?}"); + assert!( + !joined.contains("prior turn") && !joined.contains("old"), + "prior turn must not fill min-box middle: {joined:?}" + ); + assert!( + joined.contains("cmd-11"), + "current-turn pure tail on min-box middle: {joined:?}" + ); + } + + #[test] + fn dense_tail_pure_tail_keeps_message_end() { + let mut sb = ScrollbackState::new(); + let body = (0..20) + .map(|i| format!("LINE{i:02}-{}", "x".repeat(36))) + .collect::>() + .join("\n\n"); + sb.push(ScrollbackEntry::new(RenderBlock::agent_message(body))); + + let area = Rect::new(0, 0, 40, 4); + let mut buf = filled_buf(area); + paint_peek_live_tail(&sb, area, &mut buf); + let joined = plain_cells(&buf, area).join("\n"); + assert!( + joined.contains("LINE19") || joined.contains("LINE18"), + "pure tail keeps end: {joined:?}" + ); + assert!( + joined.contains('…') || !joined.contains("LINE00"), + "head omitted with ellipsis or absence: {joined:?}" + ); + } + + #[test] + fn dense_tail_does_not_mutate_scrollback_viewport() { + let mut sb = ScrollbackState::new(); + sb.push(ScrollbackEntry::new(RenderBlock::agent_message("hello"))); + let before = sb.capture_viewport_snapshot(); + let area = Rect::new(0, 0, 40, 4); + let mut buf = filled_buf(area); + paint_peek_live_tail(&sb, area, &mut buf); + let after = sb.capture_viewport_snapshot(); + assert_eq!(before, after); + } + + #[test] + fn dense_tail_empty_scrollback_is_noop() { + let sb = ScrollbackState::new(); + let area = Rect::new(0, 0, 40, 4); + let mut buf = filled_buf(area); + paint_peek_live_tail(&sb, area, &mut buf); + assert!(plain_cells(&buf, area).iter().all(|r| r.is_empty())); + } + + #[test] + fn densified_body_line_count_is_current_turn_only() { + let mut current = ScrollbackState::new(); + current.push(ScrollbackEntry::new(RenderBlock::user_prompt("ask"))); + current.push(ScrollbackEntry::new(RenderBlock::tool_call( + "bash", "one", true, + ))); + current.push(ScrollbackEntry::new(RenderBlock::tool_call( + "bash", "two", true, + ))); + let current_n = densified_body_line_count(¤t, 40); + assert!(current_n >= 2, "current-turn tools contribute: {current_n}"); + + let mut with_prior = ScrollbackState::new(); + with_prior.push(ScrollbackEntry::new(RenderBlock::user_prompt("old"))); + with_prior.push(ScrollbackEntry::new(RenderBlock::agent_message( + "prior turn bulk that is many lines of text", + ))); + with_prior.push(ScrollbackEntry::new(RenderBlock::user_prompt("ask"))); + with_prior.push(ScrollbackEntry::new(RenderBlock::tool_call( + "bash", "one", true, + ))); + with_prior.push(ScrollbackEntry::new(RenderBlock::tool_call( + "bash", "two", true, + ))); + assert_eq!( + densified_body_line_count(&with_prior, 40), + current_n, + "prior turn must not inflate densified body count" + ); + + let mut user_only = ScrollbackState::new(); + user_only.push(ScrollbackEntry::new(RenderBlock::user_prompt("only"))); + assert_eq!(densified_body_line_count(&user_only, 40), 0); + } +} diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs index 399855d..34c0553 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/render.rs @@ -9,7 +9,8 @@ use ratatui::text::Span; use super::layout::{MIN_DASHBOARD_WIDTH, compute_layout}; use super::row::{DashboardRow, RowBadge, build_rows_with_roster}; use super::state::{ - DashboardState, Filter, Focusable, Grouping, LocationPickerState, RowState, SectionKey, + DashboardRowId, DashboardState, Filter, Focusable, Grouping, LocationPickerState, RowState, + SectionKey, }; use crate::app::agent::AgentId; use crate::app::agent_view::AgentView; @@ -32,6 +33,32 @@ const NEEDS_INPUT_BLINK_DIVISOR: u64 = 10; // need a strong visual presence (needs-input, completed, failed, blocked); // hollow marks idle rows. +fn ensure_peek_viewport_lifecycle( + state: &mut DashboardState, + agents: &mut IndexMap, +) { + if state.attached_agent.is_some() { + return; + } + // Peek row → begin/keep lease; else restore agent viewport. + let Some(row) = state.peek.as_ref().map(|p| p.row.clone()) else { + state.restore_peek_viewport(agents); + return; + }; + if state + .peek_viewport + .as_ref() + .is_some_and(|lease| lease.row == row) + { + return; + } + if super::state::scrollback_available_for_row(&row, agents) { + state.begin_peek_viewport(row, agents); + } else { + state.restore_peek_viewport(agents); + } +} + // The thin left vertical bar marking the active/selected row // (`crate::glyphs::selection_bar()`, with a `│` CP437 fallback on legacy // consoles) is painted on *every* content line of a selected row so it @@ -71,7 +98,7 @@ pub fn render_dashboard( buf: &mut Buffer, area: Rect, state: &mut DashboardState, - agents: &IndexMap, + agents: &mut IndexMap, registry: &crate::actions::ActionRegistry, // App-level double-press confirmation hint (e.g. "press again to // quit" for Ctrl+Q / Ctrl+C / Ctrl+D). Threaded to the footer so the @@ -88,8 +115,6 @@ pub fn render_dashboard( // Promo upgrade CTA to paint in the header after the location label // (`None` = no CTA); field meanings live on [`HeaderUpgradeCta`]. upgrade_cta: Option>, - // `_compact` removed in this version. Hide-chrome / shortened - // activity strings are a Phase 5 polish item. ) -> Option<(u16, u16)> { // Cache whether a pinned (non-dismissible) promo CTA is live so the key // handler can steal Ctrl+O for it; the dispatch re-resolves the gate. @@ -168,99 +193,90 @@ pub fn render_dashboard( return None; } - // The peek panel is shown by DEFAULT whenever an agent row is - // selected: it replaces the new-session dispatch box, follows the - // selection cursor, and surfaces live status + the last response (or - // a pending permission / ask question). With no selection (the - // `[+ New Agent]` button focused, or after Esc) the peek closes and - // the new-session input shows instead. - // - // `apply_fields` preserves the in-progress reply draft (held by the - // dashboard-owned `peek_reply` widget), reporting a row change so - // the draft is cleared only when the peeked row changes. The panel - // only opens when the terminal is tall enough to render it; - // otherwise the dispatch box shows even with a row selected. Done - // BEFORE the layout so the box can size to live content. + // Peek: list-first allocation (see layout::allocate_peek / + // docs/internal/33-dashboard-peek-responsive-layout.md). Provisional + // layout gives dispatch width for reply wrapping before we decide + // whether peek fits. + let mut layout = compute_layout(area, false); + let fixed = super::layout::chrome_overhead(area); + let reply_text_w = layout.dispatch.width.saturating_sub(6); + match state.selected.clone() { - Some(sel) if area.height >= super::layout::MIN_PEEK_HEIGHT => { - match super::peek::compute_peek_fields(&sel, agents) { - Some(fields) => { - // Record the peeked agent's cwd so the reply's `@` - // picker can lazily retarget to it on first compose - // (the retarget itself is deferred — see - // `DashboardState::ensure_peek_reply_cwd`). + Some(sel) => match super::peek::compute_peek_fields(&sel, agents) { + Some(fields) => { + let question = fields.question.is_some(); + let peek_min = if question { + super::layout::PEEK_MIN_BOX_QUESTION + } else { + super::layout::PEEK_MIN_BOX_LIVE_TAIL + }; + let content_rows = if question { + 1 + fields.options.len().min(9) as u16 + } else { + let reply_rows = super::peek::reply_row_count( + &state.peek_reply, + reply_text_w, + super::peek::MAX_REPLY_ROWS, + ); + let max_content = super::layout::max_peek_content_rows(area); + // Middle content width ≈ dispatch box minus borders + insets. + let middle_w = layout.dispatch.width.saturating_sub(4); + let (body_measured, pin_user) = + super::state::scrollback_mut_for_row(&sel, agents) + .map(|sb| { + ( + super::peek_tail::densified_body_line_count(sb, middle_w), + super::peek_tail::scrollback_has_last_user(sb), + ) + }) + .unwrap_or((0, false)); + super::layout::peek_live_tail_desired_content( + max_content, + reply_rows, + body_measured, + pin_user, + ) + .content_rows + }; + let alloc = + super::layout::allocate_peek(area.height, fixed, content_rows, peek_min); + if alloc.show_peek { state.set_peek_reply_target_cwd(peeked_agent_cwd(&sel, agents)); - // Live model + mode for the bottom-border config badge. - // Read before `sel` is moved into the panel below. let badge = super::peek::peek_model_and_mode(&sel, agents); match state.peek.as_mut() { Some(p) => { if p.apply_fields(sel, fields) { - // Row changed under an open panel — a - // half-typed reply must not be sent to the - // newly-peeked agent (clears undo history too, - // so Ctrl+Z can't resurrect it onto the new row). state.clear_peek_reply(); } } None => state.set_peek(Some(super::peek::PeekPanelState::new(sel, fields))), } - // `apply_fields` / `new` carry only the display snapshot; - // the config badge is set live here so a `/model` switch - // or yolo toggle reflects immediately. if let Some(p) = state.peek.as_mut() { p.model_name = badge.model; p.auto_approve = badge.yolo; p.auto = badge.auto; p.plan_mode = badge.plan; } - } - // Selected agent vanished — nothing to peek. - None => { + layout = super::layout::compute_layout_with_peek_box(area, alloc.peek_box_h); + } else { state.set_peek_reply_target_cwd(None); state.set_peek(None); } } - } - // No selection (or too short to render) → new-session input. - _ => { + None => { + state.set_peek_reply_target_cwd(None); + state.set_peek(None); + } + }, + None => { state.set_peek_reply_target_cwd(None); state.set_peek(None); } } - // Compute the layout. Both the dispatch box and the peek reply grow - // vertically for multi-line input (Shift+Enter / Alt+Enter - // newlines); the peek box also sizes to its wrapped response. Both - // keep the row list usable. - let mut layout = compute_layout(area, state.peek.is_some()); - if let Some(panel) = state.peek.as_ref() { - // Size the peek box to its content. When a permission / ask - // question is pending the box holds the question (1) + its - // options and the `❯ reply` row is hidden; otherwise it holds - // status (1) + wrapped response (≤ MAX_RESPONSE_ROWS) + a blank - // breathing row (1) + the reply (which GROWS with multi-line - // drafts, ≤ MAX_REPLY_ROWS). The box width is independent of its - // height, so the wrap widths are read from the first layout pass: - // the response uses `dispatch.width − 4` (2 border + 2 inset); - // the reply text loses a further 2 for the `❯ ` prefix (− 6). - let content_rows = if panel.question.is_some() { - 1 + panel.options.len().min(9) as u16 - } else { - let inner_w = layout.dispatch.width.saturating_sub(4) as usize; - let resp = - super::peek::response_row_count(panel, inner_w, super::peek::MAX_RESPONSE_ROWS); - let reply_text_w = layout.dispatch.width.saturating_sub(6); - let reply_rows = super::peek::reply_row_count( - &state.peek_reply, - reply_text_w, - super::peek::MAX_REPLY_ROWS, - ); - // status(1) + response(resp) + blank(1) + reply(reply_rows) - resp as u16 + 2 + reply_rows - }; - layout = super::layout::compute_layout_with_dispatch(area, true, content_rows); - } else if area.height > 8 && !state.dispatch.text().is_empty() { + ensure_peek_viewport_lifecycle(state, agents); + + if state.peek.is_none() && area.height > 8 && !state.dispatch.text().is_empty() { let rows = dispatch_text_rows(state, layout.dispatch.width, area.height); if rows > 1 { layout = super::layout::compute_layout_with_dispatch(area, false, rows); @@ -314,25 +330,56 @@ pub fn render_dashboard( let voice_listening = state.voice_listening; let voice_interim = state.voice_interim.clone(); let multiline = state.multiline_mode; - let DashboardState { - peek, peek_reply, .. - } = state; - let render = peek - .as_ref() - .map(|panel| { - super::peek::render_peek_panel( - buf, - layout.dispatch, - panel, - peek_reply, - &theme, - voice_listening, - voice_interim.as_deref(), - multiline, - Some(layout.list).filter(|r| r.area() > 0), - ) - }) - .unwrap_or_default(); + let peeked_row = state.peek.as_ref().map(|p| p.row.clone()); + let question_pending = state.peek.as_ref().is_some_and(|p| p.question.is_some()); + let (empty_hint, has_scrollback) = match peeked_row.as_ref() { + Some(DashboardRowId::Subagent { + parent, + child_session_id, + }) => { + let parent_ok = agents + .get(parent) + .is_some_and(|p| p.subagent_sessions.contains_key(child_session_id)); + let loaded = agents + .get(parent) + .is_some_and(|p| p.subagent_views.contains_key(child_session_id)); + if parent_ok && !loaded { + (Some("Subagent not loaded"), false) + } else { + (None, loaded) + } + } + Some(row) => ( + None, + super::state::scrollback_available_for_row(row, agents), + ), + None => (None, false), + }; + let render = if let Some(panel) = state.peek.as_ref() { + let live_tail = if !question_pending && has_scrollback { + peeked_row + .as_ref() + .and_then(|row| super::state::scrollback_mut_for_row(row, agents)) + .map(|scrollback| super::peek::PeekLiveTailArgs { scrollback }) + } else { + None + }; + super::peek::render_peek_panel( + buf, + layout.dispatch, + panel, + &mut state.peek_reply, + &theme, + voice_listening, + voice_interim.as_deref(), + multiline, + Some(layout.list).filter(|r| r.area() > 0), + live_tail, + empty_hint, + ) + } else { + Default::default() + }; state.peek_reply_rect = render.reply_rect; let cursor = render.caret; // The reply is a full PromptWidget, so its `@` file-context @@ -6880,14 +6927,14 @@ mod tests { let seed = ratatui::style::Color::Rgb(0xFF, 0x00, 0xFF); buf.set_style(area, Style::default().bg(seed)); - let agents: IndexMap = IndexMap::new(); + let mut agents: IndexMap = IndexMap::new(); let mut state = DashboardState::new(); let registry = crate::actions::ActionRegistry::defaults(); let _ = render_dashboard( &mut buf, area, &mut state, - &agents, + &mut agents, ®istry, None, &[], @@ -7645,8 +7692,6 @@ mod tests { time_ago: String::new(), response_type: "Idle".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -7688,8 +7733,6 @@ mod tests { time_ago: String::new(), response_type: "Idle".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -7740,8 +7783,6 @@ mod tests { time_ago: String::new(), response_type: "Idle".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -7791,8 +7832,6 @@ mod tests { time_ago: String::new(), response_type: "NeedsInput".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: Some("Allow?".into()), options: vec![ ("allow".into(), "Allow".into()), @@ -7875,8 +7914,6 @@ mod tests { time_ago: String::new(), response_type: "NeedsInput".into(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: Some("Allow?".into()), options: vec![ ("allow".into(), "Allow".into()), diff --git a/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs b/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs index 18d9137..54ab678 100644 --- a/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs +++ b/crates/codegen/xai-grok-pager/src/views/dashboard/state.rs @@ -47,6 +47,49 @@ impl DashboardRowId { pub fn is_subagent(&self) -> bool { matches!(self, Self::Subagent { .. }) } + + pub(crate) fn matches_top_level_agent(&self, agent_id: AgentId) -> bool { + matches!(self, Self::TopLevel(id) if *id == agent_id) + } +} + +pub(crate) struct PeekViewportLease { + pub row: DashboardRowId, + pub snapshot: crate::scrollback::state::ViewportSnapshot, + pub page_flip_entry: Option, +} + +pub(crate) fn scrollback_mut_for_row<'a>( + row: &DashboardRowId, + agents: &'a mut indexmap::IndexMap, +) -> Option<&'a mut crate::scrollback::state::ScrollbackState> { + match row { + DashboardRowId::TopLevel(id) => agents.get_mut(id).map(|a| &mut a.scrollback), + DashboardRowId::Subagent { + parent, + child_session_id, + } => agents + .get_mut(parent) + .and_then(|p| p.subagent_views.get_mut(child_session_id)) + .map(|c| &mut c.scrollback), + DashboardRowId::Roster { .. } => None, + } +} + +pub(crate) fn scrollback_available_for_row( + row: &DashboardRowId, + agents: &indexmap::IndexMap, +) -> bool { + match row { + DashboardRowId::TopLevel(id) => agents.contains_key(id), + DashboardRowId::Subagent { + parent, + child_session_id, + } => agents + .get(parent) + .is_some_and(|p| p.subagent_views.contains_key(child_session_id)), + DashboardRowId::Roster { .. } => false, + } } /// A dispatch-input send (spawn a new session) stashed while a clipboard @@ -408,6 +451,9 @@ pub struct DashboardState { pub(crate) deferred_peek_send: Option, /// Peek panel state (Space toggles). pub peek: Option, + /// Session-scoped guest viewport for the live-tail peek (capture once + /// on select; sticky while the same row is peeked; restore on leave). + pub(crate) peek_viewport: Option, /// The peek panel's `❯ reply` input — a full [`PromptWidget`] so /// the reply gets paste chips (`[Pasted: N lines]`), word /// navigation, undo, and text selection exactly like [`Self::dispatch`]. @@ -1255,6 +1301,7 @@ impl DashboardState { deferred_dispatch_send: None, deferred_peek_send: None, peek: None, + peek_viewport: None, peek_reply, peek_reply_rect: None, peek_reply_cwd: None, @@ -1701,6 +1748,10 @@ impl DashboardState { /// open, or retarget). Per-frame refreshes of an open panel go /// through `PeekPanelState::apply_fields` (not here), so an /// in-progress draft survives live updates. + /// + /// Does **not** restore the live-tail viewport lease — permission + /// refresh may call `set_peek(None)` while the same row stays + /// selected and reopens next paint. pub fn set_peek(&mut self, peek: Option) { if peek.is_none() { self.peek_close_rect = None; @@ -1713,6 +1764,96 @@ impl DashboardState { self.peek = peek; } + pub fn restore_peek_viewport( + &mut self, + agents: &mut indexmap::IndexMap, + ) { + let Some(lease) = self.peek_viewport.take() else { + return; + }; + let Some(sb) = scrollback_mut_for_row(&lease.row, agents) else { + return; + }; + let page_flip = lease.page_flip_entry; + let w = lease.snapshot.last_width; + let h = lease.snapshot.viewport_height; + sb.restore_viewport_snapshot(lease.snapshot); + if let Some(idx) = page_flip { + if w > 0 && h > 0 { + sb.prepare_layout(w, h); + } + sb.set_selected(Some(idx)); + sb.scroll_to_entry_top(idx); + sb.enable_follow_with_preserve(); + } + } + + pub fn begin_peek_viewport( + &mut self, + row: DashboardRowId, + agents: &mut indexmap::IndexMap, + ) { + if self + .peek_viewport + .as_ref() + .is_some_and(|lease| lease.row == row) + { + return; + } + self.restore_peek_viewport(agents); + let Some(sb) = scrollback_mut_for_row(&row, agents) else { + return; + }; + let snapshot = sb.capture_viewport_snapshot(); + sb.set_view_mode(crate::scrollback::state::ViewMode::AllTurns); + sb.enable_follow_mode(); + self.peek_viewport = Some(PeekViewportLease { + row, + snapshot, + page_flip_entry: None, + }); + } + + pub fn note_page_flip_for_lease( + &mut self, + agent_id: AgentId, + agents: &mut indexmap::IndexMap, + ) { + let Some(row) = self + .peek_viewport + .as_ref() + .filter(|lease| lease.row.matches_top_level_agent(agent_id)) + .map(|lease| lease.row.clone()) + else { + return; + }; + let Some(sb) = scrollback_mut_for_row(&row, agents) else { + return; + }; + let selected = sb.selected(); + let current_turn = sb.current_turn(); + self.note_page_flip_from_scroll(agent_id, selected, current_turn); + } + + pub(crate) fn note_page_flip_from_scroll( + &mut self, + agent_id: AgentId, + selected: Option, + current_turn: Option, + ) { + let Some(lease) = self.peek_viewport.as_mut() else { + return; + }; + if !lease.row.matches_top_level_agent(agent_id) { + return; + } + lease.page_flip_entry = selected; + lease.snapshot.follow_mode = true; + lease.snapshot.follow_preserve_scroll = true; + lease.snapshot.selected = selected; + lease.snapshot.current_turn = current_turn; + } + /// Clear the peek reply draft AND its undo history. /// /// The history wipe is the load-bearing part: `set_text("")` alone @@ -5393,8 +5534,6 @@ mod tests { time_ago: String::new(), response_type: response_type.to_string(), last_user_message: None, - last_agent_lines: Vec::new(), - last_response_truncated: false, question: None, options: Vec::new(), request_id: None, @@ -6584,8 +6723,6 @@ mod tests { time_ago: String::new(), response_type: "Idle".into(), last_user_message: None, - last_agent_lines: vec![], - last_response_truncated: false, question: None, options: vec![], request_id: None, @@ -10260,4 +10397,179 @@ mod tests { other => panic!("expected DashboardChangeLocation, got {other:?}"), } } + + fn lease_fixture_agent() -> ( + AgentId, + indexmap::IndexMap, + ) { + use crate::scrollback::block::RenderBlock; + let id = AgentId(1); + let mut agent = crate::test_util::make_agent_view(Some("s1"), "/tmp"); + agent.scrollback.push_block(RenderBlock::user_prompt("one")); + agent + .scrollback + .push_block(RenderBlock::agent_message("long response body for wrap")); + agent.scrollback.push_block(RenderBlock::user_prompt("two")); + agent + .scrollback + .push_block(RenderBlock::agent_message("second reply")); + agent.scrollback.prepare_layout(80, 24); + agent.scrollback.set_selected(Some(0)); + agent.scrollback.set_scroll_offset(2); + let mut agents = indexmap::IndexMap::new(); + agents.insert(id, agent); + (id, agents) + } + + #[test] + fn peek_viewport_lease_restore_without_page_flip_keeps_pre_guest_nav() { + let (id, mut agents) = lease_fixture_agent(); + let pre = agents[&id].scrollback.capture_viewport_snapshot(); + let mut dash = DashboardState::new(); + let row = DashboardRowId::TopLevel(id); + dash.begin_peek_viewport(row, &mut agents); + assert!(dash.peek_viewport.is_some()); + assert!(agents[&id].scrollback.is_follow_mode()); + assert!( + agents + .get_mut(&id) + .unwrap() + .scrollback + .prepare_layout(40, 6), + "guest width change is Case 1" + ); + + dash.restore_peek_viewport(&mut agents); + assert!(dash.peek_viewport.is_none()); + let sb = &mut agents.get_mut(&id).unwrap().scrollback; + assert_eq!(sb.scroll_offset(), pre.scroll_offset); + assert_eq!(sb.is_follow_mode(), pre.follow_mode); + assert_eq!(sb.selected(), pre.selected); + assert!( + sb.prepare_layout(80, 24), + "restore must invalidate so full-width prepare is Case 1" + ); + let snap = sb.capture_viewport_snapshot(); + assert_eq!(snap.last_width, 80); + } + + #[test] + fn peek_viewport_lease_page_flip_re_pins_entry_on_restore() { + let (id, mut agents) = lease_fixture_agent(); + let mut dash = DashboardState::new(); + dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents); + { + let sb = &mut agents.get_mut(&id).unwrap().scrollback; + sb.prepare_layout(40, 6); + let last = sb.len().saturating_sub(1); + sb.set_selected(Some(last)); + sb.scroll_to_entry_top(last); + sb.enable_follow_with_preserve(); + } + assert!(agents[&id].scrollback.is_follow_preserve_scroll()); + dash.note_page_flip_for_lease(id, &mut agents); + assert_eq!( + dash.peek_viewport.as_ref().and_then(|l| l.page_flip_entry), + Some(agents[&id].scrollback.len().saturating_sub(1)) + ); + + dash.restore_peek_viewport(&mut agents); + let sb = &agents[&id].scrollback; + assert!(sb.is_follow_mode()); + assert!(sb.is_follow_preserve_scroll()); + assert_eq!(sb.selected(), Some(sb.len().saturating_sub(1))); + let snap = sb.capture_viewport_snapshot(); + assert_eq!(snap.last_width, 80); + } + + #[test] + fn set_peek_none_does_not_clear_viewport_lease() { + let (id, mut agents) = lease_fixture_agent(); + let mut dash = DashboardState::new(); + dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents); + assert!(dash.peek_viewport.is_some()); + dash.set_peek(None); + assert!(dash.peek_viewport.is_some()); + dash.restore_peek_viewport(&mut agents); + assert!(dash.peek_viewport.is_none()); + } + + #[test] + fn sticky_begin_peek_does_not_recapture() { + let (id, mut agents) = lease_fixture_agent(); + let mut dash = DashboardState::new(); + dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents); + let snap_offset = dash + .peek_viewport + .as_ref() + .map(|l| l.snapshot.scroll_offset) + .unwrap(); + agents.get_mut(&id).unwrap().scrollback.set_scroll_offset(0); + dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents); + assert_eq!( + dash.peek_viewport.as_ref().unwrap().snapshot.scroll_offset, + snap_offset + ); + } + + #[test] + fn note_page_flip_from_scroll_only_when_row_matches() { + let (id, mut agents) = lease_fixture_agent(); + let mut dash = DashboardState::new(); + dash.begin_peek_viewport(DashboardRowId::TopLevel(id), &mut agents); + dash.note_page_flip_from_scroll(AgentId(99), Some(3), Some(1)); + assert!( + dash.peek_viewport + .as_ref() + .unwrap() + .page_flip_entry + .is_none() + ); + dash.note_page_flip_from_scroll(id, Some(3), Some(1)); + let lease = dash.peek_viewport.as_ref().unwrap(); + assert_eq!(lease.page_flip_entry, Some(3)); + assert!(lease.snapshot.follow_preserve_scroll); + assert_eq!(lease.snapshot.selected, Some(3)); + } + + #[test] + fn note_page_flip_ignores_subagent_lease_on_parent_agent() { + let (id, mut agents) = lease_fixture_agent(); + let child = crate::test_util::make_agent_view(Some("child"), "/tmp"); + agents + .get_mut(&id) + .unwrap() + .subagent_views + .insert("child".into(), Box::new(child)); + let mut dash = DashboardState::new(); + dash.begin_peek_viewport( + DashboardRowId::Subagent { + parent: id, + child_session_id: "child".into(), + }, + &mut agents, + ); + dash.note_page_flip_from_scroll(id, Some(3), Some(1)); + assert!( + dash.peek_viewport + .as_ref() + .unwrap() + .page_flip_entry + .is_none(), + "parent drain must not write parent indices onto a subagent lease" + ); + agents + .get_mut(&id) + .unwrap() + .scrollback + .enable_follow_with_preserve(); + dash.note_page_flip_for_lease(id, &mut agents); + assert!( + dash.peek_viewport + .as_ref() + .unwrap() + .page_flip_entry + .is_none() + ); + } } diff --git a/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs b/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs index 04779a8..9fbbce3 100644 --- a/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs +++ b/crates/codegen/xai-grok-pager/src/views/extensions_modal.rs @@ -1110,6 +1110,101 @@ pub enum ModalInputOutcome { }, } +#[derive(Debug, Clone)] +pub struct McpSetupFormState { + pub server_name: String, + pub field: crate::views::mcps_modal::McpSetupField, + pub selected: usize, + pub error: Option, +} + +impl McpSetupFormState { + pub fn new(server: &crate::views::mcps_modal::McpServerInfo) -> Option { + let setup = server.setup.as_ref()?.clone(); + Self::from_setup(server.name.clone(), setup, server.setup_values.clone()) + } + + pub fn from_setup( + server_name: String, + setup: crate::views::mcps_modal::McpSetupConfig, + values: std::collections::HashMap, + ) -> Option { + if setup.fields.len() != 1 { + return None; + } + let field = setup.fields.into_iter().next()?; + if field.options.is_empty() { + return None; + } + let selected = values + .get(&field.id) + .or(field.default.as_ref()) + .and_then(|value| { + field + .options + .iter() + .position(|option| option.value == *value) + }) + .unwrap_or(0); + Some(Self { + server_name, + field, + selected, + error: None, + }) + } + + pub fn selected_value(&self) -> Option { + self.field + .options + .get(self.selected) + .map(|option| option.value.clone()) + } + + pub fn values(&self) -> Option> { + let mut values = std::collections::HashMap::new(); + values.insert(self.field.id.clone(), self.selected_value()?); + Some(values) + } + + pub fn handle_key(&mut self, key: &KeyEvent) -> McpSetupOutcome { + match key.code { + KeyCode::Esc => McpSetupOutcome::Cancel, + KeyCode::Up | KeyCode::Char('k') => { + self.error = None; + if self.selected > 0 { + self.selected -= 1; + } + McpSetupOutcome::Changed + } + KeyCode::Down | KeyCode::Char('j') => { + self.error = None; + if self.selected + 1 < self.field.options.len() { + self.selected += 1; + } + McpSetupOutcome::Changed + } + KeyCode::Enter => { + if self.selected_value().is_none() { + self.error = Some("Select an option".to_string()); + McpSetupOutcome::Changed + } else { + McpSetupOutcome::Submit + } + } + _ => McpSetupOutcome::Unchanged, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpSetupOutcome { + Changed, + Unchanged, + Cancel, + Submit, +} + /// Modal message overlay (errors, confirmations). #[derive(Debug, Clone)] pub enum ModalMessage { @@ -1776,6 +1871,7 @@ fn parse_mcp_add_fields(name: &str, url_or_cmd: &str) -> Option { transport, enabled: true, oauth: None, + setup: None, startup_timeout_sec: None, tool_timeout_sec: None, tool_timeouts: None, @@ -1823,6 +1919,7 @@ pub struct ExtensionsModalState { /// Active inline input (when the user is typing an argument for a command). /// `None` = normal button mode, `Some` = input mode. pub input: Option, + pub mcp_setup: Option, /// Modal message state (error, confirmation prompt, etc.). pub modal_message: Option, /// Description of an in-flight action (blocks buttons while set). @@ -1923,6 +2020,7 @@ impl ExtensionsModalState { plugins_data: TabDataState::Loading, button_areas: Vec::new(), input: None, + mcp_setup: None, modal_message: None, pending_action: None, pending_entry_index: None, @@ -1995,6 +2093,7 @@ impl ExtensionsModalState { self.active_tab = tab; // Clear modal flow state from the previous tab. self.input = None; + self.mcp_setup = None; self.modal_message = None; self.pending_action = None; self.pending_entry_index = None; @@ -2653,7 +2752,7 @@ pub fn render_extensions_modal( }; // Input mode hides the entry list (form overlay owns the content area). - let in_input_mode = state.input.is_some(); + let in_input_mode = state.input.is_some() || state.mcp_setup.is_some(); // Rebuild the entry list *before* footer action labels so Space // enable/disable can use this frame's mapping (passed as locals to @@ -3328,12 +3427,29 @@ pub fn render_extensions_modal( // Modal message overlay (error/confirmation) is rendered with // its own dismissal hint in the footer below — leave the // standard shortcuts list empty. - } else if state.picker_state.search_active && state.input.is_none() { + } else if state.picker_state.search_active && state.input.is_none() && state.mcp_setup.is_none() + { // Search bar has focus — hide the shortcuts footer entirely so // it doesn't compete visually with the typing cursor and so // typed letters don't appear to map to advertised actions // (they're going into the query, not triggering shortcuts). // Input-mode is handled below; it owns its own footer. + } else if state.mcp_setup.is_some() { + shortcuts.push(Shortcut { + label: "Enter save and authenticate", + clickable: false, + id: 0, + }); + shortcuts.push(Shortcut { + label: "↑/↓ select", + clickable: false, + id: 0, + }); + shortcuts.push(Shortcut { + label: "Esc cancel", + clickable: false, + id: 0, + }); } else if let Some(ref input) = state.input { // "Add"/input mode: surface the keys the input form actually // handles. Tab is either path completion (single-field) or @@ -3621,7 +3737,14 @@ pub fn render_extensions_modal( state.entry_non_selectable_clickable = non_selectable_clickable; // Render input form overlay (when in input mode). - if let Some(ref input) = state.input { + if let Some(ref setup) = state.mcp_setup { + let form_y = entries_start_y; + let form_height = entries_area.height; + if form_height > 0 { + let form_area = Rect::new(content_area.x, form_y, content_area.width, form_height); + render_mcp_setup_form(buf, form_area, setup, &theme); + } + } else if let Some(ref input) = state.input { let form_y = entries_start_y; let form_height = entries_area.height; if form_height > 0 { @@ -3779,6 +3902,61 @@ pub fn render_extensions_modal( } } +fn render_mcp_setup_form(buf: &mut Buffer, area: Rect, setup: &McpSetupFormState, theme: &Theme) { + if area.height < 6 || area.width < 20 { + return; + } + let h_inset: u16 = 2; + let x = area.x + h_inset; + let w = area.width.saturating_sub(h_inset * 2); + let rows = (setup.field.options.len() as u16).saturating_add(4); + let top = area.y + area.height.saturating_sub(rows) / 2; + let title = format!("{} — {}", setup.server_name, setup.field.label); + buf.set_string( + x, + top, + take_by_width(&title, w as usize), + Style::default() + .fg(theme.accent_user) + .bg(theme.bg_base) + .add_modifier(Modifier::BOLD), + ); + let hint = "Save and authenticate"; + buf.set_string( + x, + top.saturating_add(1), + hint, + Style::default().fg(theme.gray).bg(theme.bg_base), + ); + for (idx, option) in setup.field.options.iter().enumerate() { + let y = top.saturating_add(3).saturating_add(idx as u16); + if y >= area.y + area.height { + break; + } + let selected = idx == setup.selected; + let marker = if selected { "❯" } else { " " }; + let label = format!("{marker} {}", option.label); + let style = if selected { + Style::default() + .fg(theme.text_primary) + .bg(theme.bg_highlight) + } else { + Style::default().fg(theme.text_primary).bg(theme.bg_base) + }; + buf.set_string(x, y, " ".repeat(w as usize), style); + buf.set_string(x, y, take_by_width(&label, w as usize), style); + } + if let Some(ref err) = setup.error { + let y = area.y + area.height.saturating_sub(1); + buf.set_string( + x, + y, + take_by_width(err, w as usize), + Style::default().fg(theme.accent_error).bg(theme.bg_base), + ); + } +} + /// Kind of modal message overlay currently showing. #[derive(Debug, Clone, Copy)] enum ModalMsgKind { @@ -4162,6 +4340,55 @@ mod tests { state } + #[test] + fn mcp_setup_form_defaults_and_pref_value() { + use crate::views::mcps_modal::{ + McpServerDisplayStatus, McpServerInfo, McpSetupConfig, McpSetupField, McpSetupOption, + McpWireSource, + }; + + let mut server = McpServerInfo { + name: "acme".into(), + display_name: None, + status: McpServerDisplayStatus::SetupRequired, + tool_count: 0, + auth_required: false, + setup_required: true, + setup: Some(McpSetupConfig { + fields: vec![McpSetupField { + id: "site".into(), + label: "Site".into(), + field_type: "select".into(), + required: true, + default: Some("us1".into()), + options: vec![ + McpSetupOption { + label: "US1".into(), + value: "us1".into(), + }, + McpSetupOption { + label: "US5".into(), + value: "us5".into(), + }, + ], + }], + }), + setup_values: std::collections::HashMap::new(), + tools: vec![], + enabled: true, + source: "plugin: acme".into(), + wire_source: McpWireSource::Local, + plugin_name: Some("acme".into()), + is_managed_gateway: false, + }; + let form = McpSetupFormState::new(&server).unwrap(); + assert_eq!(form.selected_value().as_deref(), Some("us1")); + server.setup_values.insert("site".into(), "us5".into()); + let form = McpSetupFormState::new(&server).unwrap(); + assert_eq!(form.selected_value().as_deref(), Some("us5")); + assert_eq!(form.values().unwrap()["site"], "us5"); + } + #[test] fn selected_mcp_tool_returns_none_on_server_row() { let mut state = fixture_with_two_servers_and_tools(); @@ -4237,6 +4464,9 @@ mod tests { status: McpServerDisplayStatus::NeedsAuth, tool_count: 0, auth_required: true, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: vec![], enabled: true, source: "managed".into(), @@ -4285,6 +4515,9 @@ mod tests { status: McpServerDisplayStatus::Ready, tool_count: tc, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: tool_details, enabled: true, source: "local".into(), @@ -4374,6 +4607,9 @@ mod tests { status: McpServerDisplayStatus::Ready, tool_count: 0, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: vec![], enabled: true, source: "plugin: alpha".into(), @@ -4387,6 +4623,9 @@ mod tests { status: McpServerDisplayStatus::Ready, tool_count: 0, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: vec![], enabled: true, source: "plugin: beta".into(), @@ -4433,6 +4672,9 @@ mod tests { status: McpServerDisplayStatus::Ready, tool_count: 0, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: vec![], enabled: true, source: plugin diff --git a/crates/codegen/xai-grok-pager/src/views/import_claude_modal.rs b/crates/codegen/xai-grok-pager/src/views/import_claude_modal.rs index 009bcfd..1bcee69 100644 --- a/crates/codegen/xai-grok-pager/src/views/import_claude_modal.rs +++ b/crates/codegen/xai-grok-pager/src/views/import_claude_modal.rs @@ -1164,6 +1164,7 @@ mod tests { }, enabled: true, oauth: None, + setup: None, startup_timeout_sec: None, tool_timeout_sec: None, tool_timeouts: None, diff --git a/crates/codegen/xai-grok-pager/src/views/mcps_modal.rs b/crates/codegen/xai-grok-pager/src/views/mcps_modal.rs index c444486..5d02b2b 100644 --- a/crates/codegen/xai-grok-pager/src/views/mcps_modal.rs +++ b/crates/codegen/xai-grok-pager/src/views/mcps_modal.rs @@ -146,6 +146,10 @@ pub struct McpsServerEntry { #[serde(default, rename = "type")] pub config_type: Option, #[serde(default)] + pub setup: Option, + #[serde(default)] + pub setup_values: Option>, + #[serde(default)] pub session: Option, } @@ -158,6 +162,34 @@ pub struct McpsServerSession { pub tools: Vec, #[serde(default)] pub auth_required: bool, + #[serde(default)] + pub setup_required: bool, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +pub struct McpSetupConfig { + #[serde(default)] + pub fields: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +pub struct McpSetupField { + pub id: String, + pub label: String, + #[serde(rename = "type")] + pub field_type: String, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub default: Option, + #[serde(default)] + pub options: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +pub struct McpSetupOption { + pub label: String, + pub value: String, } #[derive(Debug, Clone)] @@ -175,6 +207,9 @@ pub struct McpServerInfo { pub status: McpServerDisplayStatus, pub tool_count: usize, pub auth_required: bool, + pub setup_required: bool, + pub setup: Option, + pub setup_values: std::collections::HashMap, /// Detailed tool list for expanded view. pub tools: Vec, /// Whether the server is enabled in config. @@ -192,6 +227,7 @@ pub struct McpServerInfo { pub enum McpServerDisplayStatus { Ready, NeedsAuth, + SetupRequired, Unavailable, Initializing, } @@ -202,6 +238,7 @@ impl McpServerDisplayStatus { match self { Self::Ready => theme.accent_success, Self::NeedsAuth => theme.warning, + Self::SetupRequired => theme.warning, Self::Unavailable => theme.accent_error, Self::Initializing => theme.running, } @@ -212,6 +249,7 @@ impl McpServerDisplayStatus { match self { Self::Ready => "ready", Self::NeedsAuth => "needs auth", + Self::SetupRequired => "setup required", Self::Unavailable => "unavailable", Self::Initializing => "initializing", } @@ -226,7 +264,16 @@ pub fn convert_list_response(resp: McpsListResponse) -> Vec { let (status, tool_count, tools, auth_required, enabled) = if let Some(session) = &entry.session { let enabled = session.enabled; - if session.auth_required { + // Prefer setupRequired bool; status is a fallback for older shells. + if session.setup_required { + ( + McpServerDisplayStatus::SetupRequired, + 0, + vec![], + false, + enabled, + ) + } else if session.auth_required { (McpServerDisplayStatus::NeedsAuth, 0, vec![], true, enabled) } else if !enabled { (McpServerDisplayStatus::Unavailable, 0, vec![], false, false) @@ -234,6 +281,7 @@ pub fn convert_list_response(resp: McpsListResponse) -> Vec { let st = match session.status.as_deref() { Some("ready") => McpServerDisplayStatus::Ready, Some("initializing") => McpServerDisplayStatus::Initializing, + Some("setuprequired") => McpServerDisplayStatus::SetupRequired, _ => McpServerDisplayStatus::Unavailable, }; let tools: Vec = session @@ -270,12 +318,20 @@ pub fn convert_list_response(resp: McpsListResponse) -> Vec { .source_label .or(entry.source) .unwrap_or_else(|| "local".to_string()); + let setup_required = entry + .session + .as_ref() + .is_some_and(|session| session.setup_required) + || matches!(status, McpServerDisplayStatus::SetupRequired); McpServerInfo { name: entry.name, display_name: entry.display_name, status, tool_count, auth_required, + setup_required, + setup: entry.setup, + setup_values: entry.setup_values.unwrap_or_default(), tools, enabled, source, @@ -351,6 +407,9 @@ mod tests { status, tool_count: 0, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: Vec::new(), enabled: true, source: "local".to_string(), @@ -381,11 +440,14 @@ mod tests { source: source.map(str::to_string), source_label: source_label.map(str::to_string), config_type: config_type.map(str::to_string), + setup: None, + setup_values: None, session: Some(McpsServerSession { enabled: true, status: Some("ready".into()), tools: vec![], auth_required: false, + setup_required: false, }), }], }) @@ -529,11 +591,14 @@ mod tests { source: Some("managed".to_string()), source_label: None, config_type: Some("managedGateway".to_string()), + setup: None, + setup_values: None, session: Some(McpsServerSession { enabled: true, status: Some("ready".to_string()), tools: vec![], auth_required: false, + setup_required: false, }), } } @@ -548,6 +613,45 @@ mod tests { assert_eq!(servers[1].display_name.as_deref(), Some("Zeta")); } + #[test] + fn convert_list_response_setup_required_takes_priority() { + let servers = convert_list_response(McpsListResponse { + servers: vec![McpsServerEntry { + name: "acme".into(), + display_name: None, + source: Some("local".into()), + source_label: Some("plugin: acme".into()), + config_type: Some("http".into()), + setup: Some(McpSetupConfig { + fields: vec![McpSetupField { + id: "site".into(), + label: "Site".into(), + field_type: "select".into(), + required: true, + default: Some("us1".into()), + options: vec![McpSetupOption { + label: "US1".into(), + value: "us1".into(), + }], + }], + }), + setup_values: None, + session: Some(McpsServerSession { + enabled: true, + status: Some("setuprequired".into()), + tools: vec![], + auth_required: true, + setup_required: true, + }), + }], + }); + assert_eq!(servers.len(), 1); + assert!(servers[0].setup_required); + assert!(!servers[0].auth_required); + assert_eq!(servers[0].status, McpServerDisplayStatus::SetupRequired); + assert!(servers[0].setup.is_some()); + } + #[test] fn patch_server_row_updates_existing() { let mut servers = vec![ @@ -606,6 +710,9 @@ mod tests { status: McpServerDisplayStatus::Ready, tool_count: 3, auth_required: false, + setup_required: false, + setup: None, + setup_values: std::collections::HashMap::new(), tools: vec![McpToolDetail { name: "existing".into(), display_name: None, diff --git a/crates/codegen/xai-grok-pager/src/views/queue_pane.rs b/crates/codegen/xai-grok-pager/src/views/queue_pane.rs index 5bccec7..94038c3 100644 --- a/crates/codegen/xai-grok-pager/src/views/queue_pane.rs +++ b/crates/codegen/xai-grok-pager/src/views/queue_pane.rs @@ -330,6 +330,10 @@ impl ListItem for QueuedPromptEntry { fn search_text(&self) -> &str { &self.text } + + fn copy_text(&self) -> String { + self.text.clone() + } } // --------------------------------------------------------------------------- @@ -1311,6 +1315,75 @@ mod tests { assert!(text.contains("(+4 lines)")); } + /// GB-4151: `y` on a multiline queue row must copy the full prompt text, + /// not the display line that ends with `(+N lines)`. + #[test] + fn copy_text_returns_full_prompt_not_display_suffix() { + let full = "line one\nline two\nline three\nline four"; + let entry = QueuedPromptEntry::new(&local_prompt(1, full), 1); + + // Precondition: display path still shows the collapsed row indicator. + let display: String = entry + .content() + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + assert!( + display.contains("(+3 lines)"), + "display should keep (+N lines) indicator, got: {display}" + ); + + let copied = entry.copy_text(); + assert_eq!(copied, full); + assert!( + !copied.contains("(+"), + "copied text must not include the (+N lines) display suffix" + ); + } + + /// End-to-end: `ListPaneState::copy_selected` (the `y` path) uses + /// `copy_text`, so multiline rows paste the full prompt. + #[test] + fn yank_selected_multiline_copies_full_text() { + use std::sync::{Arc, Mutex}; + + use xai_ratatui_textarea::ClipboardProvider; + + #[derive(Debug, Clone)] + struct RecordingClip { + last: Arc>>, + } + impl ClipboardProvider for RecordingClip { + fn get(&mut self) -> Option { + self.last.lock().unwrap().clone() + } + fn set(&mut self, text: &str) { + *self.last.lock().unwrap() = Some(text.to_string()); + } + } + + let full = "first line of prompt\nsecond line\nthird line"; + let mut pane = QueuePane::new(); + let mut local = std::collections::VecDeque::new(); + local.push_back(local_prompt(1, full)); + pane.sync_from_merged(&local, &[], None, None, &Default::default()); + // `select_by_id` is resolved into `selected_index` by prepare_layout. + pane.list_state.select_by_id(1); + pane.list_state.prepare_layout(&pane.entries, 80, 10); + + let clip = Arc::new(Mutex::new(None)); + pane.list_state + .set_clipboard_provider(Box::new(RecordingClip { last: clip.clone() })); + + assert!( + pane.list_state.copy_selected(&pane.entries), + "y/copy_selected must succeed for a selected queue row" + ); + let copied = clip.lock().unwrap().clone(); + assert_eq!(copied.as_deref(), Some(full)); + } + #[test] fn test_multiline_singular() { let styled = QueuedPromptEntry::build_styled("first line", 2, QueueEntryKind::Prompt, None); diff --git a/crates/codegen/xai-grok-pager/src/views/session_title.rs b/crates/codegen/xai-grok-pager/src/views/session_title.rs index f662c2d..89f3c01 100644 --- a/crates/codegen/xai-grok-pager/src/views/session_title.rs +++ b/crates/codegen/xai-grok-pager/src/views/session_title.rs @@ -71,6 +71,60 @@ fn first_user_prompt_text(agent: &AgentView) -> Option { None } +/// First line of the most recent user prompt (`RenderBlock::UserPrompt`) in +/// the agent's scrollback, ANSI-stripped + sanitised; `None` when the user +/// hasn't sent any prompts yet. +pub(crate) fn last_user_prompt_line(agent: &AgentView) -> Option { + let len = agent.scrollback.len(); + for idx in (0..len).rev() { + let entry = agent.scrollback.entry(idx)?; + if let RenderBlock::UserPrompt(b) = &entry.block { + let first = b.text.lines().next().unwrap_or("").trim(); + if first.is_empty() { + continue; + } + let stripped = strip_ansi_escapes::strip_str(first); + let safe = sanitize_display_text(&stripped).into_owned(); + return Some(safe.trim().to_string()); + } + } + None +} + +/// First renderable line of the newest agent message, ANSI-stripped + +/// sanitised. Pairing guarantee: returns `None` when a `UserPrompt` is newer +/// than every agent message (that prompt is unanswered — an older reply would +/// misrepresent the latest exchange), or when the message has no renderable +/// line (older messages are not scanned). +pub(crate) fn last_agent_message_line(agent: &AgentView) -> Option { + let len = agent.scrollback.len(); + for idx in (0..len).rev() { + let entry = agent.scrollback.entry(idx)?; + match &entry.block { + RenderBlock::AgentMessage(msg) => { + let text = msg.text(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let stripped = strip_ansi_escapes::strip_str(trimmed); + let safe = sanitize_display_text(&stripped).into_owned(); + let safe = safe.trim().to_string(); + if !safe.is_empty() { + return Some(safe); + } + } + return None; + } + // The user's latest prompt marks the turn boundary — no reply yet. + RenderBlock::UserPrompt(_) => return None, + _ => {} + } + } + None +} + /// Take the first `MAX_TITLE_CHARS` chars and append an ellipsis when /// truncated. Char-based (not byte-based) so multi-byte codepoints /// don't get split. diff --git a/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs b/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs index 6a68074..fd78afe 100644 --- a/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs +++ b/crates/codegen/xai-grok-pager/src/views/settings_modal/state.rs @@ -675,6 +675,7 @@ pub(super) fn action_for_bool(key: SettingKey, new: bool) -> Option { "contextual_hints.send_now" => Some(Action::SetContextualHintSendNow(new)), "contextual_hints.small_screen" => Some(Action::SetContextualHintSmallScreen(new)), "contextual_hints.word_select" => Some(Action::SetContextualHintWordSelect(new)), + "contextual_hints.ssh_wrap" => Some(Action::SetContextualHintSshWrap(new)), "multiline_mode" => Some(Action::SetMultilineMode(new)), "vim_mode" => Some(Action::SetVimMode(new)), "remember_tool_approvals" => Some(Action::SetRememberToolApprovals(new)), diff --git a/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs b/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs index a705018..a28db2b 100644 --- a/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs +++ b/crates/codegen/xai-grok-pager/src/views/welcome/mod.rs @@ -1673,8 +1673,9 @@ fn render_welcome_done( // Heights that don't depend on the menu — computed first so the menu // builder can probe the layout to decide whether to add a Changelog row. - // Startup-warning hint height (multi-line aware). - let hint_height = p.startup_warnings.first().map_or(0u16, |w| { + // Startup-warning hint height (multi-line aware). Must pick the same + // entry `render_startup_warnings` draws — see `startup::banner_warning`. + let hint_height = crate::startup::banner_warning(p.startup_warnings).map_or(0u16, |w| { let msg_lines = w.message.lines().count() as u16; let action_line = if w.action.is_some() { 1 } else { 0 }; msg_lines + action_line + 1 // +1 for buffer spacing @@ -2424,22 +2425,23 @@ fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &st } } -/// Render the first startup warning centered in the given area. +/// Render one startup warning centered in the given area. /// /// `startup_warnings` can hold more than one entry (the WezTerm /// kitty-keyboard banner is prepended ahead of `summarize_warnings()` -/// output — see `diagnostics::assemble_startup_warnings`), but only the -/// first is rendered; all of them point at `/terminal-setup`, which lists -/// every issue. One message line, one optional action line, plus a buffer -/// row for spacing. Severity controls color (yellow for `Warning`, dim -/// for `Info`). +/// output — see `diagnostics::assemble_startup_warnings`), but only one is +/// rendered — the severity-aware pick from `startup::banner_warning`, so a +/// runtime-pushed Warning displaces an earlier Info entry; all of them point +/// at `/terminal-setup`, which lists every issue. One message line, one +/// optional action line, plus a buffer row for spacing. Severity controls +/// color (yellow for `Warning`, dim for `Info`). fn render_startup_warnings( area: Rect, buf: &mut Buffer, theme: &Theme, warnings: &[StartupWarning], ) -> Option { - let w = warnings.first()?; + let w = crate::startup::banner_warning(warnings)?; // Skip the import-claude startup warning entirely — the import row in the // menu now carries the call-to-action with the same visual weight as diff --git a/crates/codegen/xai-grok-pager/src/wrap_filter.rs b/crates/codegen/xai-grok-pager/src/wrap_filter.rs new file mode 100644 index 0000000..4037c90 --- /dev/null +++ b/crates/codegen/xai-grok-pager/src/wrap_filter.rs @@ -0,0 +1,832 @@ +//! Streaming output filter for `grok wrap`: OSC 52 clipboard interception, +//! host-image request handling, and DEC-mode observation. +//! +//! `Osc52Filter` sits between the wrap PTY reader and stdout (see +//! `crate::pty_wrap`). It consumes OSC 52 clipboard sequences (plain and tmux +//! DCS passthrough) and the private host-image request OSC (see +//! [`crate::wrap_clipboard_image`]); everything else — including every CSI +//! sequence, which is additionally reported to the wrap mode tracker — passes +//! through verbatim. The parser handles sequences split across arbitrary +//! chunk boundaries. + +use base64::Engine as _; +use std::sync::Arc; + +use crate::wrap_restore::ModeTracker; + +/// Maximum size for a buffered escape sequence candidate (1 MiB). +/// +/// This bounds the memory used while accumulating a candidate OSC 52 or DCS +/// sequence. Must be large enough to hold the base64-encoded form of +/// `MAX_CLIPBOARD_PAYLOAD` (~1.33x expansion) plus the escape envelope. +const MAX_ESC_BUFFER: usize = 1024 * 1024; + +/// Maximum size for a buffered CSI sequence. +/// +/// CSI bytes are withheld until the final byte arrives so complete sequences +/// can be reported to the wrap mode tracker before being forwarded verbatim. +/// Must comfortably fit a single DECSET listing every tracked mode (~69 +/// bytes today; a unit test pins that relationship so mode-table growth +/// cannot silently cross the cap). Anything larger is malformed and flushes +/// through unreported (mirroring the `MAX_ESC_BUFFER` overflow pattern). +const MAX_CSI_BUFFER: usize = 128; + +/// Maximum decoded clipboard payload size (768 KiB). +/// +/// Aligned with `MAX_ESC_BUFFER`: a 768 KiB payload encodes to ~1 MiB of +/// base64, fitting within the buffer limit. Payloads larger than this are +/// unrealistic for clipboard content over SSH. +const MAX_CLIPBOARD_PAYLOAD: usize = 768 * 1024; + +/// The prefix that identifies an OSC 52 sequence after the `ESC ]`. +const OSC52_PREFIX: &[u8] = b"52;"; + +/// The tmux DCS passthrough prefix after `ESC P`: `tmux;\x1b\x1b]`. +const TMUX_DCS_PREFIX: &[u8] = b"tmux;\x1b\x1b]"; + +/// Base64 engine that accepts both padded and unpadded input. +/// +/// OSC 52 emitters in the wild (including some Go-based tools and terminals) +/// may omit `=` padding. Using `Indifferent` mode avoids silent decode +/// failures from legitimate clipboard sequences. +const BASE64_STANDARD_INDIFFERENT: base64::engine::GeneralPurpose = + base64::engine::GeneralPurpose::new( + &base64::alphabet::STANDARD, + base64::engine::GeneralPurposeConfig::new() + .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), + ); + +/// State machine states for the OSC 52 streaming parser. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FilterState { + /// Normal output passthrough. + Normal, + /// Saw ESC (0x1b), waiting for next byte to determine sequence type. + Esc, + /// Inside CSI: saw `ESC [` -- accumulating until the final byte + /// (0x40-0x7E) so the complete sequence can be reported to the mode + /// tracker, then forwarded verbatim. A fragment truncated by child EOF + /// is intentionally never flushed: emitting a half-open CSI would leave + /// the real terminal's parser mid-sequence, where it would eat the + /// restore bytes the exit path writes right after. + Csi, + /// Inside OSC: saw `ESC ]` -- accumulating until BEL or ST. + Osc, + /// Inside DCS: saw `ESC P` -- checking for tmux passthrough prefix. + Dcs, + /// Inside DCS tmux passthrough, accumulating inner OSC 52. + DcsTmuxOsc, + /// Saw ESC inside an OSC, could be ST terminator (`ESC \`). + OscEsc, + /// Saw ESC inside a DCS tmux OSC, could be inner ST or DCS ST. + DcsTmuxOscEsc, +} + +/// Clipboard sink type: a boxed closure that receives decoded clipboard data. +type ClipboardSink = Box; + +type WrapImageRequestHandler = Box; + +/// Streaming filter that intercepts OSC 52 clipboard sequences from PTY +/// output and sends their decoded payload to the local clipboard. +/// +/// All non-OSC-52 bytes pass through unchanged. The parser handles sequences +/// split across arbitrary byte boundaries. +pub(crate) struct Osc52Filter { + state: FilterState, + buf: Vec, + clipboard_sink: ClipboardSink, + wrap_image_handler: Option, + mode_tracker: Option>, +} + +impl Osc52Filter { + /// Create a new filter that sends clipboard data to the system clipboard. + pub(crate) fn new() -> Self { + Self { + state: FilterState::Normal, + buf: Vec::new(), + clipboard_sink: Box::new(set_local_clipboard), + wrap_image_handler: None, + mode_tracker: None, + } + } + + pub(crate) fn with_wrap_image_handler(mut self, handler: impl FnMut() + 'static) -> Self { + self.wrap_image_handler = Some(Box::new(handler)); + self + } + + /// Report every complete CSI sequence flowing through to `tracker`. + pub(crate) fn with_mode_tracker(mut self, tracker: Arc) -> Self { + self.mode_tracker = Some(tracker); + self + } + + /// Create a filter with a custom clipboard sink (for testing). + #[cfg(test)] + fn with_sink(sink: impl FnMut(&[u8]) + 'static) -> Self { + Self { + state: FilterState::Normal, + buf: Vec::new(), + clipboard_sink: Box::new(sink), + wrap_image_handler: None, + mode_tracker: None, + } + } + + /// Process a chunk of bytes from PTY output. + /// + /// Returns bytes that should be written to stdout. OSC 52 clipboard + /// sequences are consumed (not included in the output) and their decoded + /// payload is sent to the clipboard sink. + pub(crate) fn feed(&mut self, data: &[u8]) -> Vec { + let mut output = Vec::with_capacity(data.len()); + for &byte in data { + match self.state { + FilterState::Normal => { + if byte == 0x1b { + self.state = FilterState::Esc; + self.buf.clear(); + self.buf.push(byte); + } else { + output.push(byte); + } + } + FilterState::Esc => { + self.buf.push(byte); + match byte { + b']' => self.state = FilterState::Osc, + b'P' => self.state = FilterState::Dcs, + b'[' => self.state = FilterState::Csi, + _ => { + // Not an OSC, DCS, or CSI -- flush buffer and continue. + output.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::Normal; + } + } + } + FilterState::Csi => { + self.buf.push(byte); + if (0x40..=0x7e).contains(&byte) { + // Final byte: the sequence is complete. Report it to + // the tracker, then forward verbatim -- CSI is only + // observed, never consumed or modified. + if let Some(tracker) = &self.mode_tracker { + tracker.observe_csi(&self.buf); + } + output.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::Normal; + } else if byte == 0x1b { + // A new ESC aborts the CSI. Flush the fragment and let + // the ESC start a fresh sequence so OSC 52 right after + // a malformed CSI is still intercepted. + self.buf.pop(); + output.extend_from_slice(&self.buf); + self.buf.clear(); + self.buf.push(0x1b); + self.state = FilterState::Esc; + } else if !(0x20..=0x3f).contains(&byte) || self.buf.len() > MAX_CSI_BUFFER { + // Not a parameter/intermediate byte, or oversized: + // malformed. Flush verbatim without reporting. + output.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::Normal; + } + } + FilterState::Osc => { + self.buf.push(byte); + match byte { + // BEL terminates the OSC sequence. + 0x07 => { + if !self.try_handle_consumed_osc() { + output.extend_from_slice(&self.buf); + } + self.buf.clear(); + self.state = FilterState::Normal; + } + // ESC could be the start of ST (ESC \). + 0x1b => { + self.state = FilterState::OscEsc; + } + _ => {} + } + } + FilterState::OscEsc => { + self.buf.push(byte); + if byte == b'\\' { + // ST terminator: ESC \. + if !self.try_handle_consumed_osc() { + output.extend_from_slice(&self.buf); + } + self.buf.clear(); + self.state = FilterState::Normal; + } else { + // Not ST -- continue accumulating in Osc state. + // The ESC we saw might be part of the payload in some + // broken sequence; just keep buffering. + self.state = FilterState::Osc; + } + } + FilterState::Dcs => { + self.buf.push(byte); + // buf starts with \x1bP so tmux prefix bytes start at offset 2. + let prefix_pos = self.buf.len() - 2; + if prefix_pos <= TMUX_DCS_PREFIX.len() { + if TMUX_DCS_PREFIX[prefix_pos - 1] == byte { + if prefix_pos == TMUX_DCS_PREFIX.len() { + // Full tmux prefix matched: \x1bPtmux;\x1b\x1b] + self.state = FilterState::DcsTmuxOsc; + } + // else keep matching prefix + } else { + // Prefix mismatch: not a tmux passthrough, flush. + output.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::Normal; + } + } else { + // Exceeded prefix length without matching; flush. + output.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::Normal; + } + } + FilterState::DcsTmuxOsc => { + self.buf.push(byte); + match byte { + // BEL terminates the inner OSC. + 0x07 => { + // Inner OSC is done but we still need DCS ST + // (ESC \) to close the tmux wrapper. + // Remain in this state to catch the ESC. + } + 0x1b => { + self.state = FilterState::DcsTmuxOscEsc; + } + _ => {} + } + } + FilterState::DcsTmuxOscEsc => { + self.buf.push(byte); + if byte == b'\\' { + // DCS ST: ESC \. The full tmux-wrapped sequence is done. + if !self.try_handle_tmux_osc52() { + output.extend_from_slice(&self.buf); + } + self.buf.clear(); + self.state = FilterState::Normal; + } else { + // Not ST. Continue accumulating in DcsTmuxOsc. + self.state = FilterState::DcsTmuxOsc; + } + } + } + + // Guard: if the buffer grows beyond the limit, flush and reset. + if self.buf.len() > MAX_ESC_BUFFER { + output.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::Normal; + } + } + output + } + + /// Handle OSC 52 clipboard or wrap image request; `true` if consumed. + fn try_handle_consumed_osc(&mut self) -> bool { + let body = self.buf[2..].to_vec(); + let body = strip_osc_terminator(&body); + if self.try_handle_wrap_image_request(body) { + return true; + } + self.extract_and_set_clipboard(body) + } + + fn try_handle_wrap_image_request(&mut self, body: &[u8]) -> bool { + if body != crate::wrap_clipboard_image::REQUEST_BODY { + return false; + } + if let Some(handler) = self.wrap_image_handler.as_mut() { + handler(); + } + true + } + + /// Try to handle the buffered bytes as a tmux-wrapped OSC 52 sequence. + /// + /// Expected buffer format: + /// `\x1bPtmux;\x1b\x1b]52;;\x07\x1b\\` + /// + /// Returns `true` if the sequence was a valid OSC 52 and was consumed. + fn try_handle_tmux_osc52(&mut self) -> bool { + // Strip the DCS tmux prefix: \x1bPtmux;\x1b\x1b] (total 9 bytes) + // and the DCS ST terminator: \x1b\ (2 bytes at the end). + // Copy the body to avoid borrowing self.buf while calling &mut self. + let prefix_len = 2 + TMUX_DCS_PREFIX.len(); // \x1bP + tmux;\x1b\x1b] + if self.buf.len() < prefix_len + 2 { + return false; + } + let body = self.buf[prefix_len..self.buf.len() - 2].to_vec(); // strip DCS ST + let body = strip_osc_terminator(&body); // strip inner BEL if present + self.extract_and_set_clipboard(body) + } + + /// Parse OSC 52 body (`52;;`), decode, and set clipboard. + /// + /// Returns `true` if successfully handled. + fn extract_and_set_clipboard(&mut self, body: &[u8]) -> bool { + // Must start with "52;" + if !body.starts_with(OSC52_PREFIX) { + return false; + } + let after_52 = &body[OSC52_PREFIX.len()..]; + + // Find the selection parameter separator (next ';'). + let payload_start = match after_52.iter().position(|&b| b == b';') { + Some(pos) => pos + 1, + None => return false, + }; + let b64_payload = &after_52[payload_start..]; + + // Decode base64. + let decoded = match BASE64_STANDARD_INDIFFERENT.decode(b64_payload) { + Ok(data) => data, + Err(_) => return false, + }; + + // Check payload size limit. + if decoded.len() > MAX_CLIPBOARD_PAYLOAD { + tracing::warn!( + "OSC 52 payload too large ({} bytes), ignoring", + decoded.len() + ); + return false; + } + + (self.clipboard_sink)(&decoded); + true + } +} + +/// Strip the OSC terminator from the end of a body slice. +/// +/// Removes trailing BEL (`\x07`) or ST (`\x1b\x5c`) if present. +fn strip_osc_terminator(body: &[u8]) -> &[u8] { + if body.ends_with(&[0x1b, b'\\']) { + &body[..body.len() - 2] + } else if body.ends_with(&[0x07]) { + &body[..body.len() - 1] + } else { + body + } +} + +/// Write decoded clipboard payload to the local system clipboard. +/// +/// Delegates to [`xai_grok_shell::util::clipboard::set_text`] which uses +/// `pbcopy` on macOS and `arboard` elsewhere. Failures are logged but do +/// not propagate -- clipboard access is best-effort. +fn set_local_clipboard(data: &[u8]) { + let text = match std::str::from_utf8(data) { + Ok(s) => s, + Err(e) => { + tracing::warn!("OSC 52 payload is not valid UTF-8: {e}"); + return; + } + }; + if let Err(e) = xai_grok_shell::util::clipboard::set_text(text) { + tracing::warn!("clipboard copy failed: {e}"); + } +} + +/// Encode a host clipboard image (or NONE) as a bracketed-paste frame. +pub(crate) fn host_clipboard_image_frame() -> Vec { + let image = xai_grok_pager_render::clipboard::system_clipboard_get_image(); + crate::wrap_clipboard_image::encode_wrap_image_response(image.as_ref()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::rc::Rc; + + /// Helper: run data through the filter with a capturing clipboard sink. + /// Returns (stdout_output, captured_clipboard_payloads). + fn filter_output(input: &[u8]) -> (Vec, Vec>) { + let clips = Rc::new(RefCell::new(Vec::new())); + let clips_clone = Rc::clone(&clips); + let mut filter = Osc52Filter::with_sink(move |data: &[u8]| { + clips_clone.borrow_mut().push(data.to_vec()); + }); + let output = filter.feed(input); + let captured = clips.borrow().clone(); + (output, captured) + } + + /// Helper: run data through the filter in multiple small chunks. + fn filter_output_chunked(input: &[u8], chunk_size: usize) -> (Vec, Vec>) { + let clips = Rc::new(RefCell::new(Vec::new())); + let clips_clone = Rc::clone(&clips); + let mut filter = Osc52Filter::with_sink(move |data: &[u8]| { + clips_clone.borrow_mut().push(data.to_vec()); + }); + let mut output = Vec::new(); + for chunk in input.chunks(chunk_size) { + output.extend_from_slice(&filter.feed(chunk)); + } + let captured = clips.borrow().clone(); + (output, captured) + } + + /// Encode text as a plain OSC 52 sequence with BEL terminator. + fn make_osc52_bel(text: &str) -> Vec { + let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + format!("\x1b]52;c;{b64}\x07").into_bytes() + } + + /// Encode text as a plain OSC 52 sequence with ST terminator. + fn make_osc52_st(text: &str) -> Vec { + let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + format!("\x1b]52;c;{b64}\x1b\\").into_bytes() + } + + /// Encode text as a tmux-wrapped OSC 52 sequence. + fn make_osc52_tmux(text: &str) -> Vec { + let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + format!("\x1bPtmux;\x1b\x1b]52;c;{b64}\x07\x1b\\").into_bytes() + } + + #[test] + fn osc52_normal_text_unchanged() { + let input = b"Hello, world!\r\n"; + let (output, clips) = filter_output(input); + assert_eq!(output, input); + assert!(clips.is_empty()); + } + + #[test] + fn osc52_ansi_escapes_pass_through() { + // SGR color: ESC [ 31 m + let input = b"\x1b[31mred text\x1b[0m"; + let (output, clips) = filter_output(input); + assert_eq!(output, input.as_slice()); + assert!(clips.is_empty()); + } + + #[test] + fn osc52_plain_bel_terminated() { + let seq = make_osc52_bel("hello"); + let (output, clips) = filter_output(&seq); + assert!( + output.is_empty(), + "OSC 52 should be consumed, got: {output:?}" + ); + assert_eq!(clips.len(), 1); + assert_eq!(clips[0], b"hello"); + } + + #[test] + fn osc52_plain_st_terminated() { + let seq = make_osc52_st("hello"); + let (output, clips) = filter_output(&seq); + assert!( + output.is_empty(), + "OSC 52 should be consumed, got: {output:?}" + ); + assert_eq!(clips.len(), 1); + assert_eq!(clips[0], b"hello"); + } + + #[test] + fn osc52_with_s0_selection() { + // Selection parameter "s0" instead of "c". + let b64 = base64::engine::general_purpose::STANDARD.encode(b"clipboard data"); + let seq = format!("\x1b]52;s0;{b64}\x07").into_bytes(); + let (output, clips) = filter_output(&seq); + assert!(output.is_empty()); + assert_eq!(clips.len(), 1); + assert_eq!(clips[0], b"clipboard data"); + } + + #[test] + fn osc52_tmux_wrapped() { + let seq = make_osc52_tmux("hello from tmux"); + let (output, clips) = filter_output(&seq); + assert!( + output.is_empty(), + "tmux OSC 52 should be consumed, got: {output:?}" + ); + assert_eq!(clips.len(), 1); + assert_eq!(clips[0], b"hello from tmux"); + } + + #[test] + fn osc52_surrounded_by_text() { + let mut input = b"before ".to_vec(); + input.extend_from_slice(&make_osc52_bel("copied")); + input.extend_from_slice(b" after"); + let (output, clips) = filter_output(&input); + assert_eq!(output, b"before after"); + assert_eq!(clips.len(), 1); + assert_eq!(clips[0], b"copied"); + } + + #[test] + fn osc52_multiple_sequences() { + let mut input = make_osc52_bel("first"); + input.extend_from_slice(b"gap"); + input.extend_from_slice(&make_osc52_st("second")); + let (output, clips) = filter_output(&input); + assert_eq!(output, b"gap"); + assert_eq!(clips.len(), 2); + assert_eq!(clips[0], b"first"); + assert_eq!(clips[1], b"second"); + } + + #[test] + fn osc52_split_across_chunks() { + let seq = make_osc52_bel("split test"); + // Feed one byte at a time. + let (output, clips) = filter_output_chunked(&seq, 1); + assert!(output.is_empty(), "should be consumed even byte-by-byte"); + assert_eq!(clips.len(), 1); + assert_eq!(clips[0], b"split test"); + } + + #[test] + fn osc52_split_at_various_sizes() { + let seq = make_osc52_st("chunk test"); + for chunk_size in 2..=seq.len() { + let (output, clips) = filter_output_chunked(&seq, chunk_size); + assert!( + output.is_empty(), + "chunk_size={chunk_size}: should be consumed" + ); + assert_eq!(clips.len(), 1, "chunk_size={chunk_size}: expected 1 clip"); + assert_eq!(clips[0], b"chunk test"); + } + } + + #[test] + fn osc52_tmux_split_across_chunks() { + let seq = make_osc52_tmux("tmux split"); + let (output, clips) = filter_output_chunked(&seq, 3); + assert!(output.is_empty()); + assert_eq!(clips.len(), 1); + assert_eq!(clips[0], b"tmux split"); + } + + #[test] + fn osc52_invalid_base64_passes_through() { + // Invalid base64 payload: "!!!" is not valid base64. + let seq = b"\x1b]52;c;!!!\x07"; + let (output, clips) = filter_output(seq); + assert_eq!(output, seq.as_slice(), "invalid base64 should pass through"); + assert!(clips.is_empty()); + } + + #[test] + fn osc52_non_52_osc_passes_through() { + // OSC 0 (window title) should pass through. + let seq = b"\x1b]0;my title\x07"; + let (output, clips) = filter_output(seq); + assert_eq!(output, seq.as_slice()); + assert!(clips.is_empty()); + } + + #[test] + fn osc52_non_52_osc_st_passes_through() { + // OSC 0 with ST terminator. + let seq = b"\x1b]0;my title\x1b\\"; + let (output, clips) = filter_output(seq); + assert_eq!(output, seq.as_slice()); + assert!(clips.is_empty()); + } + + #[test] + fn osc52_oversized_buffer_flushes() { + // Build a sequence that exceeds MAX_ESC_BUFFER. + let mut seq = b"\x1b]52;c;".to_vec(); + // Fill with valid base64 chars until we exceed the limit. + seq.resize(MAX_ESC_BUFFER + 100, b'A'); + seq.push(0x07); + + let (output, clips) = filter_output(&seq); + // The oversized sequence should have been flushed through. + assert!( + !output.is_empty(), + "oversized sequence should flush through" + ); + assert!( + clips.is_empty(), + "oversized sequence should not set clipboard" + ); + } + + #[test] + fn osc52_empty_payload() { + // Empty base64 payload should still work (copies empty string). + let seq = b"\x1b]52;c;\x07"; + let (output, clips) = filter_output(seq); + assert!(output.is_empty()); + assert_eq!(clips.len(), 1); + assert_eq!(clips[0], b""); + } + + #[test] + fn osc52_non_tmux_dcs_passes_through() { + // A DCS that doesn't start with the tmux prefix should flush. + let seq = b"\x1bPother;stuff\x1b\\"; + let (output, clips) = filter_output(seq); + // The flush happens when the prefix mismatch is detected. + assert!(!output.is_empty(), "non-tmux DCS should pass through"); + assert!(clips.is_empty()); + } + + #[test] + fn osc52_missing_selection_separator() { + // No second ';' after "52;" -- missing selection param separator. + let b64 = base64::engine::general_purpose::STANDARD.encode(b"data"); + let seq = format!("\x1b]52;{b64}\x07").into_bytes(); + // This has "52;" followed by base64 with no second ';'. The parser + // will treat everything after "52;" up to the next ';' as the + // selection param. If there's no ';', it returns false. + let (output, clips) = filter_output(&seq); + assert_eq!(output, seq, "should pass through without second ';'"); + assert!(clips.is_empty()); + } + + #[test] + fn wrap_image_request_consumed_and_handler_runs() { + let calls = Rc::new(RefCell::new(0usize)); + let calls_clone = Rc::clone(&calls); + let clips = Rc::new(RefCell::new(Vec::new())); + let clips_clone = Rc::clone(&clips); + let mut filter = Osc52Filter::with_sink(move |data: &[u8]| { + clips_clone.borrow_mut().push(data.to_vec()); + }) + .with_wrap_image_handler(move || { + *calls_clone.borrow_mut() += 1; + }); + let mut input = b"before".to_vec(); + input.extend_from_slice(&crate::wrap_clipboard_image::request_osc_bytes()); + input.extend_from_slice(b"after"); + let output = filter.feed(&input); + assert_eq!(output, b"beforeafter"); + assert_eq!(*calls.borrow(), 1); + assert!(clips.borrow().is_empty()); + } + + #[test] + fn wrap_image_request_split_across_chunks() { + let calls = Rc::new(RefCell::new(0usize)); + let calls_clone = Rc::clone(&calls); + let mut filter = Osc52Filter::with_sink(|_| {}).with_wrap_image_handler(move || { + *calls_clone.borrow_mut() += 1; + }); + let seq = crate::wrap_clipboard_image::request_osc_bytes(); + let mut output = Vec::new(); + for chunk in seq.chunks(3) { + output.extend_from_slice(&filter.feed(chunk)); + } + assert!(output.is_empty(), "request OSC must be fully consumed"); + assert_eq!(*calls.borrow(), 1); + } + + /// Helper: run data through a tracker-attached filter in chunks. + /// Returns (stdout_output, restore_bytes for the final latched state). + fn filter_output_tracked(input: &[u8], chunk_size: usize) -> (Vec, Vec) { + let tracker = Arc::new(ModeTracker::new()); + let mut filter = Osc52Filter::with_sink(|_| {}).with_mode_tracker(Arc::clone(&tracker)); + let mut output = Vec::new(); + for chunk in input.chunks(chunk_size) { + output.extend_from_slice(&filter.feed(chunk)); + } + let restore = crate::wrap_restore::restore_bytes(tracker.snapshot()); + (output, restore) + } + + #[test] + fn csi_latch_and_unlatch_track_across_chunk_splits() { + let input = b"pre\x1b[?1049h\x1b[?1003hmid\x1b[?1003lpost"; + for chunk_size in 1..=input.len() { + let (output, restore) = filter_output_tracked(input, chunk_size); + assert_eq!( + output, + input.as_slice(), + "chunk_size={chunk_size}: CSI must pass through verbatim" + ); + assert_eq!( + restore, b"\x1b[?1049l", + "chunk_size={chunk_size}: only the still-latched mode restores" + ); + } + } + + #[test] + fn csi_kitty_push_tracks_across_chunk_splits() { + let input = b"\x1b[>1u"; + for chunk_size in 1..=input.len() { + let (output, restore) = filter_output_tracked(input, chunk_size); + assert_eq!(output, input.as_slice(), "chunk_size={chunk_size}"); + assert_eq!(restore, b"\x1b[ 223), `?1015`. +const MOUSE_1015: u32 = 1 << 4; +/// SGR-pixel extended mouse reporting, `?1016`. +const MOUSE_1016: u32 = 1 << 5; +/// SGR extended mouse reporting format, `?1006`. +const MOUSE_1006: u32 = 1 << 6; +/// Bracketed paste mode, `?2004`. +const PASTE_2004: u32 = 1 << 7; +/// Focus reporting (focus in/out events), `?1004`. +const FOCUS_1004: u32 = 1 << 8; +/// Synchronized update, `?2026`. +const SYNC_2026: u32 = 1 << 9; +/// Legacy alternate screen buffer, `?47`. +const ALT_47: u32 = 1 << 10; +/// Alternate screen buffer without cursor save, `?1047`. +const ALT_1047: u32 = 1 << 11; +/// Alternate screen buffer with cursor save/restore, `?1049`. +const ALT_1049: u32 = 1 << 12; +/// Cursor hidden — mode `?25` tracked INVERTED: DECTCEM's set side (`?25h`) +/// shows the cursor, so the latched (needs-repair) state is having seen +/// `?25l` without a later `?25h`. +const CURSOR_HIDDEN: u32 = 1 << 13; + +/// Mouse/paste/focus disables in the relative order pinned by +/// `xai_crash_handler::terminal::RESTORE_SEQ`'s ordering tests. +const DISABLE_ORDER: &[(u32, &[u8])] = &[ + (MOUSE_1000, b"\x1b[?1000l"), + (MOUSE_1002, b"\x1b[?1002l"), + (MOUSE_1003, b"\x1b[?1003l"), + (MOUSE_1005, b"\x1b[?1005l"), + (MOUSE_1015, b"\x1b[?1015l"), + (MOUSE_1016, b"\x1b[?1016l"), + (MOUSE_1006, b"\x1b[?1006l"), + (PASTE_2004, b"\x1b[?2004l"), + (FOCUS_1004, b"\x1b[?1004l"), +]; + +/// Bit for a tracked DECSET/DECRST parameter; `None` for untracked modes. +fn mode_bit(mode: u32) -> Option { + Some(match mode { + 25 => CURSOR_HIDDEN, + 47 => ALT_47, + 1000 => MOUSE_1000, + 1002 => MOUSE_1002, + 1003 => MOUSE_1003, + 1004 => FOCUS_1004, + 1005 => MOUSE_1005, + 1006 => MOUSE_1006, + 1015 => MOUSE_1015, + 1016 => MOUSE_1016, + 1047 => ALT_1047, + 1049 => ALT_1049, + 2004 => PASTE_2004, + 2026 => SYNC_2026, + _ => return None, + }) +} + +/// Latched-terminal-state tracker shared (via `Arc`) between the wrap output +/// filter, the exit-path drop guard, and the terminate-signal thread. +/// +/// All state is atomic: the read loop updates it while other threads snapshot +/// it, and the two-phase `restore_claimed`/`restore_done` gate keeps the +/// multiple exit paths from emitting restores twice while still letting a +/// losing path wait for the winner to finish. `SeqCst` throughout: every +/// access is on a cold path (a few RMWs per tracked mode change, none per +/// output byte), so the uniform strongest ordering is chosen over reasoning +/// about minimal per-site orderings. +#[derive(Debug, Default)] +pub(crate) struct ModeTracker { + /// Bitmask of latched modes (the `MOUSE_*`/`PASTE_*`/... bits above). + modes: AtomicU32, + /// Net kitty keyboard protocol pushes (`CSI > .. u`) minus pops + /// (`CSI < .. u`), floored at zero. + kitty_depth: AtomicU32, + /// Claim phase of the one-shot restore gate: set by the first exit path + /// that starts the restore. + restore_claimed: AtomicBool, + /// Completion phase: set by the claim winner once the restore has been + /// fully emitted, so losing exit paths know it is safe to let the + /// process exit. + restore_done: AtomicBool, +} + +/// Point-in-time copy of the latched state, safe to take from any thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ModeSnapshot { + modes: u32, + kitty_depth: u32, +} + +impl ModeTracker { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Update state from one complete CSI sequence (`ESC [ .. final`), + /// exactly as forwarded to the terminal. + /// + /// Only child→terminal output flows here, so mouse *reports* (which a + /// real terminal sends the other way, on stdin) never reach this parser. + pub(crate) fn observe_csi(&self, seq: &[u8]) { + if seq.len() < 3 { + return; + } + let final_byte = seq[seq.len() - 1]; + let body = &seq[2..seq.len() - 1]; + match final_byte { + // DECSET/DECRST only; ANSI SM/RM (no `?`) is untracked. + b'h' | b'l' => { + let Some(params) = body.strip_prefix(b"?") else { + return; + }; + let set = final_byte == b'h'; + for param in params.split(|&b| b == b';') { + if let Some(mode) = parse_decimal(param) { + self.apply_dec_mode(mode, set); + } + } + } + b'u' => match body.first() { + // Kitty keyboard push: `CSI > flags u`. + Some(b'>') => { + self.kitty_depth.fetch_add(1, Ordering::SeqCst); + } + // Kitty keyboard pop: `CSI < n u`, n defaulting to 1. The + // depth floors at zero so a child popping an entry it never + // pushed cannot make wrap pop one on its behalf later. + Some(b'<') => { + // Zero also means the default (1): under the common CSI + // zero-means-default convention a terminal may pop one + // entry for `<0u`, and over-counting depth here risks the + // destructive extra pop at exit. + let n = parse_decimal(&body[1..]).filter(|&n| n > 0).unwrap_or(1); + let _ = self.kitty_depth.fetch_update( + Ordering::SeqCst, + Ordering::SeqCst, + |depth| Some(depth.saturating_sub(n)), + ); + } + // `CSI u` restores the cursor, `CSI ? u` queries, and + // `CSI = .. u` sets flags without pushing — none are stack + // operations. + _ => {} + }, + _ => {} + } + } + + fn apply_dec_mode(&self, mode: u32, set: bool) { + let Some(bit) = mode_bit(mode) else { + return; + }; + // Mode 25 is show-cursor: its latched (needs-repair) side is `l`. + let latch = if mode == 25 { !set } else { set }; + if latch { + self.modes.fetch_or(bit, Ordering::SeqCst); + } else { + self.modes.fetch_and(!bit, Ordering::SeqCst); + } + } + + pub(crate) fn snapshot(&self) -> ModeSnapshot { + ModeSnapshot { + modes: self.modes.load(Ordering::SeqCst), + kitty_depth: self.kitty_depth.load(Ordering::SeqCst), + } + } + + /// Claim the one-shot restore shared by every exit path (drop guard, + /// signal thread): the first caller gets `true` and must call + /// [`finish_restore`](Self::finish_restore) when done; later callers get + /// `false` and must not emit (the terminal would be reset twice — the + /// kitty pop is a destructive stack operation) but should wait for + /// completion before letting the process exit. + pub(crate) fn begin_restore(&self) -> bool { + !self.restore_claimed.swap(true, Ordering::SeqCst) + } + + /// Mark the claimed restore as fully emitted. + pub(crate) fn finish_restore(&self) { + self.restore_done.store(true, Ordering::SeqCst); + } + + /// Whether a claimed restore has completed. + pub(crate) fn restore_done(&self) -> bool { + self.restore_done.load(Ordering::SeqCst) + } +} + +/// Disable sequences for exactly the latched state in `snapshot`. +/// +/// Nothing latched yields an empty vec — clean exits must stay +/// byte-transparent. The emission order matches +/// `xai_crash_handler::terminal::RESTORE_SEQ` for every element the two +/// share (pinned by a unit test below): synchronized-update end first +/// (multiplexers must stop buffering before the other resets arrive), cursor +/// show, mouse/paste/focus disables, kitty pops before the alt-screen exits +/// (the kitty stack is per-screen), alt-screen exits last. +pub(crate) fn restore_bytes(snapshot: ModeSnapshot) -> Vec { + let mut out = Vec::new(); + if snapshot.modes & SYNC_2026 != 0 { + out.extend_from_slice(b"\x1b[?2026l"); + } + if snapshot.modes & CURSOR_HIDDEN != 0 { + out.extend_from_slice(b"\x1b[?25h"); + } + for &(bit, seq) in DISABLE_ORDER { + if snapshot.modes & bit != 0 { + out.extend_from_slice(seq); + } + } + // One pop per net push: unwinds the child's stack entries exactly and + // leaves any enclosing context's entries alone. + for _ in 0..snapshot.kitty_depth { + out.extend_from_slice(b"\x1b[ Option { + if bytes.is_empty() || !bytes.iter().all(|b| b.is_ascii_digit()) { + return None; + } + let mut value: u32 = 0; + for &b in bytes { + value = value.checked_mul(10)?.checked_add(u32::from(b - b'0'))?; + } + Some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn observe_all(tracker: &ModeTracker, seqs: &[&[u8]]) { + for seq in seqs { + tracker.observe_csi(seq); + } + } + + fn restore_for(seqs: &[&[u8]]) -> Vec { + let tracker = ModeTracker::new(); + observe_all(&tracker, seqs); + restore_bytes(tracker.snapshot()) + } + + fn position_of(haystack: &[u8], needle: &[u8]) -> usize { + haystack + .windows(needle.len()) + .position(|w| w == needle) + .unwrap_or_else(|| { + panic!( + "restore bytes must contain {:?} in {:?}", + String::from_utf8_lossy(needle), + String::from_utf8_lossy(haystack) + ) + }) + } + + #[test] + fn nothing_latched_emits_nothing() { + assert!(restore_for(&[]).is_empty()); + } + + #[test] + fn balanced_enable_disable_emits_nothing() { + let out = restore_for(&[ + b"\x1b[?1049h", + b"\x1b[?1003h", + b"\x1b[?2004h", + b"\x1b[?25l", + b"\x1b[?25h", + b"\x1b[?2004l", + b"\x1b[?1003l", + b"\x1b[?1049l", + ]); + assert!( + out.is_empty(), + "balanced state must restore nothing, got {:?}", + String::from_utf8_lossy(&out) + ); + } + + #[test] + fn latched_modes_emit_only_their_disables() { + let out = restore_for(&[b"\x1b[?1003h", b"\x1b[?2004h"]); + assert_eq!(out, b"\x1b[?1003l\x1b[?2004l"); + } + + #[test] + fn multi_param_set_latches_every_mode() { + let out = restore_for(&[b"\x1b[?1002;1006h"]); + assert_eq!(out, b"\x1b[?1002l\x1b[?1006l"); + } + + #[test] + fn cursor_hide_is_tracked_inverted() { + assert_eq!(restore_for(&[b"\x1b[?25l"]), b"\x1b[?25h"); + assert!(restore_for(&[b"\x1b[?25l", b"\x1b[?25h"]).is_empty()); + // A bare show-cursor must not latch anything. + assert!(restore_for(&[b"\x1b[?25h"]).is_empty()); + } + + #[test] + fn untracked_sequences_are_ignored() { + // Autowrap reset, ANSI insert mode, SGR color, cursor restore. + let out = restore_for(&[b"\x1b[?7l", b"\x1b[4h", b"\x1b[31m", b"\x1b[u"]); + assert!(out.is_empty()); + } + + #[test] + fn kitty_two_pushes_emit_two_pops() { + let out = restore_for(&[b"\x1b[>1u", b"\x1b[>11u"]); + assert_eq!(out, b"\x1b[1u", b"\x1b[>1u", b"\x1b[<2u"]); + assert!(out.is_empty(), "CSI <2u must pop both pushes"); + } + + #[test] + fn kitty_pop_floors_at_zero() { + // Popping an entry the child never pushed must not go negative and + // must not make wrap emit pops of its own later. + assert!(restore_for(&[b"\x1b[1u", b"\x1b[1u", b"\x1b[<0u"]).is_empty()); + } + + #[test] + fn kitty_query_and_set_forms_do_not_push() { + assert!(restore_for(&[b"\x1b[?u", b"\x1b[=5;1u", b"\x1b[u"]).is_empty()); + } + + #[test] + fn restore_ends_synchronized_update_first() { + let out = restore_for(&[b"\x1b[?1049h", b"\x1b[?1003h", b"\x1b[?2026h"]); + assert!( + out.starts_with(b"\x1b[?2026l"), + "sync end must come first in {:?}", + String::from_utf8_lossy(&out) + ); + } + + #[test] + fn restore_pops_kitty_before_alt_screen_leave() { + let out = restore_for(&[b"\x1b[?1049h", b"\x1b[>1u"]); + assert!(position_of(&out, b"\x1b[1u", + ]); + assert!( + out.ends_with(b"\x1b[?1049l"), + "alt-screen leave must be last in {:?}", + String::from_utf8_lossy(&out) + ); + for needle in [ + b"\x1b[?2026l".as_slice(), + b"\x1b[?1003l".as_slice(), + b"\x1b[?1006l".as_slice(), + b"\x1b[?2004l".as_slice(), + b"\x1b[?25h".as_slice(), + b"\x1b[> = xai_crash_handler::terminal::RESTORE_SEQ + .split(|&b| b == 0x1b) + .filter(|chunk| !chunk.is_empty()) + .map(|chunk| { + let mut seq = vec![0x1b]; + seq.extend_from_slice(chunk); + seq + }) + .collect(); + assert!( + elements.len() >= 11, + "RESTORE_SEQ must parse into its CSI elements" + ); + + // Latch, for each RESTORE_SEQ element, the state it disables. + let tracker = ModeTracker::new(); + for element in &elements { + let enable: Vec = match element.as_slice() { + // Kitty pop is undone-by-tracking a single push. + b"\x1b[ b"\x1b[>1u".to_vec(), + // Show-cursor disarms the inverted hidden-cursor latch. + b"\x1b[?25h" => b"\x1b[?25l".to_vec(), + seq if seq.starts_with(b"\x1b[?") && seq.ends_with(b"l") => { + let mut enable = seq[..seq.len() - 1].to_vec(); + enable.push(b'h'); + enable + } + seq => panic!( + "unhandled RESTORE_SEQ element {:?} — extend ModeTracker \ + (and this mapping) to cover it", + String::from_utf8_lossy(seq) + ), + }; + tracker.observe_csi(&enable); + } + + // Every element present, in RESTORE_SEQ's relative order (wrap-only + // extras like ?1005/?1016/?1047 may interleave between them). + let out = restore_bytes(tracker.snapshot()); + let positions: Vec = elements + .iter() + .map(|element| position_of(&out, element)) + .collect(); + for (window, pair) in elements.windows(2).zip(positions.windows(2)) { + assert!( + pair[0] < pair[1], + "{:?} must precede {:?} to match RESTORE_SEQ, got {:?}", + String::from_utf8_lossy(&window[0]), + String::from_utf8_lossy(&window[1]), + String::from_utf8_lossy(&out) + ); + } + } +} diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/common.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/common.rs index 1d28656..0f972a6 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/common.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/common.rs @@ -6,9 +6,9 @@ pub(crate) use serde_json::json; pub(crate) use std::path::Path; pub(crate) use std::time::{Duration, Instant}; pub(crate) use xai_grok_pager_pty_harness::{ - ContentController, MockModel, PtyHarness, ScriptedResponse, SseEvent, keys, - oauth_env_for_pager, pager_binary, seed_fake_oauth, sse, wait_for_labels_absent, - wait_for_model_via_new_sessions, + ContentController, InferenceEndpoint, InferenceRequestMatcher, MockModel, PtyHarness, + ScriptedResponse, SseEvent, keys, oauth_env_for_pager, pager_binary, seed_fake_oauth, sse, + wait_for_labels_absent, wait_for_model_via_new_sessions, }; /// Default PTY size used by every e2e test. Large enough to render the @@ -1116,6 +1116,18 @@ const WRAP_DRAIN_TIMEOUT: Duration = Duration::from_secs(10); /// before auth/network/sandbox. #[cfg(unix)] pub(crate) fn run_wrap(wrap_args: &[&str], extra_env: &[(&str, &str)]) -> (Option, String) { + run_wrap_driving(wrap_args, extra_env, |_| {}) +} + +/// Like [`run_wrap`], but hands the live harness to `drive` right after spawn +/// so a test can interact mid-run (wait for output, deliver signals to wrap +/// itself) before the exit-and-drain phase. +#[cfg(unix)] +pub(crate) fn run_wrap_driving( + wrap_args: &[&str], + extra_env: &[(&str, &str)], + drive: impl FnOnce(&mut PtyHarness), +) -> (Option, String) { let binary = pager_binary().expect("resolve pager binary"); let home = tempfile::tempdir().expect("home tempdir"); let home_str = home.path().to_str().expect("utf8 home").to_owned(); @@ -1128,6 +1140,8 @@ pub(crate) fn run_wrap(wrap_args: &[&str], extra_env: &[(&str, &str)]) -> (Optio let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, &args, &env).expect("spawn grok wrap"); + drive(&mut harness); + let code = harness .wait_for_exit_and_drain(WRAP_TIMEOUT, WRAP_DRAIN_TIMEOUT) .ok(); diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_force_sends_top_queued.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_force_sends_top_queued.rs index 123e633..d4c640a 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_force_sends_top_queued.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_force_sends_top_queued.rs @@ -10,13 +10,18 @@ use super::common::*; #[ignore] async fn empty_enter_force_sends_top_queued() { let content = ContentController::start().await.expect("start content"); - // Gate turn 1's terminal event so the queue + empty-Enter provably land - // mid-turn regardless of suite load. - content.hold_agent_completions(); - content.set_turns([ - slow_turn_text("TURNONE"), - "TURNTWO reply to the promoted follow-up.".to_owned(), - ]); + let mut turn_one = content.expect_response_blocked( + "running turn before send-now", + InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions), + ScriptedResponse::sse(chat_completions_message_events(&slow_turn_text("TURNONE"))), + ); + let mut turn_two = content.expect_response( + "promoted queued follow-up", + InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions), + ScriptedResponse::sse(chat_completions_message_events( + "TURNTWO reply to the promoted follow-up.", + )), + ); let binary = pager_binary().expect("resolve pager binary"); let mut harness = @@ -32,6 +37,9 @@ async fn empty_enter_force_sends_top_queued() { harness .wait_for_text("TURNONE", Duration::from_secs(30)) .expect("turn 1 streaming"); + tokio::time::timeout(Duration::from_secs(10), turn_one.wait_blocked()) + .await + .expect("turn 1 reached the completion barrier"); harness .inject_keys(b"please also check the logs\r") @@ -44,7 +52,7 @@ async fn empty_enter_force_sends_top_queued() { // shell cancels turn 1 (the abort beats the held completion) and promotes // the row to run as turn 2. harness.inject_keys(b"\r").expect("empty Enter send-now"); - content.release_agent_completions(); + turn_one.release(); // The promoted row renders as a standard "❯ " prompt block via the // turn-start adoption (the arrow prefix distinguishes the committed block // from the prefix-less queue row). @@ -58,6 +66,9 @@ async fn empty_enter_force_sends_top_queued() { harness .wait_for_text("TURNTWO", Duration::from_secs(40)) .expect("promoted turn reply"); + tokio::time::timeout(Duration::from_secs(10), turn_two.wait_satisfied()) + .await + .expect("promoted turn expectation satisfied"); // The send-now cancel is silent: no cancelled marker between the partial // turn-1 output and the promoted prompt. diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_sends_top_not_last_of_two.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_sends_top_not_last_of_two.rs index 0aafe76..8f633eb 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_sends_top_not_last_of_two.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/empty_enter_sends_top_not_last_of_two.rs @@ -10,14 +10,25 @@ use super::common::*; #[ignore] async fn empty_enter_sends_top_not_last_of_two() { let content = ContentController::start().await.expect("start content"); - // Gate turn 1's terminal event so both queues + the empty Enter provably - // land while turn 1 is still the running turn. - content.hold_agent_completions(); - content.set_turns([ - slow_turn_text("TURNONE"), - "TURNTWO top-row send-now acknowledged.".to_owned(), - "TURNTHREE remaining queue promoted.".to_owned(), - ]); + let mut turn_one = content.expect_response_blocked( + "running turn before top-row send-now", + InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions), + ScriptedResponse::sse(chat_completions_message_events(&slow_turn_text("TURNONE"))), + ); + let mut turn_two = content.expect_response( + "top queued row", + InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions), + ScriptedResponse::sse(chat_completions_message_events( + "TURNTWO top-row send-now acknowledged.", + )), + ); + let mut turn_three = content.expect_response( + "remaining queued row", + InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions), + ScriptedResponse::sse(chat_completions_message_events( + "TURNTHREE remaining queue promoted.", + )), + ); let binary = pager_binary().expect("resolve pager binary"); let mut harness = @@ -33,6 +44,9 @@ async fn empty_enter_sends_top_not_last_of_two() { harness .wait_for_text("TURNONE", Duration::from_secs(45)) .expect("turn 1 streaming"); + tokio::time::timeout(Duration::from_secs(10), turn_one.wait_blocked()) + .await + .expect("turn 1 reached completion barrier"); harness .inject_keys(b"queue-alpha-top\r") @@ -50,7 +64,7 @@ async fn empty_enter_sends_top_not_last_of_two() { harness .inject_keys(b"\r") .expect("empty Enter send-now top"); - content.release_agent_completions(); + turn_one.release(); // Alpha (the promoted TOP row) then bravo drain back-to-back. Each // promoted "❯ …" block and the intermediate TURNTWO reply is scrolled // above the viewport by the next turn's start-adoption before a 100ms poll @@ -61,6 +75,12 @@ async fn empty_enter_sends_top_not_last_of_two() { harness .wait_for_text("TURNTHREE", Duration::from_secs(90)) .expect("all queued turns drained through to the final reply"); + tokio::time::timeout(Duration::from_secs(10), turn_two.wait_satisfied()) + .await + .expect("top queued row expectation satisfied"); + tokio::time::timeout(Duration::from_secs(10), turn_three.wait_satisfied()) + .await + .expect("remaining queued row expectation satisfied"); // The send-now cancel of turn 1 is silent. assert!( diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/wrap_child_killed_with_latched_modes_restores_terminal.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/wrap_child_killed_with_latched_modes_restores_terminal.rs new file mode 100644 index 0000000..1a468be --- /dev/null +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/wrap_child_killed_with_latched_modes_restores_terminal.rs @@ -0,0 +1,58 @@ +// Per-test-case module for the `pty_e2e` integration test crate. +#[allow(unused_imports)] +use super::common::*; + +/// Dirty-death e2e: when the wrapped child dies with DEC private modes still +/// latched, `grok wrap` must emit the matching resets so the outer terminal is +/// not left broken. +/// +/// The child SIGKILLs itself after enabling alt screen, all-motion mouse +/// tracking, SGR mouse encoding, bracketed paste, and cursor hide. From wrap's +/// perspective a child SIGKILL is byte-identical to the local ssh client dying +/// on a transport drop (laptop sleep, `~.`, remote host gone): the PTY hits +/// EOF with the enables' reset bytes never having arrived. Only wrap survives +/// to clean up the local terminal. +#[test] +#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"] +#[cfg(unix)] +fn wrap_child_killed_with_latched_modes_restores_terminal() { + let (code, raw) = run_wrap( + &[ + "/bin/sh", + "-c", + r"printf '\033[?1049h\033[?1003h\033[?1006h\033[?2004h\033[?25l'; kill -KILL $$", + ], + &[], + ); + assert!( + code.is_some(), + "wrap must exit after the child is killed\nraw:\n{raw:?}" + ); + + // All resets must appear after the last enable (the cursor hide): they can + // only have come from wrap's own restore path, not from the dead child. + let last_enable = raw + .rfind("\x1b[?25l") + .unwrap_or_else(|| panic!("child's mode enables must pass through\nraw:\n{raw:?}")); + let reset_pos = |needle: &str| -> usize { + match raw.rfind(needle) { + Some(pos) if pos > last_enable => pos, + Some(_) => { + panic!("reset {needle:?} must appear after the child's enables\nraw:\n{raw:?}") + } + None => panic!( + "wrap must emit {needle:?} for a mode the dead child left latched\nraw:\n{raw:?}" + ), + } + }; + + let alt_screen_leave = reset_pos("\x1b[?1049l"); + for needle in ["\x1b[?1003l", "\x1b[?1006l", "\x1b[?2004l", "\x1b[?25h"] { + let pos = reset_pos(needle); + assert!( + pos < alt_screen_leave, + "alt-screen leave must come last among the resets ({needle:?} at {pos} vs \ + ?1049l at {alt_screen_leave})\nraw:\n{raw:?}" + ); + } +} diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e/wrap_clean_exit_stays_byte_transparent.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e/wrap_clean_exit_stays_byte_transparent.rs new file mode 100644 index 0000000..c702e30 --- /dev/null +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e/wrap_clean_exit_stays_byte_transparent.rs @@ -0,0 +1,51 @@ +// Per-test-case module for the `pty_e2e` integration test crate. +#[allow(unused_imports)] +use super::common::*; + +/// Transparency e2e: when the wrapped child balances every mode it enables and +/// exits cleanly, `grok wrap` must add zero reset bytes of its own. Blindly +/// blasting resets on exit would be visible here (duplicate disables, and a +/// kitty pop that could corrupt an enclosing context's keyboard stack); the +/// mode tracker keeps clean exits byte-for-byte transparent. +#[test] +#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"] +#[cfg(unix)] +fn wrap_clean_exit_stays_byte_transparent() { + let (code, raw) = run_wrap( + &[ + "/bin/sh", + "-c", + concat!( + r"printf '\033[?1049h\033[?1003h\033[?1006h\033[?2004h\033[?25l'; ", + r"printf '\033[?25h\033[?2004l\033[?1006l\033[?1003l\033[?1049l'", + ), + ], + &[], + ); + assert_eq!( + code, + Some(0), + "clean child exit must propagate\nraw:\n{raw:?}" + ); + + // Exactly the child's own disables — one occurrence each. A second copy + // means wrap injected resets on a clean exit. + for needle in [ + "\x1b[?1003l", + "\x1b[?1006l", + "\x1b[?2004l", + "\x1b[?25h", + "\x1b[?1049l", + ] { + assert_eq!( + raw.matches(needle).count(), + 1, + "clean exit must stay byte-transparent: expected exactly the child's own \ + {needle:?}\nraw:\n{raw:?}" + ); + } + assert!( + !raw.contains("\x1b[ = env + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + let mut harness = PtyHarness::new_in_dir(&binary, 60, 80, &[], &env_refs, Some(content.home())) + .expect("spawn pager"); + + harness + .wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT) + .expect("welcome"); + harness + .inject_keys(format!("{PROMPT}\r").as_bytes()) + .expect("submit prompt"); + harness + .wait_for_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30)) + .expect("response"); + harness + .wait_for_text("Worked for", Duration::from_secs(20)) + .expect("turn completion marker before /copy"); + inject_keys_paced(&mut harness, b"/copy 1"); + harness + .wait_for_text("/copy 1", Duration::from_secs(10)) + .expect("/copy command ready"); + let raw_before_copy = harness.raw_output().len(); + harness.inject_keys(b"\r").expect("run /copy"); + + let copy_deadline = Instant::now() + Duration::from_secs(10); + let payloads = loop { + harness.update(Duration::from_millis(200)); + let payloads = decode_osc52_payloads(&harness.raw_output()[raw_before_copy..]); + if !payloads.is_empty() || Instant::now() >= copy_deadline { + break payloads; + } + }; + assert!( + payloads + .iter() + .any(|payload| payload.contains("clipboard delivery sentinel")), + "copy must still emit the response through OSC 52: {payloads:?}" + ); + harness + .wait_for_text("Copy sent", Duration::from_secs(10)) + .expect("unverified copy result visible at 80 columns"); + assert!(!harness.contains_text("Copy failed")); + assert!(!harness.contains_text("Copied!")); + + harness + .inject_keys(b"/terminal-setup\r") + .expect("run /terminal-setup"); + harness + .wait_for_text("status unverified", Duration::from_secs(10)) + .expect("unverified clipboard status"); + harness + .wait_for_text("grok wrap ", Duration::from_secs(10)) + .expect("wrapped SSH guidance"); + assert!(!harness.contains_text("Copy failed")); + assert!(!harness.contains_text("panicked")); + + harness.quit().expect("clean quit"); +} diff --git a/crates/codegen/xai-grok-pager/tests/pty_e2e_smoke.rs b/crates/codegen/xai-grok-pager/tests/pty_e2e_smoke.rs index 65d0439..7c5c18e 100644 --- a/crates/codegen/xai-grok-pager/tests/pty_e2e_smoke.rs +++ b/crates/codegen/xai-grok-pager/tests/pty_e2e_smoke.rs @@ -39,6 +39,10 @@ mod waiting_for_model_label; mod welcome_screen; #[path = "pty_e2e/welcome_screen_braille_logo_renders_correctly.rs"] mod welcome_screen_braille_logo_renders_correctly; +#[path = "pty_e2e/wrap_child_killed_with_latched_modes_restores_terminal.rs"] +mod wrap_child_killed_with_latched_modes_restores_terminal; +#[path = "pty_e2e/wrap_clean_exit_stays_byte_transparent.rs"] +mod wrap_clean_exit_stays_byte_transparent; #[path = "pty_e2e/wrap_echo_passthrough_and_exit_code.rs"] mod wrap_echo_passthrough_and_exit_code; #[path = "pty_e2e/wrap_explicit_path_not_found_fails_fast.rs"] @@ -47,5 +51,7 @@ mod wrap_explicit_path_not_found_fails_fast; mod wrap_not_found_alias_routes_via_shell_contract; #[path = "pty_e2e/wrap_osc52_sink_env_advertised_through_shell.rs"] mod wrap_osc52_sink_env_advertised_through_shell; +#[path = "pty_e2e/wrap_sigterm_restores_terminal_and_exit_code.rs"] +mod wrap_sigterm_restores_terminal_and_exit_code; #[path = "pty_e2e/wrap_single_string_routes_via_shell.rs"] mod wrap_single_string_routes_via_shell; diff --git a/crates/codegen/xai-grok-pager/tests/settings_e2e.rs b/crates/codegen/xai-grok-pager/tests/settings_e2e.rs index 9a418c9..bbcd504 100644 --- a/crates/codegen/xai-grok-pager/tests/settings_e2e.rs +++ b/crates/codegen/xai-grok-pager/tests/settings_e2e.rs @@ -73,6 +73,7 @@ const ALL_SETTINGS_EXERCISED: &[&str] = &[ "contextual_hints.send_now", "contextual_hints.small_screen", "contextual_hints.word_select", + "contextual_hints.ssh_wrap", ]; #[test] @@ -1602,6 +1603,7 @@ fn registry_kind_membership_through_pr_14() { "contextual_hints.send_now", "contextual_hints.small_screen", "contextual_hints.word_select", + "contextual_hints.ssh_wrap", ] .into_iter() .collect::>() @@ -1771,6 +1773,7 @@ fn defaults_round_trip_through_registry() { "contextual_hints.send_now" => SettingValue::Bool(true), "contextual_hints.small_screen" => SettingValue::Bool(true), "contextual_hints.word_select" => SettingValue::Bool(true), + "contextual_hints.ssh_wrap" => SettingValue::Bool(true), other => panic!("test must list expected default for `{other}`"), } }; diff --git a/crates/codegen/xai-grok-plugin-marketplace/src/config.rs b/crates/codegen/xai-grok-plugin-marketplace/src/config.rs index 036c6c8..35a1831 100644 --- a/crates/codegen/xai-grok-plugin-marketplace/src/config.rs +++ b/crates/codegen/xai-grok-plugin-marketplace/src/config.rs @@ -29,6 +29,25 @@ struct RawSource { branch: Option, } +/// Whether remote plugin installs/updates must pin a full commit sha. +/// +/// `[marketplace] require_sha = true` in config.toml, or +/// `GROK_MARKETPLACE_REQUIRE_SHA=1`. Tighten-only: either source can enable, +/// neither can override the other off. Defaults off so existing unpinned +/// catalogs keep installing. +pub fn load_require_sha(config: &toml::Value) -> bool { + env_require_sha() + || config + .get("marketplace") + .and_then(|m| m.get("require_sha")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + +pub fn env_require_sha() -> bool { + xai_grok_config::env_bool("GROK_MARKETPLACE_REQUIRE_SHA").unwrap_or(false) +} + /// Reads `[marketplace].sources` array. Returns empty vec if not configured. pub fn load_sources(config: &toml::Value) -> Vec { let Some(marketplace) = config.get("marketplace") else { @@ -310,6 +329,34 @@ mod tests { assert!(load_sources(&config).is_empty()); } + /// Drives the shipped composition: config alone, env alone, and the + /// tighten-only rule (falsy env cannot relax config-set true). + #[test] + fn require_sha_policy_composition() { + // Process-global env: serialize against any other env-touching test. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV_LOCK.lock().unwrap(); + + let empty: toml::Value = toml::from_str("").unwrap(); + let enabled: toml::Value = toml::from_str("[marketplace]\nrequire_sha = true\n").unwrap(); + + // SAFETY: single-threaded within the lock; restored before release. + unsafe { std::env::remove_var("GROK_MARKETPLACE_REQUIRE_SHA") }; + assert!(!load_require_sha(&empty), "absent everywhere → off"); + assert!(load_require_sha(&enabled), "config alone can enable"); + + unsafe { std::env::set_var("GROK_MARKETPLACE_REQUIRE_SHA", "1") }; + assert!(load_require_sha(&empty), "env alone can enable"); + + unsafe { std::env::set_var("GROK_MARKETPLACE_REQUIRE_SHA", "0") }; + assert!( + load_require_sha(&enabled), + "a falsy env must not relax config-set policy (tighten-only)" + ); + + unsafe { std::env::remove_var("GROK_MARKETPLACE_REQUIRE_SHA") }; + } + #[test] fn missing_sources_key_returns_empty() { let config: toml::Value = toml::from_str("[marketplace]\n").unwrap(); diff --git a/crates/codegen/xai-grok-plugin-marketplace/src/installer.rs b/crates/codegen/xai-grok-plugin-marketplace/src/installer.rs index 1cdb631..2da7916 100644 --- a/crates/codegen/xai-grok-plugin-marketplace/src/installer.rs +++ b/crates/codegen/xai-grok-plugin-marketplace/src/installer.rs @@ -62,7 +62,9 @@ pub fn install_from_marketplace( subdir: None, }; - match git_install::install_from_source(&source, registry) { + // Local copy from the synced source checkout: the pin gate governs remote + // fetches only (see install_from_remote_url's security doc). + match git_install::install_from_source(&source, registry, false) { Ok(result) => { let repo_key = result.repo_key.clone(); let installed_path = registry.install_dir().join(&repo_key); @@ -93,7 +95,7 @@ pub fn install_from_marketplace( registry.remove(&key); registry.save()?; // Retry — registry no longer has the key. - match git_install::install_from_source(&source, registry) { + match git_install::install_from_source(&source, registry, false) { Ok(result) => { let repo_key = result.repo_key.clone(); let installed_path = registry.install_dir().join(&repo_key); @@ -119,6 +121,19 @@ pub fn install_from_marketplace( /// /// Clones the plugin repo and installs it via the standard git install /// pipeline; pins to `git_sha` if set, otherwise uses `git_ref` or HEAD. +/// +/// # Security +/// +/// Marketplace plugins are **not cryptographically signed**. A remote install +/// without `git_sha` tracks a mutable ref (branch/tag/HEAD) and can be +/// substituted by anyone who can push that ref. Prefer publishing `sha` in +/// `plugin-index.json` and installing with that pin. +/// +/// `require_sha` (from [`crate::config::load_require_sha`]) fails such installs +/// closed. It covers every path that fetches plugin code from a remote git URL +/// (marketplace `remote_url` entries, direct installs, git updates). It does +/// NOT cover plugins vendored inside a marketplace source itself — those come +/// from the synced source checkout, whose branch is not yet pinnable. pub fn install_from_remote_url( url: &str, git_ref: Option<&str>, @@ -127,6 +142,7 @@ pub fn install_from_remote_url( plugin_name: &str, provenance: MarketplaceProvenance, registry: &mut InstallRegistry, + require_sha: bool, ) -> Result { let subdir = subdir .map(|s| { @@ -137,6 +153,8 @@ pub fn install_from_remote_url( }) }) .transpose()?; + // No-fetch short-circuit before the pin gate: re-install of an already-present + // plugin must not refuse just because the catalog entry is unpinned. if let Some((existing_key, _)) = find_installed_marketplace_plugin( registry, &provenance.source_url_or_path, @@ -153,7 +171,14 @@ pub fn install_from_remote_url( subdir, }; - match git_install::install_from_source(&source, registry) { + // Single pin gate lives in install_from_source; pass plugin_name so refusals + // name the catalog entry rather than the bare URL. + match git_install::install_from_source_with_label( + &source, + registry, + require_sha, + Some(plugin_name), + ) { Ok(result) => { let repo_key = result.repo_key.clone(); let installed_path = registry.install_dir().join(&repo_key); @@ -176,7 +201,12 @@ pub fn install_from_remote_url( let _ = std::fs::remove_file(&old_path); registry.remove(&key); registry.save()?; - match git_install::install_from_source(&source, registry) { + match git_install::install_from_source_with_label( + &source, + registry, + require_sha, + Some(plugin_name), + ) { Ok(result) => { let repo_key = result.repo_key.clone(); let installed_path = registry.install_dir().join(&repo_key); @@ -203,6 +233,7 @@ pub fn update_from_marketplace_entry_transactional( entry: &MarketplaceEntry, mut provenance: MarketplaceProvenance, registry: &mut InstallRegistry, + require_sha: bool, ) -> Result { let plugin_relative_path = MarketplaceRelativePath::parse(&entry.relative_path).map_err(|e| { @@ -272,12 +303,11 @@ pub fn update_from_marketplace_entry_transactional( remove_path_if_exists(&backup_path)?; let stage_result = if let Some(url) = entry.remote_url.as_deref() { - clone_repo_to_path( - url, - entry.remote_ref.as_deref(), - entry.remote_sha.as_deref(), - &staging_path, - ) + // Catalog pins published as `ref` still need hoisting for the verified clone path. + let (git_ref, git_sha) = + git_install::hoist_pin_slots(entry.remote_ref.as_deref(), entry.remote_sha.as_deref()); + git_install::ensure_pinned(require_sha, git_sha, &entry.name, url)?; + clone_repo_to_path(url, git_ref, git_sha, &staging_path) } else { let source_path = plugin_relative_path .join_under(marketplace_root) @@ -793,6 +823,144 @@ mod tests { static TEST_HOME: OnceLock = OnceLock::new(); static TEST_LOCK: Mutex<()> = Mutex::new(()); + #[test] + fn require_sha_rejects_unpinned_remote_install() { + with_test_registry(|registry| { + let err = install_from_remote_url( + "https://example.com/plugin.git", + Some("main"), + None, // no sha + None, + "plugins/demo", + MarketplaceProvenance { + source_url_or_path: "https://example.com/market.git".into(), + source_display_name: "test".into(), + plugin_subdir: "plugins/demo".into(), + }, + registry, + true, // require_sha + ) + .unwrap_err(); + assert!( + matches!(err, InstallError::UnpinnedRemoteRefused { .. }), + "expected the typed refusal, got: {err}" + ); + + let err = install_from_remote_url( + "https://example.com/plugin.git", + None, + Some("main"), + None, + "plugins/demo", + MarketplaceProvenance { + source_url_or_path: "https://example.com/market.git".into(), + source_display_name: "test".into(), + plugin_subdir: "plugins/demo".into(), + }, + registry, + true, + ) + .unwrap_err(); + assert!( + matches!(err, InstallError::UnpinnedRemoteRefused { .. }), + "a non-hex 'pin' must be refused up front, got: {err}" + ); + }); + } + + #[test] + fn require_sha_already_installed_skips_pin_gate() { + if !git_available() { + eprintln!("skipping: `git` binary not available in test sandbox"); + return; + } + with_test_registry(|registry| { + let repo = tempfile::tempdir().unwrap(); + run_git(repo.path(), &["init", "--initial-branch=main", "--quiet"]); + write_root_plugin(repo.path(), "acme", "1.0.0"); + run_git(repo.path(), &["add", "-A"]); + run_git(repo.path(), &["commit", "-m", "v1", "--quiet"]); + + let url = format!("file://{}", repo.path().display()); + let provenance = MarketplaceProvenance { + source_url_or_path: "https://example.com/marketplace.git".into(), + source_display_name: "Test".into(), + plugin_subdir: "acme".into(), + }; + + // Unpinned first install (policy off) so the registry is populated. + match install_from_remote_url( + &url, + Some("main"), + None, + None, + "acme", + provenance.clone(), + registry, + false, + ) + .unwrap() + { + MarketplaceInstallResult::Installed { .. } => {} + MarketplaceInstallResult::AlreadyInstalled { repo_key } => { + panic!("expected fresh Installed, got AlreadyInstalled {repo_key}") + } + } + + // No-fetch re-install under require_sha must not refuse unpinned catalog entries. + match install_from_remote_url( + &url, + Some("main"), + None, + None, + "acme", + provenance, + registry, + true, + ) + .unwrap() + { + MarketplaceInstallResult::AlreadyInstalled { .. } => {} + MarketplaceInstallResult::Installed { repo_key } => { + panic!("expected AlreadyInstalled, got Installed {repo_key}") + } + } + }); + } + + #[test] + fn require_sha_rejects_unpinned_remote_update() { + with_test_registry(|registry| { + let marketplace = tempfile::tempdir().unwrap(); + write_plugin(marketplace.path(), "demo", "1.0.0", "old"); + let repo_key = install_test_plugin(registry, marketplace.path(), "demo"); + let mut entry = crate::scan_marketplace(marketplace.path()) + .entries + .into_iter() + .find(|p| p.relative_path == "plugins/demo") + .unwrap(); + entry.remote_url = Some("https://example.com/plugin.git".into()); + entry.remote_sha = None; + + let err = update_from_marketplace_entry_transactional( + marketplace.path(), + &entry, + provenance(marketplace.path(), "plugins/demo"), + registry, + true, // require_sha + ) + .unwrap_err(); + assert!( + matches!(err, InstallError::UnpinnedRemoteRefused { .. }), + "expected the typed refusal, got: {err}" + ); + assert!( + registry.install_dir().join(&repo_key).exists(), + "a refused update must leave the existing install in place" + ); + }); + } + fn with_test_registry(f: impl FnOnce(&mut InstallRegistry) -> T) -> T { let _guard = TEST_LOCK.lock().unwrap(); let home = TEST_HOME.get_or_init(|| tempfile::tempdir().unwrap()); @@ -893,6 +1061,7 @@ mod tests { &entry, provenance(marketplace.path(), "plugins/demo"), registry, + false, // require_sha off: pin policy has its own tests ) .unwrap(); @@ -933,6 +1102,7 @@ mod tests { &entry, provenance(marketplace.path(), "plugins/demo"), registry, + false, // require_sha off: pin policy has its own tests ) .unwrap(); @@ -963,6 +1133,7 @@ mod tests { &entry, provenance(marketplace.path(), "plugins/demo"), registry, + false, // require_sha off: pin policy has its own tests ); unsafe { std::env::remove_var("XAI_GROK_TEST_FAIL_REGISTRY_SAVE_AFTER_SERIALIZE") }; @@ -1015,6 +1186,7 @@ mod tests { &entry, provenance(marketplace.path(), "plugins/demo"), registry, + false, // require_sha off: pin policy has its own tests ); assert!(matches!(result, Err(InstallError::InstallFailed { .. }))); @@ -1049,6 +1221,7 @@ mod tests { "acme", provenance, registry, + false, // require_sha off: pin policy has its own tests ); assert!(matches!(result, Err(InstallError::InstallFailed { .. }))); }); @@ -1135,6 +1308,7 @@ mod tests { "acme", provenance.clone(), registry, + false, // require_sha off: pin policy has its own tests ) .unwrap() { @@ -1177,6 +1351,7 @@ mod tests { &entry, provenance, registry, + false, // require_sha off: pin policy has its own tests ) .unwrap(); @@ -1240,6 +1415,7 @@ mod tests { &entry, provenance.clone(), registry, + false, // require_sha off: pin policy has its own tests ); assert!( matches!(result, Err(InstallError::InstallFailed { .. })), @@ -1291,6 +1467,7 @@ mod tests { "acme", provenance.clone(), registry, + false, // require_sha off: pin policy has its own tests ) .unwrap() { @@ -1308,6 +1485,7 @@ mod tests { "acme", provenance, registry, + false, // require_sha off: pin policy has its own tests ) .unwrap() { diff --git a/crates/codegen/xai-grok-plugin-marketplace/src/lib.rs b/crates/codegen/xai-grok-plugin-marketplace/src/lib.rs index c03a9c5..bab5be6 100644 --- a/crates/codegen/xai-grok-plugin-marketplace/src/lib.rs +++ b/crates/codegen/xai-grok-plugin-marketplace/src/lib.rs @@ -16,7 +16,8 @@ pub mod scanner; pub mod types; pub use config::{ - load_extra_sources_from_settings, load_extra_sources_from_settings_in, load_sources, + env_require_sha, load_extra_sources_from_settings, load_extra_sources_from_settings_in, + load_require_sha, load_sources, }; pub use error::MarketplaceError; pub use scanner::scan_marketplace; diff --git a/crates/codegen/xai-grok-sandbox/Cargo.toml b/crates/codegen/xai-grok-sandbox/Cargo.toml index 94e1cb6..73ab328 100644 --- a/crates/codegen/xai-grok-sandbox/Cargo.toml +++ b/crates/codegen/xai-grok-sandbox/Cargo.toml @@ -12,8 +12,11 @@ dirs = "5.0" dunce = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } +url = { workspace = true } xai-grok-config = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/crates/codegen/xai-grok-sandbox/src/lib.rs b/crates/codegen/xai-grok-sandbox/src/lib.rs index 3e92a4a..b3ca750 100644 --- a/crates/codegen/xai-grok-sandbox/src/lib.rs +++ b/crates/codegen/xai-grok-sandbox/src/lib.rs @@ -29,10 +29,15 @@ pub mod child_net; mod deny; mod logging; +mod network_policy; mod paths; mod profiles; mod types; pub use logging::SandboxLogger; +pub use network_policy::{ + ChildNetworkPolicy, NETWORK_POLICY_SNAPSHOT_VERSION, NetworkPolicySnapshot, + NetworkPolicySnapshotError, WebsiteAction, WebsiteOrigin, WebsiteOriginError, WebsitePolicy, +}; #[cfg(all(feature = "enforce", unix))] use nono::Sandbox; pub use profiles::{ @@ -46,7 +51,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; pub use types::{SandboxEvent, SandboxEventType, SandboxMetrics}; static SANDBOX: OnceLock = OnceLock::new(); static CONFIGURED_PROFILE: OnceLock = OnceLock::new(); -static RESTRICT_CHILD_NETWORK: AtomicBool = AtomicBool::new(false); static AUTO_ALLOW_BASH: AtomicBool = AtomicBool::new(false); const BWRAP_ENV_VAR: &str = "__GROK_INSIDE_BWRAP"; pub fn is_inside_bwrap() -> bool { @@ -59,10 +63,16 @@ struct GlobalSandboxState { profile: String, logger: SandboxLogger, applied: bool, + restrict_network_at_known_linux_launches: bool, } -/// Whether child subprocesses should have network blocked via seccomp. +fn restrict_network_at_known_linux_launches(applied: bool, configured: bool) -> bool { + applied && configured && cfg!(target_os = "linux") +} +/// Whether known Linux child launch paths should install the seccomp network filter. pub fn should_restrict_child_network() -> bool { - RESTRICT_CHILD_NETWORK.load(Ordering::Relaxed) + SANDBOX + .get() + .is_some_and(|state| state.restrict_network_at_known_linux_launches) } /// Whether bash commands should be auto-approved when the sandbox is active. pub fn should_auto_allow_bash() -> bool { @@ -140,6 +150,9 @@ impl SandboxManager { tracing::info!("Sandbox disabled (profile: off)"); return Ok(()); } + let config = profiles::load_sandbox_config(workspace); + let mut resolved = self.profile.resolve_profile(workspace, &config)?; + self.net_restricted = resolved.restrict_network; let support = Sandbox::support_info(); if !support.is_supported { tracing::warn!( @@ -153,19 +166,11 @@ impl SandboxManager { )); return Ok(()); } - let config = profiles::load_sandbox_config(workspace); - let caps = self - .profile - .to_capability_set_with_config(workspace, &config)?; - let mut resolved = self.profile.resolve_profile(workspace, &config)?; + let caps = ProfileName::capability_set_from_profile(workspace, &resolved)?; resolved.deny = deny::effective_deny_paths(workspace, &resolved.deny); - self.net_restricted = self.profile.restricts_network_resolved(&config); match Sandbox::apply(&caps) { Ok(_) => { self.applied = true; - if self.net_restricted { - RESTRICT_CHILD_NETWORK.store(true, Ordering::Relaxed); - } self.logger.log(SandboxEvent::profile_applied( &self.profile.to_string(), workspace, @@ -173,7 +178,7 @@ impl SandboxManager { )); tracing::info!( profile = % self.profile, workspace = % workspace.display(), - restrict_network = self.net_restricted, + restrict_network_configured = self.net_restricted, "Sandbox applied (kernel-enforced, irreversible)" ); Ok(()) @@ -208,6 +213,10 @@ impl SandboxManager { profile: self.profile.to_string(), logger: self.logger, applied: self.applied, + restrict_network_at_known_linux_launches: restrict_network_at_known_linux_launches( + self.applied, + self.net_restricted, + ), }); } /// Check whether the current platform supports sandboxing. @@ -219,9 +228,9 @@ impl SandboxManager { pub fn is_applied(&self) -> bool { self.applied } - /// Whether child subprocesses should have network blocked. + /// Whether known Linux child launch paths should install the seccomp network filter. pub fn restrict_child_network(&self) -> bool { - self.applied && self.net_restricted + restrict_network_at_known_linux_launches(self.applied, self.net_restricted) } /// The active profile name. pub fn profile(&self) -> &ProfileName { @@ -612,6 +621,15 @@ mod tests { set_configured_profile("read-only"); assert_eq!(configured_profile_name(), Some("read-only")); } + #[test] + fn known_launch_guard_is_linux_only() { + assert_eq!( + restrict_network_at_known_linux_launches(true, true), + cfg!(target_os = "linux") + ); + assert!(!restrict_network_at_known_linux_launches(false, true)); + assert!(!restrict_network_at_known_linux_launches(true, false)); + } /// Create a temp workspace whose `.grok/sandbox.toml` contains `toml_body`. /// Returns the workspace path (caller removes it). #[cfg(all(feature = "enforce", unix))] diff --git a/crates/codegen/xai-grok-sandbox/src/network_policy.rs b/crates/codegen/xai-grok-sandbox/src/network_policy.rs new file mode 100644 index 0000000..0e7a26d --- /dev/null +++ b/crates/codegen/xai-grok-sandbox/src/network_policy.rs @@ -0,0 +1,501 @@ +//! Pure policy modeling for future child website egress. +//! +//! These types are not selected by sandbox profiles or enforced by the current +//! runtime. Constructing a policy does not grant or restrict network access. + +use std::collections::BTreeSet; +use std::fmt; +use std::net::IpAddr; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use url::{Host, Url}; + +/// Version of the compact JSON produced by [`NetworkPolicySnapshot`]. +pub const NETWORK_POLICY_SNAPSHOT_VERSION: u32 = 1; + +/// Requested child-network behavior for future enforcement backends. +/// +/// This is not currently selected or enforced by the sandbox runtime. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", content = "policy", rename_all = "snake_case")] +#[non_exhaustive] +pub enum ChildNetworkPolicy { + Unrestricted, + Blocked, + Websites(WebsitePolicy), +} + +impl ChildNetworkPolicy { + pub fn from_restrict_network(restrict_network: bool) -> Self { + if restrict_network { + Self::Blocked + } else { + Self::Unrestricted + } + } +} + +/// Result of exact-origin website policy evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum WebsiteAction { + Allow, + Deny, +} + +/// Exact HTTP(S) origin with an IDNA ASCII hostname and effective nonzero port. +/// +/// Equality never includes subdomains, redirects, paths, or another scheme or +/// port. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WebsiteOrigin { + scheme: String, + hostname: String, + port: u16, +} + +impl WebsiteOrigin { + /// Parses only `http://authority`, `https://authority`, or either with `/`. + pub fn parse(value: &str) -> Result { + validate_raw_origin(value)?; + let url = Url::parse(value).map_err(|error| WebsiteOriginError::InvalidOrigin { + value: value.to_owned(), + reason: error.to_string(), + })?; + let scheme = url.scheme(); + let hostname = match url.host() { + Some(Host::Domain(hostname)) => hostname.strip_suffix('.').unwrap_or(hostname), + Some(Host::Ipv4(_) | Host::Ipv6(_)) => return Err(WebsiteOriginError::IpLiteral), + None => return Err(WebsiteOriginError::InvalidSyntax), + }; + let hostname = hostname.to_ascii_lowercase(); + if !valid_dns_hostname(&hostname) || hostname.parse::().is_ok() { + return Err(WebsiteOriginError::InvalidHost(hostname)); + } + let port = url + .port_or_known_default() + .ok_or(WebsiteOriginError::InvalidSyntax)?; + if port == 0 { + return Err(WebsiteOriginError::PortZero); + } + + Ok(Self { + scheme: scheme.to_owned(), + hostname, + port, + }) + } + + pub fn scheme(&self) -> &str { + &self.scheme + } + + pub fn hostname(&self) -> &str { + &self.hostname + } + + pub fn port(&self) -> u16 { + self.port + } +} + +fn validate_raw_origin(value: &str) -> Result<(), WebsiteOriginError> { + if value.bytes().any(|byte| byte <= b' ' || byte == 0x7f) { + return Err(WebsiteOriginError::InvalidSyntax); + } + if value.contains('\\') { + return Err(WebsiteOriginError::InvalidSyntax); + } + let authority = value + .strip_prefix("http://") + .or_else(|| value.strip_prefix("https://")) + .ok_or(WebsiteOriginError::InvalidSyntax)?; + let authority = authority.strip_suffix('/').unwrap_or(authority); + if authority.is_empty() + || authority.contains('/') + || authority.contains('?') + || authority.contains('#') + { + return Err(WebsiteOriginError::InvalidSyntax); + } + if authority.contains('@') { + return Err(WebsiteOriginError::Userinfo); + } + if authority.contains('*') { + return Err(WebsiteOriginError::Wildcard); + } + Ok(()) +} + +fn valid_dns_hostname(hostname: &str) -> bool { + !hostname.is_empty() + && hostname.len() <= 253 + && hostname.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + && label + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && label + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + }) +} + +impl fmt::Display for WebsiteOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}://{}:{}", self.scheme, self.hostname, self.port) + } +} + +impl FromStr for WebsiteOrigin { + type Err = WebsiteOriginError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl Serialize for WebsiteOrigin { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for WebsiteOrigin { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +/// Immutable exact-origin rules with deny precedence over allow and default. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebsitePolicy { + default: WebsiteAction, + allow: BTreeSet, + deny: BTreeSet, +} + +impl WebsitePolicy { + pub fn new( + default: WebsiteAction, + allow: impl IntoIterator, + deny: impl IntoIterator, + ) -> Self { + Self { + default, + allow: allow.into_iter().collect(), + deny: deny.into_iter().collect(), + } + } + + pub fn default_action(&self) -> WebsiteAction { + self.default + } + + pub fn allow(&self) -> &BTreeSet { + &self.allow + } + + pub fn deny(&self) -> &BTreeSet { + &self.deny + } + + /// Evaluates deny exact match, then allow exact match, then the default. + pub fn evaluate(&self, origin: &WebsiteOrigin) -> WebsiteAction { + if self.deny.contains(origin) { + WebsiteAction::Deny + } else if self.allow.contains(origin) { + WebsiteAction::Allow + } else { + self.default + } + } +} + +/// Versioned deterministic JSON and SHA-256 identity for later persistence. +/// +/// The snapshot is not currently written to sessions or used for enforcement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct NetworkPolicySnapshot { + version: u32, + policy: ChildNetworkPolicy, +} + +impl NetworkPolicySnapshot { + pub fn new(policy: ChildNetworkPolicy) -> Self { + Self { + version: NETWORK_POLICY_SNAPSHOT_VERSION, + policy, + } + } + + pub fn version(&self) -> u32 { + self.version + } + + pub fn policy(&self) -> &ChildNetworkPolicy { + &self.policy + } + + pub fn into_policy(self) -> ChildNetworkPolicy { + self.policy + } + + /// Serializes the stable compact JSON representation for this version. + pub fn canonical_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } + + /// Returns SHA-256 hex over [`Self::canonical_json`]. + pub fn sha256(&self) -> Result { + Ok(format!( + "{:x}", + Sha256::digest(self.canonical_json()?.as_bytes()) + )) + } + + pub fn validate_sha256(&self, expected: &str) -> Result { + Ok(self.sha256()?.eq_ignore_ascii_case(expected)) + } + + /// Decodes the version envelope before interpreting its policy payload. + pub fn from_canonical_json(value: &str) -> Result { + #[derive(Deserialize)] + struct RawSnapshot { + version: u32, + policy: serde_json::Value, + } + + let raw: RawSnapshot = serde_json::from_str(value)?; + if raw.version != NETWORK_POLICY_SNAPSHOT_VERSION { + return Err(NetworkPolicySnapshotError::UnsupportedVersion(raw.version)); + } + Ok(Self { + version: raw.version, + policy: serde_json::from_value(raw.policy)?, + }) + } +} + +/// Validation failures for strict raw exact-origin syntax. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum WebsiteOriginError { + #[error("website origin must use exact http(s)://authority syntax with optional '/'")] + InvalidSyntax, + #[error("invalid website origin '{value}': {reason}")] + InvalidOrigin { value: String, reason: String }, + #[error("website origin must not contain userinfo")] + Userinfo, + #[error("website origin must not contain wildcards")] + Wildcard, + #[error("website origin must not use an IP literal")] + IpLiteral, + #[error("invalid website origin hostname '{0}'")] + InvalidHost(String), + #[error("website origin port must be nonzero")] + PortZero, +} + +/// Snapshot encoding, decoding, and version failures. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum NetworkPolicySnapshotError { + #[error("invalid network policy snapshot: {0}")] + InvalidJson(#[from] serde_json::Error), + #[error("unsupported network policy snapshot version {0}")] + UnsupportedVersion(u32), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn origin(value: &str) -> WebsiteOrigin { + WebsiteOrigin::parse(value).unwrap() + } + + #[test] + fn normalizes_default_ports_case_trailing_dot_and_idna() { + let http = origin("http://Example.COM"); + assert_eq!(http, origin("http://example.com:80/")); + assert_eq!(http.scheme(), "http"); + assert_eq!(http.port(), 80); + + let https = origin("https://example.com."); + assert_eq!(https, origin("https://EXAMPLE.com:443")); + assert_eq!(https.port(), 443); + assert_eq!( + origin("https://bücher.example").hostname(), + "xn--bcher-kva.example" + ); + assert_eq!(origin("https://example.com:8443").port(), 8443); + } + + #[test] + fn rejects_non_origin_inputs() { + let cases = [ + ("ftp://example.com", "exact http(s)"), + ("https://127.0.0.1", "IP literal"), + ("https://[::1]", "IP literal"), + ("https://user@example.com", "userinfo"), + ("https://@example.com", "userinfo"), + ("https://:@example.com", "userinfo"), + ("https://example.com/path", "exact http(s)"), + ("https://example.com/?query=1", "exact http(s)"), + ("https://example.com/#fragment", "exact http(s)"), + ("https://*.example.com", "wildcards"), + ("https://example.*", "wildcards"), + ("https://example.com:0", "nonzero"), + ( + "https://bad_host.example", + "invalid website origin hostname", + ), + ("https://", "exact http(s)"), + ]; + + for (value, expected) in cases { + let error = WebsiteOrigin::parse(value).unwrap_err().to_string(); + assert!(error.contains(expected), "{value}: {error}"); + } + } + + #[test] + fn rejects_url_parser_repairs_and_ignored_characters() { + for value in [ + "https:example.com", + "https:/example.com", + "https:///example.com", + "https:\\example.com", + "https://example.com/..", + " https://example.com", + "https://example.com ", + "https://exam\tple.com", + "https://example.com\n", + ] { + assert_eq!( + WebsiteOrigin::parse(value), + Err(WebsiteOriginError::InvalidSyntax), + "{value:?}" + ); + } + } + + #[test] + fn evaluates_exact_origin_with_deny_precedence() { + let exact = origin("https://example.com"); + let allowed = origin("https://allowed.example"); + let policy = WebsitePolicy::new( + WebsiteAction::Deny, + [exact.clone(), allowed.clone()], + [exact.clone()], + ); + + assert_eq!(policy.evaluate(&exact), WebsiteAction::Deny); + assert_eq!(policy.evaluate(&allowed), WebsiteAction::Allow); + for different in [ + "http://example.com", + "https://sub.example.com", + "https://example.com:8443", + ] { + assert_eq!(policy.evaluate(&origin(different)), WebsiteAction::Deny); + } + } + + #[test] + fn default_action_applies_after_exact_rules() { + let allowed = origin("https://allowed.example"); + let denied = origin("https://denied.example"); + let policy = WebsitePolicy::new(WebsiteAction::Deny, [allowed.clone()], [denied.clone()]); + + assert_eq!(policy.evaluate(&allowed), WebsiteAction::Allow); + assert_eq!(policy.evaluate(&denied), WebsiteAction::Deny); + assert_eq!( + policy.evaluate(&origin("https://other.example")), + WebsiteAction::Deny + ); + } + + #[test] + fn snapshot_deduplicates_sorts_and_hashes_independent_of_input_order() { + let a = origin("https://a.example"); + let b = origin("https://b.example"); + let first = WebsitePolicy::new( + WebsiteAction::Deny, + [b.clone(), a.clone(), b.clone()], + [b.clone(), a.clone()], + ); + let second = WebsitePolicy::new( + WebsiteAction::Deny, + [a.clone(), b.clone()], + [a.clone(), b.clone(), a.clone()], + ); + let first = NetworkPolicySnapshot::new(ChildNetworkPolicy::Websites(first)); + let second = NetworkPolicySnapshot::new(ChildNetworkPolicy::Websites(second)); + + assert_eq!(first, second); + let ChildNetworkPolicy::Websites(policy) = first.policy() else { + panic!("expected website policy") + }; + assert_eq!(policy.allow().iter().collect::>(), vec![&a, &b]); + assert_eq!(first.sha256().unwrap(), second.sha256().unwrap()); + assert!(first.validate_sha256(&first.sha256().unwrap()).unwrap()); + assert!(!first.validate_sha256(&"0".repeat(64)).unwrap()); + } + + #[test] + fn snapshot_roundtrip_preserves_policy_and_hash() { + let policy = ChildNetworkPolicy::Websites(WebsitePolicy::new( + WebsiteAction::Deny, + [origin("https://allowed.example")], + [origin("http://denied.example:8080")], + )); + let snapshot = NetworkPolicySnapshot::new(policy.clone()); + let json = snapshot.canonical_json().unwrap(); + let expected = r#"{"version":1,"policy":{"mode":"websites","policy":{"default":"deny","allow":["https://allowed.example:443"],"deny":["http://denied.example:8080"]}}}"#; + assert_eq!(json, expected); + assert_eq!( + snapshot.sha256().unwrap(), + "1b076f4854a41891304774143110ef54eb9936160d3d0ea3db91ca08f1e06f84" + ); + + let decoded = NetworkPolicySnapshot::from_canonical_json(expected).unwrap(); + assert_eq!(decoded.version(), NETWORK_POLICY_SNAPSHOT_VERSION); + assert_eq!(decoded.policy(), &policy); + assert_eq!(decoded.clone().into_policy(), policy); + assert_eq!(decoded.sha256().unwrap(), snapshot.sha256().unwrap()); + let wrong_version = r#"{"version":2,"policy":{"mode":"future_mode"}}"#; + assert!(matches!( + NetworkPolicySnapshot::from_canonical_json(wrong_version), + Err(NetworkPolicySnapshotError::UnsupportedVersion(2)) + )); + } + + #[test] + fn legacy_restriction_maps_without_selecting_websites() { + assert_eq!( + ChildNetworkPolicy::from_restrict_network(false), + ChildNetworkPolicy::Unrestricted + ); + assert_eq!( + ChildNetworkPolicy::from_restrict_network(true), + ChildNetworkPolicy::Blocked + ); + } +} diff --git a/crates/codegen/xai-grok-sandbox/src/paths.rs b/crates/codegen/xai-grok-sandbox/src/paths.rs index 4728ac2..22fa547 100644 --- a/crates/codegen/xai-grok-sandbox/src/paths.rs +++ b/crates/codegen/xai-grok-sandbox/src/paths.rs @@ -4,9 +4,7 @@ //! ecosystem (package-manager / toolchain) writable paths into helpers //! consumed by [`super::profiles`]. -#[cfg(all(feature = "enforce", unix))] -use std::path::Path; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; // ── Grok state directory ──────────────────────────────────────────────────── @@ -51,7 +49,6 @@ pub(crate) const DEVICE_DIRS: &[&str] = &[ /// `/private/var/folders/` (the real `TMPDIR` / `NSTemporaryDirectory()`). /// git, compilers, and other tools write temp files to `$TMPDIR` which /// resolves to `/private/var/folders/xx/.../T/` on macOS. -#[cfg(all(feature = "enforce", unix))] pub(crate) fn temp_writable_paths() -> Vec { let mut paths = vec![PathBuf::from("/tmp"), PathBuf::from("/var/tmp")]; @@ -81,7 +78,6 @@ pub(crate) fn temp_writable_paths() -> Vec { /// Writable directory paths for profiles that allow workspace writes (workspace, devbox, strict). /// Device files are handled separately via `allow_file` in `to_capability_set_with_config`. -#[cfg(all(feature = "enforce", unix))] pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec { let mut paths = vec![workspace.to_path_buf(), grok_home()]; paths.extend(temp_writable_paths()); @@ -90,7 +86,6 @@ pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec { /// Writable directory paths for the read-only profile (minimal: just ~/.grok + temp). /// Device files are handled separately via `allow_file` in `to_capability_set_with_config`. -#[cfg(all(feature = "enforce", unix))] pub(crate) fn essential_writable_paths_minimal() -> Vec { let mut paths = vec![grok_home()]; paths.extend(temp_writable_paths()); diff --git a/crates/codegen/xai-grok-sandbox/src/profiles.rs b/crates/codegen/xai-grok-sandbox/src/profiles.rs index 3a3e242..a7664bc 100644 --- a/crates/codegen/xai-grok-sandbox/src/profiles.rs +++ b/crates/codegen/xai-grok-sandbox/src/profiles.rs @@ -16,9 +16,8 @@ use crate::deny::{ }; use crate::paths::grok_home; #[cfg(all(feature = "enforce", unix))] -use crate::paths::{ - DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal, -}; +use crate::paths::{DEVICE_DIRS, DEVICE_FILES}; +use crate::paths::{essential_writable_paths, essential_writable_paths_minimal}; /// A resolved sandbox profile ready to be converted to a `CapabilitySet`. #[derive(Debug, Clone)] @@ -69,22 +68,9 @@ pub enum ProfileName { } impl ProfileName { - pub fn restricts_network(&self) -> bool { + pub(crate) fn restricts_network(&self) -> bool { matches!(self, Self::ReadOnly | Self::Strict) } - - /// Resolve network restriction from config (handles Custom profiles). - pub fn restricts_network_resolved(&self, config: &SandboxConfig) -> bool { - match self { - Self::ReadOnly | Self::Strict => true, - Self::Workspace | Self::Devbox | Self::Off => false, - Self::Custom(name) => config - .profiles - .get(name) - .and_then(|p| p.restrict_network) - .unwrap_or(false), - } - } } impl std::fmt::Display for ProfileName { @@ -183,9 +169,9 @@ fn load_config_file(path: &Path) -> Option { } } -#[cfg(all(feature = "enforce", unix))] impl ProfileName { /// Convert this profile into a nono `CapabilitySet` for the given workspace. + #[cfg(all(feature = "enforce", unix))] pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result { let config = load_sandbox_config(workspace); self.to_capability_set_with_config(workspace, &config) @@ -195,6 +181,7 @@ impl ProfileName { /// /// A custom profile's own `deny` list is kernel-enforced (read + write/rename) /// on top of the base profile. + #[cfg(all(feature = "enforce", unix))] pub fn to_capability_set_with_config( &self, workspace: &Path, @@ -204,10 +191,15 @@ impl ProfileName { return Ok(CapabilitySet::new()); } - // Resolve to a SandboxProfile - let profile = self.resolve(workspace, config)?; + let profile = self.resolve_profile(workspace, config)?; + Self::capability_set_from_profile(workspace, &profile) + } - // Build CapabilitySet from the resolved profile + #[cfg(all(feature = "enforce", unix))] + pub(crate) fn capability_set_from_profile( + workspace: &Path, + profile: &SandboxProfile, + ) -> anyhow::Result { let mut caps = CapabilitySet::new(); // Default read access @@ -510,12 +502,83 @@ mod tests { } #[test] - fn network_restriction() { - assert!(!ProfileName::Workspace.restricts_network()); - assert!(!ProfileName::Devbox.restricts_network()); - assert!(ProfileName::ReadOnly.restricts_network()); - assert!(ProfileName::Strict.restricts_network()); - assert!(!ProfileName::Off.restricts_network()); + fn built_in_network_restriction_values() { + let workspace = std::env::current_dir().unwrap(); + let config = SandboxConfig::default(); + + for (name, expected) in [ + (ProfileName::Workspace, false), + (ProfileName::Devbox, false), + (ProfileName::ReadOnly, true), + (ProfileName::Strict, true), + ] { + let resolved = name.resolve_profile(&workspace, &config).unwrap(); + assert_eq!(resolved.restrict_network, expected, "{name}"); + } + } + + fn network_inheritance_config() -> SandboxConfig { + SandboxConfig { + profiles: HashMap::from([ + ( + "strict-inherited".to_string(), + ProfileConfig { + extends: Some("strict".to_string()), + restrict_network: None, + read_only: vec![], + read_write: vec![], + deny: vec![], + }, + ), + ( + "read-only-inherited".to_string(), + ProfileConfig { + extends: Some("read-only".to_string()), + restrict_network: None, + read_only: vec![], + read_write: vec![], + deny: vec![], + }, + ), + ( + "strict-unrestricted".to_string(), + ProfileConfig { + extends: Some("strict".to_string()), + restrict_network: Some(false), + read_only: vec![], + read_write: vec![], + deny: vec![], + }, + ), + ( + "workspace-restricted".to_string(), + ProfileConfig { + extends: Some("workspace".to_string()), + restrict_network: Some(true), + read_only: vec![], + read_write: vec![], + deny: vec![], + }, + ), + ]), + } + } + + #[test] + fn custom_network_restriction_inherits_and_overrides_base() { + let workspace = std::env::current_dir().unwrap(); + let config = network_inheritance_config(); + + for (name, expected) in [ + ("strict-inherited", true), + ("read-only-inherited", true), + ("strict-unrestricted", false), + ("workspace-restricted", true), + ] { + let profile_name = ProfileName::Custom(name.to_string()); + let resolved = profile_name.resolve_profile(&workspace, &config).unwrap(); + assert_eq!(resolved.restrict_network, expected, "{name}"); + } } #[test] diff --git a/crates/codegen/xai-grok-shared/src/ui_config.rs b/crates/codegen/xai-grok-shared/src/ui_config.rs index e6a04de..46a60cb 100644 --- a/crates/codegen/xai-grok-shared/src/ui_config.rs +++ b/crates/codegen/xai-grok-shared/src/ui_config.rs @@ -187,6 +187,10 @@ pub struct ContextualHints { /// is still fold/nav (`flash` / `hold`). #[serde(default, skip_serializing_if = "Option::is_none")] pub word_select: Option, + /// SSH wrap session-load tip (recommend `grok wrap ssh` when the session + /// runs over SSH without an OSC 52 sink). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh_wrap: Option, } impl ContextualHints { @@ -199,6 +203,7 @@ impl ContextualHints { && self.send_now.is_none() && self.small_screen.is_none() && self.word_select.is_none() + && self.ssh_wrap.is_none() } } diff --git a/crates/codegen/xai-grok-shell-base/src/util/mod.rs b/crates/codegen/xai-grok-shell-base/src/util/mod.rs index c65fc46..a5b5222 100644 --- a/crates/codegen/xai-grok-shell-base/src/util/mod.rs +++ b/crates/codegen/xai-grok-shell-base/src/util/mod.rs @@ -69,19 +69,50 @@ pub fn is_cli_chat_proxy_url(url: &str) -> bool { } false } -/// True for first-party xAI endpoints (`*.x.ai`, cli-chat-proxy, and optional -/// non-production first-party hosts when that feature is enabled). +/// True for xAI-operated endpoints (`*.x.ai`, cli-chat-proxy, and optional +/// non-production xAI hosts when that feature is enabled). /// `disable_api_key_auth` refuses keys only for these; other hosts are BYOK and /// exempt. Safe against invalid URLs and suffix attacks (`evil-x.ai.example`). -pub fn is_first_party_xai_url(url: &str) -> bool { +/// +/// Scheme-agnostic so credential *refusal* fails closed. To decide where to +/// *attach* a credential, use [`is_xai_api_bearer_url`]. +pub fn is_xai_api_url(url: &str) -> bool { + is_xai_api_url_impl(url, false) +} +/// Like [`is_xai_api_url`], but requires `https` on every arm, so a +/// session bearer is never attached to a cleartext endpoint, including loopback +/// (a co-located process could otherwise read a token sent to `http://localhost`). +pub fn is_xai_api_bearer_url(url: &str) -> bool { + is_xai_api_url_impl(url, true) +} +fn is_xai_api_url_impl(url: &str, require_https: bool) -> bool { + if require_https { + let Ok(parsed) = reqwest::Url::parse(url) else { + return false; + }; + if parsed.scheme() != "https" { + return false; + } + if is_loopback_host(&parsed) { + return false; + } + } if is_cli_chat_proxy_url(url) { return true; } reqwest::Url::parse(url) .ok() - .and_then(|u| u.host_str().map(|h| h.to_owned())) + .and_then(|u| u.host_str().map(str::to_owned)) .is_some_and(|host| host == "x.ai" || host.ends_with(".x.ai")) } +fn is_loopback_host(parsed: &reqwest::Url) -> bool { + match parsed.host() { + Some(url::Host::Domain(host)) => host == "localhost", + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + } +} /// Truncate a string to at most `max_chars` characters. /// Slices at char boundaries so multi-byte UTF-8 never panics. pub fn truncate(s: &str, max_chars: usize) -> &str { @@ -237,25 +268,39 @@ mod tests { )); } #[test] - fn test_is_first_party_xai_url() { - assert!(is_first_party_xai_url("https://api.x.ai/v1")); - assert!(is_first_party_xai_url( - "https://api.x.ai/v1/chat/completions" - )); - assert!(is_first_party_xai_url("https://x.ai")); - assert!(is_first_party_xai_url( + fn test_is_xai_api_url() { + assert!(is_xai_api_url("https://api.x.ai/v1")); + assert!(is_xai_api_url("https://api.x.ai/v1/chat/completions")); + assert!(is_xai_api_url("https://x.ai")); + assert!(is_xai_api_url( "https://cli-chat-proxy.grok.com/v1/chat/completions" )); - assert!(!is_first_party_xai_url("https://api.openai.com/v1")); - assert!(!is_first_party_xai_url("https://api.anthropic.com/v1")); - assert!(!is_first_party_xai_url( - "https://generativelanguage.googleapis.com" + assert!(!is_xai_api_url("https://api.openai.com/v1")); + assert!(!is_xai_api_url("https://api.anthropic.com/v1")); + assert!(!is_xai_api_url("https://generativelanguage.googleapis.com")); + assert!(!is_xai_api_url("https://api.x.ai.evil.example/v1")); + assert!(!is_xai_api_url("https://evil-x.ai.attacker.com/v1")); + assert!(!is_xai_api_url("https://prefixx.ai/v1")); + assert!(!is_xai_api_url("not-a-url")); + assert!(!is_xai_api_url("")); + assert!(is_xai_api_url("http://api.x.ai/v1")); + assert!(is_xai_api_url("http://localhost:11434/v1")); + } + #[test] + fn test_is_xai_api_bearer_url() { + assert!(is_xai_api_bearer_url("https://api.x.ai/v1")); + assert!(!is_xai_api_bearer_url("http://api.x.ai/v1")); + assert!(!is_xai_api_bearer_url("http://localhost:11434/v1")); + { + assert!(!is_xai_api_bearer_url("https://localhost:11434/v1")); + assert!(!is_xai_api_bearer_url("https://127.0.0.2:11434/v1")); + assert!(!is_xai_api_bearer_url("https://[::1]:11434/v1")); + } + assert!(is_xai_api_bearer_url("https://API.X.AI/v1")); + assert!(!is_xai_api_bearer_url( + "https://api.x.ai@attacker.example/v1" )); - assert!(!is_first_party_xai_url("https://api.x.ai.evil.example/v1")); - assert!(!is_first_party_xai_url("https://evil-x.ai.attacker.com/v1")); - assert!(!is_first_party_xai_url("https://prefixx.ai/v1")); - assert!(!is_first_party_xai_url("not-a-url")); - assert!(!is_first_party_xai_url("")); + assert!(!is_xai_api_bearer_url("https://х.ai/v1")); } #[test] fn test_truncate() { diff --git a/crates/codegen/xai-grok-shell-base/src/util/secure_file.rs b/crates/codegen/xai-grok-shell-base/src/util/secure_file.rs index 3c52d1f..64372da 100644 --- a/crates/codegen/xai-grok-shell-base/src/util/secure_file.rs +++ b/crates/codegen/xai-grok-shell-base/src/util/secure_file.rs @@ -26,7 +26,7 @@ use std::io::{self, Write}; use std::path::Path; #[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; /// Creates or opens a file with secure permissions (owner read/write only). /// @@ -58,17 +58,20 @@ pub fn write_secure_file(path: &Path, contents: &[u8]) -> io::Result<()> { file.write_all(contents)?; file.flush()?; - // On Windows, we need to set permissions after file creation - #[cfg(windows)] - { - set_windows_secure_permissions(path)?; - } + // Re-assert owner-only bits: `OpenOptions::mode` only applies on create, + // so an existing world-readable file would otherwise keep open perms. + ensure_owner_only_permissions(path)?; Ok(()) } /// Opens a file for writing with secure permissions set during creation (Unix) /// or prepares it for permission setting after creation (Windows). +/// +/// Callers that write secret material should also call +/// [`ensure_owner_only_permissions`] after the write (or use +/// [`write_secure_file`]), because `mode(0o600)` only applies when the file +/// is newly created — not when truncating an existing path. pub fn open_secure_file(path: &Path) -> io::Result { let mut options = OpenOptions::new(); options.truncate(true).write(true).create(true); @@ -82,6 +85,44 @@ pub fn open_secure_file(path: &Path) -> io::Result { options.open(path) } +/// Ensure `path` is owner-read/write only (Unix `0o600` / Windows user ACL). +/// +/// Best-effort on missing files (`NotFound` is ignored). Other errors +/// propagate so callers can fail closed when tightening a secret store. +/// +/// Use on **load** of credential files so a hand-copied or restored +/// world-readable `auth.json` is tightened before the process continues. +pub fn ensure_owner_only_permissions(path: &Path) -> io::Result<()> { + match ensure_owner_only_permissions_inner(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +fn ensure_owner_only_permissions_inner(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + let metadata = std::fs::metadata(path)?; + let mode = metadata.permissions().mode(); + if mode & 0o777 != 0o600 { + let mut perms = metadata.permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(path, perms)?; + } + Ok(()) + } + #[cfg(windows)] + { + set_windows_secure_permissions(path) + } + #[cfg(not(any(unix, windows)))] + { + let _ = path; + Ok(()) + } +} + /// Sets Windows-specific secure permissions on a file. /// /// This function modifies the file's ACL to: @@ -216,8 +257,6 @@ mod tests { #[cfg(unix)] #[test] fn test_unix_permissions() { - use std::os::unix::fs::PermissionsExt; - let temp_dir = tempfile::tempdir().unwrap(); let file_path = temp_dir.path().join("test_perms.txt"); @@ -228,4 +267,43 @@ mod tests { // Check that only owner has read/write (0o600), ignoring file type bits assert_eq!(mode & 0o777, 0o600); } + + #[cfg(unix)] + #[test] + fn ensure_owner_only_tightens_world_readable_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("loose.txt"); + fs::write(&file_path, b"secret").unwrap(); + let mut loose = fs::metadata(&file_path).unwrap().permissions(); + loose.set_mode(0o644); + fs::set_permissions(&file_path, loose).unwrap(); + assert_eq!( + fs::metadata(&file_path).unwrap().permissions().mode() & 0o777, + 0o644 + ); + + ensure_owner_only_permissions(&file_path).unwrap(); + assert_eq!( + fs::metadata(&file_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[cfg(unix)] + #[test] + fn write_secure_file_tightens_existing_world_readable_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("existing.txt"); + fs::write(&file_path, b"old").unwrap(); + let mut loose = fs::metadata(&file_path).unwrap().permissions(); + loose.set_mode(0o666); + fs::set_permissions(&file_path, loose).unwrap(); + + write_secure_file(&file_path, b"new secret").unwrap(); + assert_eq!( + fs::metadata(&file_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read_to_string(&file_path).unwrap(), "new secret"); + } } diff --git a/crates/codegen/xai-grok-shell/CHANGELOG.md b/crates/codegen/xai-grok-shell/CHANGELOG.md index bd266b6..94d979e 100644 --- a/crates/codegen/xai-grok-shell/CHANGELOG.md +++ b/crates/codegen/xai-grok-shell/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +# 0.2.102 — 2026-07-16 + +## Breaking Changes + +- **--minimal** and **--fullscreen** flags now apply only to the current session. + +## Features + +- **New /jump slash command** lets you quickly jump to any previous turn in the conversation. +- **New /timeline sidebar** shows a clickable tick rail for fast navigation between conversation turns. +- **grok login** now requests Grok Projects scopes so workspace listing works after consent. +- **Permission mode** can now be set fleet-wide via remote config when no local setting exists. +- **Edit tool output** has a setting to show a compact one-line summary instead of always-expanded diffs. +- **Tab completion** in !bash mode now works like a normal terminal (prefix fill, dropdown, directory drill-down). +- **Enterprise deployments** can now disable voice dictation via `requirements.toml` so `/voice` and Ctrl+Space are hidden for everyone. +- **User prompts** now appear bold only in `--minimal` mode; fullscreen keeps normal weight. +- **`grok plugin install`** now accepts a marketplace's registered name as a qualifier. +- Consecutive edits to the same file now collapse into a single scrollback row when collapsed edit blocks are enabled. +- Local sessions now inherit your shell environment variables and keep the current directory across commands. + +## Bug Fixes + +- **Login and re-login** no longer stack multiple device-code polls or leave stale flows running. +- **Background task tools** now render with correct icons and titles instead of the generic MCP wrench. +- **Task tool** now correctly validates and displays allowed model slugs for subagents. +- **Rewind** now correctly handles bash transcripts, permission follow-ups, and sessions that mix old and new prompt markers. +- **Re-login** during a session now immediately uses the new token instead of requiring a new session. +- **Terminal commands** using globs now behave the same on zsh as on bash and no longer fail with shell errors. +- **Installer** no longer replaces stowed shell configuration symlinks with plain files on upgrade. +- **Voice transcription** now works with enterprise API bases and API-key authentication. +- **Fixed crashes** on some network-mounted home directories by using a safer SQLite journal mode. +- **Home and End keys** now move to the ends of the current wrapped line in the prompt. +- **Arrow keys and Esc** now work correctly inside viewers opened from the dashboard. +- **Warns at startup** when user and project sandbox profiles define the same name differently. +- **Billing upgrade links** now show the full URL in the transcript (and copy it) when a browser cannot be opened. +- **Fixed Ctrl+Y yank** no longer working after sending a prompt. +- **No longer shows permission prompts** seconds after a turn was cancelled with Esc or Ctrl+C. +- **Page Up and Page Down** now move the highlighted entry to the top or bottom of the visible scrollback area. +- Conflicting project and user sandbox profiles now show a clear warning on the welcome screen. +- **OAuth login URLs** no longer contain duplicate referrer parameters. +- **File links** in official VS Code Remote-SSH terminals now use VS Code's native path handling. +- **Minimal mode** now shows the folder-trust prompt after sign-in when required. +- **Skills** whose names collide with built-in slash commands are now reachable via qualified names. +- **Fixed background task tracking** when using grok -p --no-wait-for-background so tasks are properly reaped on exit. +- **Rate limit errors (429)** now show specific server messages (capacity, team limits, free-usage) instead of generic upgrade prompts, with correct copy based on auth type. +- **`/copy` slash command** is now available in minimal mode. + +## Performance + +- **Improved recap and compaction** behavior. + + # 0.2.101 — 2026-07-13 ## Features @@ -101,7 +153,7 @@ - **Clipboard copy** now reports success correctly when using iTerm2 over SSH. - **Fixed scroll/input conflicts** when plan approval appeared over an open edit block. - **Fixed frequent MCP and skills reloads** that could freeze sessions on devboxes. -- **MCP servers using HTTP** (such as streamable HTTP MCP servers) now automatically recover from disconnects. +- **MCP servers using HTTP** (such as HTTP MCP servers for Slack) now automatically recover from disconnects. - **Next reset time** in /usage now shows in your local timezone instead of Pacific Time. @@ -236,7 +288,7 @@ - **Always allow** grants for MCP, web_fetch and bash now take effect immediately in auto mode without re-prompting. - **Cmd/Ctrl+click** on bare http(s) links now opens only once on Warp terminals. - **Cmd/Ctrl+click** now works on imagine media paths and URLs that wrap across multiple terminal rows. -- **grok update** on Windows no longer fails when a previous.old executable is still running. +- **grok update** on Windows no longer fails when a previous .old executable is still running. ## Performance @@ -297,7 +349,7 @@ - **Try Again** on the free-usage paywall now correctly resubmits after rate-limit retries. - **Cursor** now respects your terminal's default blink style instead of always blinking. - **Skill commands** in scrollback now highlight only the command name, not the arguments. -- **Plan files** now default to.grok/plan.md to match Grok conventions. +- **Plan files** now default to .grok/plan.md to match Grok conventions. - **LaTeX math** renders correctly for display equations and complex subscripts. - **Queue hint** in the terminal no longer shows incorrect bold text on part of the message. @@ -953,7 +1005,7 @@ ## Breaking Changes -- **`grok mcp add`** now accepts positional arguments (e.g. `grok mcp add filesystem -- npx...`), supports --scope project, and adds -e/-H flags for env/headers. +- **`grok mcp add`** now accepts positional arguments (e.g. `grok mcp add filesystem -- npx ...`), supports --scope project, and adds -e/-H flags for env/headers. ## Features @@ -1318,7 +1370,7 @@ ## Bug Fixes -- **MCP servers** declared in both a plugin's.mcp.json and plugin.json are now registered instead of dropped. +- **MCP servers** declared in both a plugin's .mcp.json and plugin.json are now registered instead of dropped. - **Git operations** now correctly target the repository for each session's working directory. # 0.2.19 @@ -1531,3323 +1583,3 @@ - **Large chat sessions** now use substantially less memory and run faster during forks, rewinds, and compaction. -# 0.1.220-alpha.4 - -## Features - -- **Version output** now shows [alpha] or [stable] in `--version`, banners, `/session-info`, and `grok inspect`. -- **New "always-approve"** option appears first in permission dialogs. Bash commands now resolve reliably on NixOS and Homebrew installs. - -## Bug Fixes - -- **Auxiliary model calls** such as session titles and image descriptions now succeed for API-key users. - - -# 0.1.220-alpha.3 - -## Bug Fixes - -- **Pasting or dropping files from Finder** and handling multiple images now works reliably on macOS. -- **Clipboard copy and paste** now works reliably on Linux Wayland desktops. - - -# 0.1.220-alpha.2 - -## Bug Fixes - -- **Web and X search tools** now activate correctly after switching to a model that supports backend search. - - -# 0.1.220-alpha.1 - -## Features - -- **Release notes** are now fetched from the CDN instead of being bundled inside the binary. - -## Bug Fixes - -- **--restore-code** now stashes dirty changes before restoring and shows clear success or failure messages. -- **Shell** background tasks now create their output file immediately and AwaitShell exits early on pattern match. - - -# 0.1.220 - -## Features - -- **Model selection** now respects API-key permissions and defaults auxiliary calls like summaries to your current model. -- **Release notes** command now loads the latest notes from the network instead of bundled files. -- **Version output** now shows [alpha] or [stable] labels based on the release-channel pointer. -- **Permission prompts** now offer an always-approve option; shell commands resolve more reliably across installs. - -## Bug Fixes - -- **--restore-code** now safely stashes your uncommitted changes before restoring and shows clear success or failure messages. -- **Pasting or dropping files** from Finder now works reliably and images no longer lose their numbers or chips. -- **Background shell output** is now available immediately after starting and AwaitShell exits early when a pattern matches. -- **Copy to clipboard** now works reliably on Linux Wayland desktops using wl-copy or xclip fallbacks. -- **Web and X search** now activate correctly after switching to a backend-search model without restarting the session. - - -# 0.1.219-alpha.1 - -## Bug Fixes - -- **read_file** now always returns the file content instead of a duplicate-read message after compaction. - - -# 0.1.219 - -## Bug Fixes - -- **read_file** tool now always returns file content instead of a duplicate-read message after compaction. - - -# 0.1.218-alpha.1 - -## Features - -- **Project instruction files** now recognize.claude/CLAUDE.md and.claude/CLAUDE.local.md. - -## Bug Fixes - -- **run_terminal_command** on Windows PowerShell no longer suggests unavailable Unix utilities. - - -# 0.1.218 - -## Features - -- **Project instruction files** named.claude/CLAUDE.md are now discovered automatically. - -## Bug Fixes - -- **Windows PowerShell users** no longer see model suggestions for Unix commands that do not exist. - - -# 0.1.217-alpha.5 - -## Features - -- **New `/export` slash command** and `grok export` CLI command let you save or copy conversation transcripts as Markdown. - -## Bug Fixes - -- **Image pasting** now correctly prefers images from the clipboard and keeps dragged screenshots visible instead of losing them. -- **Video generation** no longer forces short 5-second clips; longer durations now work as requested. - - -# 0.1.217-alpha.4 - -## Features - -- **New `grok plugin` commands** let you manage plugins and marketplaces directly from the terminal. - -## Bug Fixes - -- **Image pasting** now correctly prefers images from the clipboard and keeps dragged screenshots visible. -- **Subagents** now correctly use bring-your-own-key credentials when the parent model specifies them. -- **MCP server connections** no longer fail with initialization errors under concurrent tool calls. -- **Signal sync** is more resilient to transient authentication hiccups. -- **Authentication recovery** after subscription changes no longer causes duplicate IdP calls. - - -# 0.1.217-alpha.3 - -## Features - - -## Bug Fixes - -- **Image description** now works correctly when using custom models with bring-your-own-key configs. -- **Subagents** now correctly show the parent model's display name instead of the internal routing slug. - - -# 0.1.217-alpha.2 - -## Bug Fixes - -- **Model picker** now correctly shows all entries including aliases when multiple models share the same slug. - - -# 0.1.217-alpha.1 - -## Features - - -## Bug Fixes - -- **Tool calls** now appear in the output as soon as they start instead of waiting until they finish. - - -# 0.1.217 - -## Features - -- **Todo list reminders** now appear at turn end when pending tasks remain after compaction. -- **Laziness detection** can now be enabled per-model to automatically nudge idle agents. -- **New `grok plugin` commands** let you manage plugins and marketplaces directly from the terminal. -- New `/export` command and `grok export` let you save or copy conversation transcripts as Markdown. - -## Bug Fixes - -- **Pasting images** from the clipboard or dragged screenshots now works reliably without temporary paths or duplicates. -- **Subagents** now correctly use per-model API keys instead of falling back to OAuth when both are present. -- **Model picker** now shows all models including aliases like "auto" when multiple entries share a slug. -- **Tool calls** now appear in the scrollback immediately when dispatched. -- **Cursor image and PDF handling** now processes attachments reliably without errors. -- Signal tracking for sessions is now more resilient to temporary login hiccups. -- Subscription detection now correctly respects access gates from the server. -- Image description now works reliably with custom models and bring-your-own-key configs. -- Subagents now show the correct parent model name instead of an internal routing slug. -- Authentication recovery after subscription changes no longer causes duplicate login attempts. -- Video generation no longer forces short ~5s clips when no duration is specified. -- **Fixed excessive authentication errors** that could occur during token refresh in background operations. - - -# 0.1.216-alpha.1 - -## Features - -- **Grok** now reminds itself to take action in very long conversations while still obeying instructions like "just plan". - - -# 0.1.216 - -## Features - -- **Grok** now reminds itself to execute plans in long conversations while strictly following instructions like "just plan". - -## Bug Fixes - -- **Image pasting** on Linux now works reliably when the clipboard contains images. - - -# 0.1.215-alpha.1 - -## Features - -- **Default models** now use plan mode tools (enter/exit plan mode, ask user) unless overridden. - -## Bug Fixes - -- **Skills** now resolve relative links to bundled files like reference docs inside the skill directory. -- **Subagents** no longer cause the parent session to lose write or execute tools. - - -# 0.1.215 - -## Features - -- **Default agent behavior** now uses plan mode tools for models without an explicit agent_type. - -## Bug Fixes - -- **Relative links** inside skill files now resolve to absolute paths automatically. -- **Subagents** no longer cause the parent session to lose access to tools. - - -# 0.1.214-alpha.1 - - -# 0.1.214 - -## Features - - -## Bug Fixes - -- **Image and video generation** no longer causes the model to repeat internal instructions about inline display. -- **Sessions** now resume reliably even if the original model is removed from the catalog. -- **Fixed crashes** when tool output or prompts contain multibyte characters like CJK or emoji. -- **Web search** now correctly switches between local and hosted tools when changing models. -- **Custom agent profiles** from clients are now respected for default models. - - -# 0.1.213-alpha.7 - -## Features - -- **New settings pane** lets users configure appearance, themes, and behavior directly in the terminal UI. -- **New image_edit tool** supports reference-based editing using uploaded photos or data URLs. -- **Goal mode** now surfaces verification-blocked pauses with human-readable reasons and resume guidance. -- **Added `grok logout` subcommand** to sign out from the terminal without launching the TUI. -- **Backend search** is now enabled by default for web_search and x_search. -- **Added per-MCP-server `expose_image_base64` option** so raw image data remains available in tool output. -- **Improved credit-limit experience** for max-tier users with an inline scrollback card instead of a modal. -- **Automatically detects** mid-session subscription upgrades after a credit-limit error. - -## Bug Fixes - -- **Goal mode** now auto-pauses on repeated failures or doom loops and resumes cleanly without errors. -- **Fixed sessions** in directories with very long or CJK names that previously failed with "File name too long". -- **Fixed /btw errors** on Anthropic opus models caused by orphaned tool calls and temperature conflicts. -- **Doom-loop halts** now correctly report harness termination instead of misleading "user cancelled" messages. -- **API-key users** no longer see unavailable models such as grok-build that 404 on selection. -- **Image and video generation** now recover better from network issues and large response bodies. -- **Improved error messages** when hitting a subscription wall with an API key set, suggesting `grok logout`. -- **Removed --budget flag** from /goal and made goal verification stricter. -- **Fixed a crash** on exit when using MCP servers that require token refresh. -- **Fixed follow-up errors** after backend search and prevented unsupported tools from reaching certain models. - - -# 0.1.213-alpha.6 - -## Performance - -- **Large sessions** now upload and process without long stalls or excessive memory use. - - -# 0.1.213-alpha.5 - -## Features - -- **Web search and X search** can now execute server-side with full session persistence when GROK_BACKEND_SEARCH is enabled. -- **read_file** can now append configurable rule reminders for matching.cursor/rules files when enabled. -- **read_file** now extracts text from PowerPoint (.pptx) files in addition to PDF and images. - -## Bug Fixes - -- **run_terminal_cmd** now reports clearer "exit: killed (reason)" headers for processes terminated by timeout or cancellation. -- **Fixed model errors** when switching to non-reasoning models after previously setting reasoning effort. -- **Compaction summaries** no longer cause the model to echo the original summarization instructions after resume. - - -# 0.1.213-alpha.4 - -## Features - -- **New /goal slash command** for tracking multi-step coding goals with live status and automatic pausing on Ctrl+C. -- **Goal mode** is now opt-in via config.toml or the GROK_GOAL environment variable. -- **Commands** in workspace/proxy mode now preserve environment variables, cwd, functions, and aliases across calls. -- **Background tasks** now report termination signals, reject self-killing patterns, and support unbounded timeouts. -- **Grok CLI** now runs natively on Intel Macs via x86_64 builds. - -## Bug Fixes - -- **Goal mode prompts** now work on all platforms and correctly place source code in your project directory. -- **Background tasks** now correctly trigger auto-wake even when a blocking wait times out. -- **Goal status bar** now shows the correct deliverable number matching the detail modal. -- **Task tool** now accepts model-emitted paths containing stray quotes or a leading tilde. -- **Subagent task output** no longer appears twice in the scrollback or leaves trailing system reminders. -- **Failed background subagents** now report errors and appear in task lists instead of silently disappearing. -- **Subscription purchases** now correctly refresh authentication so API calls succeed after the paywall lifts. - -## Performance - -- **MCP tool output** is now truncated earlier to reduce wasted context when models rarely follow up. - - -# 0.1.213-alpha.3 - -## Bug Fixes - -- **Credit status** now updates live after topping up or changing spending limits when at your usage cap. -- **Paywall** now lifts automatically after subscribing via grok login or external purchase without needing to re-login. -- **Images from /imagine** no longer render twice in the scrollback. - - -# 0.1.213-alpha.2 - -## Bug Fixes - -- **Fixed terminal UI corruption** on macOS that occurred when system malloc messages interleaved with the interface. - - -# 0.1.213-alpha.1 - -## Features - -- **Marketplace plugins** from URLs can now be pinned to a specific commit SHA for tamper protection. -- **Terminal output** now shows both the start and end of long command results instead of only the tail. -- **/imagine** now works reliably after session reload and shows images directly in tool results. -- **Images and PDFs** read with the file tool now appear immediately instead of in a later message. - -## Bug Fixes - -- **Plugins and Marketplace** reloads now show a clear loading indicator instead of misleading per-item badges. -- **Search tool** now gives the model better guidance for finding MCP tools by server and action name. -- **Windows users** now get a more reliable shell (PowerShell preferred) that avoids path mangling. -- **Model list** no longer gets stuck on only grok-build after waking from sleep on macOS. -- **Custom xai_api_base_url** settings in config.toml now correctly route session commands for enterprise deployments. - - -# 0.1.213 - -## Features - -- **/imagine** now renders generated images inline in tool results. -- **Images from read_file** now appear inline inside tool results instead of a follow-up message. -- **Goal mode** (opt-in) now tracks objectives and deliverables with live progress updates via the UpdateGoal tool. -- **New `/goal` slash command** lets you set objectives, track deliverables, pause/resume, and receive live progress updates during long tasks. -- **New Settings pane** (consolidated) for configuring appearance, themes, and general options directly from the UI. -- **Goal mode** is now opt-in via `[features] goal = true` in config.toml or the GROK_GOAL environment variable. -- **Rewind (Ctrl+Z)** now works in proxy/hub mode by routing file-state tracking through the remote workspace. -- **Paywall** now lifts automatically after subscribing via grok login or external purchase without needing to re-login. -- **Commands** in workspace/proxy mode now preserve environment variables, cwd, functions, and aliases across calls. -- **New /goal slash command** for tracking multi-step coding goals with live status and automatic pausing on Ctrl+C. -- **Grok CLI** now runs natively on Intel Macs via x86_64 builds. -- **New `image_edit` tool** supports editing images from reference photos. -- **Goal mode** now supports a "verification blocked" pause state with a user-visible reason. -- **read_file** now extracts text from PowerPoint (.pptx) files in addition to PDF and images. -- **Goal mode** verifiers now act as reviewers and subagent resumes use the latest ID. -- **New `grok logout` command** clears your cached login session directly from the terminal. -- **Web search and X search** now run server-side by default for improved results. -- **MCP image tools** can now expose raw base64 data so agents can forward images via file tools. -- **Max-tier users** now see an inline message with pay-as-you-go options when hitting credit limits. - -## Bug Fixes - -- **Windows shell detection** now prefers PowerShell and prevents Git Bash path mangling. -- **Fixed model list** getting stuck on only grok-build after waking from sleep on macOS by refreshing the catalog on every auth token update. -- **Fixed cross-platform prompt** text and ensured goal-mode workers write source code in the project workspace rather than temporary directories. -- **Enterprise config fix**: setting `xai_api_base_url` in config.toml now correctly routes session commands without also needing to set `cli_chat_proxy_base_url`. -- **Fixed hub disconnects** so the CLI degrades gracefully instead of panicking when the Computer Hub WebSocket drops. -- **Doom-loop terminations** now show "Agent was unable to make progress — turn ended in Xs." instead of the misleading user-cancel message. -- **Fixed terminal UI corruption** on macOS that occurred when system malloc messages interleaved with the interface. -- **Images from /imagine** no longer render twice in the scrollback. -- **Goal status bar** now shows the correct deliverable number matching the detail modal. -- **Task tool** now accepts model-emitted paths containing stray quotes or a leading tilde. -- **Subagent task output** no longer appears twice in the scrollback or leaves trailing system reminders. -- **Background tasks** now report termination signals, reject self-killing patterns, and support unbounded timeouts. -- **Failed background subagents** now report errors and appear in task lists instead of silently disappearing. -- **Subscription purchases** now correctly refresh authentication so API calls succeed after the paywall lifts. -- **run_terminal_cmd** now reports clearer "exit: killed (reason)" headers for processes terminated by timeout or cancellation. -- **Fixed model errors** when switching to non-reasoning models after previously setting reasoning effort. -- **Compaction summaries** no longer cause the model to echo the original summarization instructions after resume. -- **Fixed /btw** 400 errors on certain Anthropic models by cleaning up mid-turn tool state. -- **API key users** no longer see unavailable models like grok-build in the model list. -- **Image and video generation** now handles slow or interrupted network responses more reliably without generic decode errors. -- **Fixed error messages** when hitting a subscription wall with an API key set, now suggesting `grok logout`. -- **Fixed a crash** on exit when using MCP servers that require token refresh. -- **Reduced 400 errors** on background flush/dream operations for thinking models. -- **Fixed follow-up search errors** and ensured backend search only activates on supported models. -- **Subscription tier upgrades** are now detected automatically after a credit limit error without needing to log out. - -## Performance - -- **Large sessions** now upload and process without long stalls or excessive memory use. - - -# 0.1.212-alpha.5 - -## Bug Fixes - -- **Fixed /btw side questions** failing with 400 errors on models that use extended thinking. - - -# 0.1.212-alpha.4 - -## Breaking Changes - -- **Subagents** now always start fresh and must be fully briefed via the prompt instead of inheriting parent conversation history. - -## Features - -- **Memories** now avoid storing low-value tool counts and file lists while skipping near-duplicate entries via semantic similarity checks. - - -# 0.1.212-alpha.3 - -## Features - -- **Added configuration options** for the auto-compaction threshold on a per-model or global basis. -- **Shows 'Logged in with API key'** indicator on the welcome screen when using an API key. - -## Bug Fixes - -- **Improved error messages** when calling use_tool with an invalid or built-in tool name. -- **Fixed context window** incorrectly dropping to 128k for some models, preventing mid-session cascading compactions. -- **Fixed incorrect context windows** for models referenced by multiple catalog entries with different keys. - - -# 0.1.212-alpha.2 - -## Features - -- **Linkify URLs and file paths** across all TUI output -- **Improve /sessions modal** and /new /fork banners -- **Improve review plan UX** in the TUI - -## Bug Fixes - -- **Use multipart upload** for S3 payloads exceeding 8 MiB - - -# 0.1.212-alpha.1 - -## Features - -- **web_fetch** now downloads and saves images and videos to disk (with magic-byte checks) instead of rejecting them. - - -# 0.1.212 - -## Breaking Changes - -- **Subagents** no longer support fork_context; they always start fresh and must be fully briefed via the prompt. - -## Features - -- **Images and videos** now display directly in the scrollback with click-to-play support. -- **Skills** now show their author in the extensions list and search results. -- **web_fetch** now downloads and saves images and videos to disk instead of rejecting them. -- **Added configuration options** for the auto-compaction threshold on a per-model or global basis. -- **Shows 'Logged in with API key'** indicator on the welcome screen when using an API key. - -## Bug Fixes - -- **Long bash task output** no longer inflates context; full logs remain available via disk pointers. -- **Auth file locking** is now more robust against hung processes and concurrent refreshes. -- **Concurrent logins** and refreshes now wait efficiently without timeouts or retry storms. -- **Improved error messages** when calling use_tool with an invalid or built-in tool name. -- **Fixed context window** incorrectly dropping to 128k for some models, preventing mid-session cascading compactions. -- **Fixed incorrect context windows** for models referenced by multiple catalog entries with different keys. -- **Improved memory quality** by removing noisy auto-saved details and avoiding duplicate stored memories. -- **Fixed `/btw` errors** on models that use extended thinking by excluding internal reasoning from side-question requests. - -## Performance - -- **search_tool** now instantly finds exact tool names instead of falling back to fuzzy search. -- **search_tool** now better matches compound tool names like grafana-ai__SearchDashboards. - - -# 0.1.211-alpha.3 - -## Features - -- **New browser tools** for tab and network inspection. -- **Browser tools security hardening** with cookie mode and domain allowlisting. -- **Plugin install shorthand** now accepts owner/repo format. -- **Memory modal** now renders markdown with mouse interaction and scrollbar. -- **Keyboard shortcut dialog** (Ctrl+.) redesigned for discoverability. -- **Ask-question modal UX** with double-click selection and copy support. -- **Loading spinner** added to image viewer overlay. -- **PowerShell install scripts** added for Windows users. - -## Bug Fixes - -- **Fixed diff line numbers** to show correct absolute positions. -- **Fixed tasks stuck** in killing state in tasks pane. -- **Privacy opt-out** now persists across token refresh. -- **Fixed infinite restart loop** during auto-update failures. -- **Gitignored files** now readable by default. -- **Fixed auth refresh storm** on expired tokens. -- **/privacy opt-out** now works for personal OIDC users. -- **Model catalog** now retries after sleep/resume. -- **Fixed terminal rendering leak** on exit. - - -# 0.1.211-alpha.2 - -## Bug Fixes - -- **Login now handles corrupt authentication files** by backing them up instead of prompting repeatedly. -- **Fixed loops getting stuck** when resuming sessions. -- **Stopped duplicate notifications** for background tasks and subagents waited on directly. -- **Loop indicators now disappear** properly when subagents shut down. - - -# 0.1.211 - -## Features - -- **Added browser tools** for navigation, screenshots, and network inspection via Chrome. -- **Background tasks and subagents** now automatically notify the agent when they finish. -- **Browser tools** now use safer cookie handling and domain restrictions by default. - -## Bug Fixes - -- **Skills** now reliably apply persona instructions to subagents via shared definitions. -- **Notifications** from background tasks now render correctly without duplication. -- **Login** now recovers from corrupted auth files instead of failing repeatedly. -- **Loop tasks** no longer get stuck when resuming previous sessions. -- **Directly waited** background tasks no longer trigger extra notifications. -- **Loop indicators** are now properly removed when subagents shut down. -- **run_terminal_cmd** tool name restored; background parameter rename preserved. -- **Model picker** now recovers automatically after sleep/resume or temporary network issues instead of staying stuck on Grok Build. -- **MCP server connections** now succeed for servers that enforce audience binding on OAuth tokens. -- **read_file** now accesses gitignored files by default (grep/list_dir/search_replace still block them unless configured). -- **--no-ask-user** flag now correctly disables the ask_user_question tool in both TUI and headless modes. - - -# 0.1.210-alpha.6 - -## Bug Fixes - -- **File writes on Windows** now automatically retry if briefly locked by editors or antivirus. -- **Login process** now waits up to 10 minutes instead of 5 and shows a clearer timeout message. -- **Terminal interface** no longer gets garbled by subprocesses like git or npm. -- **Plan mode** no longer activates unexpectedly from tool descriptions containing similar phrases. -- **Authentication** now recovers immediately when using multiple terminals after token refreshes. - - -# 0.1.210-alpha.5 - - -# 0.1.210-alpha.4 - -## Bug Fixes - -- **`grok sessions search`** now finds local and remote sessions correctly, and **`grok -r `** restores remote sessions reliably. - - -# 0.1.210-alpha.3 - - -# 0.1.210-alpha.2 - -## Features - -- **Tool permission prompts and lists** now clearly show server name followed by action. - - -# 0.1.210-alpha.1 - -## Bug Fixes - -- **Model list** no longer collapses to default after long inactivity or re-login. -- **Session restore** no longer crashes on Windows after system reboot. -- **Fixed authentication failures** after token refresh errors. -- **Completed background tasks** now remain queryable via get_task_output. - -## Performance - -- **Interactive sessions** now initialize MCP servers progressively for faster startup. - - -# 0.1.210 - -## Features - -- **Faster startup** for interactive sessions with progressive MCP loading. -- **MCP tools now show** as 'Server: Action' in permission screens. - -## Bug Fixes - -- **Full model list** now survives long inactivity, logins, and token refreshes. -- **Fixed crash** restoring sessions on Windows after system reboot. -- **Windows file saves** now retry brief editor or AV locks automatically. -- **Completed background tasks** now remain queryable via get_task_output. -- **Fixed terminal garbage** during auto-updates on macOS. -- **`grok sessions search`** now finds local and remote sessions correctly, and **`grok -r `** restores remote sessions reliably. -- **Login timeout** increased to 10 minutes with clearer error message. -- **Fixed pager getting stuck in plan mode** due to tool titles mentioning plan commands. - - -# 0.1.209 - -## Features - -- **Read file** now renders PDF pages as images with optional page range. -- **PDF files now render as images** when read by the AI assistant. -- **PDF files can now be read as text** by specifying pages='text' in the read tool. - -## Bug Fixes - -- **Subagents** now show as cancelled when you reject dangerous commands like `rm -rf`. -- **Model selection** no longer resets on login or token refresh. -- **Background tasks** now wait for completion when `block=true` is requested. -- **Subagent summaries** now show accurate tool calls and turns. -- **AI now sees** completed background bash tasks between turns. -- **Sessions with content** now appear in desktop sidebar after restart. -- **AI warns** about duplicate background tasks before starting new ones. -- **Resumed sessions now inform** the AI about any background tasks still running from before. -- **Background tasks automatically resume** the conversation when they finish, even if idle. -- **Python background scripts now stream output** in real-time without buffering delays. - - -# 0.1.208-alpha.2 - -## Features - -- **PDF files** can now be read to extract and display rendered page images. -- **PDF files now render as page images** when using the read_file tool. -- **Extract plain text from PDFs** using pages="text" or format="text" in read_file. - -## Bug Fixes - -- **Subagents cancelled by permission prompts** now correctly reported to parent agents. -- **Custom model selections** no longer reset on login, logout, or token refresh. -- **Blocking task output requests** now wait reliably for completion. -- **Subagent stats** in task output and session cards now accurate. -- **Background task completions** now promptly visible to model between turns. -- **Saved sessions** now appear correctly in desktop sidebar after restart. -- **Resumed sessions now inform model** about previously running background tasks. -- **Model automatically wakes up** when background tasks complete. - - -# 0.1.208-alpha.1 - -## Features - -- **Shell command suggestions** from history and PATH now appear as ghost text. -- **File and directory completion** now available in shell suggestions. -- **AI shell command suggestions** enabled via GROK_SUGGESTIONS_AI=1 environment variable. -- **Plan approval status** now clickable; new Abandon option exits plan mode. - -## Bug Fixes - -- **Fixed restoring sessions** from cloud without local cache. -- **Fixed image errors** when loading old sessions with bad images. -- **Skill commands** now display cleanly in history, titles, and search. -- **Cloud sessions** now report correct OS, shell, paths, and git status. - - -# 0.1.208 - -## Features - -- **Shell command suggestions** now appear as dim italic ghost text after the cursor. -- **File and directory path completion** now available in shell suggestions. -- **AI-powered shell suggestions** configurable via `[suggestions]` or env vars. -- **Plan status** now clickable; new **Abandon plan** button and `d` key. - -## Bug Fixes - -- **Remote session restores** no longer fail with 401 auth errors. -- **Corrupt images** in history are now skipped or replaced with placeholders. -- **Skill commands** now display cleanly in history, search, and titles. -- **Cloud sessions** now use correct remote OS, shell, paths, and git status. -- **Bundled skills** like `/check` and `/best-of-n` now work in TUI sessions. - - -# 0.1.207 - -## Features - -- **New /check-work (/verify, /check) and /best-of-n (/bon)** slash commands now work in headless mode. - -## Bug Fixes - -- **Fixed session workspace setup** so git, shell, and file operations no longer fail. - - -# 0.1.206-alpha.1 - -## Features - -- **Simple mode** now persists across sessions in config.toml. - -## Bug Fixes - -- **Git and bash commands now work** reliably in the desktop app on Windows. -- **Slash command autocomplete** no longer duplicates unique skill entries. -- **Git branch changes** now update the status bar after shell commands. - - -# 0.1.206 - -## Features - -- **Simple mode** now saved in config.toml across restarts. - -## Bug Fixes - -- **Fixed git and bash commands** failing in Grok Desktop on Windows. -- **Fixed duplicate slash commands** in autocomplete dropdown. - - -# 0.1.205-alpha.1 - -## Features - -- **New `/check-work`** and **`/best-of-n`** commands** for self-verification and parallel task solving. - -## Bug Fixes - -- **Fixed authentication failures** during file uploads. -- **Fixed terminal display corruption** from mouse events and subprocess output. -- **Fixed sessions stuck** on read-only filesystem after login. - - -# 0.1.205 - -## Features - -- **New `/check-work` and `/best-of-n`** commands for self-verification and parallel solving. - -## Bug Fixes - -- **Fixed authentication failures** during file and artifact uploads. -- **Fixed terminal garbling** from mouse events and child process output. - - -# 0.1.204-alpha.3 - -## Features - -- **Remote tools** from hub-registered workspace servers are now discoverable and callable in sessions. -- **Worktree folders** now use readable names like 'projects-my-repo' instead of session IDs. -- **Session lists** now show AI-generated titles, git branches, repo names, and worktree labels. -- **`grok -w my-worktree`** creates a custom-named worktree folder. -- **Session picker** now groups entries by repository with headers and indents. -- **Remote workspaces** now handle multiple sessions over one connection. -- **`/usage manage`** now opens billing page; **`grok login`** enforces tiers correctly after refresh. - -## Bug Fixes - -- **Fixed AI-suggested paths** missing leading slash or quotes in tools like grep. -- **Fixed file upload failures** during large batch transfers over unstable connections. - - -# 0.1.204-alpha.2 - -## Bug Fixes - -- **Error messages for model not found or auth failures** now show available models, auth type, version, and login fix instructions. - - -# 0.1.204-alpha.1 - -## Features - -- **Subagents** now enabled by default, fixing skills like `/implement` and `/execute-plan`. - -## Bug Fixes - -- **Legacy web login** no longer treated as first-party xAI account for sharing and billing; prompts **`grok login`** upgrade. -- **Billing display** now includes pay-as-you-go status and monthly limits with better error handling. -- **Copy auth URLs** now works in Docker containers and displays cleanly for external providers. - -## Performance - -- **Large tool outputs** shortened when sent to Grok, full text saved to file in session folder. - - -# 0.1.204 - -## Breaking Changes - -- **Legacy authentication** removed; run **`grok login`** to upgrade to OAuth. - -## Features - -- **Hub tools** like read_file now discoverable and callable in pager sessions. -- **Worktree directories** now named readably (e.g., projects-my-repo) instead of random IDs. -- **Session lists** show AI-generated titles, branches, repos, and worktree labels. -- **Session picker** now groups entries by repository for easier navigation. -- **Remote workspace servers** supported via **`--hub-workspace-mode remote=`**. -- **Subagents** now enabled by default, fixing `/implement` and `/execute-plan` skills. -- **`/usage manage`** opens billing page; **`/usage`** or `show` displays credits. - -## Bug Fixes - -- **Legacy web login** no longer treated as premium xAI account; run **`grok login`** instead. -- **Session picker** removes duplicate blank sessions from same folder. -- **Sessions from deleted worktrees** still appear in picker after restart. -- **Credit bar** now shows pay-as-you-go status and monthly usage limits. -- **Auth URLs copy** correctly in Docker containers and display cleanly for external providers. -- **Model/auth errors** now show available models, auth type, version, and login fixes. -- **Tool path resolution** fixed for absolute paths missing leading slash. -- **Fixed upload failures** from HTTP/2 connection degradation. -- **Memory search** now reliably includes global and workspace MEMORY.md files. - -## Performance - -- **Large tool results** shortened in chat; full text saved to file with path link. - - -# 0.1.203-alpha.2 - -## Bug Fixes - -- **`/btw` slash command** now works correctly with all backends including Anthropic. - - -# 0.1.203-alpha.1 - -## Features - -- **New `grok-build-v2` agent** available in config.toml for prompt/tool testing. - -## Bug Fixes - -- **Fixed duplicate MCP and skill reminders** when resuming sessions. -- **Plan review overlay** now always shown, even in always-approve mode. -- **Fixed empty model list** and unknown model after login or logout. -- **Fixed unwanted login prompts** during brief network issues at startup. -- **Fixed `grok sessions list`** auth failures. - - -# 0.1.203 - -## Features - -- **New `grok-build-v2` agent** option in config.toml for testing prompt and tool changes. -- **Session picker `/load`** deep search now shows spinner, content matches, and previews on expand. - -## Bug Fixes - -- **Resumed sessions** no longer duplicate MCP server and skill reminders. -- **Plan mode exits** now always show interactive approval, even in always-approve mode. -- **Model picker** now updates correctly after login/logout without empty lists or 'unknown'. -- **`grok sessions list`** now works reliably after login. -- **`/btw` slash command** now supports all backends like Anthropic Messages API. - - -# 0.1.202-alpha.1 - -## Features - -- **Added /usage command** to view account usage and billing information. -- **Integrated Computer Hub client** for remote computer-use sessions. -- **Added --hub-url flag** to configure Computer Hub endpoint. -- **Implemented /rewind** with inline overlay, message picker, and live dimming preview. -- **Added /memory slash command** with browse and toggle routing. -- **Memory save notifications** in scrollback with remember/forget/recall instructions. -- **Added grok memory clear** CLI command with scope selection and confirmation. -- **Added /memory on|off** to toggle memory mid-session. -- **Vision support** for base64 images extracted from user queries. -- **Added --allow/--deny CLI flags** with PolicyDeny variant and deny rule UX improvements. -- **Configurable parallel verifiers** with improved verification prompt. -- **MCP connection progress UI** with system reminders and progressive strategy fix. - -## Bug Fixes - -- **Fixed session resume** defaulting to legacy auth instead of OIDC. -- **Reject WebLogin tokens** and force interactive login; fixed devbox auth. -- **Normalized skill names** by converting underscores to hyphens. -- **Fixed auth revalidation** to always check token validity. -- **Fixed memory search scoring**, modal UX, and pager display. - - -# 0.1.202 - -## Features - -- **Add /usage command** and billing extension fix -- **Computer Hub integration** into xai-grok-shell -- **--hub-url flag** and thread hub config to shell -- **Implement /rewind** with inline overlay, picker, and live dimming preview -- **Stream tool-call argument deltas** via the buffered path -- **LocalRegistry cloneable handle** with shared dispatch across workspace -- **Add /memory slash command** with browse and toggle routing -- **Memory save notifications** in scrollback with remember, forget, and recall instructions -- **grok memory clear command** with scope and confirmation -- **Support /memory on|off** to toggle memory mid-session -- **Extract base64 images** from user queries as vision tokens -- **--allow/--deny CLI flags** with PolicyDeny variant and deny rule UX improvements -- **Configurable parallel verifiers** with improved prompt -- **MCP connection progress UI** with system reminders and progressive strategy fix -- **require_plan_approval config flag** in pager -- **Best-of-N mode** for grok-build -- **Anthropic Messages backend** with streaming reasoning traces end-to-end - -## Bug Fixes - -- **Fix terminal tool calls** when agent runs via scheduled task on Windows -- **Fix resume** defaulting to legacy auth instead of OIDC -- **Reject WebLogin tokens** and force interactive login; fix devbox auth -- **Normalize underscores to hyphens** in skill names -- **Fix auth** to always revalidate -- **Fix memory search scoring**, modal UX, and pager display - - -# 0.1.201-alpha.1 - -## Features - -- **New `--self-verify` flag** starts automatic work-checking after each response. - -## Bug Fixes - -- **Reasoning effort settings** no longer incorrectly apply to unsupported models like grok-build. -- **Web search, image gen, and video gen tools** now avoid 401 errors during auth provider gaps. -- **`/share` command** now works in token's final 5 minutes and guides API key users to login. -- **`/billing` command** now works reliably in token's final 5 minutes. -- **Deny rules in config** now block actions even with `--always-approve`. -- **Skills** now provide follow-up context automatically after use. - - -# 0.1.201 - -## Features - -- **New `--self-verify` flag** starts sessions with automatic work-checking after each response. - -## Bug Fixes - -- **Reasoning effort settings** no longer incorrectly apply to models without support, like grok-build. -- **Web search, image generation, and video generation tools** no longer fail with missing API key errors. -- **Deny rules in config.toml** now block tool calls even with auto-approval enabled. - - -# 0.1.200-alpha.1 - -## Features - -- **grok-build model** now defaults to Chat Completions API. - -## Bug Fixes - -- **MCP tools** now work more reliably by always fetching schemas before use. -- **MCP tool calls** preserve all custom arguments without dropping extras. - - -# 0.1.200 - -## Bug Fixes - -- **MCP tools** now work more reliably by enforcing schema lookup before calls. - - -# 0.1.197-alpha.5 - -## Bug Fixes - -- **MCP tools** now always use correct parameters by requiring schema lookup first. - - -# 0.1.197-alpha.4 - -## Features - -- **`/model` command** now lets you pick reasoning effort levels like high/medium/low for supported models. -- **Images in tool output** now appear as pictures the AI can see, not long base64 strings. - -## Bug Fixes - -- **Shell commands now work** in Grok Desktop on Windows. -- **Image compression messages** now explain if size, dimensions, or both triggered resizing. - - -# 0.1.197-alpha.1 - -## Features - -- **Agent profiles** now support tool allow/deny lists, reasoning effort, turn limits, and reliable model overrides. -- **Custom models** in config.toml now support both Bearer token and x-api-key authentication. -- **Built-in docx, xlsx, pptx skills** now available for office document editing. -- **MCP tools** now time out after 100 minutes by default instead of 1 minute. -- **MCP server reminders** now include instructions to discover and use tools correctly. -- **New Verify mode** (Shift+Tab) runs automatic checks and fixes after each agent turn. -- **Tool call arguments** now stream progressively as generated by the model. - -## Bug Fixes - -- **Agents auto-exit plan mode** without prompts when started in always-approve mode. - - -# 0.1.196-alpha.6 - -## Bug Fixes - -- **Fixed interactive shell, command syntax, and git editor issues** on Windows. - - -# 0.1.196-alpha.2 - -## Features - -- **New `grok-build-orchestrator` agent** uses a lead model to coordinate subagents for builds. - -## Bug Fixes - -- **Custom UI settings in config.toml** no longer get lost when saving other changes. - - -# 0.1.196 - -## Features - -- **New `grok import`** resumes Claude Code sessions with `grok import `. -- **New `grok-build-orchestrator`** coordinates subagents for complex builds. -- **`/skills` panel** now includes **toggles** to enable or disable individual skills. - -## Bug Fixes - -- **Discovers CLI tools** from `.zshrc`/`.bashrc`/virtualenvs (e.g., tclips). -- **Custom UI settings** like timestamps persist across config saves. -- **Model switches** rebuild harness and hold prompts until complete. -- **Model list refreshes** correctly after login or logout. -- **Subagents** now execute tools in the **specified working directory** instead of always using the parent's. -- **Generated session titles** now display correctly in `/sessions` list and terminal. -- **Fixed shell detection on Windows** for interactive PTYs, command syntax, and git editors. -- **Fixed `grok share`** failing with backend ZodError due to duplicate request headers. - -## Performance - -- **Faster session startup** on large repositories by backgrounding project info. - - -# 0.1.195-alpha.7 - -## Features - -- **New `grok login --devbox`** option for signing in from headless devbox environments without browser access. -- **Skill file reads** now display as **'Skill {name}'** for cleaner scrollback view. - - -# 0.1.195-alpha.4 - -## Features - -- **Billing credits and usage** now fetchable for display in pager/desktop. -- **New `grok mcp doctor`** diagnoses MCP servers and suggests fixes. -- **Session picker** now shows precise last activity times. - -## Bug Fixes - -- **Fixed generation hangs** on persistent authentication failures. - - -# 0.1.195-alpha.3 - -## Bug Fixes - -- **Image and video generation** now works reliably after long idle periods. -- **Fixed crashes** from large images read by the Read tool. -- **Improved Windows support** for auto-updates, installation, and shell commands. - - -# 0.1.195-alpha.2 - - -# 0.1.195-alpha.1 - -## Bug Fixes - -- **Terminal stays responsive** when cancelling shell commands. -- **Fixed requests** to Anthropic models from remote model lists. - - -# 0.1.195 - -## Features - -- **New `grok mcp doctor`** diagnoses MCP server configuration and connectivity. -- **Subagents** can now target specific directories without new worktrees. -- **Session picker** now shows accurate last activity times. - -## Bug Fixes - -- **Image and video generation** now works reliably after long idle periods. -- **Large images read from files** no longer crash API requests. -- **Terminal stays responsive** when cancelling long-running shell commands. -- **Anthropic models** from remote lists now route to correct API endpoints. -- **Fixed resume and fork crashes** from old session files. -- **Fixed crashes from large images** in tools and attachments. -- **Fixed generation hangs** on persistent authentication failures. - - -# 0.1.194-alpha.1 - -## Features - -- **After plan approval**, Grok suggests `/implement` for structured coding when available. - -## Bug Fixes - -- **Model lists and pickers** now show only models accessible via your `GROK_CODE_XAI_API_KEY`. - - -# 0.1.194 - -## Features - -- **Plan mode** now recommends the **/implement** skill after user approval when available. - -## Bug Fixes - -- **`grok models`** and TUI picker now show team-specific models when **`GROK_CODE_XAI_API_KEY`** is set. - - -# 0.1.193-alpha.4 - - -# 0.1.193-alpha.3 - -## Features - -- **Pressing Ctrl+C** before any response now restores your prompt to the input box, ready to edit or resend. -- **Native Windows x86_64 support** now available via install script and auto-updater. - -## Bug Fixes - -- **Configured MCP tools** now appear reliably in initial session context messages. - - -# 0.1.193-alpha.2 - -## Bug Fixes - -- **Fixed false-positive loop warnings** for different shell commands during edit-test cycles. - - -# 0.1.193-alpha.1 - -## Features - -- **Native Windows support** now available experimentally for running Grok without WSL. -- **Interactive menu for importing Claude settings** lets you select items like permissions or MCP servers on welcome screen or `/import-claude`. - -## Bug Fixes - -- **Image compression** now skips small files and warns if large images can't be resized under 3.75 MB limit. -- **Feedback errors** now appear in the session instead of failing silently. -- **Clearer login error messages** now suggest `grok login`, API key env var, or config.toml. - - -# 0.1.193 - -## Features - -- **Native Windows support** (opt-in) lets you run Grok CLI directly without WSL, with MCP server compatibility. -- **New interactive import** for Claude settings (Ctrl-I or /import-claude) lets you pick permissions, env vars, MCP servers, hooks, or paths to migrate. -- **Cancel with Ctrl+C** before any response to restore your unsent prompt (text and images) to the input box. -- **dontAsk permission mode** now auto-rejects tools without prompting in headless sessions. - -## Bug Fixes - -- **Large images** (>3.75MB after decoding) now compress to JPEG efficiently, keeping originals if needed. -- **Authentication errors** now clearly suggest `grok login`, env var, or config.toml api_key. -- **Fixed false-positive loop warnings** for different shell commands during edit-test cycles. -- **Configured MCP servers** now appear reliably in the initial session prompt. - - -# 0.1.192-alpha.3 - -## Features - -- **Import settings** from `.claude/` (permissions, env vars, MCP servers) into `config.toml` using startup prompt or `/import-claude`. -- **Claude migration** now fully disables legacy `.claude/` fallbacks after import. - -## Bug Fixes - -- **Worktree errors** now display the specific failure reason instead of generic messages. -- **Shell commands** now appear immediately in the status bar during execution. - - -# 0.1.192-alpha.2 - -## Breaking Changes - -- **Temporarily removed the `Auto` option** from the `/model` picker. - - -# 0.1.192-alpha.1 - -## Features - -- **Skills reload automatically** when editing and saving SKILL.md files without restarting. - -## Bug Fixes - -- **Slash commands like `/loop`** now only appear when supported by the current model. -- **Fixed empty model hash** display in status bar and feedback channels. -- **Fixed authentication errors** for OIDC users with per-model API keys in config.toml. - - -# 0.1.192 - -## Breaking Changes - -- **Temporarily removed the `Auto` option** from the `/model` picker. - -## Features - -- **Import Claude settings** from `.claude/` into `.grok/config.toml` via welcome screen ('i') or `/import-claude`. -- **Skills reload automatically** when editing/saving SKILL.md files without restarting `grok`. - -## Bug Fixes - -- **Slash commands like `/loop`** now only appear when the current model supports them. - - -# 0.1.191-alpha.6 - -## Features - -- **Skills reload automatically** when you edit and save SKILL.md files without restarting. - -## Bug Fixes - -- **Slash commands like `/loop`** now only appear when supported by the current model. - - -# 0.1.191-alpha.5 - - -# 0.1.191-alpha.4 - - -# 0.1.191-alpha.3 - -## Bug Fixes - -- **Ctrl+C clears drafts** without cancelling turns; press twice on empty prompt to cancel. - - -# 0.1.191-alpha.2 - - -# 0.1.191-alpha.1 - -## Bug Fixes - -- **Context bar** now reflects live token usage and matches **`/context`**. - - -# 0.1.191 - -## Features - -- **Improved `grok login`** with automatic code delivery from browser consent page. - -## Bug Fixes - -- **Context bar** now accurately reflects live token usage matching `/context`. -- **Ctrl+C clears drafts** without cancelling turns; press twice on empty prompt to cancel. - - -# 0.1.190-alpha.3 - -## Features - -- **New `[[version_overrides]]`** sections apply config patches for specific CLI version ranges. - -## Bug Fixes - -- **`/context`** now updates correctly with token usage per conversation turn. - - -# 0.1.190-alpha.2 - - -# 0.1.190-alpha.1 - -## Features - -- **New `cli.minimum_version`** config enforces version floor and prompts updates. - - -# 0.1.190 - - -# 0.1.189-alpha.4 - - -# 0.1.189-alpha.3 - - -# 0.1.189-alpha.2 - -## Features - -- **New slash command** `/feedback` available in headless and stdio agent modes. -- **New `error_reporting` config** enables Sentry independently of telemetry. - -## Bug Fixes - -- **Fixed session cleanup race** preventing errors in concurrent headless sessions. - - -# 0.1.189-alpha.1 - - -# 0.1.189 - -## Bug Fixes - -- **Auth screen** now has consistent styling and better spacing. -- **Fixed authentication errors** when starting new sessions after long idle. -- **Prevents auth errors** on welcome screen from background tasks during idle. - - -# 0.1.188-alpha.1 - -## Bug Fixes - -- **Fixed misleading auth errors** for OIDC sessions with per-model API keys in config.toml. - - -# 0.1.188 - -## Bug Fixes - -- **Fixed misleading 'Invalid API key' errors** for OIDC sessions with per-model credentials. - - -# 0.1.187 - -## Features - -- **Added reset button** for tool permissions in desktop agent settings. -- **Added mid-turn interjection** via Ctrl+Enter without canceling the turn. - -## Bug Fixes - -- **Fixed OIDC session refresh** when using per-model API keys in config.toml. -- **Fixed write denials** on symlinked paths like `/tmp` after prior reads. - - -# 0.1.186-alpha.1 - -## Performance - -- **Reduced CPU spikes** during git operations like pull and checkout. - - -# 0.1.186 - -## Performance - -- **Reduced CPU spikes** from file watcher during git pull, checkout, and other operations. - - -# 0.1.185-alpha.2 - - -# 0.1.185-alpha1 - - -# 0.1.185 - - -# 0.1.184-alpha.2 - - -# 0.1.184-alpha.1 - -## Bug Fixes - -- **Large images** now compressed client-side before API with user notification. -- **Plan mode reentry** reminder now includes explicit exit instructions to prevent hangs. -- **Model unavailability** on account changes now handled gracefully with auto-switch. -- **Session picker** shows all sessions; remote fetches now have timeouts. -- **Credential prompts** no longer corrupt TUI by detaching child processes from terminal. -- **Remote sessions** now matched by repo URL across machines and protocols. -- **Fixed ghost text in input field** from config warnings and log messages. - - -# 0.1.184 - -## Bug Fixes - -- **Large images** now compressed client-side before API with user notification. -- **Model unavailability** after account switch now handled with clear notifications. -- **Session picker** now shows all sessions; remote loads have timeouts. -- **Credential prompts** no longer corrupt TUI in terminal commands. -- **Remote sessions** now matched by normalized repo URL across machines. -- **TUI input field** no longer shows ghost text from config warnings and log messages. - - -# 0.1.183-alpha.1 - -## Features - -- **New `grok trace`** command uploads or exports session data for debugging. -- **Slash command arguments** now support fuzzy autocompletion with Tab. - - -# 0.1.183 - -## Features - -- **New `grok trace`** command exports or uploads session data for debugging. - - -# 0.1.182-alpha.4 - -## Bug Fixes - -- **Model list** refreshes correctly after **account or team switch** to prevent auth errors. -- **MCP tools** no longer corrupt **TUI display** with credential prompts like GPG pinentry. - - -# 0.1.182-alpha.3 - -## Features - -- **`grok models`** now lists available models and the default. -- **Permission prompts** improved with numeric shortcuts and scope selector. - -## Bug Fixes - -- **Same-file edits** now serialize to prevent lost concurrent changes. - - -# 0.1.182-alpha.2 - - -# 0.1.182-alpha.1 - - -# 0.1.182 - -## Features - -- **`grok models`** command now lists available models and authentication status. -- **Permission prompts** now use numeric shortcuts, scope selector, and syntax highlighting. - -## Bug Fixes - -- **Improved auth token refresh** prevents 401 failures in long conversations. -- **Login checks** now use shared client with 5s timeout to prevent hangs. -- **Concurrent edits** to the same file now serialize to prevent lost changes. -- **Fixed TUI corruption** from credential prompts in MCP tools like GPG pinentry. - - -# 0.1.181-alpha.2 - -## Bug Fixes - -- **Improved auth token refresh** prevents failures during long conversations. -- **Added timeout** to login checks prevents indefinite hangs. - - -# 0.1.181-alpha.1 - -## Features - -- **X Premium and Premium+** tiers now grant access to Grok Build. - -## Bug Fixes - -- **Model picker** now shows config alias names instead of internal slugs. - - -# 0.1.181 - -## Bug Fixes - -- **Model picker** now displays config key names like `grok-build` for aliased models. - - -# 0.1.180-alpha.3 - - -# 0.1.180-alpha.2 - - -# 0.1.180-alpha.1 - -## Features - -- **New `--restore-code` flag** restores original git commit on session resume. -- **Session resume** warns if current git HEAD **diverged** from original commit. -- **Welcome screen** shows **SuperGrok upsell** without active subscription. -- **`/share`** slash command hidden when sharing disabled remotely. - -## Bug Fixes - -- **`/privacy opt-out`** now works for OAuth2 and CLI team tokens. -- **Esc** unfocuses prompt; double-Esc cancels running turns. - - -# 0.1.180 - -## Features - -- **New `--restore-code`** flag restores session commit when resuming with `-w`. -- **Session resume** now warns if **codebase HEAD** diverged from original. -- **Welcome screen** restricts actions and upsells SuperGrok without subscription. -- **`/share` slash command** now hidden when sharing is disabled remotely. - -## Bug Fixes - -- **`/privacy opt-out`** now works for **OAuth2 and CLI team tokens**. -- **Esc unfocuses prompt** during turns; **double-Esc from scrollback** cancels running turns. -- **Safety and policy errors** no longer trigger unnecessary re-authentication. -- **`list_dir`** cleans display paths for `.`, empty, and `./` targets. - - -# 0.1.179-alpha.5 - -## Bug Fixes - -- **Prevents disk accumulation** from orphaned upload files and scratch directories in long sessions. - - -# 0.1.179-alpha.4 - -## Bug Fixes - -- **Prevents disk accumulation** from orphaned upload files and scratch directories in long sessions. - - -# 0.1.179-alpha.3 - -## Bug Fixes - -- **Fixed conversation retries** on empty model responses lacking content. -- **Improved bash commands** in permission prompts with syntax highlighting, wrapping, and allow-always persistence. - - -# 0.1.179-alpha.2 - - -# 0.1.179-alpha.1 - - -# 0.1.179 - -## Features - -- **How-to guides** bundled in binary, accessible via Ctrl+P and model-readable. -- **`/logout`** slash command clears credentials and returns to login. -- **`/privacy`** slash command toggles coding data retention opt-in/out. -- **SuperGrok upsell** shown on welcome screen without subscription. -- **Live countdown** and direct cancel added for `/loop` scheduled tasks. - - -# 0.1.178-alpha.1 - -## Bug Fixes - -- **Fixed authentication failures** and web search disabling during token refresh periods. - - -# 0.1.178 - -## Bug Fixes - -- **Fixed authentication failures** during token refresh periods preventing 401 errors. - - -# 0.1.177-alpha.1 - -## Bug Fixes - -- **New file creations** now render as all-green diffs with 'Creating' prefix. - - -# 0.1.177 - -## Bug Fixes - -- **write_file tool outputs** now render as proper diffs with 'Creating' prefix. -- **Authentication token refreshes** now robust across shell, pager, and desktop. - - -# 0.1.176-alpha.1 - -## Features - -- **`grok sessions search`** now finds sessions by content with scores and snippets. -- **Session picker** now combines fuzzy matches with deep content search. - -## Bug Fixes - -- **Scheduled tasks** now require explicit user confirmation before cancellation. -- **Ctrl+V image paste** now handles empty clipboard text and bracketed paste. - - -# 0.1.176 - -## Features - -- **`grok sessions search`** now supports **full-text queries** across chat content and tools. -- **Session picker** now combines fuzzy matching with **deep content search** results. -- **Unified logs** now saved to `~/.grok/logs/unified.jsonl` with **Download Logs** in desktop. - -## Bug Fixes - -- **Scheduled tasks** now require **user confirmation** before cancellation. -- **Image pasting** now handles **Ctrl+V** and whitespace-only clipboard events. - - -# 0.1.175-alpha.1 - -## Features - -- **Configurable announcements and tips** now load from local config files. -- **Live MCP server toggle** enables enable/disable without session restart. -- **New slash command** `/session-info` displays model, context usage, turns, and session title. - -## Bug Fixes - -- **Auto-update restart** now launches the correct new version. -- **Auth.json hot-reload** fixed for grok login in other terminals. -- **Claude permission settings** now merge rules across all files with correct precedence. - -## Performance - -- **Status bar git info** updates faster without synchronous spawns. - - -# 0.1.175 - -## Features - -- **New slash command** `/dream` triggers **manual memory consolidation**. - -## Bug Fixes - -- **Context bar** now updates **token count** immediately after **compaction**. -- **Remote session restore** with `-w` now targets **worktree** without source pollution. - - -# 0.1.174-alpha.2 - -## Features - -- **Configurable announcements and tips** now load from local config files. -- **Live MCP server toggle** enables/disables without session restart. - -## Bug Fixes - -- **Auto-update restart** now launches the correct new version. -- **Auth.json hot-reload** fixed for `grok login` in other terminals. - -## Performance - -- **Status bar git info** updates instantly without synchronous spawns. - - -# 0.1.174-alpha.1 - -## Features - -- **Announcements and tips** now load from local config files. -- **MCP servers** can now be toggled live without restarting the session. - -## Bug Fixes - -- **Auto-update restart** now launches the correct new version. -- **Auth.json hot-reload** now detects `grok login` from other terminals. -- **API error messages** no longer include request body for privacy. -- **Status bar counting fixed**, **diff indentation preserved**, and **bash timeout** increased to 10 hours. -- **Rate limit errors** now show friendly messages with **upgrade instructions**. -- **External auth tokens** now refresh correctly on 401 errors. - -## Performance - -- **Status bar git info** now updates without synchronous git spawns. - - -# 0.1.174 - -## Features - -- **New slash command** `/session-info` displays model, context usage, turns, and session title. - - -# 0.1.173-alpha.2 - -## Features - -- **Configurable announcements and tips** now load from local config files. -- **Live MCP server toggle** enables/disables without session restart. - -## Bug Fixes - -- **Auto-update restart** now launches the correct new version. -- **Auth.json hot-reload** fixed for `grok login` in other terminals. -- **Fixed status bar counting**, **diff indentation**, and **increased bash timeout** to 10 hours. -- **Rate limit errors** now show friendly messages with **upgrade instructions**. - -## Performance - -- **Status bar git info** updates faster without synchronous spawns. - - -# 0.1.173-alpha.1 - -## Features - -- **Announcements and tips** now configurable from local files like config.toml. -- **Live MCP server toggle** enables disable/re-enable without session restart. -- **New config options** `[features] managed_config` and `[endpoints] managed_config_url` for enterprises. -- **Crash handler** opt-in via `[diagnostics] crash_handler`. -- **`/loop` slash command** and **monitor tool** for recurring tasks. -- **Vim-style quit commands** `:q`, `:q!`, `:wq`, `:wq!` now exit TUI. - -## Bug Fixes - -- **Auto-update restart** now launches correct new version via symlink. -- **External `grok login`** now hot-reloads auth without app restart. -- **Fixed hangs** from broken streaming responses. -- **Stale background tasks** now marked complete on session reload. -- **MCP servers** loaded from `~/.claude.json` and `.mcp.json` files. - -## Performance - -- **Status bar git info** renders faster using cached notifications. - - -# 0.1.173 - -## Features - -- **Live MCP server toggle** enables disable/re-enable without session restart. -- **Blocked state** now displayed in settings with profile images and manage button. -- **New config flags** `managed_config` and `managed_config_url` for enterprise setups. -- **MCP servers** now load from ~/.claude.json and.mcp.json files. -- **Opt-in crash handler** captures SIGSEGV/SIGBUS dumps via config or env var. -- **New /loop slash command** schedules recurring tasks with monitor tool. -- **Vim quit commands** :q, :q!, :wq, :wq! now exit TUI. -- **Signal-safe crash handler** wired into pager with opt-in config gate. - -## Bug Fixes - -- **Auth refresh** now handles re-auth, OIDC principal context, and stale tokens. -- **Stale background tasks** now marked complete on session reload. - - -# 0.1.172-alpha.1 - -## Features - -- **Configurable announcements and tips** now load from local config files. - -## Bug Fixes - -- **Auto-updates** now restart with the correct new version. -- **Auth.json hot-reload** fixed for `grok login` in other terminals. - -## Performance - -- **Status bar** git info updates faster without synchronous spawns. - - -# 0.1.172 - -## Features - -- **Announcements and tips** now configurable from local files like config.toml. - -## Bug Fixes - -- **Auto-updates** now restart with the correct new version displayed. -- **External `grok login`** now hot-reloads auth without restarting. - -## Performance - -- **Status bar git info** renders faster using cached notifications. - - -# 0.1.171-alpha.5 - -## Bug Fixes - -- **Auto-update restart** now launches the correct new version and displays updated version number. - - -# 0.1.171-alpha.4 - -## Bug Fixes - -- **Exit plan mode** now shows full approve/revise/feedback options instead of simple y/n prompt. - - -# 0.1.171-alpha.3 - -## Features - -- **New `grok ssh`** command enables clipboard copy from remote sessions in Apple Terminal. - - -# 0.1.171-alpha.2 - - -# 0.1.171-alpha.1 - -## Features - -- **MCP servers** can now be enabled or disabled from the UI. - - -# 0.1.170-alpha.2 - -## Features - -- **`grok login --device-auth`** enables login from SSH, Docker, and other headless environments without port forwarding. -- **Skills tab** added to the hooks/plugins modal. -- **Permission rules** now support path globs like `Edit(src/**/*.rs)` and recursive `**` patterns. -- **Slash command dropdown** widened to show plugin names. -- **`Ctrl+P` command palette** for quick access to commands and actions. -- **MCP integration tools** are now discoverable and usable via `search_tool` and `use_tool`. -- **Terminal multiplexer clipboard support** with automatic routing and diagnostics for tmux/zellij/screen. -- **`/btw` side-questions** now appear as a compact inline panel above the prompt instead of a fullscreen overlay. -- **Command palette sections** group commands by category for easier navigation. -- **'New Session in Worktree'** command added to the palette. -- **Environment variables** from `.claude/settings.json` are now loaded into sessions. -- **`Ctrl+Shift+V` / `Cmd+Shift+V`** pastes text inline without triggering tool calls. -- **Marketplace** now supports GitHub source type and a known_marketplaces.json registry. - -## Bug Fixes - -- **Login with cached credentials** no longer fails when upgrading between auth methods. -- **`Shift+1` in VSCode terminal** now correctly triggers bash mode. -- **Single-line command display** no longer shows extra newlines. - -## Performance - -- **Streaming zstd compression** for file uploads reduces transfer size. - - -# 0.1.170-alpha.1 - - -# 0.1.170 - -## Features - -- **`grok login --device-auth`** enables login from SSH, Docker, and other headless environments without port forwarding. -- **Skills tab** added to the hooks/plugins modal. -- **Permission rules** now support path globs like `Edit(src/**/*.rs)` and recursive `**` patterns. -- **Slash command dropdown** widened to show plugin names. -- **`Ctrl+P` command palette** for quick access to commands and actions. -- **MCP integration tools** are now discoverable and usable via `search_tool` and `use_tool`. -- **Terminal multiplexer clipboard support** with automatic routing and diagnostics for tmux/zellij/screen. -- **`/btw` side-questions** now appear as a compact inline panel above the prompt instead of a fullscreen overlay. -- **Command palette sections** group commands by category for easier navigation. -- **'New Session in Worktree'** command added to the palette. -- **Environment variables** from `.claude/settings.json` are now loaded into sessions. -- **`Ctrl+Shift+V` / `Cmd+Shift+V`** pastes text inline without triggering tool calls. -- **Marketplace** now supports GitHub source type and a known_marketplaces.json registry. - -## Bug Fixes - -- **Login with cached credentials** no longer fails when upgrading between auth methods. -- **`Shift+1` in VSCode terminal** now correctly triggers bash mode. -- **Single-line command display** no longer shows extra newlines. - -## Performance - -- **Streaming zstd compression** for file uploads reduces transfer size. - - -# 0.1.169-alpha.2 - -## Features - -- **Added** `grok login --device-auth` **flag** for headless environments like SSH and Docker. - - -# 0.1.169 - - -# 0.1.168-alpha.1 - -## Features - -- **`/compact-mode`** slash command toggles denser UI layout. - - -# 0.1.168 - -## Features - -- **`/compact-mode`** slash command toggles denser UI layout. - - -# 0.1.167-alpha.2 - -# 0.1.167-alpha.1 - -## Bug Fixes - -- **Automatic cleanup** deletes stale session files older than 30 days. -- **Linux binaries** now fully static for older distro compatibility. - -# 0.1.167 - -# 0.1.162 - -## Features - -- **Marketplace plugin provenance** tracked in InstalledRepo with source metadata and name collision warnings. -- **web_fetch tool** enables web content fetching with domain permissions and proxy support. -- **video_gen tool** integrated with remote settings gating and session-based authentication. -- **/feedback command** activates dedicated mode for submitting session feedback to x.ai. -- **Git stash-all** via ACP extension and desktop Changes panel button including untracked files. -- **Pager --oauth flag** forces OAuth flow for deferred welcome-screen authentication. -- **Fractional bash timeouts** like 30.5s now supported in toolset.bash config.toml. -- **.mcp.json support** enables team-shared MCP servers via project root config with full precedence. -- **Auth info extension** returns login method ID and email via x.ai/auth/info ACP method. -- **Branch diff section** shows PR-style diffs vs default branch in desktop changes panel. -- **Jujutsu VCS support** enables detection, operations, routing, and workspaces. -- **Startup announcements** emits x.ai/announcements/refreshed ACP notification on agent init. -- **Auto-enroll updates** defaults to enabling automatic updates without interactive prompt. -- **Subagent visualizations** in pager now show persona, role, and model metadata. -- **Bundled agents** discovered from ~/.grok/bundled/agents/ with lowest precedence after project/user/built-in. -- **Pager welcome screen** displays rotated tip-of-the-day from RemoteSettings honoring config and env overrides. -- **image_gen/video_gen tools** instruct model to display generated media inline via markdown. -- **Project plugins** default disabled with enable via [plugins].enabled and added counts for UI. -- **Enterprise managed configs** via ~/.grok/managed_config.toml/requirements.toml with `grok inspect` and `grok setup`. -- **web_fetch tool** enabled via [features] web_fetch in config.toml alongside env/remote settings. -- **grok login defaults to OAuth** instead of legacy relay; use --legacy or GROK_OAUTH_ENABLED=0 for old behavior. -- **Clipboard copy over SSH/tmux** now reaches local terminal via OSC 52 alongside native clipboard writes. - -## Bug Fixes - -- **Cross-CWD session resume** now finds local sessions under any stored directory before remote restore. -- **Automatic cleanup** removes old grok/grok-pager binaries post-update, keeping current + previous. -- **Preserves legacy credentials** when clearing OAuth scope in auth.json during login. -- **Plan file path fix** includes session-specific path in EnterPlanMode output for correct writes. -- **Rewind truncation fix** skips synthetic system-reminder messages when counting user prompts. -- **User prompt history** excludes subagent prompts preventing Ctrl+R pollution. -- **Path-not-found hints** from remote settings now reach TUI and shell agent config. -- **Legacy WebLogin users** regain managed MCP config fetching post-OIDC migration. -- **Skill discovery** handles missing frontmatter using dir name and normalizes spaced names to hyphens. -- **web_fetch PDFs** saved to disk with read_file guidance instead of garbled text. - -## Performance - -- **Parallel tool dispatch** accelerates multi-tool batches via concurrent execution. -- **Faster session memory staleness** via 7-day half-life default and equalized session source weights. - - -# 0.1.161-alpha.3 - -## Features - -- **Interactive plan approval dialog** enables approve/reject/feedback on exit_plan_mode before building. - -## Bug Fixes - -- **Prevents model crashes** on large files by capping hashline_read output at MAX_LINES_READ like read_file. -- **Automatic cleanup** removes old grok/grok-pager binaries post-update, keeping only current and previous. - - -# 0.1.161-alpha.2 - -## Features - -- **Parallel tool dispatch by default** accelerates multi-tool execution via batched skills and timing fixes. -- **Web fetch tool** enables controlled web content retrieval with interactive domain permissions and config gating. - -## Bug Fixes - -- **Fixed pager login screen** for enterprise OIDC via centralized interactive auth detection. -- **Per-key error streak detection** prevents cross-tool interference in doom loop termination. - - -# 0.1.161-alpha.1 - -## Features - -- **Force OAuth login** from pager welcome screen using new `--oauth` startup flag. - - -# 0.1.160-alpha.8 - -## Features - -- **Per-hook enable/disable toggles** via ~/.grok/disabled-hooks file and 'e' key with **multi-line j/k navigation fix**. -- **Bash-mode execute blocks auto-expand** after completion to display output immediately. -- **Video generation tool** supports xAI API with async polling, download and sequential MP4 naming. -- **Marketplace plugin installs** route through git_install with provenance tracking. -- **Parallel tool dispatch** via GROK_PARALLEL_TOOL_DISPATCH=1 runs concurrent Phase 2 execution. -- **VideoGenConfig threading** gates video_gen tool via feature flag and session auth. -- **/feedback command** enters teal-accent mode for fire-and-forget session feedback POST. -- **modelId in UserMessageChunk _meta** enables frontend turn-to-model association. -- **Git stash-all** via x.ai/git/stash ACP extension wired to desktop Changes panel. -- **Float timeout_secs** in toolset.bash config.toml enables fractional second timeouts. - -## Bug Fixes - -- **Silent refresh prevents re-auth** on near-expiry tokens, fixes auth_type propagation and telemetry staleness. - - -# 0.1.160-alpha.7 - -## Features - -- **Mid-session hook reloading** on trust/untrust/add/remove without restart. -- **Per-turn Stop hooks** plus **UserPromptSubmit** event fire before processing with scrollback annotations. -- **OAuth login option** via --oauth/--legacy flags, GROK_OAUTH_ENABLED env, feature flag (legacy default). - -## Bug Fixes - -- **Cross-CWD session resume** finds local sessions from worktrees via --resume/--load. - -## Performance - -- **Sequential N.jpg filenames** replace UUIDs in image_gen for massive token savings. - - -# 0.1.160-alpha.6 - -## Features - -- **Live auto-refresh** of open hooks/plugins modal on registry changes like install + reload. -- **Adds live PR status display** in desktop changes panel via new ACP x.ai/pr/status extension. -- **Adds domain allowlist and typed WebFetchOutput enum** with DomainNotAllowed and CrossHostRedirect variants. - -## Bug Fixes - -- **Prevents false-positive background '&' detection** inside heredoc bodies like `cat << EOF... &http.Request... EOF`. -- **Restores web search functionality** by reinjecting required proxy headers like x-grok-client-version. -- **Ensures config.toml base_url fully overrides** default model api_base_url across all credential paths. - - -# 0.1.160-alpha.5 - -## Features - -- **GrokNight default theme** with **runtime color quantization** ensures correct rendering across truecolor, 256-color, and 16-color terminals. -- **ACP endpoints for hooks/plugins listing** enable pager modals to display loaded hooks and discovered plugins. -- **ACP action endpoints** for hooks/plugins management support trust, install, reload, and update operations via pager. -- **Legacy relay auth flow** via `grok login --legacy` restores pre-OIDC accounts.x.ai token exchange. - -## Bug Fixes - -- **Terminal-width-constrained tables** wrap cell text proportionally without overflow or column misalignment. -- **Auth fallback to legacy scope** reads old accounts.x.ai tokens on devboxes provisioned via x setup. - - -# 0.1.160-alpha.4 - -## Features - -- **WebFetch tool** enables secure URL fetching, HTML-to-markdown conversion, SSRF guards, and in-memory caching. - - -# 0.1.160-alpha.3 - -## Breaking Changes - -- **OIDC OAuth replaces relay auth**; removed `--auth-signin-url`/`exchange-code-url`/`redirect-target` flags, run `grok login` to migrate. - -## Features - -- **LSP integration** injects diagnostics as reminders and exposes opt-in code intelligence tools via `.grok/lsp.json`. -- **Glob-aware permission rules** support prefix/suffix wildcards, tool=* and refined bash/edit matching. -- **Claude settings.json compatibility** loads legacy rules as fallback when native TOML absent. -- **Path-specific Read permissions** match against file paths from read_file/list_dir tools. -- **Web search model override** via CLI, config.toml or env defaults to grok-4-1-fast-reasoning. -- **Grep-specific permissions** enable path-only matching for Claude-compatible rules. -- **Remote bash timeout toggle** via remote settings auto-backgrounds foreground commands with local override. -- **Image generation tool** creates Imagine API images saved to session/images/ folder. -- **Configurable web search disable** from config.toml via `disable_web_search = true`, OR-ed with CLI flag. -- **Image generation tool** gated behind a server-side flag or `GROK_IMAGE_GEN=1` env var with session auth. -- **Dynamic nested skill discovery** in `.grok/skills/` subdirs with runtime system reminder announcements. - -## Bug Fixes - -- **Quit confirmation** responds immediately on second ^D without infinite delay. -- **Terminal restore** disables raw mode before CSI sequences to prevent garbage output. -- **Session summary** falls back to prompt prefix on tool parse failure avoiding crash. -- **Prevents YOLO auto-approval** of plan reviews by switching ExitPlanMode to independent ext_method channel. - - -# 0.1.160-alpha.2 - -## Features - -- **Configurable permission policies** enable automatic allow/deny rules via ~/.grok/config.toml before existing checks. -- **Prevents timeout doom loops** by auto-backgrounding foreground commands exceeding default 120s without explicit timeout. -- **ACP git extensions** enable desktop git info/branches/checkout without local shelling, supporting cloud workspaces. -- **Richer hook annotations** add HTTP URL/status/response previews to scrollback for pre-tool-use summaries. - -## Bug Fixes - -- **Robust session cancellation** shares logic with 5s safety-net timer to prevent stuck working states. -- **Suppresses config warning** for [desktop] section owned by grok-desktop using opaque serde sink field. -- **Prevents hook reload panics** by wrapping registry in Arc to avoid RefCell borrows across awaits. - -## Performance - -- **6x faster worktree creation** skips redundant LFS smudge filters on BTRFS snapshots using GIT_LFS_SKIP_SMUDGE=1. - - -# 0.1.160-alpha.1 - -## Features - -- **New TUI slash commands** /clear alias, /context usage breakdown, /version, and /login auth status. -- **Skills discovery expands** to.agents/skills directories alongside.grok and.claude. - -## Performance - -- **Fast btrfs worktree creation** on rootless devboxes via gRPC delegation to explorer agent. - - -# 0.1.160 - -## Breaking Changes - -- **OIDC OAuth2 replaces legacy relay login** for `grok login`; add `--legacy` flag for old flow and remove `--auth-signin-url` etc. CLI flags (migrate by deleting auth.json). -- **Adds `grok login --legacy`** fallback; removes `--auth-signin-url` etc flags (use env/config defaults, no migration needed for most users). - -## Features - -- **ACP git extensions** (`x.ai/git/info`, `/branches`, `/checkout`) enable cloud-local git ops without subprocesses. -- **Detailed hook scrollback** shows HTTP POST URLs, status codes, and response previews per executed hook. -- **Remote bash timeout config** via remote settings `auto_background_on_timeout` overrides local config.toml. -- **ACP hooks/plugins listing** (`x.ai/hooks/list`, `/plugins/list`) exposes loaded configs for pager modals. -- **Image generation tool** via xAI Imagine API saves to session/images/ folder. -- **Management actions for hooks/plugins** via x.ai/{hooks,plugins}/action with live modal refresh. -- **disable_web_search now honors config.toml** value ORed with CLI flag. -- **WebFetch tool scaffold** securely fetches URLs to markdown with SSRF protection. -- **Dynamic discovery of nested skills** from.grok/skills/ subdirs at runtime via reminders. -- **PR status extension (x.ai/pr/status)** fetches branch PR state, title, number, and URL via gh CLI. -- **Web fetch domain allowlist support** with enum outputs for blocked domains and cross-host redirects. - -## Bug Fixes - -- **Readable markdown tables** in narrow terminals via proportional column shrinking and smart cell text wrapping. -- **Improved permission UX** by reordering 'don't ask again' option first across TUI, pager, and desktop clients. -- **Reliable prompt cancellation** via shared logic and 5s safety-net timer prevents stuck 'working' states. -- **Silent [desktop] config handling** consumes grok-desktop section without spurious 'unrecognized key' warnings. -- **Panic-free hook reloads** via Arc-wrapped registry prevents BorrowMutError during concurrent plugin updates. -- **Clean terminal restore** disables raw mode before CSI sequences to avoid garbage on Ctrl+D exit. -- **Robust session summaries** handle chat completion errors gracefully without panics. -- **Eliminates compiler unused import warnings** by scoping test imports to cfg-gated function. -- **Prevents YOLO auto-approval of exit plans** by switching to independent ext_method. -- **Fixes web search 426 errors** from missing client version header in requests to the API proxy. -- **Config.toml base_url fully overrides** default models' api_base_url for all credential sources. - -## Performance - -- **6x faster worktree creation** by skipping redundant LFS smudge filters on snapshot checkouts and resets. - - -# 0.1.159-alpha.11 - -## Breaking Changes - -- **Default model updated** enables seamless checkpoint swaps; migrate by renaming `[internal_models]` to `[models]` in config.toml. - -## Features - -- **New hooks-remove and hooks-untrust commands** with shared helpers across shell and TUI. -- **Bash mode (`! cmd`)** enables direct shell execution from pager, bypassing agent loop. -- **Subagents default_model config** forces all subagents to one model, overriding others. - -## Performance - -- **Faster exit-plan-mode** by sending plan content inline, eliminating client readFile round-trip. -- **Longer inference idle timeout** default raised to 10min for complex responses. - - -# 0.1.159-alpha.10 - -## Breaking Changes - -- **Default model updated** enables seamless checkpoint swaps; migrate by renaming `[internal_models]` to `[models]` in config.toml. - - -# 0.1.159-alpha.9 - -## Breaking Changes - -- **Config validation warns** on unknown keys with typed sections; migrate `auto_update` etc. to `[cli]`, `[models]`. - -## Features - -- **Resolved model ID** now shown in `/session-info` output and feedback submissions. -- **Model sees task completions** as `` tags in tool results, eliminating polling loops. -- **Ctrl+R fuzzy history search** in pager prompt supports Up/Down navigation and mouse. - -## Bug Fixes - -- **Consistent tool/parameter names** in model-facing text and errors via TemplateRenderer resolution. -- **Single progress lines** during remote session restore, eliminating duplicates. -- **Plan mode UI updates** correctly on agent entry and across session switches. -- **Compaction succeeds** for Responses API models by preserving encrypted reasoning. - - -# 0.1.159-alpha.8 - -## Features - -- **Question-answer panel** in pager with **GrokBuildPlanNoSubagents** mode excluding subagent tools. -- **New hook events** Stop, Notification, UserPromptSubmit, SubagentStart/Stop plus **Claude-compatible** toolUseId schema. -- **Resume completed subagents** via task `resume_from` inheriting raw transcript, tool state, model; schema/docs/provenance/observability. -- **`/clear` slash command** aliases `/new` to start a fresh session. - - -# 0.1.159-alpha.7 - -## Features - -- **Install and uninstall plugins** via `/plugins install ` and `/plugins uninstall ` supporting git refs, subdirs, local symlinks, and multi-plugin repos. - -## Bug Fixes - -- **Prevents delimiter deletion** in hashline_edit replace by clarifying inclusive anchor/end_anchor range in tool docs. -- **Prevents 413 errors** by compressing large `read_file` images to max 1024px JPEG under 768KB with progressive quality reduction. -- **Eliminates TUI model jump** by patching leader initialize response to use client's `default_model` instead of agent's. - - -# 0.1.159-alpha.6 - -## Features - -- **Execute inline hooks and MCP servers** from plugin manifests with shell `/plugins add/remove` and HTTP handler support. -- **Redesigned OAuth consent page** uses icon cards; shell callback pages now styled with success/error feedback. -- **MCP catalogs expose scope_name** for human-readable labels alongside scope/scope_id. -- **Discover skills from**.claude/skills directories alongside.grok/skills. -- **TUI hook messages render** inline as scrollback annotations with hashline aliases. - -## Bug Fixes - -- **use_tool now normalizes** double-encoded JSON strings in tool_input to objects for reliable MCP dispatch. -- **Hashline anchor docs/errors** now specify LINE:HASH1:HASH2 format accurately. -- **Codex grep_files uses** shared rg_path() matching grok_build pattern. -- **Safe truncation prevents** mid-character cuts in subagent/sampling previews. -- **insert_after empty content** now inserts blank line matching docs. -- **Config watcher deduplicates** events per debounce batch for stability. - - -# 0.1.159-alpha.5 - -## Features - -- **Dev tool usage stats pane** shows real-time activity breakdown, timeline, and inter-token latency. -- **Claude frontmatter parity** parses allowed-tools lists/strings, model, and effort in skill frontmatter. -- **Auto-injected managed MCPs** from grok.com for WebLogin, deduped with config.toml opt-out. -- **ACP worktree management** adds list/show/gc/db methods with filters and dry-run. -- **ACP x.ai/auth/logout** removes scopes from ~/.grok/auth.json. -- **Custom npm registry** via config.toml or GROK_NPM_REGISTRY respects enterprise.npmrc. -- **CLI worktree commands use ACP** with repo-wide session resolution for -w -r. -- **Repo-wide worktree session resume** resolves locally across same-repo directories via ACP before remote fallback. -- **TUI worktree mode** uses repo-wide ACP resolution for interactive and headless resume. - -## Bug Fixes - -- **Legacy search_replace** skips nearest-match hint computation matching confusable_hint gating. -- **Shorter run_terminal_cmd error messages** revert verbose background & operator wording. -- **fsnotify forwards.git events** to watch_git clients including HEAD/index changes. - - -# 0.1.159-alpha.4 - -## Features - -- **ACP worktree management** adds list/show/gc/db methods with filters and dry-run support. - -## Bug Fixes - -- **Legacy search_replace** skips nearest-match hint for 0.4.10 clients matching confusable_hint pattern. - - -# 0.1.159-alpha.3 - -## Features - -- **Subagent worktrees preserved** after completion with path in output; role-level fork and isolation defaults added. -- **Always-approve mode** renames yolo flag/slash command with backward-compatible aliases preserved. -- **Help skill reads ~/.grok/config.toml** to answer MCP server and model configuration queries. -- **Deployment keys supported** on all API proxy endpoints including storage and sessions. -- **Public install-grok.sh script** supports deployment keys and channels without VPN. -- **Subagent prompts** use dedicated compact template with system role/persona. -- **SubagentSessionMetadata v1** enables GCS persistence with full provenance and registry extensions. - -## Bug Fixes - -- **Subagents inherit parent file toolset** resolving hashline vs standard configuration correctly. -- **Subagent forking fixed** with system prompt injection, conversation loading, and provenance tracking. -- **Subagent personas override model/reasoning** with full precedence chain and PromptMode::Extend for built-ins. -- **TUI no longer stuck after auto-compact** by preserving original turn ID. -- **Blocks nested subagent spawning** by limiting depth to 1. -- **Subagent permission prompts show tool details**; hashline_edit now requires approval. -- **Forked subagent context normalized** to System + BackgroundContext + Task for recency. -- **Subagents inherit parent yolo mode and OTEL trace**; depth limited to prevent recursion. -- **Unified skill resolution** rejects ambiguities across shell, TUI, backends with qualified names and alternatives. -- **Prevents TUI session errors** on leader connect by skipping eager new session creation. -- **Multi-edit diff previews** for hashline_edit tool parse details array in TUI. -- **Fixes subagent terminal stalls** using spawn_local on LocalSet single-threaded runtimes. -- **Prunes orphaned kill_task/get_task_output** after capability filtering removes providers. - -## Performance - -- **Eliminates hyper DispatchGone errors** with 2 idle connections and 90s pool timeout. - - -# 0.1.159-alpha.2 - -## Features - -- **Namespaced plugin skills and agents** enable qualified resolution with deduping and plugin provenance. -- **/plugins slash commands** add list, reload, trust with plugin config in config.toml. -- **Remote default_model** resolution falls back silently if unavailable in user list. -- **Subagent permission attribution** enriches TUI dialogs and events with child provenance. -- **Subagent lineage tree structures** enable recursive depth-sorted display in TUI tasks panel. -- **Structured subagent details and lineage** render diagnostics below selected TUI tasks rows. -- **TUI /plugins commands** support live reload, qualified autocomplete, and plugin source display. -- **Subagent safety guards** fallback to parent model on unknown config or fork context overflow. -- **Bundled /help skill** extracts README.md to ~/.grok/ for slash command and model self-help. -- **Identifies worktree sessions** in client sidebars via session_kind and source_workspace_dir metadata in summary.json. - -## Bug Fixes - -- **Per-edit diff metadata** prevents cascade from line shifts in hashline multi-edits. -- **Prevents SQLITE_BUSY errors** during concurrent SQLite WAL setup by setting busy_timeout before journal_mode. - - -# 0.1.159-alpha.1 - -## Features - -- **Subagent persona support** enables layered instructions from config.toml and.grok/personas/*.toml with fail-closed resolution. -- **Compaction-safe fork inheritance** preserves parent prefix while summarizing only child-owned suffix. -- **Enhanced task tool guidance** coaches model on fork_context, capability_mode, overrides, and personas. -- **External auth providers** support custom login binaries with TUI/headless flows. -- **Configurable feedback/trace endpoints** route enterprise telemetry independently. -- **Subagent capability enforcement** filters disallowed built-in tools at spawn time. -- **--no-memory flag** disables cross-session persistence overriding all other configs. -- **Subagent diagnostics** in TUI tasks panel display fork source, capability mode, and persona. -- **Worktree isolation** enables subagents to edit without affecting parent workspace. -- **A/B retry** discards failed forks and replays prompt via new ACP extension method. -- **Synchronous user questions** via client RequestPermission block agent until answered. -- **User approval** for exit_plan_mode via RequestPermission blocks until build or revise. -- **Reasoning deltas** streamed to thinking panel for real-time visibility. - -## Bug Fixes - -- **Fork-safety filtering** removes synthetic messages and truncates incomplete turns from subagent inherited context. -- **Compact ACP diff metadata** returns only changed lines for scattered edits spanning over 80 lines. -- **Per-client terminal/FS routing** prevents misrouting in leader mode across mixed clients. -- **BYOK model api_keys** preserved during proactive auth refresh by skipping session token overwrite. -- **Unified turn_number** across remote traces and backend storage enables reliable data joins post-rewind. -- **Clean error messages** for session restore failures in non-git directories without backtraces. -- **Stable slash autocomplete** prevents tick refreshes overwriting command suggestions. -- **Explicit AuthType** gates refresh to prevent overwriting user-provided api_keys. - - -# 0.1.159 - -## Breaking Changes - -- **Default model updated**; rename `[internal_models]` to `[models]` in config.toml. - -## Features - -- **Server-controlled default model** via remote settings with CLI/config fallback. -- **Subagent capability modes** enforce toolset filtering at spawn time. -- **--no-memory flag** disables cross-session persistence overriding all configs. -- **Subagent metadata** shows fork source, capability, and persona in TUI. -- **Worktree isolation** runs subagents in private git worktrees. -- **A/B retry handler** replays failed dual forks with same prompt. -- **Permission dialogs** attribute requests to child subagents. -- **Lineage tree builder** groups subagents by parent for diagnostics. -- **Subagent detail pane** shows diagnostics and child lineage tree. -- **Synchronous user questions** intercept ask_user_question tool to block agent on client answers via RequestPermission. -- **TUI plugin management** adds /plugins list/reload/trust with live hook reload and CLI --plugin-dir. -- **Interactive plan approval** intercepts exit_plan_mode to block until client Build/Revise. -- **Question-answer panel** adds pager UI for interactive permissions and subagent-free GrokBuildPlan agent. -- **Live reasoning deltas** stream raw text chunks to thinking panel. -- **Dev tool stats pane** docks below scrollback with timeline, ITL p50/p99, and category breakdowns. -- **Skills reject ambiguous short names** with qualified alternatives listed. -- **Subagents use dedicated prompt template** excluding persona catalog. -- **Subagent role/persona now in system prompt** for durable behavioral identity. -- **Skills parse Claude frontmatter** for allowed-tools, model, effort overrides. -- **Plugin install/uninstall commands** support git repos, tags, subdirs, local paths. -- **Auto-injects managed MCPs** from grok.com into CLI/TUI for WebLogin users. -- **ACP worktree management** adds list/show/gc/db methods with filters and dry-run. -- **ACP logout method** removes scopes from ~/.grok/auth.json. -- **Discovers skills from.claude/skills** alongside.grok/skills across local, repo, and user directories. -- **Inline TUI scrollback annotations** for hooks with ✓ success indicator and hashline_* Claude aliases. -- **New hook events** Stop/Notification/UserPromptSubmit/SubagentStart/Stop with Claude-compatible PreToolUse schema. -- **Exposes resolvedModelId** in feedback submissions and /session-info slash command. -- **Warns on unrecognized config.toml keys** at startup using serde_ignored and typed sections. -- **/clear slash command alias** starts new session like /new. -- **Full resume command** shown on TUI exit including cd and --resume with session ID. -- **Git staging awareness** in hunk-tracker with batch get-all-file-contents endpoint. -- **Direct bash mode** (`! cmd`) in pager bypassing agent for shell execution. -- **Global subagent default_model** config overrides all other model sources. - -## Bug Fixes - -- **Preserves BYOK api_keys** during proactive session token refresh. -- **Unified turn_number** across traces and DB enables reliable remote data joins. -- **Clean errors** on session restore without backtraces or panics. -- **Slash autocomplete** no longer clobbered by tick-driven arg refreshes. -- **Prevents token refresh overwriting** user-provided API keys via explicit AuthType tracking. -- **A/B forks strip reasoning** from assistant messages to prevent CoT leakage. -- **Per-edit diff metadata** captures regions directly avoiding cascade in multi-edits. -- **TUI no longer stuck streaming** after auto-compact turn_id desync. -- **Prevents recursive subagent spawning** by limiting depth to 1. -- **Subagent permission prompts** now display full tool details and paths. -- **Forked subagents get normalized prompts** as [System, BackgroundContext, Task]. -- **Leader TUI avoids session conflicts** by skipping eager new session. -- **TUI renders multi-edit diffs** correctly for hashline_edit tools. -- **Subagent tool stalls fixed** via spawn_local for terminal actor. -- **Prevents subagent toolset failures** by pruning orphaned kill_task/get_task_output after capability filtering. -- **Skips nearest-match hint computation** for legacy 0.4.10 search_replace clients. -- **Shortens run_terminal_cmd & error messages** by removing redundant background clause. -- **Fixes deployment key auth** in feedback/sampling clients and external endpoint headers. -- **Normalizes double-encoded tool_input** strings to objects in use_tool dispatch. -- **Clarified hashline anchor formats** to LINE:HASH1:HASH2 in tool descriptions, docs, and errors. -- **Codex grep_files uses shared rg_path** matching grok_build and opencode patterns. -- **Char-boundary-safe truncation** prevents invalid UTF-8 in subagent, sampling, and prompt previews. -- **insert_after empty content inserts blank line** matching tool docs and replace behavior. -- **Deduplicated config watcher events** within debounce batches prevents flaky rapid-write tests. -- **hashline_edit docs specify inclusive replace range** preserving endpoint delimiters like }. -- **Compresses read_file images to JPEG max 1024px/768KB** preventing oversized payload 413 errors. -- **Frictionless desktop auto-auth** by refreshing auth_manager from disk in initialize(). -- **Eliminates duplicate summary** in BackgroundTaskStarted tool output. -- **Suppresses duplicate reminders** after kill_task or completed get_task_output. -- **Prevents false non-git warnings** from unexpected libgit2 errors. -- **Blocks unsafe pipelines** by tree-sitter parsing all command segments. - -## Performance - -- **Fewer `DispatchGone` errors** via larger HTTP pool (2 idle connections, 90s timeout). -- **Inline plan content** in exit_plan_mode avoids client readFile round-trip. - - -# 0.1.158-alpha.14 - -## Features - -- **Full-text search across past sessions** via x.ai/session/search ACP method with FTS5 indexing and workspace filtering. -- **ACP resume session in worktree** via x.ai/git/worktree/resume_session for programmatic clients. - -## Bug Fixes - -- **Scoped subagent cancellation** targets only current turn with TUI confirmation modal. -- **Prevents corrupted post-session diffs** by awaiting hooks before root repo replication. -- **Fast AB cancellation** with async hook awaits and 30s timeouts to avoid hangs. -- **Compact snippets for scattered edits** using per-region views with gap markers over 80 lines. -- **Large session sharing** via signed GCS URLs bypassing 413 payload limits. - -## Performance - -- **Efficient subagent progress updates** with coalescing, adaptive 1-5s polling, stale indicators, and elapsed time display. - - -# 0.1.158-alpha.12 - -## Features - -- **Live subagent progress tracking** in TUI tasks panel via ACP `list_running`/`get` polling and push notifications. -- **Configurable bash tool params** like timeouts injected from config.toml `[toolset.bash]` into GrokBuild actors. - -## Bug Fixes - -- **Precise io::ErrorKind mapping** from ACP file errors enables consistent NotFound and PermissionDenied handling. -- **Silent grok-pager installation** hides internal details from install and auto-update user output. -- **Improved batch edit errors** explicitly state atomic all-or-nothing semantics and retry guidance. - - -# 0.1.158-alpha.11 - -## Features - -- **Configurable hashline toolset** with mutual exclusion validation, scheme parameters, and dynamic descriptions. -- **Multi-source Claude-compatible hooks** from settings files and directories with project trust gating and unified /hooks commands. -- **Live subagent progress** shows turns, tool calls, token usage, tools used, and errors while running. - -## Bug Fixes - -- **SSE stream parsing** handles flat error format from Grok proxy without deserialization failure. -- **Grep and list_dir outputs** use display paths to prevent leaking internal worktree paths to model. - - -# 0.1.158-alpha.10 - -## Features - -- **Plan mode state machine** integrates into session lifecycle with persistence and tool support. -- **Post-A/B session hooks** via GROK_AB_POST_SESSION_HOOK capture worktree changes. - -## Bug Fixes - -- **Prevents Linux Docker overlayfs hangs** in A/B sessions using git-based worktree replication. -- **Eliminates false tool timeouts** for long-running builds after fixing overlayfs hangs. -- **Fixes grep large-output gRPC errors** by raising tools-server limit to 128 MiB. - - -# 0.1.158-alpha.8 - -## Features - -- **Enhanced announcement UX** adds paging, prev/next commands, and `GROK_DEV_ANNOUNCEMENTS` override. -- **`--disable-web-search` flag** omits web search tool from agent for benchmark isolation. -- **`hashline_read` tool** outputs files with line anchors using chunk h=3 c=8 scheme. -- **Optional auth for feedback** enables unauthenticated submissions with `GROK_USER_METADATA`. -- **Plan mode tools** add `enter_plan_mode`, `exit_plan_mode`, and `ask_user_question` with notifications. -- **Auto-installs grok-pager** alongside grok during internal and GitHub release updates. -- **Mid-session token refresh** re-runs external auth or uses OIDC refresh_token on expiry. -- **`hashline_edit` tool** supports replace/insert_after/write with anchor validation and overlap checks. -- **Tiered range warnings** for hashline edits caution on medium/large multi-line rewrites. -- **Shifted-anchor recovery** in hashline edits suggests retry anchors, reports ambiguities, with wider context. -- **Anchor-annotated grep** injects stable anchors into ripgrep output for seamless edit workflows. -- **Hooks v0 system** runs project scripts on pre/post-tool-use and session lifecycle events. -- **Independent feedback gating** via GROK_FEEDBACK_ENABLED separates it from telemetry controls. -- **Leader CLI tooling** supports discovery, targeting, profiling commands, dev spawn. - -## Bug Fixes - -- **Bash `run_terminal_cmd` respects `enabled_background=false`** by hiding `is_background` in schema and rejecting at runtime. -- **Structured CLI errors** in tool-server with code, message, and retryable fields. -- **Async metadata** in list_dir prevents blocking the executor on overlayfs-backed paths. -- **Overlay path rewriting** in AcpSessionFs guards AB isolation against display path leaks. -- **ESC cancel recovery** clears cancelling state on prompt-complete notifications. - -## Performance - -- **Runtime CPU profiling foundation** enables leader process profiling via control protocol and pprof. - - -# 0.1.158-alpha.7 - -## Features - -- **Managed MCPs flag** resolves env>config>remote settings>default, disabled in headless mode. -- **Grok 4.20 default model** with CLI/env/config/remote-settings overrides for catalog models. - -## Bug Fixes - -- **Prevents queued prompts flushing together** in TUI via turn IDs ignoring stale responses. -- **Fixed viewport height** enables full prompt dropdown expansion in one frame. -- **Respects explicit yoloMode=false** per-session overriding client defaults. -- **Aborts path walks on first timeout** preventing 25-minute hangs on slow filesystems. -- **5-minute tool timeouts** prevent registry lock hangs with execution tracing spans. - -## Performance - -- **Tuned HTTP client for IC** adds keepalives, disables Nagle, sets fast connect timeout. -- **Streams request bodies directly** to IC without buffering on common non-rewrite path. - - -# 0.1.158-alpha.6 - - -# 0.1.158-alpha.5 - -## Breaking Changes - -- **Context window requirement** forces BYOK users to explicitly set it in config.toml, with migration via Serde error messages. - -## Features - -- **Dynamic model context updates** ensure the chat shell uses the latest limits from the backend for accurate operations. -- **Custom models endpoint** lets users configure their own OpenAI-compatible proxy for enterprise needs. -- **Configurable AB turn timeout** enables dynamic limits via remote settings without redeploying. -- **Automatic file re-reading after compaction** injects recent file contents for immediate model context. -- **Persisted memory reminders** maintain context in chat history for improved session continuity. -- **Timeout for AB comparisons** cancels stalled sessions after a set time, improving reliability. -- **Configurable memory injection** enables controlling first-turn searches for better session starts. - -## Bug Fixes - -- **Alpha channel updates** now correctly select the latest version, fixing issues with stale releases. -- **Non-blocking memory flushes** ensure sessions progress without delays during idle operations. -- **Accurate prompt completion handling** prevents errors from being misreported as cancellations. -- **Stable application startup** fixes crashes from nested runtimes during initialization. -- **Correct A/B session context windows** ensure auto-compaction uses the right thresholds. -- **Reliable TUI startup** eliminates panics from nested runtimes. -- **Async file handling** prevents session hangs on slow filesystems in AgentsMdTracker. -- **TUI layout fixes** keep prompt anchored and status visible for smoother user interaction. - -## Performance - -- **Faster SSE error detection** reduces CPU overhead by avoiding full JSON parses on normal chunks. -- **Reused HTTP connections** speed up turns by eliminating TLS handshakes between requests. -- **Single-pass request serialization** cuts overhead for large conversations in streaming paths. - - -# 0.1.158-alpha.4 - -## Features - -- **Remote announcements** surfaced from remote settings with tolerant parsing, periodic refresh, expiry filtering, and persistent hide/show state. -- **`grok completions` subcommand** generates bash/zsh completion scripts, with fast-path exit before any network or auth warmup. -- **Running subagents preserved after compaction** with IDs, types, and descriptions injected into the post-compaction system reminder. -- **External auth provider** delegates login to a user-supplied binary for sandboxed VMs, CI, and air-gapped environments with automatic mid-session token refresh. -- **Unicode confusable resilience** across search_replace, read_file, and doom-loop paths — smart quotes and em-dashes no longer cause silent edit failures. - -## Bug Fixes - -- **use_tool works in all server contexts** by dispatching through ToolCallContext instead of session-actor interception, fixing silent failures in grok-tools-server. -- **MCP tool calls use fresh auth** by looking up the current client at call time, fixing silent 401s after OAuth token refresh. -- **Remote settings forwarded through proxy** — telemetry, trace upload, tool search, and writeback flags were silently dropped and never reached the client. -- **Leader startup fetches remote settings** so telemetry, doom loop, and other remote-settings-gated features work in leader mode. -- **AuthManager uses configured proxy URL** instead of a hardcoded default proxy host for user-info fetches. -- **Stale todo spinners cleared at turn end** via transient Plan notification that marks lingering in_progress items as completed for display. -- **Config.toml model overrides applied correctly** — custom api_key, env_key, and base_url on built-in model keys were silently dropped by the enum's per-variant field layout. -- **AB comparison cancel cleanup** now uses canonical teardown path, fixing overlay mount and FS isolation registration leaks. -- **Connect timeout for sampling clients** (10s default, configurable) prevents frozen CLI on unreachable inference servers; TLS warmup now targets the sampling path. -- **Leader protocol backwards compatibility** by defaulting the `ready` field when connecting to older leader binaries. -- **Custom skill path discovery** now works end-to-end — paths added via `x.ai/skills/add` or config.toml are included in agent skill lookup. - - -# 0.1.158-alpha.2 - -## Features - -- **Configurable telemetry destinations** via `[telemetry]` config section and env overrides, with a trace-upload kill switch for noisy GCS warnings. -- **Pull-on-miss session restore** fetches remote sessions when not found locally, with remote-settings-gated writeback for TUI sessions. -- **Session restore with dedup archives** materializes GCS-referenced patches, blobs, and untracked files before applying. -- **Binary file attachments** persisted to session storage with content-hash dedup and surfaced as path hints for model context. -- **Subagent support** gated behind `--subagents` flag, env var, config, or remote settings — disabled by default. -- **Non-git-repo startup warning** with blocking quit/continue prompt, gated behind a server-side feature flag. -- **Line-numbered memory_get output** matching read_file format, with config-backed search defaults from `[memory.search]`. -- **Richer session auto-save** captures tool-usage breakdown and file paths touched; shell commands excluded to avoid persisting secrets. -- **`grok memory reindex` and `doctor` CLI commands** for index maintenance, plus access-frequency boost in hybrid search. -- **Cursor-based session reconnect** skips already-seen replay events, forwarding only post-cursor updates as live. -- **Subagent support in web UI** with clickable session cards, persistent spawn/finish notifications, and structured completion output. -- **Session restore progress tracking** with phase-level events, elapsed timers, and explicit incomplete-vs-complete outcome differentiation. -- **Per-subagent model routing** via `[subagents.models]` config and agent definition `model` field, with conditional override for heavy parent models. -- **Transparent stdio bridge reconnect** replays cached `initialize` and `session/load` after leader restart so external clients resume immediately. -- **Leader/client version mismatch notification** surfaces a TUI banner and headless log warning when client and leader binaries diverge. -- **OIDC manual paste fallback** races stdin against the loopback server so remote VM users can paste auth codes directly, with `[auth]` config alias. -- **Session ID clipboard copy** on /session-info, with a transient status banner for success and error feedback. -- **Telemetry defaults to off** with centralized env > config > remote settings > default precedence for telemetry and trace uploads. -- **User-defined subagents** via .grok/agents/*.md files with per-subagent config toggles and dynamic Task tool descriptions. - -## Bug Fixes - -- **Doom-loop detection hardened** with whitespace-normalized fingerprints, per-file failure tracking, nearest-match hints, and outcome-aware error streaks. -- **Restore no longer overwrites git identity** — synthetic commits use scoped env vars instead of writing to repo-local git config. -- **Session token routing fixed** for default models — `grok login` users no longer get 400 errors on proxy-routed models. -- **Richer API error diagnostics** with redacted headers, request body previews, and response metadata in failure messages. -- **Worktree list preserves full IDs** by computing dynamic column width instead of truncating to 16 characters. -- **Restored remote sessions create distinct local children** with parent tracking, preventing identity reuse and duplicate restores on repeated `grok -r`. -- **Auto A/B testing works on non-macOS** by treating absent worktree pool as passthrough instead of gate. -- **Worktree ID collision eliminated** by switching from time-based UUID v7 prefix to random UUID v4. -- **Cancelled and restored sessions no longer hang** by sending explicit shutdown commands before dropping session handles. -- **Memory search config and watcher correctly applied** — `[memory.search]` was silently ignored and first-use sessions missed watcher startup. -- **Auto-continue prompts excluded from real user query counts** in compaction, memory hooks, and session-end telemetry. -- **Deleted memory files removed from search results** and appended content indexed immediately without watcher restart. -- **Doom-loop detection overhaul** with polling-aware stagnation tracking, turn-scoped resets, targeted per-context warnings, and richer error classification. -- **A/B overlay path sanitization** across system prompt, tool results, URL-encoded paths, error messages, and `get_task_output` command display. -- **MCP server retry on init failure** by restoring HTTP config after handshake errors instead of permanently dying. -- **Sessions no longer hang on stalled inference** — content-aware idle timeout distinguishes keepalive SSE events from real completion tokens. -- **Turn completion signaled after empty-response retry** by emitting a fallback `AgentMessageChunk` when streaming events were lost. -- **Up-arrow no-op on empty history** prevents entering history search mode when no previous prompts exist. -- **Safer conversation compaction** by stripping orphaned tool results and falling back when validation fails. -- **Reliable prompt persistence** by gating persist_ack on chat history acceptance and flush barrier completion. -- **Leader startup deadlock resolved** by releasing the file lock before connecting so the leader can reach readiness. - -## Performance - -- **Faster session restore** by downloading codebase, memory, and state archives concurrently instead of sequentially. -- **Bounded hunk tracker memory** — binary and >1MB files tracked without retaining content, fixing 1.2GB retention from large files. -- **Non-blocking telemetry uploads** prevent prompt turns from stalling when the GCS proxy is unhealthy. -- **Reduced conversation query overhead** with narrow targeted queries that avoid full O(n) conversation clones. -- **Faster session resume** via selective prompt extraction that skips full deserialization of non-prompt update lines. -- **Bounded long-session memory** via eager in-memory pruning of old tool results after each user turn. - - -# 0.1.158-alpha.1 - -## Breaking Changes - -- **Repeated failed edits now trigger reread guidance** and search_replace NoMatchesFound responses changed shape to include file_path; update ACP/tool-output parsers. -- **Bounded hunk-tracker memory usage** adds explicit file-content status views to the ACP response; clients should migrate from legacy content fields to `baseline` and `current` metadata. - -## Features - -- **Configurable telemetry routing** with custom event endpoints, Mixpanel controls, and remote kill switches for analytics and trace uploads. -- **Remote session restore on local miss** by hydrating backend sessions into local storage and gating writeback sync with a server-side flag. -- **More complete remote restores** by materializing deduplicated patches and blobs before replay, with partial-restore warnings for external users. -- **Binary file attachments are now supported** by decoding blob resources to session storage with MIME metadata, size limits, and content-hash deduplication. -- **Subagent task execution** adds child agent sessions with lifecycle tracking, progress rendering, and background-task management across the CLI stack. -- **Opt-in subagent spawning** adds a `--subagents` flag plus env, config, and remote settings controls to gate the task tool. -- **Safer startup outside repositories** with a feature-flagged non-git warning that lets users quit before losing git-backed tracking and rewind features. -- **More useful memory tooling** with line-numbered memory_get, config-backed memory_search defaults, clearer memory docs, and richer auto-saved summaries that omit shell commands. -- **Memory maintenance commands** with reindex/doctor workflows and search ranking that lightly boosts frequently retrieved memories. -- **Versioned tool contracts** with preset-selected legacy behavior for run_terminal_cmd and read_file, plus contract metadata for integrators. -- **Subagent transcripts in web** with persisted status cards, structured completion data, and reload-safe child session replay. -- **Per-subagent model routing** with config and agent-definition overrides, plus correct inheritance from each parent session's live model. -- **Transparent stdio reconnect recovery** by replaying cached `initialize` and session state after leader restarts, then notifying clients. -- **Remote-VM sign-in fallback** by accepting pasted OIDC redirect URLs or tokens when the browser runs on another machine. -- **One-click session ID copying** from session info, with a transient status banner and clipboard error reporting. -- **Telemetry stays off by default**, with env, config, and remote settings precedence for telemetry and trace uploads. - -## Bug Fixes - -- **Stronger repeated-edit detection** by normalizing whitespace in search_replace fingerprints so indentation-only retries now trigger doom-loop warnings. -- **Faster edit recovery after misses** with nearest-match hints in search_replace errors and tighter guidance to use minimal unique anchors. -- **Session restore no longer overwrites repo git identity** by scoping synthetic commit author details to the restore subprocess only. -- **Error-loop termination is more reliable** by tracking per-file tool failures separately and only resetting warnings after successful writes. -- **Longer repeating tool cycles are detected** by raising doom-loop cycle length coverage and using deterministic fingerprint hashing. -- **Logged-in ACP clients can use default fast models again** by routing session tokens through the chat API proxy instead of api.x.ai. -- **API failures are easier to diagnose** with redacted request and response context in sampling errors and improved error propagation. -- **Full worktree IDs in listings** prevent truncated UUIDs and hashes, with dynamic column sizing in `worktree list` output. -- **Restored sessions keep local lineage** by creating a new local child session and tracking the remote parent for repeatable resumes. -- **Auto A/B works on Linux and other non-macOS systems** by falling back to on-demand worktree creation when no pool exists. -- **Concurrent worktree creation avoids collisions** by switching temporary worktree IDs from time-based UUID prefixes to random UUID v4 values. -- **Cancelled and restored sessions stop getting stuck** by routing prompt-complete notifications correctly and sending explicit shutdowns during teardown. -- **More reliable cross-session memory** by sharing one backend configuration, filtering synthetic prompts, fixing delete and append reindexing, and correcting telemetry counts. -- **Cursor-aware reconnect replay** skips already processed updates and resumes session streams from the last seen event. -- **Fewer false doom-loop stops** with smarter polling detection, per-turn resets, targeted warnings, and synthetic warning tags. -- **A/B comparison path isolation** so prompts, tool results, and task output no longer expose overlay worktrees or wrapper commands. -- **Recoverable MCP startup failures** by restoring HTTP configs for retries and surfacing readable MCP App handshake errors. -- **Clearer restore progress and failure states** with phased events, elapsed times, and explicit incomplete-session-state reporting. -- **Hung streaming requests now fail fast** by timing out keepalive-only SSE streams that stop producing real model content. -- **Leader startup no longer races client connects** by binding the IPC socket early and gating ACP traffic on explicit readiness. -- **More reliable A/B filesystem isolation** with a configurable non-overlay scratch base before falling back to tmpfs mounts. -- **Clients can distinguish auto-update restarts** by propagating explicit leader shutdown reasons through the IPC protocol and reconnect state. -- **Completed turns no longer disappear downstream** by emitting a fallback text chunk when streaming events were lost after retries. -- **Leader/client version mismatches are surfaced immediately** with ACP notifications, TUI banners, and headless warnings after registration. -- **Prompt history search no longer opens empty** by ignoring Up-arrow history mode when both queued prompts and saved history are absent. -- **Legacy list_dir compatibility** for older clients, restoring empty-directory, error, and depth-threshold output parity. -- **Safer conversation compaction** by stripping orphaned tool results and falling back when replayable history validation fails. -- **More reliable prompt persistence** by acknowledging prompts only after chat history accepts them and the flush barrier completes. -- **Legacy task error parity** for older clients, restoring exact not-found wording in get_task_output and kill_task. - -## Performance - -- **Faster session restore** downloads codebase, memory, and state archives concurrently while surfacing clearer restore strategy and warning summaries. -- **Prompt turns stay responsive** by moving GCS telemetry uploads to fire-and-forget paths when retries or queue fallbacks occur. -- **Lower memory use in long sessions** by replacing full conversation clones, selectively scanning updates, and pruning retained tool results. -- **Faster startup before code navigation** by lazily building indexes only for web clients that advertise x.ai/codeNavigation. - - -# 0.1.158 - -## Features - -- **Remote announcements** from remote settings with tolerant deserializer. -- **Shell completions** via `grok completions `, leader CLI, and stdio reconnect replay. -- **User-defined subagents** via.grok/agents/*.md with config toggles. -- **Post-compaction reminder includes running subagents** with IDs, types, and poll/cancel instructions. -- **External auth provider binary** enables login via custom commands in sandboxed/air-gapped environments. -- **Custom models_base_url auto-fetches** OpenAI-compatible model list for enterprise proxies. -- **Guarded normalized fallback matching** enables search_replace on confusable Unicode typography (gated by flag). -- **Full-text search across sessions** via new `x.ai/session/search` ACP extension with FTS5 indexing. -- **Retained file context post-compaction** by re-reading up to 5 recent files into history. -- **Persisted memory reminders** upsert into conversation system prompt without duplicating transient injections. -- **Configurable AB turn timeout** cancels stalled comparisons after wall-clock limit with cleanup and observability. -- **Configurable first-turn memory injection** with dedicated thresholds and remote settings support. -- **Managed MCPs feature flag** skips fetching by default in headless mode. -- **Paged announcement UX** adds prev/next commands with reliable startup visibility. -- **Disables web search tool** via `--disable-web-search` flag or `GROK_DISABLE_WEB_SEARCH=1` for benchmarks. -- **hashline_read tool** annotates file output with anchors like `LINE:LOCAL:CONTEXT→CONTENT`. -- **Grok 4.20 default model** with CLI/env/config/remote-settings overrides for web search and summaries. -- **Unauthenticated feedback submission** via optional tokens and `GROK_USER_METADATA` env. -- **Plan mode tools** EnterPlanMode, ExitPlanMode, AskUserQuestion send structured notifications. -- **Auto-installs grok-pager** alongside grok during internal and GitHub release updates. -- **Mid-session token refresh** via OIDC grants or external auth binaries prevents expiry. -- **Hashline toolset** enables anchor-stable file read/edit/grep with validation, recovery, ranges, and config integration. -- **Hooks system** executes custom scripts for pre/post-tool and session events from ~/.grok/hooks/ with deny-wins trust controls. -- **Independent feedback flag** gates /feedback and popups separately from telemetry via GROK_FEEDBACK_ENABLED. -- **Leader CLI** adds `grok leader list/info/profile/kill/dev` for discovery and CPU profiling. -- **Plan mode state machine** enables agent planning phase with enter/exit tools and session persistence. -- **Hooks from Claude settings** loads from `~/.claude/settings.json` and project `.claude/settings.json`. -- **Live subagent progress** shows turns, tools, tokens, errors in `get_task_output` for running tasks. -- **Unified `/hooks` command** supports `list`, `trust`, `add ` with fuzzy autocomplete. -- **ACP `x.ai/subagent/list_running`** queries live progress for all running subagents of parent session. -- **Bash tool params from config.toml** override schema defaults like `timeout_secs` for GrokBuild. -- **Live subagent progress** in TUI tasks panel via ACP polling and push notifications. -- **ACP extension resumes sessions in worktrees** via single call matching `grok -w -r` CLI flow. -- **Signed GCS uploads for shares** bypass proxy limits to prevent 413 errors on large sessions. - -## Bug Fixes - -- **Prompt persistence ack** gated on chat history flush barrier completion. -- **Leader spawn deadlock** resolved by releasing lock before connect. -- **Headless clipboard failures** handled silently in session info. -- **Server-side flags forwarded** via proxy for telemetry and other features. -- **Leader fetches settings** enabling remote-settings-gated runtime features. -- **AuthManager respects configured proxy URL** instead of hardcoding the default proxy host. -- **Clears stale todo spinners** at turn end with transient Plan notification without mutating underlying state. -- **Config.toml model overrides now apply** custom api_key/base_url to built-in models. -- **Prevents queued prompts flushing together** via per-turn ID guarding race between notification and response. -- **Alpha updates pick max(stable, alpha)** and harden channel admissibility across all installers. -- **Unicode confusables now detected and normalized** in search_replace/read_file with diagnostics, reminders, and guarded fallback matching. -- **Fixes overlay mount and FS isolation leaks** on AB comparison cancel by using canonical cleanup. -- **Adds connect timeout to sampling clients** and warms TLS roots for first-chat cold-start latency. -- **Leader protocol compatibility** by defaulting `ServerMessage::Registered.ready` to true. -- **Custom skills now discoverable** via config.toml paths and `x.ai/skills/add` in system prompts. -- **Non-blocking memory flushes** by spawning idle-timer tasks asynchronously. -- **Accurate turn end display** by not defaulting missing prompt-complete stop reasons to cancelled. -- **Correct error display** for retries by checking explicit `Cancelled` stop reason only. -- **Startup panic eliminated** by replacing nested tokio runtime with direct await. -- **Correct auto-compact in AB forks** by overriding context_window from target model registry. -- **Async FS operations** in AgentsMdTracker prevent hangs on overlayfs-backed filesystems. -- **Fixed TUI viewport layout** anchors prompt at bottom with reliable status bar. -- **run_terminal_cmd exported schema** hides and rejects `is_background` when disabled via params. -- **Fixed viewport height** enables single-frame dropdown resize without animation. -- **Respects explicit per-session yoloMode=false** overriding client defaults in leader and TUI. -- **Aborts path walks on first filesystem timeout** preventing 25-minute hangs on slow mounts. -- **5-minute tool execution timeout** prevents registry hangs with diagnostic tracing spans. -- **Non-blocking list_dir** uses tokio::fs::metadata to avoid executor hangs on overlayfs-backed paths. -- **A/B fork writes** are guarded by display-to-overlay path rewriting in AcpSessionFs. -- **ESC cancel** resets [cancelling] state via relaxed prompt-complete guard. -- **Linux A/B forks** skip overlayfs and replicate via git diff to prevent container syscall hangs. -- **No tool timeouts** allows long-running builds without false positives. -- **Matches tools server gRPC limits** to 128 MiB preventing `OutOfRange` on large grep outputs. -- **SSE parsing handles flat errors** from Grok proxy alongside OpenAI-standard nested format. -- **ACP file errors map to `io::ErrorKind`** ensuring consistent `NotFound`/permission dispatch. -- **Display paths in grep/list_dir** prevent internal worktree leaks to model in A/B forks. -- **Silent grok-pager installation** hides implementation details from install/update output. -- **Improved hashline_edit batch errors** state atomicity and retry-all guidance. -- **Scoped subagent cancellation** targets only current-turn subagents. -- **Post-session hooks await before** replication avoids root-repo diff corruption. -- **AB cancellation unblocked** via async hooks and cleanup timeouts. -- **Per-region snippets for scattered edits** cap output at ~40 lines instead of 10K+ for distant changes. - -## Performance - -- **SSE error detection skips JSON parse** on normal chunks via fast contains("\"error\"") guard. -- **Faster LLM turns** by persisting HTTP/2 client with connection pooling across conversation turns. -- **Faster streaming completions** by single-pass serialization replacing serde Value mutation. -- **Tuned reqwest client** enables keepalives, nodelay, and timeouts for faster IC backend requests. -- **Streamed request bodies** skip buffering and parsing for large non-rewritten payloads. - - -# 0.1.157-alpha.1 - -## Features - -- **Subagent support** gated behind `--subagents` flag, `GROK_SUBAGENTS` env var, config.toml, or remote settings — disabled by default. - -## Bug Fixes - -- **Cancelled and restored sessions no longer hang** by sending explicit shutdown commands before dropping session handles. - -## Performance - -- **Faster session restore** by downloading codebase, memory, and state archives concurrently instead of sequentially. - - -# 0.1.157 - -## Features - -- **Remote session restore on miss** fetches session data from backend when not found locally, with remote-settings-gated writeback. -- **Dedup-referenced codebase content materialised during restore** by downloading GCS-backed patches and blobs before replay. -- **Binary file attachments** decoded from base64, written to session storage with content-hash dedup, and surfaced as path hints to the model. -- **Subagent spawning and lifecycle management** with coordinator tracking, TUI progress rendering, and task tool integration. -- **Non-git directory warning** with blocking confirmation modal at startup, gated behind a server-side feature flag. -- **Line-numbered `memory_get` output** matching `read_file` format, with `memory_search` defaults now respecting `[memory.search]` config. -- **Richer session-end auto-saves** now include tool-usage breakdown and file paths touched; shell commands excluded to prevent credential leakage. -- **`grok memory reindex` and `doctor` CLI commands** for index maintenance, plus access-frequency boosting for retrieved memory chunks. - -## Bug Fixes - -- **Doom-loop detection hardened** with whitespace-normalized fingerprints, per-file failure tracking, nearest-match hints, and error-streak termination. -- **Session restore no longer overwrites git identity** — synthetic commits use subprocess env vars instead of repo-local config. -- **Session-authenticated users no longer get 400 errors** on default models — credential routing checks actual token source, not advertisement method. -- **API error messages include structured context** — request URL, relevant headers, and body preview surfaced on auth, payload, and server failures. -- **Worktree list displays full IDs** with dynamic column width instead of truncating to 16 characters. -- **Memory search config honored** — `[memory.search]` settings were silently ignored across all three backend construction paths. -- **Correct memory injection after compaction** — first-turn context now uses the real user query, not the auto-continue prompt. -- **Deleted memory files no longer searchable** — watcher sync removes stale chunks, and `/memory append` content is indexed immediately. - -## Performance - -- **Reduced session memory pressure** by replacing 8 full-conversation clone sites with narrow single-field actor queries. -- **Faster session resume** via selective `updates.jsonl` scanning that skips full deserialization of irrelevant update types. -- **Bounded long-session memory** by eagerly pruning old tool results from the retained conversation after each user turn. - - -# 0.1.156 - -## Features - -- **Per-session tip rotation** via a persistent cursor so each launch shows the next tip in sequence instead of the same UTC-day tip. -- **Cross-devbox session restore** via `grok sessions list/search` CLI subcommands and hardened `grok -r` with upload-ordering safety, cwd-scoped lookups, and staged-vs-unstaged correctness. - -## Bug Fixes - -- **Scoped MCP connector selection** so multiple connectors with the same URL but different auth scopes (Personal/Team/Org) route to the correct token. -- **GCS upload reliability** with panic-catching upload tasks, full error-chain logging, and aggressive HTTP connection pool eviction to prevent stale-connection retries. -- **Doom loop detection for repetitive edits** that were previously invisible because interleaved bash commands reset the detector. -- **Binary blob size cap** enforced during archive building, preventing multi-GB binaries from attempting uploads that would timeout. -- **Graceful upload drain on shutdown** so pending GCS uploads complete before exit instead of being silently abandoned. -- **Malformed tool-call JSON sanitization** replaces invalid arguments with `{}` before sending to providers, preventing permanent 400 loops from broken model output. -- **MCP tool name validation** skips tools with invalid characters (e.g. spaces) that cause Anthropic/OpenRouter 400 errors, instead of crashing the session. -- **Patch restore compatibility with older git** by removing `--allow-empty` flag and pre-checking for diff content before calling `git apply`. - -## Performance - -- **Streaming multipart upload for large files** (>50 MB) bypasses proxy body limits and avoids loading entire files into memory. - - -# 0.1.155-alpha.6 - -## Features - -- **Tip of the day** shown at startup, served dynamically from remote settings with local opt-out via `[cli] show_tips = false`. -- **OAuth2 Authorization Code + PKCE** login flow replacing legacy relay auth, with server-controlled rollout and permanent legacy fallback. - -## Bug Fixes - -- **Gitignore enforcement** on `read_file` and `search_replace` to reduce accidental secret exposure — aligns with `list_dir` and `grep` behavior. -- **Session resume race fix** — prevents `--resume` from creating a shadow session when `LoadSession` is in-flight via leader IPC. -- **Atomic summary writes** via temp-file + rename — fixes EOF parse errors when A/B comparison startup races with summary updates. -- **Memory tool labels** — `memory_search` and `memory_get` now render as "Memory Search" and "Memory Read" instead of generic labels. -- **HTTP/2 connection poisoning fix** — rebuilds the sampling client on final retry to recover from stale connections after server GOAWAY/RST_STREAM. - - -# 0.1.155-alpha.5 - -## Features - -- **Tip of the day** shown at startup from remote settings, rotated daily; opt out with `[cli] show_tips = false`. -- **Gitignore-aware file tools** — read_file and search_replace now refuse access to ignored paths, matching grep and list_dir. - -## Bug Fixes - -- **Session resume no longer races or silently creates a new session** when leader IPC or replay fails. -- **Atomic summary.json writes** prevent EOF parse errors during concurrent A/B comparison session loads. -- **Memory Search and Memory Read labels** in TUI instead of generic Search/Read for memory tool calls. -- **HTTP/2 connection poisoning recovery** via idle timeout eviction and fresh client rebuild on final retry attempt. -- **Bash command output appears immediately** by flushing the replay buffer before turn end in `!` prefix mode. - - -# 0.1.155-alpha.4 - -## Features - -- **End-to-end distributed tracing** across clients and the API proxy via W3C `_meta.traceparent` propagation into a single distributed trace. - -## Bug Fixes - -- **Structured error variants** for read_file, list_dir, search_replace, and todo_write so callers can distinguish file-not-found, permission-denied, and duplicate-id failures. -- **Replay notifications now guaranteed before session/load response** via `forward_with_completion()` drain; removes public `acp_send_fire_and_forget` from xai-acp-lib. -- **Writeback sync now includes cwd and title** in backend metadata on every flush and rename, fixing null values in remote session listings. -- **Typed TaskNotFound errors** for kill_task and get_task_output with known-task-ID hints, enabling Python-side structured error classification. -- **Terminal commands detached from controlling TTY** via `setsid()` so subprocesses like ssh cannot steal input from the TUI. - - -# 0.1.155-alpha.3 - -## Features - -- **MCP Apps support** for rendering interactive UI tools served via `ui://` resources from MCP servers. -- **OpenID Connect login** with PKCE, local callback server, and configuration via config.toml or environment variables. -- **On-demand MCP tool discovery** via BM25 search and `use_tool` meta-dispatch for KV cache-stable definitions. -- **Kill background tasks by task_id** via new `x.ai/task/kill` ACP extension method. -- **Integration tool names visible in TUI** instead of generic search_tool/use_tool plumbing labels. - -## Bug Fixes - -- **Rewind correctness overhaul** — mutation-free preview, ghost message filtering, compaction-aware replay for all targets. -- **Memory flush and compaction no longer fail** on orphaned tool_result messages by stripping tool blocks before summarization. -- **Slash command autocomplete enters argument phase** instead of executing immediately, and option-key word traversal corrected. -- **Cancel cleanup awaits child process exit** to reclaim memory before returning, preventing cascading OOM kills. - -## Performance - -- **Parallel tool execution** via shared resources and explicit ToolCallId, removing the sequential single-tool bottleneck. - - -# 0.1.155-alpha.2 - -## Features - -- **Config hot-reload** watches auth, MCP servers, memory, skills, UI, and models for live changes without restart. -- **`/memory` slash command** appends notes to workspace or global MEMORY.md with smart Markdown heading normalization. -- **cgroup v2 memory limits for spawned commands** gracefully OOM-kill only the offending process, keeping the session alive. -- **Debugging technique retention** across sessions by capturing API endpoints, CLI commands, and investigation workflows in memory flush. -- **File overwrite guard** on search_replace prevents empty old_string from silently replacing existing file contents. -- **Full command preview in scrollback** for long or multi-line bash commands that exceed the status bar. - -## Bug Fixes - -- **Duplicate tool name validation** prevents unreachable tools when two share the same client-facing name. -- **Large pasted text written to disk** so the model's read_file fallback finds the content instead of file-not-found. -- **TUI reconnect no longer hangs permanently** after leader restart — 30s timeout and guaranteed completion signal. -- **Duplicate leader exits cleanly** with 30s lock timeout and early bail-out when an existing socket is detected. -- **A/B fork session traces** now include repo_root and remote_url via shared git2 discovery logic. -- **Distinct fs tool error messages** differentiate 'does not exist' from 'is a file, not a directory' to prevent agent loops. -- **Drain timeout for inherited pipes** prevents `cmd &` without stdout redirect from blocking the actor loop indefinitely. -- **Leader log level defaults to info** instead of debug, cutting log volume ~10x over long-running sessions. -- **Memory flush sanitization** strips tool_result blocks before windowing to prevent orphaned tool_use_id 400 errors. -- **A/B comparison disk leak fix** — correct overlay unmount ordering prevents 4.9 GB leak, and primaryModelId now reflects the actual variant model. -- **Revert memory flush sanitization** that stripped tool messages and broke flush request formatting. - -## Performance - -- **Faster first prompt on large repos** by capping startup git_status at 2s instead of blocking 10-20s on index refresh. -- **Dedup blob existence pre-check** skips redundant uploads when another agent has already written the same SHA256 content. -- **Session eviction on client disconnect** drops SessionHandles to reclaim ~100-500MB per session when IPC clients leave. -- **mimalloc allocator on macOS Apple Silicon** for alpha builds, fixing 15GB+ RSS from system allocator's unreturned pages. - - -# 0.1.155 - -## Bug Fixes - -- **HTTP/1.1 fallback for sampling requests** when HTTP/2 connections fail with transport errors from GOAWAY or RST_STREAM frames. -- **Handle response.failed and error streaming events** from the Responses API instead of silently dropping them. -- **Prevent file descriptor exhaustion on macOS** by capping parallel walker threads and raising the soft FD limit at startup. - - -# 0.1.154-alpha.6 - -## Features - -- **Overlay-based A/B replication** replaces fsnotify+hunk-tracker with overlayfs to capture all terminal-created files during A/B sessions, fixing lost-file bugs on Linux. -- **/flush slash command** in the TUI triggers an on-demand memory flush to disk via the existing backend extension method. - - -# 0.1.154-alpha.5 - -## Features - -- **Tool execution tracing** via call_id passthrough, follow-up messages, and structured error detail fields in CLI output. -- **Mandatory AGENTS.md discovery** — runtime-discovered project instruction files must now be read before proceeding. -- **PTY notification routing** to originating client via targetClientId, with cwd fallback and terminal UI polish. -- **`--no-auto-update` flag** for leader mode to prevent update-triggered shutdowns in long-running sessions. -- **On-demand memory flush** via `/flush` command and `x.ai/memory/flush` extension with concurrent-flush prevention. -- **Tool state export/import** via GetToolState RPC and initial_tool_state_json, enabling session warm-start and cloning. -- **`GROK_MEMORY=0` force-disables memory** regardless of config.toml or remote settings; CLI flag still overrides. -- **Per-tool MCP timeout overrides** via `tool_timeouts` in config.toml and `toolTimeoutsMs` in ACP session meta. - -## Bug Fixes - -- **Slash command completion** now respects optional arguments, fixing `/load`, `/model`, `/compact`, and `/theme` execution. -- **Per-session model, sampling, and YOLO tracking** in leader mode, preventing cross-client state contamination. -- **Complete session replay on reconnect** by switching to unbounded client channels, preventing silently dropped notifications. -- **Zombie process prevention** on leader respawn with child reaper, config.toml auto_update respect, and stale-binary resolution. -- **OOM prevention in codebase indexer** via 5MB file-size cap, binary detection, and hidden-directory filtering. -- **Per-client YOLO mode isolation** in leader mode — toggling no longer contaminates other clients' sessions. -- **A/B merge-back recovers files in new directories** created via terminal, fixing the inotify recursive-watch race. -- **Worktree cwd preserves subdirectory offset** instead of resetting to the repo root. -- **8 MiB stack for session threads** prevents stack overflow on macOS where the default is 512 KB. -- **Grep tool description clarifies raw regex syntax** to prevent the model from quoting patterns. -- **Tool kind params merge across all tools** of the same ToolKind, fixing template render failures for multi-tool configs. -- **User query placed before attached file contents** in the prompt for better model attention. -- **Read file errors now include the file path and underlying cause** across all tool implementations. - -## Performance - -- **Lightweight index status queries** via GetFileCount/GetStats, eliminating a full ScopeGraphIndex clone on every status check. -- **Hunk tracker skips unnecessary file reads** in AgentOnly mode and eliminates a redundant content clone. - - -# 0.1.154-alpha.4 - -## Features - -- **Background command lifecycle** reworked with graceful SIGTERM→SIGKILL escalation, non-blocking I/O, and proper agent tracking for user-backgrounded commands. - -## Bug Fixes - -- **Auto A/B comparisons deferred until worktree pool is ready**, preventing 30-60s stall on large repos. -- **Headless mode (`grok -p`) exit panic** fixed by flushing telemetry before runtime teardown. - - -# 0.1.154-alpha.3 - -## Features - -- **MCP server management** via mcp/list, mcp/call, and mcp/servers_updated extension methods with multi-scope connector resolution. - -## Bug Fixes - -- **PTY notifications reach the correct client** via _meta routing metadata, with shared helpers and session-aware cwd fallback. -- **Concurrent tool calls no longer crash** the tools server; semaphore serializes access to the thread-local toolset. -- **Cross-session notification leaks eliminated** in leader mode for relay, dead-client, and ext/notification routing paths. -- **Leader auto-update pre-downloads the binary** before shutdown and resolves the ~/.grok/bin symlink at spawn time. -- **First-compaction memory flush fires correctly** by pre-incrementing the compaction counter before the flush guard. -- **Interval memory flush resumes after compaction** by resetting the conversation length counter when history is compacted. -- **Session content restored on TUI reconnect** by clearing stale scrollback, resetting turn state, and gating live updates during replay. -- **Memory flush entries accumulate across cycles** by appending to daily log files instead of overwriting each flush. -- **A/B experiment tool output shows original project paths** instead of internal worktree directories in streaming updates. -- **File write truncation eliminated** by switching to tokio::fs and disabling ACP filesystem routing from the TUI. -- **Token refresh works for JWTs with aud claims** by disabling audience validation during expiration parsing. -- **Session resume and fork no longer crash** with "Is a directory" error caused by a regression in the Tool trait migration. - - -# 0.1.154-alpha.2 - -## Features - -- **Structured headless prompts** via --prompt-json and --prompt-file flags, with --verbatim to skip query wrapping. -- **Bring-your-own-key for A/B comparisons** by reading [ab] openrouter_api_key from config.toml. - - -# 0.1.154-alpha.1 - -## Features - -- **Mid-session MCP server toggling** via x.ai/session/update_mcp_servers extension method with optimistic rollback. -- **Sandbox profile configurable via GROK_SANDBOX env variable**, defaulting to workspace profile on devboxes. -- **Agent version exposed in InitializeResponse** metadata for relay and client version discovery. -- **`grok worktree` subcommand** for listing, inspecting, removing, and garbage-collecting session worktrees. -- **Character-budget BFS directory listing** replaces depth-based summarization, expanding small deep directories when budget allows. - -## Bug Fixes - -- **Tool-server finalize subcommand aligned** with the Python client via named --tools/--truncation args and --json output. -- **Completion requirement tracking restored** in recovery loop, fixing duplicate tool calls introduced by the DynTool migration. -- **Foreground command cancellation no longer deadlocks** — terminal backend access moved outside the registry mutex. -- **A/B session filesystem isolation fixed** by replacing overlayfs with bind-mounts, eliminating split brain between bash and file tools. -- **ACP file operations** were silently bypassing the client filesystem backend, breaking ask-mode write rejection in GrokCode. -- **Zero-argument MCP tool calls** no longer fail with JSON parse error when the model emits an empty arguments string. -- **Model selection in leader mode** now persists across sessions — changing models mid-session no longer reverts on /clear. -- **Git index.lock contention on devboxes** resolved by adding --no-optional-locks to all background git operations. -- **System reminders** now fully respect the disabled flag, and template parameter defaults auto-populate from input schemas. -- **Memory tools now render in TUI and web UIs** with search/read cards, query text, and file path display. - -## Performance - -- **Session thread isolation** via per-session tokio runtimes, preventing tool execution in one session from blocking another. - - -# 0.1.154 - -## Features - -- **`/flush` slash command** in the TUI for on-demand memory flush to disk. - -## Bug Fixes - -- **A/B session file preservation** via host-side overlayfs, fixing terminal-created files silently lost during merge-back. -- **Memory flush no longer infers preferences** from unchallenged assistant actions, requiring explicit user statements. - - -# 0.1.152-alpha.1 - -## Features - -- **Slash command autocomplete** with ACP-advertised built-in and skill commands, keyboard navigation, and fuzzy filtering. -- **Compact and rewind controls** exposed to the web frontend via typed SessionClient methods with backwards-compatible camelCase aliases. -- **OpenCode agent mode** with 8 dedicated tools (bash, read, edit, write, grep, glob, todowrite, skill) using opencode conventions. - -## Bug Fixes - -- **Codex agent tool accuracy** restored by switching to dedicated codex-specific read, list-dir, and grep implementations. -- **File editing on macOS** fixed by canonicalizing search_replace paths to match read_file's case-normalized tracker keys. -- **Workspace disambiguation in /load** prevents session list overwrites when multiple workspaces share the same directory basename. -- **ACP agent profile support** restored for JSON object payloads, fixing permission mode and prompt loss for web and remote clients. - - -# 0.1.151 - -## Features - -- **Interactive web terminals** let clients run full PTY shells over ACP WebSocket with resize, reconnect replay, and unified terminal lifecycle APIs. -- **Configurable Codex system prompts** let builds toggle custom base templates per agent definition while preserving correct agent-type resolution from the active default model. - -## Bug Fixes - -- **Graceful tool cancellation handling** prevents agent-loop aborts when gRPC calls are cancelled, with structured CANCELLED errors and end-to-end cancellation coverage. -- **Accurate edit preview line numbers** now appear before approval by sending diff metadata through standard tool_call_update events instead of permission payload plumbing. - - -# 0.1.149 - -## Features - -- **Offline relay session sync** keeps shared sessions resilient with reconnect and persisted cursors, adding connection-state notifications for reliable TUI status updates. -- **Foreground tool progress visibility** shows active tool execution in the TUI status bar, generalizing streaming state beyond bash commands. -- **Config-driven skill loading** lets users add and ignore custom skill paths from config.toml, extending discovery beyond default local and user directories. -- **Persistent leader availability after disconnects** by spawning leader with --no-exit-on-disconnect, allowing new clients to reconnect without restarting the subprocess. - -## Bug Fixes - -- **Reliable CLI updates** now refresh grok-latest during installs, ensuring symlink-based setups launch the newly installed binary in fresh shells. -- **Parallel tool-call stability** prevents Anthropic ordering errors by deferring follow-up user messages until all tool_result blocks in a batch are emitted. -- **Channel-aware update checks** correctly install alpha builds during channel switches by combining semver comparison with explicit stable-versus-alpha mismatch logic. -- **More reliable tool execution in forks** validates concatenated JSON against the named tool schema and preserves FileReadTracker state by copying tool_state.json. -- **Client disconnect cancellation** now stops running tools and triggers cleanup for foreground commands in unary and streaming execution paths. -- **Stable A/B fork path behavior** keeps model-visible project paths consistent while resolving stale absolute paths safely to worktrees and preserving edit-read tracking state. -- **Reliable session resume across providers** by repairing dangling tool calls throughout conversation history, preventing persistent 400 errors on interrupted legacy sessions. -- **Accurate replayed terminal state** by skipping streaming bash update handling during replay, preventing ghost in-progress timers from out-of-order persisted chunks. -- **Cleaner TUI tool-call logs** by sending no placeholder raw_input until tagged ToolInput arrives, eliminating repeated 'missing field variant' parse errors. - - -# 0.1.146 - -## Features - -- **A/B filesystem isolation** prevents cross-session contamination by running forked sessions in overlayfs and syncing winner side-effects safely. -- **A/B safety guardrails** now block unsafe side-effecting commands during comparisons and abort contaminated experiments before execution. - -## Bug Fixes - -- **Responsive A/B cancellation** now interrupts worktree preparation and mid-copy operations, avoiding stuck comparisons and stale vote blocking. -- **Background task visibility** now shows immediate execution feedback and caps blocking waits, preventing apparent freezes during long-running commands. - - -# 0.1.145 - -## Bug Fixes - -- **Reliable hunk acceptance and replication** by refreshing baselines on git state changes and preserving tracked paths after accept/reject workflows. -- **Cleaner client tool event stream** by suppressing updates for skipped tool calls while still persisting cancellation context in conversation history. - - -# 0.1.144 - -## Features - -- **Offline-safe feedback retention** writes feedback and A/B votes to local session JSONL before network submission, including previously dropped vote rationale fields. -- **Agent environment metadata becomes queryable** by merging GROK_AGENT_METADATA into initialize responses and persisting the full blob in backend agent records. -- **Dynamic leader-mode control** resolves from CLI, local config, then remote settings, and startup also adopts remote upload limits. - -## Bug Fixes - -- **Complete prompt trace capture** preserves full pre-truncation text in GCS and records truncation metadata for reliable debugging of long prompts. -- **Backward-compatible metadata semantics** distinguish absent legacy prompt flags from explicit false values by serializing truncation/image indicators as optional booleans. -- **Rewind preview no longer crashes** on multibyte text by truncating at UTF-8 character boundaries instead of raw byte offsets. -- **Large file attachments are safely bounded** by emitting metadata stubs above a token threshold, preventing context blowups while preserving file discoverability. -- **Non-interactive edit workflows** no longer fail read-before-edit checks because tools server defaults `skip_read_before_edit` to true at startup. -- **A/B forks now keep subdirectory context** so comparison sessions run in the same repo subpath and replicate changes correctly. -- **Server-side A/B gating is now enforced** by honoring classification results before forking, reducing unnecessary comparisons for low-value prompts. -- **Forked A/B sessions now inherit current YOLO mode** so tool permissions stay consistent with user state and comparisons avoid approval stalls. - -## Performance - -- **Faster A/B fork creation** removes backend registration latency from fork setup by spawning session sync as background telemetry work. -- **Lower A/B fork overhead** skips low-value telemetry calls for ephemeral comparison forks, avoiding extra session-load work during experiments. -- **Quicker dual-worktree setup** reuses one precomputed dirty-state scan across both A/B syncs, eliminating redundant git status calls on large repos. -- **Shorter A/B comparison startup** overlaps both fork-and-spawn flows with concurrent joins, reducing sequential network wait in dual-fork initialization. -- **Pool restart latency drops** by adopting valid orphan worktrees instead of recreating them, with tested cleanup and adoption flow hardening. - - -# 0.1.143 - -## Features - -- **Prompt introspection from the CLI** is available via `grok prompt`, with JSON or section output and persisted session prompt_context snapshots. -- **Prompt image tracing** now captures user-supplied images as decoded per-turn files in GCS, improving multimodal debugging and auditability. -- **A/B comparisons now support ask/plan read-only mode** by forking sessions without git worktrees, enabling comparisons outside repositories with safer non-mutating defaults. -- **Per-server MCP timeouts** can be set via `_meta.mcpConfig`, with relay passthrough and precedence over config defaults during server startup and tool calls. -- **Ask/plan A/B can force worktrees** through `[cli].ab_force_worktrees`, overriding read-only defaults when operators need full filesystem-isolated comparisons. -- **Installation diagnostics** help troubleshoot conflicting grok binaries by listing canonical and invoked paths, versions, update status, and optional JSON output. - -## Bug Fixes - -- **Reliable todo status updates** now accept merge-only status patches by defaulting missing content to todo IDs instead of rejecting updates. -- **Accurate prompt re-rendering** now respects active tool overrides and disabled tools by centralizing all prompt assembly through a shared PromptContext. -- **A/B cancellation no longer double-finishes comparisons** by suppressing completion notifications and after-uploads when cancel handlers already removed active comparison state. -- **Running outside git repos no longer panics** by skipping gitignore construction without a repo root and guarding absolute-path ignore checks. -- **npm installs now use a canonical Grok binary path** via postinstall copy to `~/.grok/bin/grok`, preventing installer conflicts and version confusion. - - -# 0.1.142 - -## Features - -- **Hidden directories now appear in file listings** by defaulting fs list requests to include hidden paths and removing unused session fs/git handlers. -- **Faster A/B comparisons in large macOS repos** via reusable prefilled worktree pools, atomic claiming, and preparation signaling. -- **CLI self-update downloads** now use authenticated, time-limited URLs with environment-scoped auth for updates. -- **Prompt context now includes editor focus state** by rendering focused files, open files, and regular resource links in structured system reminders. -- **Richer editor context in prompts** now includes focused and open files as resource links with cursor metadata for better code-grounded responses. -- **More reliable self-updates in restricted environments** by falling back to GitHub Releases via `gh` when npm or internal installers fail. - -## Bug Fixes - -- **Web search works again** by passing sampling credentials through toolset overrides so the web_search tool is registered correctly. -- **Unique agent identities across containers** by incorporating Linux HOSTNAME into ID derivation and normalizing stored agent IDs to UUIDv5. -- **Embedded file attachments parse more URI formats** by accepting optional file:// prefixes and both #Lstart-end and #Lstart-Lend fragments. -- **Single-@ file references now work in TUI prompts** by broadening reference parsing to support optional @ and L-prefixed line ranges. -- **Compacted conversations preserve query structure** by wrapping summaries in `` tags so resumed sessions keep consistent context formatting. -- **Duplicate session messages are prevented** by making saveSessionData append only unseen message tails based on persisted message counts. -- **A/B winner changes now land in your main repo** by replicating worktree edits to the original source directory explicitly. -- **Debug extension methods now accept camelCase params** while keeping snake_case aliases, improving client compatibility without breaking existing callers. -- **Fewer stale HTTP client failures** by disabling idle connection pooling for sampling requests and relying on HTTP/2 keep-alive checks. - -## Performance - -- **Faster worktree pool operations** reduce acquire and cleanup overhead with skip-clean paths and streamlined removal logic. - - -# 0.1.141 - -## Breaking Changes - -- **Per-model concise behavior** replaces global [toolset].use_concise; migrate by setting model..use_concise=true because the old global key is ignored. - -## Features - -- **Faster completion acceptance** lets you confirm prompt, file, and history suggestions with Right Arrow alongside Enter and Tab. -- **Inline code review comments** can now be created and deleted via new extension methods, with append-only GCS event records for pipelines. -- **Machine-readable update checks** add --check and --json support to update and version commands for automation-friendly version status reporting. -- **Automatic project-instruction discovery** surfaces newly encountered AGENTS.md and Claude.md paths during tool access, including post-compaction reminders outside the initial directory chain. - -## Bug Fixes - -- **Cleaner pager command output** relies on shell no-color mode, removing local ANSI stripping and preserving streamed text exactly. -- **Accurate restored session metrics** persist full SessionSignals snapshots and reload them on resume, avoiding turn-count drift after compaction. -- **More reliable long streaming requests** use per-sampling HTTP clients with keep-alive tuning instead of a shared global client. -- **Startup stability for external instruction files** avoids gitignore panics by skipping ignore checks for paths outside the repository root. -- **Tool-call protocol compliance** now emits cancellation and rejection outputs for unexecuted tools, preventing model errors when expected tool results are missing. - - diff --git a/crates/codegen/xai-grok-shell/Cargo.toml b/crates/codegen/xai-grok-shell/Cargo.toml index 2360008..7d2caf7 100644 --- a/crates/codegen/xai-grok-shell/Cargo.toml +++ b/crates/codegen/xai-grok-shell/Cargo.toml @@ -1,7 +1,7 @@ [package] license = "Apache-2.0" name = "xai-grok-shell" -version = "0.2.101" +version = "0.2.102" edition.workspace = true [features] diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.102.json b/crates/codegen/xai-grok-shell/changelogs/0.2.102.json new file mode 100644 index 0000000..b785a84 --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.102.json @@ -0,0 +1,187 @@ +[ + { + "category": "breaking", + "description": "**--minimal** and **--fullscreen** flags now apply only to the current session.", + "breaking_change": true + }, + { + "category": "features", + "description": "**New /jump slash command** lets you quickly jump to any previous turn in the conversation.", + "breaking_change": false + }, + { + "category": "features", + "description": "**New /timeline sidebar** shows a clickable tick rail for fast navigation between conversation turns.", + "breaking_change": false + }, + { + "category": "features", + "description": "**grok login** now requests Grok Projects scopes so workspace listing works after consent.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Permission mode** can now be set fleet-wide via remote config when no local setting exists.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Edit tool output** has a setting to show a compact one-line summary instead of always-expanded diffs.", + "breaking_change": false + }, + { + "category": "features", + "description": "**Tab completion** in !bash mode now works like a normal terminal (prefix fill, dropdown, directory drill-down).", + "breaking_change": false + }, + { + "category": "features", + "description": "**Enterprise deployments** can now disable voice dictation via `requirements.toml` so `/voice` and Ctrl+Space are hidden for everyone.", + "breaking_change": false + }, + { + "category": "features", + "description": "**User prompts** now appear bold only in `--minimal` mode; fullscreen keeps normal weight.", + "breaking_change": false + }, + { + "category": "features", + "description": "**`grok plugin install`** now accepts a marketplace's registered name as a qualifier.", + "breaking_change": false + }, + { + "category": "features", + "description": "Consecutive edits to the same file now collapse into a single scrollback row when collapsed edit blocks are enabled.", + "breaking_change": false + }, + { + "category": "features", + "description": "Local sessions now inherit your shell environment variables and keep the current directory across commands.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Login and re-login** no longer stack multiple device-code polls or leave stale flows running.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Background task tools** now render with correct icons and titles instead of the generic MCP wrench.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Task tool** now correctly validates and displays allowed model slugs for subagents.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Rewind** now correctly handles bash transcripts, permission follow-ups, and sessions that mix old and new prompt markers.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Re-login** during a session now immediately uses the new token instead of requiring a new session.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Terminal commands** using globs now behave the same on zsh as on bash and no longer fail with shell errors.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Installer** no longer replaces stowed shell configuration symlinks with plain files on upgrade.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Voice transcription** now works with enterprise API bases and API-key authentication.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Fixed crashes** on some network-mounted home directories by using a safer SQLite journal mode.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Home and End keys** now move to the ends of the current wrapped line in the prompt.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Arrow keys and Esc** now work correctly inside viewers opened from the dashboard.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Warns at startup** when user and project sandbox profiles define the same name differently.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Billing upgrade links** now show the full URL in the transcript (and copy it) when a browser cannot be opened.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Fixed Ctrl+Y yank** no longer working after sending a prompt.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**No longer shows permission prompts** seconds after a turn was cancelled with Esc or Ctrl+C.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Page Up and Page Down** now move the highlighted entry to the top or bottom of the visible scrollback area.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "Conflicting project and user sandbox profiles now show a clear warning on the welcome screen.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**OAuth login URLs** no longer contain duplicate referrer parameters.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**File links** in official VS Code Remote-SSH terminals now use VS Code's native path handling.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Minimal mode** now shows the folder-trust prompt after sign-in when required.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Skills** whose names collide with built-in slash commands are now reachable via qualified names.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Fixed background task tracking** when using grok -p --no-wait-for-background so tasks are properly reaped on exit.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**Rate limit errors (429)** now show specific server messages (capacity, team limits, free-usage) instead of generic upgrade prompts, with correct copy based on auth type.", + "breaking_change": false + }, + { + "category": "fixes", + "description": "**`/copy` slash command** is now available in minimal mode.", + "breaking_change": false + }, + { + "category": "performance", + "description": "**Improved recap and compaction** behavior.", + "breaking_change": false + } +] diff --git a/crates/codegen/xai-grok-shell/changelogs/0.2.102.md b/crates/codegen/xai-grok-shell/changelogs/0.2.102.md new file mode 100644 index 0000000..0bee0ca --- /dev/null +++ b/crates/codegen/xai-grok-shell/changelogs/0.2.102.md @@ -0,0 +1,50 @@ +# 0.2.102 — 2026-07-16 + +## Breaking Changes + +- **--minimal** and **--fullscreen** flags now apply only to the current session. + +## Features + +- **New /jump slash command** lets you quickly jump to any previous turn in the conversation. +- **New /timeline sidebar** shows a clickable tick rail for fast navigation between conversation turns. +- **grok login** now requests Grok Projects scopes so workspace listing works after consent. +- **Permission mode** can now be set fleet-wide via remote config when no local setting exists. +- **Edit tool output** has a setting to show a compact one-line summary instead of always-expanded diffs. +- **Tab completion** in !bash mode now works like a normal terminal (prefix fill, dropdown, directory drill-down). +- **Enterprise deployments** can now disable voice dictation via `requirements.toml` so `/voice` and Ctrl+Space are hidden for everyone. +- **User prompts** now appear bold only in `--minimal` mode; fullscreen keeps normal weight. +- **`grok plugin install`** now accepts a marketplace's registered name as a qualifier. +- Consecutive edits to the same file now collapse into a single scrollback row when collapsed edit blocks are enabled. +- Local sessions now inherit your shell environment variables and keep the current directory across commands. + +## Bug Fixes + +- **Login and re-login** no longer stack multiple device-code polls or leave stale flows running. +- **Background task tools** now render with correct icons and titles instead of the generic MCP wrench. +- **Task tool** now correctly validates and displays allowed model slugs for subagents. +- **Rewind** now correctly handles bash transcripts, permission follow-ups, and sessions that mix old and new prompt markers. +- **Re-login** during a session now immediately uses the new token instead of requiring a new session. +- **Terminal commands** using globs now behave the same on zsh as on bash and no longer fail with shell errors. +- **Installer** no longer replaces stowed shell configuration symlinks with plain files on upgrade. +- **Voice transcription** now works with enterprise API bases and API-key authentication. +- **Fixed crashes** on some network-mounted home directories by using a safer SQLite journal mode. +- **Home and End keys** now move to the ends of the current wrapped line in the prompt. +- **Arrow keys and Esc** now work correctly inside viewers opened from the dashboard. +- **Warns at startup** when user and project sandbox profiles define the same name differently. +- **Billing upgrade links** now show the full URL in the transcript (and copy it) when a browser cannot be opened. +- **Fixed Ctrl+Y yank** no longer working after sending a prompt. +- **No longer shows permission prompts** seconds after a turn was cancelled with Esc or Ctrl+C. +- **Page Up and Page Down** now move the highlighted entry to the top or bottom of the visible scrollback area. +- Conflicting project and user sandbox profiles now show a clear warning on the welcome screen. +- **OAuth login URLs** no longer contain duplicate referrer parameters. +- **File links** in official VS Code Remote-SSH terminals now use VS Code's native path handling. +- **Minimal mode** now shows the folder-trust prompt after sign-in when required. +- **Skills** whose names collide with built-in slash commands are now reachable via qualified names. +- **Fixed background task tracking** when using grok -p --no-wait-for-background so tasks are properly reaped on exit. +- **Rate limit errors (429)** now show specific server messages (capacity, team limits, free-usage) instead of generic upgrade prompts, with correct copy based on auth type. +- **`/copy` slash command** is now available in minimal mode. + +## Performance + +- **Improved recap and compaction** behavior. diff --git a/crates/codegen/xai-grok-shell/src/agent/config.rs b/crates/codegen/xai-grok-shell/src/agent/config.rs index c02a5c7..c934b1c 100644 --- a/crates/codegen/xai-grok-shell/src/agent/config.rs +++ b/crates/codegen/xai-grok-shell/src/agent/config.rs @@ -3917,10 +3917,9 @@ impl ModelEntry { api_base_url: entry.api_base_url.clone(), } } - /// The model's own (BYOK) credential: a non-empty `api_key`, else the first - /// set, non-empty `env_key` value. `None` means the model has no usable own - /// credential and resolution should fall through to the session / global key. - fn own_credential(&self) -> Option { + /// Non-empty `api_key`, else first non-empty resolved `env_key`. + /// `None` → fall through to session / global key. + pub(crate) fn own_credential(&self) -> Option { first_own_credential(self.api_key.as_deref(), self.env_key.as_ref()) } /// `true` when the model has a non-empty `api_key` or an `env_key` that @@ -4360,7 +4359,7 @@ pub fn enforce_disable_api_key_auth( ) { if disable_api_key_auth && creds.auth_type == xai_chat_state::AuthType::ApiKey - && crate::util::is_first_party_xai_url(&creds.base_url) + && crate::util::is_xai_api_url(&creds.base_url) { creds.auth_type = xai_chat_state::AuthType::SessionToken; creds.api_key = session_key.map(str::to_owned); @@ -9216,7 +9215,6 @@ agent_type = "cursor" url = "https://mcp.test.com" [toolset.bash] timeout_secs = 120 - persistent_shell = true [shortcuts] ctrl_k = "search" [grok_com_config] diff --git a/crates/codegen/xai-grok-shell/src/agent/handlers/model_switch.rs b/crates/codegen/xai-grok-shell/src/agent/handlers/model_switch.rs index 2d4043a..9b2d1ba 100644 --- a/crates/codegen/xai-grok-shell/src/agent/handlers/model_switch.rs +++ b/crates/codegen/xai-grok-shell/src/agent/handlers/model_switch.rs @@ -223,11 +223,12 @@ pub(crate) async fn apply( current_agent_type: None, }); if agent.cfg.borrow().mode != config::AgentMode::Leader { - agent.models_manager.set_current_model_id(model_id); + agent.models_manager.set_current_model_id(model_id.clone()); agent .models_manager .set_current_reasoning_effort(applied_effort); } + agent.sync_process_static_api_key(Some(model_id.0.as_ref())); Ok(acp::SetSessionModelResponse::new().meta( serde_json::json!({ "model" : updated_model, }) .as_object() diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs index 567dbc8..275b9cb 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/acp_agent.rs @@ -367,6 +367,7 @@ impl acp::Agent for MvpAgent { ); self.set_auth_method(default_id); } + self.sync_process_static_api_key(None); let current_working_directory = self.launch_cwd.clone(); let hostname = gethostname::gethostname(); let mcp_servers: Vec = Vec::new(); @@ -513,6 +514,7 @@ impl acp::Agent for MvpAgent { } } self.set_auth_method(arguments.method_id.clone()); + self.sync_process_static_api_key(None); self.ensure_telemetry_client(); if crate::agent::chat_modes::process_chat_mode_enabled() { self.chat_modes.warm_in_background(); @@ -718,44 +720,52 @@ impl acp::Agent for MvpAgent { ), ); let login_override = auth_meta.login_override(); - let (auth, _did_auth) = if !auth_meta.headless { + let mut cancelled = false; + let client_seq = auth_meta.request_seq; + let auth_result = if !auth_meta.headless { let (url_tx, url_rx) = tokio::sync::oneshot::channel(); let (code_tx, code_rx) = tokio::sync::mpsc::channel(1); - *self.auth_code_tx.borrow_mut() = Some(code_tx); - *self.auth_url_rx.borrow_mut() = Some(url_rx); - let result = crate::auth::run_auth_flow_with_stderr_bridge( - &self.auth_manager, - grok_ctx, - crate::auth::AuthChannels { - url_tx: Some(url_tx), - code_rx, - }, - auth_meta.reauth, - auth_meta.force_interactive, - login_override, - ) - .await; - *self.auth_code_tx.borrow_mut() = None; - *self.auth_url_rx.borrow_mut() = None; - result + let (cancel, _guard) = self + .interactive_auth + .begin( + Some( + crate::auth::single_flight::AttemptChannels::new( + code_tx, + url_rx, + ), + ), + client_seq, + ); + tokio::select! { + biased; _ = cancel.cancelled() => { cancelled = true; + Err(anyhow::anyhow!("Authentication cancelled")) } r = crate + ::auth::run_auth_flow_with_stderr_bridge(& self.auth_manager, + grok_ctx, crate ::auth::AuthChannels { url_tx : Some(url_tx), + code_rx, }, auth_meta.reauth, auth_meta.force_interactive, + login_override,) => r, + } } else { - crate::auth::run_auth_flow( - &self.auth_manager, - grok_ctx, - auth_meta.reauth, - None, - None, - None, - login_override, - ) - .await - } + let (cancel, _guard) = self.interactive_auth.begin(None, client_seq); + tokio::select! { + biased; _ = cancel.cancelled() => { cancelled = true; + Err(anyhow::anyhow!("Authentication cancelled")) } r = crate + ::auth::run_auth_flow(& self.auth_manager, grok_ctx, auth_meta + .reauth, None, None, None, login_override,) => r, + } + }; + let (auth, _did_auth) = auth_result .map_err(|e| { emit_login_span( false, arguments.method_id.0.as_ref(), None, - Some("login_flow_failed"), + Some( + if cancelled { + "login_cancelled" + } else { + "login_flow_failed" + }, + ), ); let mut err = acp::Error::auth_required(); err.message = e.to_string(); @@ -2081,8 +2091,8 @@ impl acp::Agent for MvpAgent { return Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)); } } - let intake_lock = self.prompt_intake_lock(&arguments.session_id); - let intake_guard = intake_lock.lock().await; + let dispatch_lock = self.dispatch_lock(&arguments.session_id); + let dispatch_guard = dispatch_lock.lock().await; let meta_prompt_mode = arguments .meta .as_ref() @@ -2312,7 +2322,7 @@ impl acp::Agent for MvpAgent { acp::Error::internal_error() .data(format!("failed to dispatch prompt to session: {e}")) })?; - drop(intake_guard); + drop(dispatch_guard); self.push_roster_activity_delta( &arguments.session_id, crate::agent::roster::RosterActivity::Working, @@ -3081,6 +3091,8 @@ impl acp::Agent for MvpAgent { .and_then(|m| m.get("rewindIfPristine")) .and_then(|v| v.as_bool()) .unwrap_or(false); + let dispatch_lock = self.dispatch_lock(&args.session_id); + let _dispatch_guard = dispatch_lock.lock().await; let _ = handle .cmd_tx .send(SessionCommand::Cancel { diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs index cb4faa4..57f2da3 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/agent_ops.rs @@ -3,6 +3,18 @@ //! Inherent [`MvpAgent`] helpers (MCP/clients/gateway, settings/models, session ops, spawn). //! Co-located child of `mvp_agent` (`use super::*`). use super::*; +/// `preferred` model, else catalog `current`, else first with own credentials. +fn byok_from_models( + models: &indexmap::IndexMap, + preferred: Option<&str>, + current: &str, +) -> Option { + preferred + .and_then(|id| models.get(id)) + .and_then(|m| m.own_credential()) + .or_else(|| models.get(current).and_then(|m| m.own_credential())) + .or_else(|| models.values().find_map(|m| m.own_credential())) +} impl MvpAgent { pub(super) fn resolve_image_description_model(&self) -> String { self.cfg @@ -78,6 +90,20 @@ impl MvpAgent { pub(super) fn set_auth_method(&self, id: acp::AuthMethodId) { self.auth_method_id.store(Some(std::sync::Arc::new(id))); } + /// Publish model-owned credentials for voice/tools static fallthrough. + /// Only [`ModelEntry::own_credential`] — not `sampling_config.api_key` (may be a session JWT). + pub(crate) fn sync_process_static_api_key(&self, preferred_model_id: Option<&str>) { + if self.cfg.borrow().grok_com_config.api_key_auth_disabled() { + self.auth_manager.set_process_static_api_key(None); + return; + } + let models = self.models_manager.models(); + let current = self.models_manager.current_model_id(); + self.auth_manager + .set_process_static_api_key( + byok_from_models(&models, preferred_model_id, current.0.as_ref()), + ); + } /// Return auth for sync config construction. pub(super) fn current_or_buffered_auth(&self) -> Option { self.auth_manager @@ -1414,6 +1440,14 @@ impl MvpAgent { ) -> Self { models_manager.set_gateway(gateway.clone()); let sampling_config = models_manager.sampling_config(); + if !cfg.grok_com_config.api_key_auth_disabled() { + let models = models_manager.models(); + let current = models_manager.current_model_id(); + auth_manager + .set_process_static_api_key( + byok_from_models(&models, None, current.0.as_ref()), + ); + } crate::upload::trace::spawn_purge_stale_upload_scratch(); let storage_mode = cfg.storage_mode; let default_yolo_mode = cfg.default_yolo_mode; @@ -1468,7 +1502,7 @@ impl MvpAgent { sessions: RefCell::new(HashMap::new()), activity, loading_sessions: RefCell::new(HashMap::new()), - prompt_intake_locks: RefCell::new(HashMap::new()), + dispatch_locks: RefCell::new(HashMap::new()), session_threads: RefCell::new(HashMap::new()), resident_roster_titles: RefCell::new(HashMap::new()), initialize_request: OnceLock::new(), @@ -1504,8 +1538,7 @@ impl MvpAgent { auth_method_id: crate::agent::auth_method::new_shared_auth_method_id(None), sampling_config: RefCell::new(sampling_config), auth_manager, - auth_code_tx: RefCell::new(None), - auth_url_rx: RefCell::new(None), + interactive_auth: Default::default(), client_type: RefCell::new(ClientType::default()), code_nav_enabled: std::cell::Cell::new(false), interactive_trust_client: std::cell::Cell::new(false), diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs index 0251251..50336f9 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/mod.rs @@ -62,7 +62,7 @@ use xai_grok_sampling_types::{ supports_reasoning_effort_meta, }; use crate::agent::update_chunk_merge; -use crate::auth::{AuthManager, AuthUrlInfo}; +use crate::auth::AuthManager; use crate::config::StorageMode; use crate::extensions::notification::{SessionNotification, SessionUpdate}; use xai_grok_telemetry::id::{agent_id, agent_instance_id}; @@ -614,19 +614,14 @@ pub struct MvpAgent { loading_sessions: RefCell< HashMap>, >, - /// Per-session prompt-intake serialization lock. LEADER-SAFE(per-session): - /// keyed by SessionId, mirrors `sessions` lifecycle. - /// - /// Each incoming `session/prompt` RPC is dispatched as its own task by the - /// ACP message loop, and [`Self::prompt`] runs an async preamble (prompt-mode - /// query, trace context, model lookup) BEFORE it enqueues - /// `SessionCommand::Prompt` onto the actor's FIFO mailbox. Without - /// serialization those preambles interleave across tasks, so the mailbox — - /// and therefore the authoritative prompt queue — receives prompts out of - /// submission order. `prompt()` holds this lock across the preamble and - /// releases it immediately after the enqueue (the turn itself runs unlocked), - /// which makes intake order match arrival order. - prompt_intake_locks: RefCell< + /// Per-session lock ordering dispatch onto the actor's mailbox: + /// [`Self::prompt`] holds it across its intake preamble and + /// [`Self::cancel`] around its `Cancel` send, so prompts land in + /// submission order and a cancel cannot overtake the prompt it targets + /// (see `cancel_never_overtakes_in_flight_prompt_intake`). Cancels wait + /// out preambles held ahead of them — keep preambles lean; bridge cancels + /// are unordered. LEADER-SAFE(per-session): mirrors `sessions` lifecycle. + dispatch_locks: RefCell< HashMap>>, >, /// LEADER-SAFE(per-session): keyed by SessionId. Mirrors `sessions` lifecycle. @@ -654,10 +649,11 @@ pub struct MvpAgent { /// grok.com chat-product catalog (`/rest/modes`) for chat sessions; distinct /// from `models_manager` (the build `/v1/models` catalog). pub(crate) chat_modes: crate::agent::chat_modes::ChatModesManager, - /// Forwards pasted codes from `handle_auth_submit_code` to the auth flow. - pub(crate) auth_code_tx: RefCell>>, - /// Receives the auth URL from the auth flow; read by `handle_auth_get_url`. - pub(crate) auth_url_rx: RefCell>>, + /// Single-flight guard for interactive login (device poll / loopback + /// wait). Owns the active attempt's cancel token and its code/url + /// channels; a new `authenticate` or `x.ai/auth/cancel` cancels the + /// prior attempt. + pub(crate) interactive_auth: crate::auth::single_flight::AuthSingleFlight, /// Client type. LEADER-SAFE(init-once): set once during `initialize` from /// `_meta.clientIdentifier` (injected by the IPC server in leader mode). /// @@ -1127,6 +1123,10 @@ struct AuthRequestMeta { /// user abandons the browser flow, the current session continues. #[serde(default)] force_interactive: bool, + /// Pager auth `request_seq` for this attempt. Scopes `x.ai/auth/cancel` + /// so a delayed cancel cannot tear down a successor login. + #[serde(default)] + request_seq: Option, } impl AuthRequestMeta { /// `--oauth` → force loopback; otherwise default (loopback). diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs index 741695e..544ded6 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/session_lifecycle.rs @@ -30,14 +30,10 @@ impl MvpAgent { }); } } - /// Remove a session and its thread handle without finalizing the cloud - /// replica; the conversation stays resumable on disk. Reached by - /// dead-actor reaping and the terminal close and delete paths. Idle - /// unload does not route here: `handle_evict_sessions` removes its - /// handle inline and keeps the thread for reconnect. + /// Remove a session without finalizing; it stays resumable on disk. pub(crate) fn remove_session(&self, id: &acp::SessionId) { self.sessions.borrow_mut().remove(id); - self.prompt_intake_locks.borrow_mut().remove(id); + self.dispatch_locks.borrow_mut().remove(id); self.session_threads.borrow_mut().remove(id); self.session_index_claims.borrow_mut().remove(id); self.require_gateway_sessions.borrow_mut().remove(id); @@ -51,13 +47,10 @@ impl MvpAgent { ops.end_local_session(id.0.as_ref()); } } - /// Get-or-create the per-session prompt-intake lock (see - /// [`Self::prompt_intake_locks`]). Cheap clone of the shared `Rc`. - pub(super) fn prompt_intake_lock( - &self, - id: &acp::SessionId, - ) -> std::rc::Rc> { - self.prompt_intake_locks + /// Get-or-create the per-session dispatch lock (see + /// [`Self::dispatch_locks`]). Cheap clone of the shared `Rc`. + pub(super) fn dispatch_lock(&self, id: &acp::SessionId) -> std::rc::Rc> { + self.dispatch_locks .borrow_mut() .entry(id.clone()) .or_default() diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs index b332ae2..24aeb4b 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/subagent_coordinator.rs @@ -74,9 +74,6 @@ impl MvpAgent { .borrow_mut() .register_block_wait(&subagent_id, slot.clone()); } - this.subagent_coordinator - .borrow_mut() - .evict_stale_completed(); result }; let snapshot = resolve_snapshot(lookup).await; diff --git a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs index 3ffc023..db2226b 100644 --- a/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs +++ b/crates/codegen/xai-grok-shell/src/agent/mvp_agent/tests.rs @@ -3000,6 +3000,58 @@ fn cancel_does_not_forward_to_bridge_in_local_mode() { ); }); } +/// Regression (post-cancel slot hang, first bad release 0.2.101; see +/// `dispatch_locks`). SDK e2e shape: +/// `test_cancel_ends_in_flight_turn_and_frees_slot` (grok-agent-sdk). +#[test] +fn cancel_never_overtakes_in_flight_prompt_intake() { + use crate::session::SessionCommand; + use acp::Agent as _; + run_local_for_bridge_test(|| async { + let agent = build_minimal_agent_for_tests(); + let sid = acp::SessionId::new("sess-cancel-intake-race"); + let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None); + agent.sessions.borrow_mut().insert(sid.clone(), handle); + let order: std::rc::Rc>> = + std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let (intake_parked_tx, intake_parked_rx) = tokio::sync::oneshot::channel::<()>(); + let driver_order = order.clone(); + tokio::task::spawn_local(async move { + let mut intake_parked_tx = Some(intake_parked_tx); + while let Some(cmd) = cmd_rx.recv().await { + match cmd { + SessionCommand::GetCurrentPromptMode { .. } => { + if let Some(tx) = intake_parked_tx.take() { + let _ = tx.send(()); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + SessionCommand::Prompt { .. } => driver_order.borrow_mut().push("prompt"), + SessionCommand::Cancel { .. } => driver_order.borrow_mut().push("cancel"), + _ => {} + } + } + }); + let prompt_fut = agent.prompt(acp::PromptRequest::new( + sid.clone(), + vec![acp::ContentBlock::from("hi")], + )); + let cancel_fut = async { + intake_parked_rx + .await + .expect("prompt intake reaches the fake actor"); + let _ = agent + .cancel(acp::CancelNotification::new(sid.clone())) + .await; + }; + let _ = futures::join!(prompt_fut, cancel_fut); + assert_eq!( + order.borrow().as_slice(), + ["prompt", "cancel"], + "cancel must land on the actor mailbox after the prompt it targets" + ); + }); +} use crate::session::SessionCommand as TestSessionCommand; /// Build a session handle wired to a *live* command channel. Returns the /// handle (move into `sessions`) plus a probe `cmd_tx`/`cmd_rx` so a test diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs index 23a6c45..4d578cb 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_lifecycle.rs @@ -323,8 +323,10 @@ impl SubagentCoordinator { effective_model_id: String::new(), block_waited: false, explicitly_killed: false, + persisted_output_dir: None, }, ); + self.enforce_completed_cap(); if surface_completion { self.pending_completions .push(SubagentCompletionSummary { @@ -353,6 +355,7 @@ impl SubagentCoordinator { description: String, subagent_type: String, result: SubagentResult, + persisted_output_dir: Option, ) -> Option { let tracker = self.active.remove(id); self.sync_running_gauge(); @@ -383,30 +386,26 @@ impl SubagentCoordinator { let block_waited = tracker.as_ref().is_some_and(|t| t.block_waited); let explicitly_killed = tracker.as_ref().is_some_and(|t| t.explicitly_killed); let surface_completion = tracker.as_ref().is_none_or(|t| t.surface_completion); - self.completed - .insert( - id.to_string(), - CompletedSubagent { - subagent_id: id.to_string(), - parent_session_id, - parent_prompt_id, - child_session_id, - description, - subagent_type, - persona, - started_at, - completed_at: std::time::Instant::now(), - result, - resumed_from, - child_cwd, - worktree_path, - snapshot_ref: None, - effective_model_id, - block_waited, - explicitly_killed, - }, - ); - let completed = self.completed.get(id).expect("just inserted"); + let mut completed = CompletedSubagent { + subagent_id: id.to_string(), + parent_session_id, + parent_prompt_id, + child_session_id, + description, + subagent_type, + persona, + started_at, + completed_at: std::time::Instant::now(), + result, + resumed_from, + child_cwd, + worktree_path, + snapshot_ref: None, + effective_model_id, + block_waited, + explicitly_killed, + persisted_output_dir, + }; let success = completed.result.success && !completed.result.cancelled; { let preview = crate::util::truncate(&completed.result.output, 200); @@ -444,6 +443,11 @@ impl SubagentCoordinator { output: completed.result.output.clone(), }); } + if completed.persisted_output_dir.is_some() { + completed.result.output = Arc::from(""); + } + self.completed.insert(id.to_string(), completed); + self.enforce_completed_cap(); self.completion_notify.notify_waiters(); tracker } diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs index 45a5135..a9ed648 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/coordinator_query.rs @@ -55,8 +55,17 @@ impl SubagentCoordinator { reason: completed.result.error.clone(), } } else if completed.result.success { + let output = match &completed.persisted_output_dir { + Some(dir) => { + read_subagent_output(dir) + .unwrap_or_else(|| { + OUTPUT_UNAVAILABLE_PLACEHOLDER.to_string() + }) + } + None => completed.result.output.to_string(), + }; SubagentSnapshotStatus::Completed { - output: completed.result.output.to_string(), + output, tool_calls: completed.result.tool_calls, turns: completed.result.turns, worktree_path: completed.result.worktree_path.clone(), @@ -209,7 +218,7 @@ impl SubagentCoordinator { /// to a different parent session (prevents cross-session context bleed). /// /// Fast path: checks the in-memory `completed` map first. When that - /// misses (e.g. after TTL eviction), falls back to on-disk metadata + /// misses (e.g. after cap eviction), falls back to on-disk metadata /// in `{parent_session_dir}/subagents/{id}/meta.json`. pub(crate) fn resumable_source_for( &self, @@ -293,10 +302,21 @@ impl SubagentCoordinator { will_wake: false, }) } - /// TTL cleanup: remove completed entries older than 30 minutes. - pub fn evict_stale_completed(&mut self) { - let cutoff = std::time::Duration::from_secs(30 * 60); - self.completed.retain(|_, entry| entry.completed_at.elapsed() < cutoff); + /// Oldest completions are evicted first; their `output.json` stays on disk. + pub fn enforce_completed_cap(&mut self) { + if self.completed.len() <= MAX_COMPLETED_ENTRIES { + return; + } + let excess = self.completed.len() - MAX_COMPLETED_ENTRIES; + let mut by_age: Vec<(std::time::Instant, String)> = self + .completed + .iter() + .map(|(id, e)| (e.completed_at, id.clone())) + .collect(); + by_age.sort_unstable_by_key(|(completed_at, _)| *completed_at); + for (_, id) in by_age.into_iter().take(excess) { + self.completed.remove(&id); + } } /// Snapshot all currently-running subagents for compaction state context. /// diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs index f6e51da..39fbccd 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/handle_request.rs @@ -1765,7 +1765,8 @@ pub(crate) async fn handle_subagent_request( } } } - update_subagent_meta_completed(&subagent_meta_dir, &result, &gcs_upload_ctx); + let persisted_output_dir = persist_subagent_output(&subagent_meta_dir, &result); + persist_subagent_completion(&subagent_meta_dir, &result, &gcs_upload_ctx); let final_status = result.status().to_string(); let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled(); let telemetry_tokens = if result.tool_calls > 0 || result.success { @@ -1994,6 +1995,7 @@ pub(crate) async fn handle_subagent_request( request.description.clone(), request.subagent_type.clone(), result.clone(), + persisted_output_dir, ); if let Some(snapshot_ref) = disposed_snapshot_ref { coordinator.borrow_mut().set_completed_snapshot_ref(&request.id, snapshot_ref); diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs index 8ba5abf..62f2695 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/mod.rs @@ -474,7 +474,7 @@ pub(crate) struct CompletedSubagent { pub subagent_type: String, pub persona: Option, pub started_at: std::time::Instant, - /// When the subagent moved to the completed map. Used for TTL eviction. + /// When the subagent moved to the completed map. Orders cap eviction. pub completed_at: std::time::Instant, pub result: SubagentResult, /// ID of the source subagent this session was resumed from. @@ -492,6 +492,12 @@ pub(crate) struct CompletedSubagent { pub block_waited: bool, /// Set when the model explicitly killed this subagent via the kill tool. pub explicitly_killed: bool, + /// Directory whose `output.json` holds the output text; when set, the + /// stored `result.output` is cleared and `lookup` reads from disk. + /// `None` (failures, empty outputs, failed writes) serves from memory. + /// Process-scoped and local-only: resume survives a restart via + /// `meta.json`, and trace upload carries the text to GCS. + pub persisted_output_dir: Option, } /// Lightweight entry for subagents that have been requested but are still /// initializing (creating worktree, resolving config, spawning session). @@ -583,6 +589,11 @@ pub(crate) struct SubagentCoordinator { /// Cleared on freeze/cancel. See AGENTS.md rule 3 for the completeness model. subagent_usage_not_applied_prompts: std::collections::HashSet, } +/// Cap on the completed map (entries are small: identity, counts, and an +/// error string; successful output text lives in `output.json`). +pub(crate) const MAX_COMPLETED_ENTRIES: usize = 1024; +/// Served when an entry's `output.json` cannot be read back. +pub(crate) const OUTPUT_UNAVAILABLE_PLACEHOLDER: &str = "[subagent output no longer available]"; fn tracker_to_summary(t: &SubagentTracker) -> ActiveSubagentSummary { ActiveSubagentSummary { subagent_id: t.subagent_id.clone(), @@ -2131,7 +2142,7 @@ fn fail_subagent( duration_ms, ..Default::default() }; - update_subagent_meta_completed(subagent_meta_dir, &result, gcs_ctx); + persist_subagent_completion(subagent_meta_dir, &result, gcs_ctx); emit_subagent_notification( gateway, parent_session_id, @@ -2190,7 +2201,7 @@ async fn cancel_pending_subagent_at_promote( duration_ms, ..Default::default() }; - update_subagent_meta_completed(subagent_meta_dir, &result, gcs_ctx); + persist_subagent_completion(subagent_meta_dir, &result, gcs_ctx); emit_subagent_notification( gateway, parent_session_id, @@ -2552,14 +2563,21 @@ impl SubagentSessionMetadata { } } } +/// Write via a same-directory temp file and rename, so a crash mid-write +/// cannot leave a torn `meta.json` or `output.json`. +fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> { + let parent = path.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent") + })?; + std::fs::create_dir_all(parent)?; + let tmp = tempfile::NamedTempFile::new_in(parent)?; + std::fs::write(tmp.path(), contents)?; + tmp.persist(path)?; + Ok(()) +} /// Write `meta.json`. Returns `true` on success so callers on the resume-pointer /// path can gate worktree disposal on a durable write. fn write_subagent_meta(dir: &Path, meta: &SubagentMeta) -> bool { - if let Err(e) = std::fs::create_dir_all(dir) { - tracing::warn!(error = % e, "failed to create subagent meta dir"); - return false; - } - let meta_path = dir.join("meta.json"); let json = match serde_json::to_string_pretty(meta) { Ok(json) => json, Err(e) => { @@ -2567,12 +2585,63 @@ fn write_subagent_meta(dir: &Path, meta: &SubagentMeta) -> bool { return false; } }; - if let Err(e) = std::fs::write(&meta_path, json) { + if let Err(e) = atomic_write(&dir.join("meta.json"), &json) { tracing::warn!(error = % e, "failed to write subagent meta"); return false; } true } +/// On-disk schema of `output.json`, written beside `meta.json`. +#[derive(serde::Deserialize)] +struct SubagentOutputFile { + schema_version: u32, + output: String, +} +/// Borrowed twin of [`SubagentOutputFile`] so serialization does not copy +/// the output text. +#[derive(serde::Serialize)] +struct SubagentOutputFileRef<'a> { + schema_version: u32, + output: &'a str, +} +const SUBAGENT_OUTPUT_SCHEMA_VERSION: u32 = 1; +fn write_subagent_output(dir: &Path, output: &str) -> bool { + let file = SubagentOutputFileRef { + schema_version: SUBAGENT_OUTPUT_SCHEMA_VERSION, + output, + }; + let json = match serde_json::to_string(&file) { + Ok(json) => json, + Err(e) => { + tracing::warn!(error = % e, "failed to serialize subagent output"); + return false; + } + }; + if let Err(e) = atomic_write(&dir.join("output.json"), &json) { + tracing::warn!(error = % e, "failed to write subagent output"); + return false; + } + true +} +/// Read back `output.json`. `None` on any read or parse failure. +pub(crate) fn read_subagent_output(dir: &Path) -> Option { + let data = std::fs::read_to_string(dir.join("output.json")).ok()?; + let file: SubagentOutputFile = match serde_json::from_str(&data) { + Ok(file) => file, + Err(e) => { + tracing::warn!(error = % e, "failed to parse subagent output.json"); + return None; + } + }; + if file.schema_version != SUBAGENT_OUTPUT_SCHEMA_VERSION { + tracing::warn!( + found = file.schema_version, + expected = SUBAGENT_OUTPUT_SCHEMA_VERSION, + "unexpected output.json schema version" + ); + } + Some(file.output) +} /// Extra runtime context for GCS artifact upload. `SubagentMeta` doesn't /// persist these fields, so they're carried from the spawn site. #[derive(Clone)] @@ -2595,7 +2664,7 @@ struct GcsUploadContext { /// any read/parse/write failure is `warn!`-logged (this is the critical resume /// pointer) so the caller keeps the worktree rather than removing it without a /// recoverable ref. Also re-asserts the terminal `status` so a failed -/// `update_subagent_meta_completed` write can't leave a non-terminal record that +/// `persist_subagent_completion` write can't leave a non-terminal record that /// `resumable_source_for` rejects after the worktree is removed. fn update_subagent_meta_snapshot_ref(dir: &Path, snapshot_ref: &str, status: &str) -> bool { let meta_path = dir.join("meta.json"); @@ -2622,7 +2691,12 @@ fn update_subagent_meta_snapshot_ref(dir: &Path, snapshot_ref: &str, status: &st meta.status = status.to_string(); write_subagent_meta(dir, &meta) } -fn update_subagent_meta_completed(dir: &Path, result: &SubagentResult, gcs_ctx: &GcsUploadContext) { +#[must_use] +fn persist_subagent_output(dir: &Path, result: &SubagentResult) -> Option { + (result.success && !result.output.is_empty() && write_subagent_output(dir, &result.output)) + .then(|| dir.to_path_buf()) +} +fn persist_subagent_completion(dir: &Path, result: &SubagentResult, gcs_ctx: &GcsUploadContext) { let meta_path = dir.join("meta.json"); if let Ok(data) = std::fs::read_to_string(&meta_path) && let Ok(mut meta) = serde_json::from_str::(&data) diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs index 36756e2..6d19a64 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/mod.rs @@ -208,6 +208,7 @@ fn lookup_returns_ready_for_completed_subagent() { duration_ms: 1234, ..Default::default() }, + None, ); let lookup = coordinator.lookup("sub-1"); assert!(lookup.is_some()); @@ -384,6 +385,7 @@ async fn running_gauge_tracks_pending_and_active() { "gauge task".into(), "general-purpose".into(), SubagentResult::default(), + None, ); assert_eq!(gauge.load(Ordering::Relaxed), 0, "completed does not count"); coordinator @@ -435,6 +437,7 @@ fn mark_block_waited_sets_flag_on_completed() { child_session_id: "sub-bw".into(), ..Default::default() }, + None, ); assert!(! coordinator.is_block_waited("sub-bw")); coordinator.mark_block_waited("sub-bw"); @@ -525,6 +528,7 @@ async fn mark_explicitly_killed_active_then_propagates_to_completed() { child_session_id: "sub-ek".into(), ..Default::default() }, + None, ); assert!( coordinator.is_explicitly_killed("sub-ek"), @@ -631,6 +635,7 @@ fn mark_explicitly_killed_sets_flag_on_completed() { child_session_id: "sub-ek-c".into(), ..Default::default() }, + None, ); assert!(! coordinator.is_explicitly_killed("sub-ek-c")); coordinator.mark_explicitly_killed("sub-ek-c"); @@ -658,6 +663,7 @@ async fn block_waited_propagates_through_move_to_completed() { child_session_id: "sub-prop".into(), ..Default::default() }, + None, ); assert!(coordinator.is_block_waited("sub-prop")); } @@ -676,6 +682,7 @@ fn complete_dummy(coordinator: &mut SubagentCoordinator, id: &str, surface: bool child_session_id: id.into(), ..Default::default() }, + None, ); } #[tokio::test] @@ -861,44 +868,173 @@ fn move_pending_to_cancelled_creates_cancelled_entry() { } } } +fn completed_with_output( + id: &str, + text: &str, + persisted_output_dir: Option, +) -> CompletedSubagent { + CompletedSubagent { + subagent_id: id.into(), + parent_session_id: String::new(), + parent_prompt_id: None, + child_session_id: String::new(), + description: "task".into(), + subagent_type: "explore".into(), + persona: None, + started_at: std::time::Instant::now(), + completed_at: std::time::Instant::now(), + result: SubagentResult { + success: true, + output: std::sync::Arc::from(text), + ..Default::default() + }, + resumed_from: None, + child_cwd: String::new(), + worktree_path: None, + snapshot_ref: None, + effective_model_id: String::new(), + block_waited: false, + explicitly_killed: false, + persisted_output_dir, + } +} +fn lookup_output(coordinator: &SubagentCoordinator, id: &str) -> String { + match coordinator.lookup(id) { + Some(SnapshotLookup::Ready(snap)) => { + match snap.status { + SubagentSnapshotStatus::Completed { output, .. } => output, + other => panic!("expected Completed status, got {other:?}"), + } + } + other => { + panic!( + "expected Ready lookup, got {:?}", other.map(| _ | "NeedsSignals/other") + ) + } + } +} #[test] -fn evict_stale_completed_uses_completion_time() { +fn lookup_degrades_to_placeholder_when_output_file_is_missing() { + let dir = tempfile::tempdir().expect("tempdir"); let mut coordinator = SubagentCoordinator::new(); coordinator .completed .insert( - "sub-recent".to_string(), - CompletedSubagent { - subagent_id: "sub-recent".into(), - parent_session_id: String::new(), - parent_prompt_id: None, - child_session_id: String::new(), - description: "long-running".into(), - subagent_type: "explore".into(), - persona: None, - started_at: std::time::Instant::now() - - std::time::Duration::from_secs(31 * 60), - completed_at: std::time::Instant::now(), - result: SubagentResult { - success: true, - ..Default::default() - }, - resumed_from: None, - child_cwd: String::new(), - worktree_path: None, - snapshot_ref: None, - effective_model_id: String::new(), - block_waited: false, - explicitly_killed: false, - }, + "sub-gone".to_string(), + completed_with_output("sub-gone", "", Some(dir.path().to_path_buf())), ); - coordinator.evict_stale_completed(); - assert!( - coordinator.completed.contains_key("sub-recent"), - "recently completed subagent should not be evicted" + assert_eq!( + lookup_output(& coordinator, "sub-gone"), OUTPUT_UNAVAILABLE_PLACEHOLDER, + "an entry whose output.json is gone must degrade, not fail the query" ); } #[test] +fn lookup_serves_unpersisted_output_from_memory() { + let mut coordinator = SubagentCoordinator::new(); + coordinator + .completed + .insert("sub-mem".to_string(), completed_with_output("sub-mem", "output", None)); + assert_eq!( + lookup_output(& coordinator, "sub-mem"), "output", + "an entry with nothing on disk must serve its in-memory output" + ); +} +#[test] +fn completed_entries_are_capped_oldest_first() { + let mut coordinator = SubagentCoordinator::new(); + let base = std::time::Instant::now(); + for i in 0..(MAX_COMPLETED_ENTRIES + 2) { + let mut entry = completed_with_output( + &format!("sub-{i}"), + "", + Some(std::path::PathBuf::from("/nonexistent")), + ); + entry.completed_at = base + std::time::Duration::from_millis(i as u64); + coordinator.completed.insert(format!("sub-{i}"), entry); + } + coordinator.enforce_completed_cap(); + assert_eq!( + coordinator.completed.len(), MAX_COMPLETED_ENTRIES, + "the completed map must be capped at MAX_COMPLETED_ENTRIES" + ); + assert!( + ! coordinator.completed.contains_key("sub-0") && ! coordinator.completed + .contains_key("sub-1"), "the oldest completions must be evicted first" + ); + assert!( + coordinator.completed.contains_key("sub-2"), + "entries within the cap must survive" + ); +} +#[test] +fn move_to_completed_clears_persisted_output_after_the_summary_clone() { + let dir = tempfile::tempdir().expect("tempdir"); + let full_output = "final report".repeat(100); + assert!(write_subagent_output(dir.path(), & full_output)); + let mut coordinator = SubagentCoordinator::new(); + coordinator + .move_to_completed( + "sub-e2e", + "task".into(), + "explore".into(), + SubagentResult { + success: true, + output: std::sync::Arc::from(full_output.as_str()), + subagent_id: "sub-e2e".into(), + child_session_id: "sub-e2e".into(), + ..Default::default() + }, + Some(dir.path().to_path_buf()), + ); + let entry = coordinator.completed.get("sub-e2e").expect("entry inserted"); + assert!( + entry.result.output.is_empty(), + "a persisted entry must not keep the output in memory" + ); + assert_eq!( + lookup_output(& coordinator, "sub-e2e"), full_output, + "lookup must serve the persisted output from disk" + ); + let summaries = coordinator.drain_pending_completions(); + assert_eq!( + &* summaries[0].output, full_output, + "the completion summary must carry the full output" + ); +} +#[test] +fn persist_gate_only_persists_successful_nonempty_outputs() { + let dir = tempfile::tempdir().expect("tempdir"); + let ok = SubagentResult { + success: true, + output: std::sync::Arc::from("text"), + ..Default::default() + }; + assert_eq!( + persist_subagent_output(dir.path(), & ok), Some(dir.path().to_path_buf()) + ); + let empty = SubagentResult { + success: true, + ..Default::default() + }; + assert_eq!(persist_subagent_output(dir.path(), & empty), None); + let failed = SubagentResult { + success: false, + output: std::sync::Arc::from("partial"), + ..Default::default() + }; + assert_eq!(persist_subagent_output(dir.path(), & failed), None); +} +#[test] +fn subagent_output_roundtrips_through_output_json() { + let dir = tempfile::tempdir().expect("tempdir"); + let output = "line one\nline two with unicode ✓"; + assert!(write_subagent_output(dir.path(), output)); + assert_eq!(read_subagent_output(dir.path()).as_deref(), Some(output)); + assert_eq!(read_subagent_output(& dir.path().join("missing")), None); + std::fs::write(dir.path().join("output.json"), "not json").expect("corrupt file"); + assert_eq!(read_subagent_output(dir.path()), None); +} +#[test] fn cancel_with_outcome_fires_pending_token() { let mut coordinator = SubagentCoordinator::new(); let token = CancellationToken::new(); @@ -945,6 +1081,7 @@ async fn cancel_with_outcome_returns_variant_for_active_finished_unknown() { subagent_id: "sub-done".to_string(), ..Default::default() }, + None, ); assert!( matches!(coordinator.cancel_with_outcome("sub-done"), @@ -1026,6 +1163,7 @@ fn completed_takes_precedence_over_pending_in_lookup() { child_session_id: "child-dup".to_string(), ..Default::default() }, + None, ); let lookup = coordinator.lookup("sub-dup"); assert!( diff --git a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs index e3f33b3..0066a4e 100644 --- a/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs +++ b/crates/codegen/xai-grok-shell/src/agent/subagent/tests/rest.rs @@ -346,6 +346,7 @@ fn resumable_source_returns_info_for_completed_subagent() { effective_model_id: "grok-3".into(), block_waited: false, explicitly_killed: false, + persisted_output_dir: None, }, ); let info = coordinator @@ -373,6 +374,7 @@ fn resumable_source_survives_move_to_completed_with_metadata() { child_session_id: "sub-moved".into(), ..Default::default() }, + None, ); let info = coordinator .resumable_source_for("sub-moved", "", Path::new("/tmp")) @@ -593,7 +595,7 @@ fn snapshot_ref_write_promotes_nonterminal_status_to_terminal() { assert_eq!("completed", reread.status); } /// The coordinator setter stamps the snapshot ref onto the in-memory -/// completed entry so `resume_from` can rehydrate before TTL eviction. +/// completed entry so `resume_from` can rehydrate before cap eviction. #[tokio::test] async fn set_completed_snapshot_ref_updates_in_memory_entry() { let mut coordinator = SubagentCoordinator::new(); @@ -609,6 +611,7 @@ async fn set_completed_snapshot_ref_updates_in_memory_entry() { child_session_id: "sa-mem".into(), ..Default::default() }, + None, ); let before = coordinator .resumable_source_for("sa-mem", "session-A", Path::new("/tmp")) @@ -621,7 +624,7 @@ async fn set_completed_snapshot_ref_updates_in_memory_entry() { .unwrap(); assert_eq!(after.snapshot_ref.as_deref(), Some("refs/grok/subagents/sa-mem")); } -/// Unknown id is a no-op (entry already TTL-evicted; meta.json still holds it). +/// Unknown id is a no-op (entry already cap-evicted; meta.json still holds it). #[test] fn set_completed_snapshot_ref_unknown_id_is_noop() { let mut coordinator = SubagentCoordinator::new(); @@ -704,6 +707,7 @@ fn coordinator_with_completed(id: &str) -> SubagentCoordinator { child_session_id: id.into(), ..Default::default() }, + None, ); coordinator } @@ -775,7 +779,8 @@ async fn gate_on_completion_clears_model_facing_worktree_path_but_resume_retains if worktree_removed { result.worktree_path = None; } - coordinator.move_to_completed("disp-1", "task".into(), "explore".into(), result); + coordinator + .move_to_completed("disp-1", "task".into(), "explore".into(), result, None); coordinator .set_completed_snapshot_ref("disp-1", "refs/grok/subagents/disp-1".into()); let listed = coordinator.completed.get("disp-1").expect("completed entry"); @@ -805,7 +810,8 @@ async fn gate_on_completion_retains_worktree_path_when_not_removed() { if worktree_removed { result.worktree_path = None; } - coordinator.move_to_completed("keep-1", "task".into(), "explore".into(), result); + coordinator + .move_to_completed("keep-1", "task".into(), "explore".into(), result, None); let entry = coordinator.completed.get("keep-1").expect("completed entry"); assert_eq!(Some(wt.to_string_lossy().into_owned()), entry.result.worktree_path); } @@ -856,6 +862,7 @@ async fn disposal_completes_before_subagent_is_observable() { child_session_id: "order-1".into(), ..Default::default() }, + None, ); if let Some(r) = disposed_snapshot_ref { coordinator.set_completed_snapshot_ref("order-1", r); @@ -1351,6 +1358,7 @@ fn resumable_source_rejects_cross_session_lookup() { effective_model_id: String::new(), block_waited: false, explicitly_killed: false, + persisted_output_dir: None, }, ); assert!( @@ -1803,6 +1811,7 @@ async fn reconcile_reemits_rewound_finish_even_when_id_still_in_completed_regist success: true, ..Default::default() }, + None, ); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); let unfinished = vec![(id.to_string(), format!("child-{id}"))]; @@ -1845,6 +1854,7 @@ async fn reconcile_reemits_real_outcome_for_completed_with_running_meta() { success: true, ..Default::default() }, + None, ); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); let unfinished = vec![(id.to_string(), format!("child-{id}"))]; @@ -2166,6 +2176,7 @@ fn completed_subagent_propagates_resumed_from() { effective_model_id: "grok-3".into(), block_waited: false, explicitly_killed: false, + persisted_output_dir: None, }, ); let refs = coordinator.spawned_refs_for_prompt("prompt-1"); @@ -2193,6 +2204,7 @@ async fn completion_notify_fires_on_move_to_completed() { duration_ms: 100, ..Default::default() }, + None, ); tokio::time::timeout(std::time::Duration::from_millis(50), notified) .await @@ -2216,6 +2228,7 @@ fn drain_pending_completions_returns_and_clears() { duration_ms: 500, ..Default::default() }, + None, ); coordinator .move_to_completed( @@ -2231,6 +2244,7 @@ fn drain_pending_completions_returns_and_clears() { duration_ms: 200, ..Default::default() }, + None, ); let summaries = coordinator.drain_pending_completions(); assert_eq!(summaries.len(), 2); @@ -2262,6 +2276,7 @@ fn drain_pending_completions_cancelled_is_not_success() { child_session_id: "sub-c1".to_string(), ..Default::default() }, + None, ); let summaries = coordinator.drain_pending_completions(); assert_eq!(summaries.len(), 1); @@ -2315,6 +2330,7 @@ async fn outstanding_for_prompt_excludes_completed() { child_session_id: "sub-done".to_string(), ..Default::default() }, + None, ); let outstanding = coordinator.outstanding_for_prompt("prompt-X"); assert!( @@ -2369,6 +2385,7 @@ async fn subagent_usage_not_applied_sticky_after_completion_and_is_prompt_scoped child_session_id: "sub-1".to_string(), ..Default::default() }, + None, ); assert!(coordinator.outstanding_for_prompt("p-1").is_empty()); assert!(coordinator.subagent_usage_not_applied("p-1")); @@ -2444,6 +2461,7 @@ fn completions_buffered_while_turn_inactive_drained_later() { child_session_id: "sub-idle".to_string(), ..Default::default() }, + None, ); let drained = coordinator.drain_pending_completions(); assert_eq!(drained.len(), 1); diff --git a/crates/codegen/xai-grok-shell/src/auth/credential_provider.rs b/crates/codegen/xai-grok-shell/src/auth/credential_provider.rs index 339870a..2ff55a0 100644 --- a/crates/codegen/xai-grok-shell/src/auth/credential_provider.rs +++ b/crates/codegen/xai-grok-shell/src/auth/credential_provider.rs @@ -87,6 +87,24 @@ impl AuthCredentialProvider for ShellAuthCredentialProvider { self.static_credentials.deployment_key.is_none() } } +/// Resolves the embedding credentials for `embed_base_url`, attaching the xAI +/// session credential only to xAI-operated endpoints over `https`. +pub(crate) fn embedding_session_credentials( + embed_base_url: &str, + auth_manager: Option<&Arc>, + api_key_provider: Option, +) -> xai_grok_memory::EndpointScopedCredentials { + let auth_credentials = auth_manager.map(|am| { + Arc::new(ShellAuthCredentialProvider::new(am.clone(), None, None)) + as Arc + }); + xai_grok_memory::EndpointScopedCredentials::for_endpoint( + embed_base_url, + crate::util::is_xai_api_bearer_url, + auth_credentials, + api_key_provider, + ) +} /// Build a `StorageClient` for proxy uploads (including the high-volume /// `batch_upload` used for repo context / `repo_changes_dedup`). /// @@ -568,6 +586,31 @@ mod tests { "snapshot must reflect refreshed token for subsequent apply() calls" ); } + #[test] + fn embedding_session_credentials_scopes_to_first_party() { + let _guard = EarlyInvalidationGuard::pin_to_default(); + let dir = tempfile::tempdir().unwrap(); + let mgr = make_manager( + &dir, + Some(make_auth("xai-session-token", ChronoDuration::hours(1))), + ); + let api_key_provider: xai_grok_tools::types::SharedApiKeyProvider = + Arc::new(crate::auth::manager::SharedAuthKeyProvider(mgr.clone())); + for denied in ["https://byok.attacker.example/v1", "http://api.x.ai/v1"] { + let resolved = + embedding_session_credentials(denied, Some(&mgr), Some(api_key_provider.clone())); + assert!( + resolved.is_empty(), + "session credentials must not reach {denied}" + ); + } + let resolved = embedding_session_credentials( + "https://api.x.ai/v1", + Some(&mgr), + Some(api_key_provider), + ); + assert!(!resolved.is_empty()); + } /// Deployment-key path has no recovery (operator owns the bearer). #[tokio::test] async fn refresh_after_unauthorized_is_noop_for_deployment_key() { diff --git a/crates/codegen/xai-grok-shell/src/auth/manager.rs b/crates/codegen/xai-grok-shell/src/auth/manager.rs index 417994c..ab82a81 100644 --- a/crates/codegen/xai-grok-shell/src/auth/manager.rs +++ b/crates/codegen/xai-grok-shell/src/auth/manager.rs @@ -164,6 +164,9 @@ pub struct AuthManager { disk_state: RwLock>, /// See [`Self::cached_disk_api_key`]. static_key_cache: parking_lot::Mutex>, + /// Model `api_key` / resolved `env_key` for voice/tools without a session. + /// Not a session token (those live on `inner`). Prefers over disk; env wins. + process_static_api_key: parking_lot::RwLock>, sleep_gate: SleepGate, /// Count of in-flight IdP refreshes (the network call only), so a /// sleep-imminent transition can wait for a refresh straddling suspend to @@ -405,6 +408,7 @@ impl AuthManager { refresh_notify: Arc::new(tokio::sync::Notify::new()), disk_state: RwLock::new(disk_state), static_key_cache: parking_lot::Mutex::new(None), + process_static_api_key: parking_lot::RwLock::new(None), sleep_gate: SleepGate::default(), refresh_in_flight: std::sync::atomic::AtomicU32::new(0), refresh_drain_lock: parking_lot::Mutex::new(()), @@ -2221,11 +2225,8 @@ pub(crate) fn compute_proactive_sleep(this: &AuthManager) -> StdDuration { } } -/// Tools + pager voice: session token first, then static API key. -/// -/// Static fallthrough (`XAI_API_KEY` / `auth.json` `xai::api_key`) makes voice -/// work on API-key-only setups without OAuth. API-key login already persists -/// the env key to disk. +/// Tools + pager voice bearer. Static: env → process model key → disk. +/// Kill-switch / `preferred_method = oidc` block static keys. pub(crate) struct SharedAuthKeyProvider(pub Arc); impl xai_grok_tools::types::ApiKeyProvider for SharedAuthKeyProvider { @@ -2266,7 +2267,7 @@ fn prefers_static_api_key(am: &AuthManager) -> bool { ) } -/// Env → `auth.json` `xai::api_key`. Off under kill-switch or `preferred_method = oidc`. +/// Env → process model key → disk. Off under kill-switch / oidc pin. fn resolve_static_api_key(am: &AuthManager) -> Option { if am.grok_com_config.api_key_auth_disabled() { return None; @@ -2278,6 +2279,7 @@ fn resolve_static_api_key(am: &AuthManager) -> Option { return None; } non_empty_key(crate::agent::auth_method::read_xai_api_key_env().ok()) + .or_else(|| non_empty_key(am.process_static_api_key.read().clone())) .or_else(|| am.cached_disk_api_key()) } @@ -2328,6 +2330,12 @@ impl AuthManager { } } } + + /// Set the process model key (empty clears). Not for session tokens. + pub fn set_process_static_api_key(&self, key: Option) { + let key = key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()); + *self.process_static_api_key.write() = key; + } } fn non_empty_key(key: Option) -> Option { diff --git a/crates/codegen/xai-grok-shell/src/auth/manager_tests.rs b/crates/codegen/xai-grok-shell/src/auth/manager_tests.rs index 07e5075..03df5e9 100644 --- a/crates/codegen/xai-grok-shell/src/auth/manager_tests.rs +++ b/crates/codegen/xai-grok-shell/src/auth/manager_tests.rs @@ -3586,6 +3586,105 @@ async fn shared_api_key_provider_disk_memo_follows_rewrites() { assert_eq!(provider.current_api_key_async().await, None); } +#[tokio::test] +#[serial_test::serial] +async fn process_key_from_model_env_key() { + use crate::agent::config::{Config, resolve_model_list}; + use xai_grok_test_support::EnvGuard; + + const ENV: &str = "TEST_MODEL_ENV_KEY"; + const TOKEN: &str = "model-env-token"; + + let _xai = EnvGuard::unset("XAI_API_KEY"); + let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY"); + let _tok = EnvGuard::set(ENV, TOKEN); + + let dm = crate::models::default_model(); + let cfg = Config::new_from_toml_cfg( + &toml::from_str(&format!( + r#" + [model."{dm}"] + model = "{dm}" + env_key = "{ENV}" + "# + )) + .unwrap(), + ) + .unwrap(); + let key = resolve_model_list(&cfg, None) + .get(dm) + .and_then(|m| m.own_credential()) + .unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default())); + assert!(mgr.current().is_none()); + mgr.set_process_static_api_key(Some(key)); + assert_eq!( + shared_api_key_provider(mgr) + .current_api_key_async() + .await + .as_deref(), + Some(TOKEN) + ); +} + +#[tokio::test] +#[serial_test::serial] +async fn process_key_precedence() { + use xai_grok_test_support::EnvGuard; + + let _xai = EnvGuard::unset("XAI_API_KEY"); + let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY"); + let dir = tempfile::tempdir().unwrap(); + let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default())); + let provider = shared_api_key_provider(mgr.clone()); + + assert_eq!(provider.current_api_key_async().await, None); + + crate::auth::store_api_key(dir.path(), "disk").unwrap(); + assert_eq!( + provider.current_api_key_async().await.as_deref(), + Some("disk") + ); + + mgr.set_process_static_api_key(Some(" process ".into())); + assert_eq!( + provider.current_api_key_async().await.as_deref(), + Some("process") + ); + + { + let _key = EnvGuard::set("XAI_API_KEY", "env"); + assert_eq!( + provider.current_api_key_async().await.as_deref(), + Some("env") + ); + } + + mgr.set_process_static_api_key(None); + assert_eq!( + provider.current_api_key_async().await.as_deref(), + Some("disk") + ); + + let dir_blocked = tempfile::tempdir().unwrap(); + let blocked = Arc::new(AuthManager::new( + dir_blocked.path(), + GrokComConfig { + disable_api_key_auth: Some(true), + ..GrokComConfig::default() + }, + )); + blocked.set_process_static_api_key(Some("ignored".into())); + assert_eq!( + shared_api_key_provider(blocked) + .current_api_key_async() + .await, + None + ); +} + fn expired_oidc() -> GrokAuth { GrokAuth { key: "expired-key".into(), diff --git a/crates/codegen/xai-grok-shell/src/auth/mod.rs b/crates/codegen/xai-grok-shell/src/auth/mod.rs index 5fcc5d8..5d8e262 100644 --- a/crates/codegen/xai-grok-shell/src/auth/mod.rs +++ b/crates/codegen/xai-grok-shell/src/auth/mod.rs @@ -13,6 +13,7 @@ mod model; pub mod oidc; pub(crate) mod recovery; pub(crate) mod refresh; +pub(crate) mod single_flight; mod storage; pub(crate) mod token_type; pub(crate) use config::LEGACY_AUTH_SCOPE; diff --git a/crates/codegen/xai-grok-shell/src/auth/single_flight.rs b/crates/codegen/xai-grok-shell/src/auth/single_flight.rs new file mode 100644 index 0000000..51a5e03 --- /dev/null +++ b/crates/codegen/xai-grok-shell/src/auth/single_flight.rs @@ -0,0 +1,359 @@ +//! Single-flight guard for interactive login. +//! +//! At most one device-code / loopback wait runs at a time: starting a new +//! attempt (or an explicit `x.ai/auth/cancel`) cancels the previous one, so +//! remint/retry cannot stack device-code mints. +//! +//! The attempt owns **all** attempt-scoped state — the cancellation token and +//! the code/url channels — so replacing an attempt swaps everything +//! atomically, and a cancelled predecessor that finishes late structurally +//! cannot touch its successor's channels. Generations guard `end()` the same +//! way: a stale finisher must not clear a newer attempt. Client `request_seq` +//! scopes explicit cancels so a delayed `x.ai/auth/cancel` cannot tear down a +//! successor login. + +use std::cell::{Cell, RefCell}; +use tokio_util::sync::CancellationToken; + +use super::flow::AuthUrlInfo; + +/// Channels wired between the ACP ext handlers and one interactive auth flow. +/// `None` for headless attempts (no URL to show, no code to paste). +pub(crate) struct AttemptChannels { + /// Forwards pasted codes from `x.ai/auth/submit_code` to the flow. + code_tx: tokio::sync::mpsc::Sender, + /// Yields the auth URL to `x.ai/auth/get_url`. `Option` so + /// [`AuthSingleFlight::take_url_rx`] can move it out while the attempt + /// lives on (one-shot read). + url_rx: Option>, +} + +struct Attempt { + token: CancellationToken, + channels: Option, + /// Pager `request_seq` for this attempt (scopes delayed cancel RPCs). + client_seq: Option, +} + +/// Why [`AuthSingleFlight::submit_code`] failed. +#[derive(Debug)] +pub(crate) enum SubmitCodeError { + /// No interactive attempt is waiting for a code (idle or headless). + NoPendingAttempt, + /// Channel send failed (attempt channels already closed). + SendFailed(tokio::sync::mpsc::error::TrySendError), +} + +#[derive(Default)] +pub(crate) struct AuthSingleFlight { + active: RefCell>, + generation: Cell, +} + +/// RAII end for a [`AuthSingleFlight::begin`] generation: calls [`AuthSingleFlight::end`] +/// on drop so an aborted authenticate future cannot leak attempt state. +pub(crate) struct AuthAttemptGuard<'a> { + sf: &'a AuthSingleFlight, + generation: u64, + ended: Cell, +} + +impl AuthAttemptGuard<'_> { + /// Explicit end (same as drop). Idempotent. + pub(crate) fn end(&self) { + if !self.ended.replace(true) { + self.sf.end(self.generation); + } + } + + #[cfg(test)] + pub(crate) fn generation(&self) -> u64 { + self.generation + } +} + +impl Drop for AuthAttemptGuard<'_> { + fn drop(&mut self) { + self.end(); + } +} + +impl AuthSingleFlight { + /// Start a new attempt, cancelling any prior in-flight one. Returns the + /// new attempt's token and an [`AuthAttemptGuard`] that ends this generation + /// on drop (pass no separate `end` — the guard is the only closer). + /// + /// `client_seq` is the pager auth `request_seq` (when known); used by + /// [`Self::cancel_for_client_seq`] so a delayed cancel cannot kill a + /// successor attempt. + pub(crate) fn begin( + &self, + channels: Option, + client_seq: Option, + ) -> (CancellationToken, AuthAttemptGuard<'_>) { + let generation = self.generation.get().wrapping_add(1); + self.generation.set(generation); + let token = CancellationToken::new(); + if let Some(prev) = self.active.borrow_mut().replace(Attempt { + token: token.clone(), + channels, + client_seq, + }) { + tracing::info!("auth: cancelling prior interactive auth for single-flight"); + prev.token.cancel(); + } + ( + token, + AuthAttemptGuard { + sf: self, + generation, + ended: Cell::new(false), + }, + ) + } + + /// Finish an attempt: drops its token *and channels* only if `generation` + /// is still the active one (a stale finisher must not clear a newer + /// attempt's state). + pub(crate) fn end(&self, generation: u64) { + if self.generation.get() == generation { + *self.active.borrow_mut() = None; + } + } + + /// Cancel the active attempt, if any. Idempotent. Prefer + /// [`Self::cancel_for_client_seq`] when the caller has a pager `request_seq` + /// so a delayed cancel cannot tear down a newer login. + pub(crate) fn cancel(&self) { + if let Some(prev) = self.active.borrow_mut().take() { + tracing::info!("auth: interactive auth cancelled"); + prev.token.cancel(); + } + } + + /// Cancel only if the active attempt was started for `client_seq`. A stale + /// cancel (successor already began) is a no-op. + pub(crate) fn cancel_for_client_seq(&self, client_seq: u64) { + let mut active = self.active.borrow_mut(); + match active.as_ref() { + Some(a) if a.client_seq == Some(client_seq) => { + if let Some(prev) = active.take() { + tracing::info!( + client_seq, + "auth: interactive auth cancelled for client request_seq" + ); + prev.token.cancel(); + } + } + Some(a) => { + tracing::debug!( + client_seq, + active_client_seq = ?a.client_seq, + "auth: ignoring stale cancel for superseded request_seq" + ); + } + None => { + tracing::debug!( + client_seq, + "auth: cancel_for_client_seq with no active attempt" + ); + } + } + } + + /// Forward a pasted code to the active attempt's flow. + pub(crate) fn submit_code(&self, code: String) -> Result<(), SubmitCodeError> { + match self + .active + .borrow() + .as_ref() + .and_then(|a| a.channels.as_ref()) + { + Some(ch) => ch + .code_tx + .try_send(code) + .map_err(SubmitCodeError::SendFailed), + None => Err(SubmitCodeError::NoPendingAttempt), + } + } + + /// Take the active attempt's URL receiver (one-shot; subsequent calls + /// return `None`, as does an idle or headless attempt). + pub(crate) fn take_url_rx(&self) -> Option> { + self.active + .borrow_mut() + .as_mut() + .and_then(|a| a.channels.as_mut().and_then(|ch| ch.url_rx.take())) + } +} + +impl AttemptChannels { + pub(crate) fn new( + code_tx: tokio::sync::mpsc::Sender, + url_rx: tokio::sync::oneshot::Receiver, + ) -> Self { + Self { + code_tx, + url_rx: Some(url_rx), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn channels() -> (AttemptChannels, tokio::sync::mpsc::Receiver) { + let (code_tx, code_rx) = tokio::sync::mpsc::channel(1); + let (_url_tx, url_rx) = tokio::sync::oneshot::channel(); + (AttemptChannels::new(code_tx, url_rx), code_rx) + } + + #[test] + fn begin_cancels_prior_attempt() { + let sf = AuthSingleFlight::default(); + let (first, _g1) = sf.begin(None, None); + let (second, _g2) = sf.begin(None, None); + assert!(first.is_cancelled(), "prior attempt must be cancelled"); + assert!(!second.is_cancelled(), "new attempt must be live"); + } + + #[test] + fn cancel_stops_active_attempt_and_is_idempotent() { + let sf = AuthSingleFlight::default(); + let (token, _g) = sf.begin(None, None); + sf.cancel(); + assert!(token.is_cancelled()); + sf.cancel(); // no active attempt — must not panic + } + + #[test] + fn stale_end_does_not_clear_newer_attempt() { + let sf = AuthSingleFlight::default(); + let (_first, first_guard) = sf.begin(None, None); + let first_gen = first_guard.generation(); + // Keep first_guard alive but end via generation (stale after second begin). + let (second, _second_guard) = sf.begin(None, None); + sf.end(first_gen); // stale finisher + sf.cancel(); // must still cancel the second attempt's token + assert!( + second.is_cancelled(), + "stale end() must not have cleared the active token" + ); + } + + #[test] + fn current_end_drops_the_stored_attempt() { + let sf = AuthSingleFlight::default(); + let (token, guard) = sf.begin(None, None); + guard.end(); + sf.cancel(); // nothing active — must not cancel the finished attempt + assert!(!token.is_cancelled()); + } + + /// The race the attempt object exists to prevent: a cancelled + /// predecessor finishing late must not drop the successor's channels. + #[test] + fn stale_end_leaves_successor_channels_intact() { + let sf = AuthSingleFlight::default(); + let (_first, first_guard) = sf.begin(None, None); + let first_gen = first_guard.generation(); + let (ch, mut code_rx) = channels(); + let (_second, _g2) = sf.begin(Some(ch), Some(2)); + + sf.end(first_gen); // stale finisher (attempt #1's cleanup) + + sf.submit_code("1234".into()) + .expect("successor's code channel must still be wired"); + assert_eq!(code_rx.try_recv().as_deref(), Ok("1234")); + assert!( + sf.take_url_rx().is_some(), + "successor's url receiver must still be present" + ); + } + + #[test] + fn submit_code_and_url_rx_absent_when_idle_or_headless() { + let sf = AuthSingleFlight::default(); + assert!( + matches!( + sf.submit_code("x".into()), + Err(SubmitCodeError::NoPendingAttempt) + ), + "idle: no attempt is waiting for a code" + ); + assert!(sf.take_url_rx().is_none()); + let _g = sf.begin(None, None); // headless attempt: token only + assert!( + matches!( + sf.submit_code("x".into()), + Err(SubmitCodeError::NoPendingAttempt) + ), + "headless: no channels" + ); + assert!(sf.take_url_rx().is_none()); + } + + #[test] + fn cancel_for_client_seq_ignores_stale_seq() { + let sf = AuthSingleFlight::default(); + let (first, _g1) = sf.begin(None, Some(1)); + let (second, _g2) = sf.begin(None, Some(2)); + assert!(first.is_cancelled()); + sf.cancel_for_client_seq(1); // delayed cancel for attempt 1 + assert!( + !second.is_cancelled(), + "stale cancel must not tear down the successor" + ); + sf.cancel_for_client_seq(2); + assert!(second.is_cancelled()); + } + + #[test] + fn attempt_guard_ends_on_drop() { + let sf = AuthSingleFlight::default(); + let (token, guard) = sf.begin(None, Some(7)); + drop(guard); + sf.cancel(); // nothing active + assert!(!token.is_cancelled()); + assert!(matches!( + sf.submit_code("x".into()), + Err(SubmitCodeError::NoPendingAttempt) + )); + } + + /// Headless (and interactive) authenticate `select!`s on this token — + /// cancel must interrupt a long wait rather than leaving it racing + /// (logout / unscoped cancel path). + #[tokio::test] + async fn cancel_interrupts_waiting_select() { + let sf = AuthSingleFlight::default(); + let (cancel, _guard) = sf.begin(None, Some(42)); // headless: no channels + let waiter = tokio::spawn(async move { + tokio::select! { + biased; + _ = cancel.cancelled() => "cancelled", + _ = tokio::time::sleep(std::time::Duration::from_secs(30)) => "timeout", + } + }); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + sf.cancel(); // same as handle_logout / unscoped cancel + assert_eq!(waiter.await.expect("join"), "cancelled"); + } + + /// Logout-style unscoped cancel, then a new begin must not see prior channels. + #[test] + fn cancel_then_begin_is_clean_for_successor() { + let sf = AuthSingleFlight::default(); + let (ch, mut code_rx) = channels(); + let (old, _g) = sf.begin(Some(ch), Some(1)); + sf.cancel(); + assert!(old.is_cancelled()); + let (ch2, mut code_rx2) = channels(); + let (new, _g2) = sf.begin(Some(ch2), Some(2)); + assert!(!new.is_cancelled()); + sf.submit_code("ok".into()).expect("successor wired"); + assert_eq!(code_rx2.try_recv().as_deref(), Ok("ok")); + assert!(code_rx.try_recv().is_err(), "prior channel must be dead"); + } +} diff --git a/crates/codegen/xai-grok-shell/src/auth/storage.rs b/crates/codegen/xai-grok-shell/src/auth/storage.rs index 9ee59d6..a232267 100644 --- a/crates/codegen/xai-grok-shell/src/auth/storage.rs +++ b/crates/codegen/xai-grok-shell/src/auth/storage.rs @@ -52,6 +52,16 @@ pub fn read_auth_json(auth_file: &Path) -> std::io::Result { let mut contents = String::new(); file.read_to_string(&mut contents)?; + // Tighten world-readable copies (hand-restored, umask edge cases, etc.). + // Best-effort: a chmod failure must not block login/read paths. + if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) { + tracing::warn!( + path = %auth_file.display(), + error = %e, + "auth: failed to enforce owner-only permissions on auth.json" + ); + } + // Empty files are valid (recover from prior crash/partial write). let trimmed = contents.trim(); if trimmed.is_empty() { @@ -114,6 +124,8 @@ pub(crate) fn backup_corrupt_auth_file(path: &Path) -> Option { match std::fs::rename(path, &backup) { Ok(()) => { + // Corrupt backups still hold token material — keep them owner-only. + let _ = crate::util::secure_file::ensure_owner_only_permissions(&backup); tracing::warn!( original = %path.display(), backup = %backup.display(), @@ -246,9 +258,17 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> { .into_inner() .map_err(|e| e.into_error())? .sync_all()?; - #[cfg(windows)] - { - crate::util::secure_file::set_windows_secure_permissions(path)?; + // `open_secure_file` mode bits apply only on create; tighten existing paths. + // Best-effort after durable content: a chmod-only failure must not look + // like a failed write. The in-place fallback restores the prior snapshot + // on any `write_store_to` Err, which would discard freshly written tokens. + // Load path re-tightens on next read. + if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(path) { + tracing::warn!( + error = %e, + path = %path.display(), + "auth: failed to ensure owner-only permissions after write" + ); } Ok(()) } @@ -263,6 +283,15 @@ fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io:: let _ = std::fs::remove_file(auth_file); } std::fs::rename(&tmp, auth_file)?; + // Re-assert on the final path (covers rename edge cases / FS quirks). + // Best-effort: rename already published the new tokens. + if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) { + tracing::warn!( + error = %e, + path = %auth_file.display(), + "auth: failed to ensure owner-only permissions after rename" + ); + } Ok(()) } @@ -317,10 +346,7 @@ fn restore_prior_bytes(auth_file: &Path, bytes: &[u8]) -> std::io::Result<()> { let mut file = open_secure_file(auth_file)?; file.write_all(bytes)?; file.sync_all()?; - #[cfg(windows)] - { - crate::util::secure_file::set_windows_secure_permissions(auth_file)?; - } + crate::util::secure_file::ensure_owner_only_permissions(auth_file)?; Ok(()) } @@ -433,6 +459,46 @@ mod write_fallback_tests { assert_eq!(mode & 0o777, 0o600, "in-place write must stay 0o600"); } + #[cfg(unix)] + #[test] + fn write_tightens_preexisting_world_readable_auth_json() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.json"); + std::fs::write(&path, b"{}").unwrap(); + let mut loose = std::fs::metadata(&path).unwrap().permissions(); + loose.set_mode(0o644); + std::fs::set_permissions(&path, loose).unwrap(); + + write_auth_json(&path, &sample_store()).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!( + mode & 0o777, + 0o600, + "rewrite must tighten preexisting open perms" + ); + } + + #[cfg(unix)] + #[test] + fn read_tightens_world_readable_auth_json() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.json"); + write_auth_json(&path, &sample_store()).unwrap(); + let mut loose = std::fs::metadata(&path).unwrap().permissions(); + loose.set_mode(0o644); + std::fs::set_permissions(&path, loose).unwrap(); + + let _ = read_auth_json(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!( + mode & 0o777, + 0o600, + "load must tighten open auth.json perms" + ); + } + /// A `StorageFull` (ENOSPC) failure on the atomic path must fall back to /// the in-place write so the credential still lands on disk. #[test] diff --git a/crates/codegen/xai-grok-shell/src/config/mod.rs b/crates/codegen/xai-grok-shell/src/config/mod.rs index d4203ee..ac7dd18 100644 --- a/crates/codegen/xai-grok-shell/src/config/mod.rs +++ b/crates/codegen/xai-grok-shell/src/config/mod.rs @@ -818,12 +818,11 @@ pub use xai_grok_config::ConfigLayers; pub use xai_grok_config::{ MDM_REQUIREMENTS_SOURCE, RequirementsLayer, RequirementsSource, ServingIdentity, SyncMarker, claude_managed_settings_probe_path, confirmed_team_switch, confirmed_team_switch_at, - fail_closed_flag_from_str, is_managed_config_hard_stale_for, is_managed_config_stale_for, - load_config_file, load_from_disk, load_managed_config, load_merged_requirements, - load_system_managed_config, load_toml_file, managed_config_identity_changed_at, - managed_deployment_id, managed_policy_compromised_for, mark_managed_config_synced, - mark_managed_config_synced_at, normalize_identity, requirements_layers, system_config_dir, - user_grok_home, + is_managed_config_hard_stale_for, is_managed_config_stale_for, load_config_file, + load_from_disk, load_managed_config, load_merged_requirements, load_system_managed_config, + load_toml_file, managed_config_identity_changed_at, managed_deployment_id, + managed_policy_compromised_for, mark_managed_config_synced, mark_managed_config_synced_at, + normalize_identity, requirements_layers, system_config_dir, user_grok_home, }; /// Map of "dotted.path" to which config file the value came from. pub fn config_origins( diff --git a/crates/codegen/xai-grok-shell/src/extensions/auth.rs b/crates/codegen/xai-grok-shell/src/extensions/auth.rs index b8f1545..03bc648 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/auth.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/auth.rs @@ -19,6 +19,7 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { "x.ai/setApiKey" => handle_set_api_key(args), "x.ai/auth/submit_code" => handle_submit_code(agent, args), "x.ai/auth/get_url" => handle_get_url(agent).await, + "x.ai/auth/cancel" => handle_cancel(agent, args), "x.ai/auth/logout" => handle_logout(agent, args).await, "x.ai/auth/info" => handle_info(agent), "x.ai/auth/check_subscription" => handle_check_subscription(agent).await, @@ -26,6 +27,26 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { } } +/// Stop an in-flight interactive login (device poll / loopback wait). +/// Idempotent: no-op when nothing is waiting. +/// +/// When `request_seq` is present, only that attempt is cancelled — a delayed +/// cancel cannot tear down a successor login that already replaced it. +fn handle_cancel(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { + #[derive(Deserialize)] + struct CancelParams { + #[serde(default)] + request_seq: Option, + } + let params: CancelParams = + serde_json::from_str(args.params.get()).unwrap_or(CancelParams { request_seq: None }); + match params.request_seq { + Some(seq) => agent.interactive_auth.cancel_for_client_seq(seq), + None => agent.interactive_auth.cancel(), + } + to_raw_response(&serde_json::json!({ "cancelled": true })) +} + async fn handle_get_bearer_token(agent: &MvpAgent) -> ExtResult { let token = match agent.auth_manager.get_valid_token().await { Ok(token) => Some(token), @@ -85,20 +106,20 @@ fn handle_submit_code(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { let params: SubmitCodeParams = serde_json::from_str(args.params.get()) .map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?; - let auth_code_tx = agent.auth_code_tx.borrow(); - if let Some(ref tx) = *auth_code_tx { - tx.try_send(params.code).map_err(|e| { - acp::Error::internal_error().data(format!("failed to submit auth code: {e}")) - })?; - to_raw_response(&serde_json::json!({ "submitted": true })) - } else { - Err(acp::Error::invalid_params().data("no pending auth session")) + match agent.interactive_auth.submit_code(params.code) { + Ok(()) => to_raw_response(&serde_json::json!({ "submitted": true })), + Err(crate::auth::single_flight::SubmitCodeError::SendFailed(e)) => { + Err(acp::Error::internal_error().data(format!("failed to submit auth code: {e}"))) + } + Err(crate::auth::single_flight::SubmitCodeError::NoPendingAttempt) => { + Err(acp::Error::invalid_params().data("no pending auth session")) + } } } /// Awaits the auth URL from the oneshot channel (blocks until ready). async fn handle_get_url(agent: &MvpAgent) -> ExtResult { - let rx = agent.auth_url_rx.borrow_mut().take(); + let rx = agent.interactive_auth.take_url_rx(); // `None` when no URL was sent (cached creds, early error, second poll): // report mode as `null` rather than mislabeling it `loopback`. let (auth_url, mode) = match rx { @@ -125,6 +146,9 @@ async fn handle_logout(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { let params: LogoutParams = serde_json::from_str(args.params.get()) .map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?; + // Stop any in-flight login so it cannot write credentials back after logout. + agent.interactive_auth.cancel(); + let result = crate::auth::perform_logout(&agent.auth_manager, params.scope.as_deref()) .map_err(|e| acp::Error::internal_error().data(format!("failed to logout: {e}")))?; // `auth.lifecycle` (not `auth`) avoids colliding with the pre-existing diff --git a/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs b/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs index 7372729..aad0cc0 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/marketplace.rs @@ -288,11 +288,13 @@ async fn handle_update( plugin_subdir: plugin_relative_path.to_string(), }; let mut registry = xai_grok_agent::plugins::install_registry::InstallRegistry::load(); + let require_sha = crate::plugin::marketplace_require_sha(); let update_result = installer::update_from_marketplace_entry_transactional( &marketplace_root, &entry, provenance, &mut registry, + require_sha, ); drop(marketplace_lease); @@ -398,6 +400,7 @@ async fn handle_install( plugin_subdir: plugin_relative_path.to_string(), }; let mut registry = xai_grok_agent::plugins::install_registry::InstallRegistry::load(); + let require_sha = crate::plugin::marketplace_require_sha(); match installer::install_from_remote_url( &remote_url, remote_ref.as_deref(), @@ -406,6 +409,7 @@ async fn handle_install( plugin_relative_path, provenance, &mut registry, + require_sha, ) { Ok(installer::MarketplaceInstallResult::Installed { repo_key }) => { // Auto-enable installed plugin so it's active after reload. diff --git a/crates/codegen/xai-grok-shell/src/extensions/mcp.rs b/crates/codegen/xai-grok-shell/src/extensions/mcp.rs index 9ce206e..a054e84 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/mcp.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/mcp.rs @@ -38,6 +38,7 @@ pub mod mcp_methods { pub const READ_RESOURCE: &str = "x.ai/mcp/read_resource"; pub const AUTH_STATUS: &str = "x.ai/mcp/auth_status"; pub const AUTH_TRIGGER: &str = "x.ai/mcp/auth_trigger"; + pub const SETUP: &str = "x.ai/mcp/setup"; pub const TOGGLE: &str = "x.ai/mcp/toggle"; pub const TOGGLE_TOOL: &str = "x.ai/mcp/toggle_tool"; pub const UPSERT: &str = "x.ai/mcp/upsert"; @@ -83,6 +84,10 @@ pub struct McpServerEntry { pub source: McpServerSource, #[serde(skip_serializing_if = "Option::is_none")] pub source_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub setup: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub setup_values: Option>, #[serde(flatten)] pub config: McpServerConfig, #[serde(skip_serializing_if = "Option::is_none")] @@ -142,6 +147,8 @@ pub struct McpServerSessionState { pub tools: Vec, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub auth_required: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub setup_required: bool, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -149,6 +156,7 @@ pub struct McpServerSessionState { pub enum McpSessionStatus { Ready, Initializing, + SetupRequired, Unavailable, } @@ -339,6 +347,7 @@ enum McpRoute { ReadResource, AuthStatus, AuthTrigger, + Setup, Toggle, ToggleTool, Upsert, @@ -352,6 +361,7 @@ fn route_mcp_method(method: &str) -> Option { mcp_methods::READ_RESOURCE => McpRoute::ReadResource, mcp_methods::AUTH_STATUS => McpRoute::AuthStatus, mcp_methods::AUTH_TRIGGER => McpRoute::AuthTrigger, + mcp_methods::SETUP => McpRoute::Setup, mcp_methods::TOGGLE => McpRoute::Toggle, mcp_methods::TOGGLE_TOOL => McpRoute::ToggleTool, mcp_methods::UPSERT => McpRoute::Upsert, @@ -368,6 +378,7 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { Some(McpRoute::ReadResource) => handle_read_resource(agent, args).await, Some(McpRoute::AuthStatus) => handle_auth_status(agent, args).await, Some(McpRoute::AuthTrigger) => handle_auth_trigger(agent, args).await, + Some(McpRoute::Setup) => handle_setup(agent, args).await, Some(McpRoute::Toggle) => handle_toggle(agent, args).await, Some(McpRoute::ToggleTool) => handle_toggle_tool(agent, args).await, Some(McpRoute::Upsert) => handle_upsert(agent, args).await, @@ -423,6 +434,8 @@ pub fn build_mcp_catalog_with_gateway_tools( scope_name: config.scope_name.clone(), }, source_label: None, + setup: None, + setup_values: None, session: None, }); } @@ -459,6 +472,8 @@ pub fn build_mcp_catalog_with_gateway_tools( source: McpServerSource::Managed, config: McpServerConfig::ManagedGateway, source_label: None, + setup: None, + setup_values: None, session: Some(McpServerSessionState { enabled: !server_disabled, status: (!auth_required && !server_disabled).then_some(McpSessionStatus::Ready), @@ -476,6 +491,7 @@ pub fn build_mcp_catalog_with_gateway_tools( }) .collect(), auth_required, + setup_required: false, }), }); } @@ -520,6 +536,8 @@ pub fn build_mcp_catalog_with_gateway_tools( source, config, source_label: None, + setup: None, + setup_values: None, session: None, }); } @@ -572,12 +590,15 @@ fn disabled_server_placeholder_entry(name: &str) -> McpServerEntry { .map(str::to_owned), source, source_label: None, + setup: None, + setup_values: None, config, session: Some(McpServerSessionState { enabled: false, status: None, tools: vec![], auth_required: false, + setup_required: false, }), } } @@ -939,8 +960,9 @@ async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { session_state_fut ); - let local_servers = - crate::util::config::load_mcp_servers(&cwd, &agent.cfg.borrow().compat_resolved); + let compat = agent.cfg.borrow().compat_resolved; + let plugin_registry_snapshot = agent.plugin_registry_snapshot(); + let local_servers = crate::util::config::load_mcp_servers(&cwd, &compat); let disabled_tools = crate::util::config::get_all_mcp_disabled_tools(&cwd); let mut servers = build_mcp_catalog_with_gateway_tools( &managed_configs, @@ -948,10 +970,65 @@ async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { gateway_catalog.as_ref(), &disabled_tools, ); + let disabled_names = crate::util::config::disabled_mcp_server_names(&cwd); + let setup_entries = crate::util::config::collect_mcp_setup_configs( + &cwd, + plugin_registry_snapshot.as_deref(), + &compat, + ); + let preferences = crate::util::config::load_mcp_preferences().file(); + for (name, setup_entry) in setup_entries { + if servers.iter().any(|entry| entry.name == name) { + continue; + } + let enabled = !disabled_names.contains(&name); + let setup_schema = setup_entry.config.setup.clone(); + let (setup, setup_required, status) = match setup_entry + .config + .resolve_setup(preferences.servers.get(&name)) + { + crate::util::config::McpSetupResolution::Required(setup) => { + (Some(setup), true, Some(McpSessionStatus::SetupRequired)) + } + // Surface schema/template breakage instead of dropping the row. + crate::util::config::McpSetupResolution::Invalid(_) => { + (setup_schema, true, Some(McpSessionStatus::SetupRequired)) + } + crate::util::config::McpSetupResolution::Resolved(_) => continue, + }; + let values = preferences + .servers + .get(&name) + .map(|prefs| prefs.values.clone()); + servers.push(McpServerEntry { + name: name.clone(), + display_name: None, + source: McpServerSource::Local, + source_label: setup_entry + .source + .plugin + .as_ref() + .map(|plugin| format!("plugin: {plugin}")), + setup, + setup_values: values, + config: McpServerConfig::Http { + url: String::new(), + scope: None, + scope_id: None, + scope_name: None, + }, + session: Some(McpServerSessionState { + enabled, + status, + tools: vec![], + auth_required: false, + setup_required, + }), + }); + } // Include disabled servers from config so they appear in the list // with enabled=false and can be re-enabled by the user. - let disabled_names = crate::util::config::disabled_mcp_server_names(&cwd); let catalog_names: std::collections::HashSet = servers.iter().map(|s| s.name.clone()).collect(); for name in &disabled_names { @@ -999,6 +1076,13 @@ async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { // Annotate catalog entries with session state. for entry in &mut servers { + if entry + .session + .as_ref() + .is_some_and(|session| session.setup_required) + { + continue; + } let managed_gateway_session = entry.source == McpServerSource::Managed && matches!(&entry.config, McpServerConfig::ManagedGateway); if managed_gateway_session { @@ -1027,6 +1111,7 @@ async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { status, tools, auth_required: snapshot.auth_required.contains(&entry.name), + setup_required: false, }); } @@ -1038,6 +1123,8 @@ async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { display_name: None, source: McpServerSource::Local, source_label: None, + setup: None, + setup_values: None, config: McpServerConfig::Stdio { command: std::path::PathBuf::new(), args: Vec::new(), @@ -1048,6 +1135,7 @@ async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { status: Some(client_status.status.clone()), tools: client_status.tools.clone(), auth_required: snapshot.auth_required.contains(&client_status.name), + setup_required: false, }), }); } @@ -1056,9 +1144,11 @@ async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { // Tag servers with the owning plugin (covers both a plugin's .mcp.json and // its inline plugin.json mcpServers via the registry's deduped owner map). - if let Some(registry) = agent.plugin_registry_snapshot() { + if let Some(registry) = plugin_registry_snapshot.as_ref() { for entry in &mut servers { - if let Some(plugin_name) = registry.mcp_server_owner(&entry.name) { + if entry.source_label.is_none() + && let Some(plugin_name) = registry.mcp_server_owner(&entry.name) + { entry.source_label = Some(format!("plugin: {plugin_name}")); } } @@ -1397,6 +1487,8 @@ struct McpAuthTriggerRequest { #[derive(serde::Serialize)] struct McpAuthTriggerResponse { status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + setup: Option, /// Descriptive failure reason from the shell. `None` on success and on /// failures with no detail; surfaced verbatim by the TUI. #[serde(skip_serializing_if = "Option::is_none")] @@ -1409,21 +1501,174 @@ async fn handle_auth_trigger(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtRes let handle = agent .get_session_handle(&acp_id) .ok_or_else(|| acp::Error::invalid_params().data("session not found"))?; + let cwd = agent + .get_session_cwd(&acp_id) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let setup_entries = crate::util::config::collect_mcp_setup_configs( + &cwd, + agent.plugin_registry_snapshot().as_deref(), + &agent.cfg.borrow().compat_resolved, + ); + let preferences = crate::util::config::load_mcp_preferences().file(); + if let Some(entry) = setup_entries.get(&req.server_name) { + match entry + .config + .resolve_setup(preferences.servers.get(&req.server_name)) + { + crate::util::config::McpSetupResolution::Required(setup) => { + return to_ext_response(Ok(McpAuthTriggerResponse { + status: "setup_required", + setup: Some(setup), + error: None, + })); + } + crate::util::config::McpSetupResolution::Invalid(reason) => { + return to_ext_response(Ok(McpAuthTriggerResponse { + status: "setup_required", + setup: entry.config.setup.clone(), + error: Some(reason), + })); + } + crate::util::config::McpSetupResolution::Resolved(_) => {} + } + } match handle.mcp_auth_trigger(req.server_name).await { Ok(()) => to_ext_response(Ok(McpAuthTriggerResponse { status: "authenticated", + setup: None, error: None, })), Err(e) => { tracing::warn!(%e, "MCP auth trigger failed"); to_ext_response(Ok(McpAuthTriggerResponse { status: "failed", + setup: None, error: Some(e), })) } } } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct McpSetupRequest { + session_id: String, + server_name: String, + values: HashMap, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct McpSetupResponse { + ok: bool, +} + +async fn handle_setup(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult { + let req = parse_params::(args)?; + let acp_id = acp::SessionId::new(req.session_id.clone()); + let handle = agent + .get_session_handle(&acp_id) + .ok_or_else(|| acp::Error::invalid_params().data("session not found"))?; + let cwd = agent + .get_session_cwd(&acp_id) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let setup_entries = crate::util::config::collect_mcp_setup_configs( + &cwd, + agent.plugin_registry_snapshot().as_deref(), + &agent.cfg.borrow().compat_resolved, + ); + let entry = setup_entries + .get(&req.server_name) + .ok_or_else(|| acp::Error::invalid_params().data("server setup not found"))?; + let setup = entry + .config + .setup + .as_ref() + .ok_or_else(|| acp::Error::invalid_params().data("server setup not found"))?; + + // Only schema field ids (never arbitrary client keys). + let filtered_values: HashMap = setup + .fields + .iter() + .filter_map(|field| { + req.values + .get(&field.id) + .map(|value| (field.id.clone(), value.clone())) + }) + .collect(); + + let pending_preferences = crate::util::config::McpServerPreferences { + values: filtered_values, + source: Some(entry.source.clone()), + updated_at: Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)), + }; + match entry.config.resolve_setup(Some(&pending_preferences)) { + crate::util::config::McpSetupResolution::Resolved(_) => {} + crate::util::config::McpSetupResolution::Required(_) => { + return Err(acp::Error::invalid_params().data("setup values incomplete")); + } + crate::util::config::McpSetupResolution::Invalid(reason) => { + return Err(acp::Error::invalid_params().data(reason)); + } + } + + let load = crate::util::config::load_mcp_preferences(); + if !load.is_writable() { + return Err(acp::Error::internal_error().data( + "MCP preferences file is unreadable; fix or remove mcp_preferences.json before saving", + )); + } + let mut prefs = load.file(); + let previous_entry = prefs.servers.get(&req.server_name).cloned(); + prefs + .servers + .insert(req.server_name.clone(), pending_preferences); + crate::util::config::save_mcp_preferences(&prefs) + .await + .map_err(|e| acp::Error::internal_error().data(e.to_string()))?; + + let rollback = || async { + let _ = crate::util::config::restore_mcp_preference_server( + &req.server_name, + previous_entry.clone(), + ) + .await; + }; + + let managed_configs = agent.get_managed_mcp_configs().await; + let all_servers_with_policy = + crate::session::managed_mcp::merge_managed_mcp_servers_with_policy( + vec![], + &cwd, + &managed_configs, + agent.plugin_registry_snapshot().as_deref(), + &agent.cfg.borrow().compat_resolved, + ); + let found = match all_servers_with_policy + .into_iter() + .find(|s| crate::session::mcp_servers::mcp_server_name(&s.server) == req.server_name) + { + Some(found) => found, + None => { + rollback().await; + return Err(acp::Error::internal_error().data("server did not resolve after setup")); + } + }; + if let Some(reason) = found.disabled_reason { + rollback().await; + return Err(acp::Error::invalid_params().data(reason.to_string())); + } + if let Err(e) = handle + .toggle_mcp_server(req.server_name.clone(), true, Some(found.server)) + .await + { + rollback().await; + return Err(acp::Error::internal_error().data(e.to_string())); + } + + to_ext_response(Ok(McpSetupResponse { ok: true })) +} + // ── mcp/toggle handler ─────────────────────────────────────────────── #[derive(serde::Deserialize)] @@ -1834,6 +2079,8 @@ mod tests { scope_name: Some("Grok CLI".to_string()), }, source_label: None, + setup: None, + setup_values: None, session: None, }, McpServerEntry { @@ -1841,6 +2088,8 @@ mod tests { display_name: None, source: McpServerSource::Local, source_label: None, + setup: None, + setup_values: None, config: McpServerConfig::Stdio { command: "/usr/bin/mcp-filesystem".into(), args: vec!["--root".to_string(), "/home".to_string()], @@ -1850,6 +2099,7 @@ mod tests { enabled: true, status: Some(McpSessionStatus::Ready), auth_required: false, + setup_required: false, tools: vec![McpToolEntry { name: "read_file".to_string(), display_name: None, @@ -1876,12 +2126,15 @@ mod tests { display_name: Some("linear".to_string()), source: McpServerSource::Managed, source_label: None, + setup: None, + setup_values: None, config: McpServerConfig::ManagedGateway, session: Some(McpServerSessionState { enabled: true, status: Some(McpSessionStatus::Ready), tools: vec![], auth_required: false, + setup_required: false, }), }) .unwrap(); @@ -2152,10 +2405,54 @@ mod tests { assert_eq!(json["isError"], false); } + #[test] + fn test_mcp_list_setup_required_serialization() { + let entry = McpServerEntry { + name: "acme".to_string(), + display_name: None, + source: McpServerSource::Local, + source_label: Some("plugin: acme".to_string()), + setup: Some(crate::util::config::McpSetupConfig { + fields: vec![crate::util::config::McpSetupField { + id: "site".to_string(), + label: "Site".to_string(), + field_type: crate::util::config::McpSetupFieldType::Select, + required: true, + default: Some("us1".to_string()), + options: vec![crate::util::config::McpSetupOption { + label: "US5".to_string(), + value: "us5".to_string(), + }], + }], + variables: HashMap::new(), + }), + setup_values: Some(HashMap::from([("site".to_string(), "us5".to_string())])), + config: McpServerConfig::Http { + url: String::new(), + scope: None, + scope_id: None, + scope_name: None, + }, + session: Some(McpServerSessionState { + enabled: true, + status: Some(McpSessionStatus::SetupRequired), + tools: vec![], + auth_required: false, + setup_required: true, + }), + }; + let json = serde_json::to_value(&entry).unwrap(); + assert_eq!(json["session"]["status"], "setuprequired"); + assert_eq!(json["session"]["setupRequired"], true); + assert_eq!(json["setup"]["fields"][0]["id"], "site"); + assert_eq!(json["setupValues"]["site"], "us5"); + } + #[test] fn test_mcp_auth_trigger_response_success_no_error_field() { let resp = McpAuthTriggerResponse { status: "authenticated", + setup: None, error: None, }; let json = serde_json::to_value(&resp).unwrap(); @@ -2170,6 +2467,7 @@ mod tests { fn test_mcp_auth_trigger_response_failure_carries_error() { let resp = McpAuthTriggerResponse { status: "failed", + setup: None, error: Some("MCP server 'linear' does not use OAuth".to_string()), }; let json = serde_json::to_value(&resp).unwrap(); @@ -2184,6 +2482,7 @@ mod tests { fn test_mcp_auth_trigger_response_failure_omits_error_when_none() { let resp = McpAuthTriggerResponse { status: "failed", + setup: None, error: None, }; let json = serde_json::to_value(&resp).unwrap(); @@ -2198,6 +2497,8 @@ mod tests { display_name: None, source: McpServerSource::Managed, source_label: None, + setup: None, + setup_values: None, config: McpServerConfig::Http { url: "https://mcp.slack.com".to_string(), scope: Some("user".to_string()), @@ -2209,6 +2510,7 @@ mod tests { status: None, tools: vec![], auth_required: false, + setup_required: false, }), }; let json = serde_json::to_value(&entry).unwrap(); diff --git a/crates/codegen/xai-grok-shell/src/extensions/session_admin.rs b/crates/codegen/xai-grok-shell/src/extensions/session_admin.rs index 2ddc52c..67ea045 100644 --- a/crates/codegen/xai-grok-shell/src/extensions/session_admin.rs +++ b/crates/codegen/xai-grok-shell/src/extensions/session_admin.rs @@ -586,6 +586,7 @@ fn handle_reload_models(agent: &MvpAgent) -> ExtResult { let merged_config = agent.cfg.borrow().clone(); agent.models_manager.apply_config(merged_config); + agent.sync_process_static_api_key(None); let count = agent.models_manager.models().len(); tracing::info!(count, "model list reloaded from config.toml"); @@ -608,6 +609,7 @@ fn handle_reload_models(agent: &MvpAgent) -> ExtResult { /// rather than rebuilding the catalog and notifying clients mid-flight. fn handle_reload_models_cache(agent: &MvpAgent) -> ExtResult { agent.models_manager.reload_from_disk_cache(); + agent.sync_process_static_api_key(None); ExtMethodResult::success(serde_json::json!({ "reloaded": true })) .to_ext_response() .map_err(|e| acp::Error::internal_error().data(e.to_string())) diff --git a/crates/codegen/xai-grok-shell/src/managed_config.rs b/crates/codegen/xai-grok-shell/src/managed_config.rs index 7616718..82a9485 100644 --- a/crates/codegen/xai-grok-shell/src/managed_config.rs +++ b/crates/codegen/xai-grok-shell/src/managed_config.rs @@ -9,10 +9,11 @@ use response::{ApplyOutcome, ManagedConfigResponse, ManagedConfigSource, verify_ /// Server-synced policy artifacts. Excludes the sync marker ([`remove_managed_config_files`] /// removes that last, only on full success). -pub const MANAGED_ARTIFACT_FILES: [&str; 3] = [ +pub const MANAGED_ARTIFACT_FILES: [&str; 4] = [ xai_grok_config::MANAGED_CONFIG_FILENAME, xai_grok_config::REQUIREMENTS_FILENAME, xai_grok_config::signed_policy::SIGNATURE_SIDECAR_FILE, + xai_grok_config::signed_policy::MANAGED_IDENTITY_SIDECAR_FILE, ]; /// Delete server-synced files then the marker (never `config.toml`). @@ -37,6 +38,10 @@ fn remove_managed_config_files(home: &std::path::Path) { "{}.", xai_grok_config::signed_policy::SIGNATURE_SIDECAR_FILE ), + format!( + "{}.", + xai_grok_config::signed_policy::MANAGED_IDENTITY_SIDECAR_FILE + ), ]; if let Ok(entries) = std::fs::read_dir(home) { for entry in entries.flatten() { @@ -374,7 +379,7 @@ fn managed_config_sync_interval() -> std::time::Duration { } /// Periodically sync managed config in the background. Best-effort. -pub(crate) fn spawn_sync(cancel: tokio_util::sync::CancellationToken) { +pub fn spawn_sync(cancel: tokio_util::sync::CancellationToken) { tokio::spawn(async move { let mut interval = tokio::time::interval(managed_config_sync_interval()); interval.tick().await; // skip immediate first tick @@ -388,6 +393,10 @@ pub(crate) fn spawn_sync(cancel: tokio_util::sync::CancellationToken) { // Clear a logged-out team's files before deciding to fetch, so // stale enforced policy never outlives the tick. clear_orphan(); + // Raise the floor each tick so a long offline session keeps recording + // observed time; otherwise a later rollback could make an expired policy + // read valid. + bump_managed_rollback_floor(); if !crate::config::is_managed_config_stale_for(¤t_serving_identity()) || !is_fetch_enabled() @@ -652,6 +661,15 @@ fn apply_fetched( if let Some(verified) = verified { clear_squatting_dir(&home.join(xai_grok_config::signed_policy::SIGNATURE_SIDECAR_FILE)); xai_grok_config::signed_policy::write_sidecar(&home, &verified.sidecar)?; + // Disk errors are fatal, like the policy sidecar's. + if let Some(claim_sidecar) = + verified_claim_sidecar(body, served_principal_of(&verified.payload)) + { + clear_squatting_dir( + &home.join(xai_grok_config::signed_policy::MANAGED_IDENTITY_SIDECAR_FILE), + ); + xai_grok_config::signed_policy::write_managed_identity_sidecar(&home, &claim_sidecar)?; + } } // Marker last, still under the lock: written post-release, a concurrent purge could // delete the files it describes. A squatting dir would fail the atomic rename forever. @@ -675,8 +693,48 @@ fn apply_fetched( Ok(ApplyOutcome::Applied { wrote }) } +/// The principal a verified payload binds: `deployment_id`, else `team_id` (server parity). +fn served_principal_of(payload: &xai_grok_config::signed_policy::SignedPayload) -> Option<&str> { + payload + .deployment_id + .as_deref() + .or(payload.team_id.as_deref()) +} + +/// The fetched claim envelope, if it verifies and binds to the served principal. +/// `None` skips (old server / unverifiable / foreign): a bad claim must not fail +/// the apply — it only hardens the policy sidecar. +fn verified_claim_sidecar( + body: &ManagedConfigResponse, + served_principal: Option<&str>, +) -> Option { + use xai_grok_config::signed_policy::now_unix; + let sidecar = body.managed_identity_sidecar()?; + // Unclamped wall clock, like the policy verify: a fresh claim heals an inflated floor. + let claim = match xai_grok_config::signed_policy::verify_fetched_claim(&sidecar, now_unix()) { + Ok(claim) => claim, + Err(e) => { + tracing::debug!("is-managed claim did not verify; not persisting it: {e}"); + return None; + } + }; + if !claim_binds_to(&claim, served_principal) { + tracing::debug!("is-managed claim is bound to a different principal; not persisting it"); + return None; + } + Some(sidecar) +} + +/// The persist rule: a verified claim persists only when bound to the served principal. +fn claim_binds_to( + claim: &xai_grok_config::signed_policy::ManagedIdentityClaim, + served_principal: Option<&str>, +) -> bool { + served_principal == Some(claim.principal.as_str()) +} + /// Evict the prior principal's policy artifacts on a confirmed switch; this apply then -/// writes the new set and rebinds the marker. Includes the sidecar — a verification-inactive +/// writes the new set and rebinds the marker. Includes the sidecars — a verification-inactive /// build must not leave the prior tenant's sidecar to read foreign-bound on a signing build. fn evict_prior_managed_config(home: &std::path::Path) { for name in MANAGED_ARTIFACT_FILES { @@ -880,6 +938,8 @@ pub fn managed_policy_gate() -> Result<(), String> { } // Purge first so an offline team switch isn't misread as a substituted cache. purge_prior_tenant_on_identity_change(); + // Raise the floor after the purge so a purged marker stays absent. + bump_managed_rollback_floor(); managed_policy_gate_decision( managed_principal_present(), // Expiry-ignoring: a backdated auth.json must not resolve Team→None and relax binding. @@ -919,6 +979,22 @@ fn purge_prior_tenant_on_identity_change() { } } +/// Floor tick (session start + background sync tick), best-effort under the +/// managed-config lock — a failed tick must not refuse a session. +fn bump_managed_rollback_floor() { + // Re-checked inside `bump_rollback_floor`; this early-out skips the lock I/O when dark. + if !xai_grok_config::signed_policy::verification_active() { + return; + } + let home = crate::util::grok_home::grok_home(); + match try_lock_managed_config(&home) { + Some(_lock) => { + xai_grok_config::bump_rollback_floor(&home); + } + None => tracing::debug!("managed-config lock contended; skipping the floor tick"), + } +} + /// Pure decision behind [`managed_policy_gate`]: fail closed only when a managed principal is active AND its policy is compromised. fn managed_policy_gate_decision( managed_principal_present: bool, diff --git a/crates/codegen/xai-grok-shell/src/managed_config/response.rs b/crates/codegen/xai-grok-shell/src/managed_config/response.rs index 462be77..39efbe9 100644 --- a/crates/codegen/xai-grok-shell/src/managed_config/response.rs +++ b/crates/codegen/xai-grok-shell/src/managed_config/response.rs @@ -91,6 +91,11 @@ pub(super) struct ManagedConfigResponse { /// signed payload's policy is the trusted copy when verification is on. #[serde(default)] pub(super) signatures: Option>, + /// The is-managed claim envelopes (additive; absent from old servers), same + /// rotation shape as `signatures`, persisted as their own sidecar. + #[serde(default)] + pub(super) managed_identity_signatures: + Option>, } impl ManagedConfigResponse { @@ -105,21 +110,20 @@ impl ManagedConfigResponse { pub(super) fn signature_sidecar( &self, ) -> Option { - self.signature_sidecar_with(xai_grok_config::signed_policy::embedded_key_id_trusted) + pick_trusted_envelope( + self.signatures.as_deref(), + xai_grok_config::signed_policy::embedded_key_id_trusted, + ) } - /// Predicate-injected core of [`Self::signature_sidecar`] so tests can pick - /// without a compiled-in key set. - fn signature_sidecar_with( + /// The claim envelope to verify — same picking rule as [`Self::signature_sidecar`]. + pub(super) fn managed_identity_sidecar( &self, - key_id_trusted: impl Fn(&str) -> bool, ) -> Option { - let envelopes = self.signatures.as_deref()?; - envelopes - .iter() - .find(|e| key_id_trusted(&e.key_id)) - .or_else(|| envelopes.first()) - .cloned() + pick_trusted_envelope( + self.managed_identity_signatures.as_deref(), + xai_grok_config::signed_policy::embedded_key_id_trusted, + ) } /// Non-empty served content, recorded in the marker so staleness can later detect a deleted file. @@ -133,12 +137,23 @@ impl ManagedConfigResponse { self.requirements.as_deref().is_some_and(|s| !s.is_empty()) } - /// The served opt-in (`fail_closed`), read from the payload not disk, so it's authoritative even when - /// the on-disk apply is skipped under lock contention. + /// Served `fail_closed` from the payload (not disk). Non-bool → warn once, treat as false. pub(super) fn requirements_fail_closed(&self) -> bool { - self.requirements - .as_deref() - .is_some_and(crate::config::fail_closed_flag_from_str) + let Some(req) = self.requirements.as_deref() else { + return false; + }; + use prod_mc_cli_chat_proxy_types::{FailClosedFlag, fail_closed_flag_status}; + let status = fail_closed_flag_status(req); + if matches!(status, FailClosedFlag::Invalid) { + static WARN_ONCE: std::sync::Once = std::sync::Once::new(); + WARN_ONCE.call_once(|| { + tracing::warn!( + "served requirements fail_closed is present but not a boolean \ + (e.g. fail_closed = \"true\"); treating as false - use fail_closed = true" + ); + }); + } + status.is_enabled() } } @@ -166,6 +181,19 @@ impl ApplyOutcome { } } +/// Pick the envelope whose (hint-only) key_id is trusted, else the first. +fn pick_trusted_envelope( + envelopes: Option<&[xai_grok_config::signed_policy::SignatureEnvelope]>, + key_id_trusted: impl Fn(&str) -> bool, +) -> Option { + let envelopes = envelopes?; + envelopes + .iter() + .find(|e| key_id_trusted(&e.key_id)) + .or_else(|| envelopes.first()) + .cloned() +} + /// A fetched envelope that passed verification: the sidecar to persist, plus its /// parsed (now-trusted) payload. pub(super) struct VerifiedEnvelope { @@ -184,6 +212,7 @@ pub(super) fn verify_signed_envelope( let sidecar = body.signature_sidecar().ok_or_else(|| { "managed policy is required but the server returned no signature".to_owned() })?; + // Unclamped wall clock: a fresh envelope must heal an inflated floor, not be refused by it. let payload = signed_policy::verify_fetched(&sidecar, active_team_id, now_unix()) .map_err(|e| e.to_string())?; if body.managed_config != payload.managed_config || body.requirements != payload.requirements { @@ -196,39 +225,33 @@ pub(super) fn verify_signed_envelope( mod tests { use super::*; - /// Picking: the first trusted-key_id entry wins; no trusted entry → the first entry - /// (picking must not invent absence); no array (old/unsigned server) → None. + /// Picking (shared by the policy and claim carriers): the first trusted-key_id + /// entry wins; no trusted entry → the first entry (picking must not invent + /// absence); no array (old/unsigned server) → None. #[test] - fn signature_sidecar_picks_trusted_envelope_then_falls_back() { + fn pick_trusted_envelope_prefers_trusted_then_falls_back() { use xai_grok_config::signed_policy::SignatureEnvelope; let envelope = |kid: &str| SignatureEnvelope { signed_payload: format!("payload-{kid}"), signature: format!("sig-{kid}"), key_id: kid.to_owned(), }; - let body = ManagedConfigResponse { - signatures: Some(vec![envelope("v1"), envelope("v2")]), - ..Default::default() - }; + let envelopes = vec![envelope("v1"), envelope("v2")]; // A rotated client trusting only v2 picks the v2 envelope from the array. - let picked = body.signature_sidecar_with(|id| id == "v2").unwrap(); + let picked = pick_trusted_envelope(Some(&envelopes), |id| id == "v2").unwrap(); assert_eq!(picked.key_id, "v2"); assert_eq!(picked.signed_payload, "payload-v2"); // Trusting v1 picks the primary entry (first in the array). - let picked = body.signature_sidecar_with(|id| id == "v1").unwrap(); + let picked = pick_trusted_envelope(Some(&envelopes), |id| id == "v1").unwrap(); assert_eq!(picked.key_id, "v1"); // No trusted id → the first entry, so verification reports UnknownKeyId. - let picked = body.signature_sidecar_with(|_| false).unwrap(); + let picked = pick_trusted_envelope(Some(&envelopes), |_| false).unwrap(); assert_eq!(picked.key_id, "v1"); // Nothing signed at all → None. - assert!( - ManagedConfigResponse::default() - .signature_sidecar_with(|_| true) - .is_none() - ); + assert!(pick_trusted_envelope(None, |_| true).is_none()); } } diff --git a/crates/codegen/xai-grok-shell/src/managed_config/tests.rs b/crates/codegen/xai-grok-shell/src/managed_config/tests.rs index 2718b74..3d39868 100644 --- a/crates/codegen/xai-grok-shell/src/managed_config/tests.rs +++ b/crates/codegen/xai-grok-shell/src/managed_config/tests.rs @@ -338,7 +338,8 @@ fn marker_is_not_a_managed_artifact() { [ "managed_config.toml", "requirements.toml", - "managed_config.sig.json" + "managed_config.sig.json", + "managed_identity.sig.json" ], "the artifact list is load-bearing for every derived loop; change it deliberately" ); @@ -393,3 +394,52 @@ fn purge_keeps_marker_when_an_artifact_removal_fails() { "with every artifact removed, the marker goes last" ); } + +// --- The is-managed claim persist rules --- + +/// Deployment id wins over team id (server parity). +#[test] +fn served_principal_prefers_deployment_id() { + use xai_grok_config::signed_policy::SignedPayload; + let payload = |dep: Option<&str>, team: Option<&str>| SignedPayload { + typ: xai_grok_config::signed_policy::MANAGED_POLICY_TYP.into(), + version: 1, + deployment_id: dep.map(Into::into), + team_id: team.map(Into::into), + managed_config: None, + requirements: None, + fail_closed: false, + expires_at: 0, + key_id: "v1".into(), + }; + assert_eq!( + served_principal_of(&payload(Some("dep-1"), Some("team-007"))), + Some("dep-1") + ); + assert_eq!( + served_principal_of(&payload(None, Some("team-007"))), + Some("team-007") + ); + assert_eq!(served_principal_of(&payload(None, None)), None); +} + +/// A verified claim persists ONLY when bound to the served principal. +#[test] +fn claim_persists_only_when_bound_to_served_principal() { + let claim = |principal: &str| xai_grok_config::signed_policy::ManagedIdentityClaim { + typ: xai_grok_config::signed_policy::MANAGED_IDENTITY_TYP.into(), + principal: principal.into(), + fail_closed: true, + expires_at: 4_000_000_000, + key_id: "v1".into(), + }; + assert!(claim_binds_to(&claim("team-007"), Some("team-007"))); + assert!(!claim_binds_to(&claim("team-evil"), Some("team-007"))); + assert!(!claim_binds_to(&claim("team-007"), None)); +} + +/// Old server, no claim envelopes: nothing persists, nothing errors. +#[test] +fn absent_claim_is_skipped() { + assert!(verified_claim_sidecar(&ManagedConfigResponse::default(), Some("team-007")).is_none()); +} diff --git a/crates/codegen/xai-grok-shell/src/plugin.rs b/crates/codegen/xai-grok-shell/src/plugin.rs index 80adbaf..75c8a80 100644 --- a/crates/codegen/xai-grok-shell/src/plugin.rs +++ b/crates/codegen/xai-grok-shell/src/plugin.rs @@ -53,7 +53,8 @@ pub fn install_plugin(source: &str, cwd: &Path) -> Result { apply_update_to_registry(&mut registry, repo_key, &result); RepoUpdateOutcome::Updated { @@ -527,6 +530,7 @@ pub fn classify_install_error(err: &InstallError) -> String { InstallError::Json { .. } => "json", InstallError::PluginNotFound { .. } => "not_found", InstallError::ShaMismatch { .. } => "sha_mismatch", + InstallError::UnpinnedRemoteRefused { .. } => "unpinned_remote_refused", InstallError::InstallFailed { .. } => "install_failed", } .to_string() @@ -699,6 +703,15 @@ fn bullet_list(items: &[String]) -> String { .join("\n") } +/// The require-sha pin policy for remote plugin code. Disk-only config + env, +/// both tighten-only: a remote campaign overlay must not be able to relax a +/// local security policy, and an unreadable config falls back to the env knob. +pub fn marketplace_require_sha() -> bool { + xai_grok_config::load_effective_config_disk_only() + .map(|c| xai_grok_plugin_marketplace::load_require_sha(&c)) + .unwrap_or_else(|_| xai_grok_plugin_marketplace::env_require_sha()) +} + /// Marketplace sources from config.toml + settings JSON, unfiltered. pub fn load_marketplace_sources() -> Vec { let config = crate::config::load_effective_config() @@ -1059,6 +1072,7 @@ fn install_marketplace_entry( }; let result = if let Some(remote_url) = entry.remote_url.as_deref() { + let require_sha = crate::plugin::marketplace_require_sha(); installer::install_from_remote_url( remote_url, entry.remote_ref.as_deref(), @@ -1067,6 +1081,7 @@ fn install_marketplace_entry( &plugin_subdir, provenance, registry, + require_sha, ) } else { installer::install_from_marketplace(marketplace_root, &plugin_subdir, provenance, registry) @@ -1429,6 +1444,13 @@ mod tests { }), "sha_mismatch" ); + assert_eq!( + classify_install_error(&InstallError::UnpinnedRemoteRefused { + plugin: "p".into(), + url: "u".into() + }), + "unpinned_remote_refused" + ); assert_eq!( classify_install_error(&InstallError::InstallFailed { detail: "x".into() }), "install_failed" @@ -1462,7 +1484,7 @@ mod tests { plugins: HashMap::new(), marketplace: None, }; - let status = git_install::update_repo("local", &repo).unwrap(); + let status = git_install::update_repo("local", &repo, false).unwrap(); assert!(matches!(status, UpdateStatus::LiveLocal)); } diff --git a/crates/codegen/xai-grok-shell/src/sampling/error.rs b/crates/codegen/xai-grok-shell/src/sampling/error.rs index 5d4c259..db7534c 100644 --- a/crates/codegen/xai-grok-shell/src/sampling/error.rs +++ b/crates/codegen/xai-grok-shell/src/sampling/error.rs @@ -12,9 +12,12 @@ use agent_client_protocol as acp; /// ACP error code for rate-limited requests (HTTP 429). /// Uses the JSON-RPC implementation-defined server error range (-32000 to -32099). /// -/// Contract: this code must only be set for actual HTTP 429 responses from the -/// sampling client. Clients (desktop, pager) suppress error detail when they -/// see this code and show a user-friendly upgrade message instead. +/// Contract: set only for actual HTTP 429 responses from the sampling client. +/// User-facing text is produced by [`format_rate_limited_user_message`] (free-usage +/// paywall rewrite, else server body, else a generic fallback). Pager/headless +/// use that helper. Desktop may still special-case `stopReason: rate_limit` with +/// its own UI and ignore the body — that is a client choice, not a shell +/// requirement to suppress detail. pub const RATE_LIMITED_ERROR_CODE: i32 = -32003; /// OAuth / session rate-limit copy (personal plan upgrade path). @@ -40,6 +43,59 @@ pub fn rate_limited_user_message(is_api_key_auth: bool) -> &'static str { } } +/// Well-known free-usage exhaustion code CCP returns on HTTP 429. +/// Matches `prod_util_well_known_errors::SUBSCRIPTION_FREE_USAGE_EXHAUSTED`. +/// sampling-types' `parse_error_bytes` prepends the flat `code` to the +/// flattened message, so this reaches clients embedded in error detail. +pub const FREE_USAGE_EXHAUSTED_ERROR_CODE: &str = "subscription:free-usage-exhausted"; + +/// User-facing free-usage exhaustion copy (paywall). Deliberately promises no +/// reset duration — the quota window is backend-config-driven. +pub const FREE_USAGE_USER_MESSAGE: &str = "You\u{2019}ve reached your free Grok Build usage limit for now. Get SuperGrok for much higher limits, or try again later: https://grok.com/supergrok?referrer=grok-build"; + +/// Whether flattened server detail is free-usage-quota exhaustion (paywall), +/// not transient throttling. Sniffs the well-known code embedded by +/// `parse_error_bytes`. +pub fn is_free_usage_exhausted_error(detail: &str) -> bool { + detail.contains(FREE_USAGE_EXHAUSTED_ERROR_CODE) +} + +/// User-facing text for an ACP -32003 rate-limit error. +/// +/// 1. Free-usage well-known code → [`FREE_USAGE_USER_MESSAGE`] (OAuth product paywall). +/// 2. Non-empty body that pushes a **personal** Grok subscription, when the +/// caller is on **API key** auth → [`RATE_LIMITED_USER_MESSAGE_API_KEY`] +/// (team credits / console rate-limit tiers; not grok.com SuperGrok). +/// 3. Any other non-empty body → shown as-is (capacity, team RPS limits, etc.). +/// 4. Empty body → [`rate_limited_user_message`]. +/// +/// Pass the real active auth method (`AppView.is_api_key_auth` / +/// `AuthMethodKind::is_api_key`), not env-only key presence. +pub fn format_rate_limited_user_message( + server_detail: Option<&str>, + is_api_key_auth: bool, +) -> String { + if server_detail.is_some_and(is_free_usage_exhausted_error) { + return FREE_USAGE_USER_MESSAGE.to_string(); + } + if let Some(detail) = server_detail.map(str::trim).filter(|s| !s.is_empty()) { + if is_api_key_auth && pushes_consumer_subscription_upsell(detail) { + return RATE_LIMITED_USER_MESSAGE_API_KEY.to_string(); + } + return detail.to_string(); + } + rate_limited_user_message(is_api_key_auth).to_string() +} + +/// IC sometimes reuses OAuth free-tier upsell copy on 429s ("upgrade to a Grok +/// subscription" / grok.com/supergrok). That is wrong for API-key / team auth: +/// higher limits come from credits and spend-based rate-limit tiers, not a +/// personal SuperGrok plan. +fn pushes_consumer_subscription_upsell(detail: &str) -> bool { + let d = detail.to_ascii_lowercase(); + d.contains("grok.com/supergrok") || d.contains("upgrade to a grok subscription") +} + /// Map a `SamplingError` to an ACP `Error` for client-facing responses. /// This stays in xai-grok-shell because it depends on `agent_client_protocol::Error`. pub fn map_sampling_err_to_acp(err: SamplingError) -> acp::Error { @@ -334,6 +390,93 @@ mod tests { assert!(!RATE_LIMITED_USER_MESSAGE_API_KEY.contains("Upgrade your account")); } + #[test] + fn format_rate_limited_surfaces_nonempty_server_detail() { + let service = "The service is temporarily at capacity. Please retry your request shortly."; + assert_eq!( + format_rate_limited_user_message(Some(service), false), + service + ); + assert_eq!( + format_rate_limited_user_message(Some(service), true), + service + ); + + // Team console rate-limit copy has no personal SuperGrok upsell — surface as-is. + let team = "resource-exhausted: Too many requests for team abc. See https://console.x.ai/team/default/rate-limits."; + assert_eq!(format_rate_limited_user_message(Some(team), true), team); + assert_eq!( + format_rate_limited_user_message(Some("slow down"), false), + "slow down" + ); + } + + #[test] + fn format_rate_limited_api_key_rewrites_consumer_subscription_upsell() { + let rpm = "Some resource has been exhausted: You are sending requests too quickly. \ + Please slow down, or upgrade to a Grok subscription for higher limits: \ + https://grok.com/supergrok"; + // OAuth keeps the IC body (personal plan upgrade is correct). + assert_eq!(format_rate_limited_user_message(Some(rpm), false), rpm); + // API key must not push grok.com SuperGrok — team credits / rate-limit tiers. + assert_eq!( + format_rate_limited_user_message(Some(rpm), true), + RATE_LIMITED_USER_MESSAGE_API_KEY + ); + assert!( + RATE_LIMITED_USER_MESSAGE_API_KEY + .contains("https://docs.x.ai/developers/rate-limits#rate-limit-tiers") + ); + assert!(!RATE_LIMITED_USER_MESSAGE_API_KEY.contains("grok.com/supergrok")); + } + + #[test] + fn is_free_usage_exhausted_error_sniffs_well_known_code() { + assert!(is_free_usage_exhausted_error( + "subscription:free-usage-exhausted: You have used all your free usage." + )); + assert!(is_free_usage_exhausted_error( + "API error (status 429): subscription:free-usage-exhausted quota hit" + )); + assert!(!is_free_usage_exhausted_error("throttled")); + assert!(!is_free_usage_exhausted_error( + "The service is temporarily at capacity." + )); + } + + #[test] + fn format_rate_limited_free_usage_uses_paywall_copy() { + let detail = "subscription:free-usage-exhausted: You have used all your free usage."; + assert_eq!( + format_rate_limited_user_message(Some(detail), false), + FREE_USAGE_USER_MESSAGE + ); + assert_eq!( + format_rate_limited_user_message(Some(detail), true), + FREE_USAGE_USER_MESSAGE + ); + } + + #[test] + fn format_rate_limited_empty_detail_uses_auth_aware_fallback() { + assert_eq!( + format_rate_limited_user_message(None, false), + RATE_LIMITED_USER_MESSAGE_OAUTH + ); + assert_eq!( + format_rate_limited_user_message(Some(""), false), + RATE_LIMITED_USER_MESSAGE_OAUTH + ); + assert_eq!( + format_rate_limited_user_message(None, true), + RATE_LIMITED_USER_MESSAGE_API_KEY + ); + assert_eq!( + format_rate_limited_user_message(Some(" "), true), + RATE_LIMITED_USER_MESSAGE_API_KEY + ); + } + #[test] fn rate_limit_error_uses_dedicated_code() { let err = SamplingError::Api { diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hooks_plugins.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hooks_plugins.rs index 25022eb..d3ef9ae 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hooks_plugins.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/hooks_plugins.rs @@ -311,6 +311,7 @@ impl SessionActor { match xai_grok_agent::plugins::git_install::install_from_source( &install_source, ®istry, + crate::plugin::marketplace_require_sha(), ) { Ok(result) => { let repo = xai_grok_agent::plugins::git_install::build_installed_repo( @@ -448,7 +449,11 @@ impl SessionActor { let mut messages = Vec::new(); let mut any_updated = false; for (key, repo) in &repos_to_update { - match xai_grok_agent::plugins::git_install::update_repo(key, repo) { + match xai_grok_agent::plugins::git_install::update_repo( + key, + repo, + crate::plugin::marketplace_require_sha(), + ) { Ok(status) => { use xai_grok_agent::plugins::git_install::UpdateStatus; match status { diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/mcp.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/mcp.rs index d703be3..2cde1bc 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/mcp.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/mcp.rs @@ -414,16 +414,10 @@ impl SessionActor { if server_name.starts_with(crate::session::managed_mcp::MANAGED_MCP_PREFIX) { return Err("To authenticate, visit grok.com".to_string()); } - let client = { - let state = self.mcp_state.lock().await; - state - .get_client(server_name) - .cloned() - .ok_or_else(|| format!("MCP server '{}' not found", server_name))? + let client = match self.mcp_state.lock().await.get_client(server_name).cloned() { + Some(c) if c.has_auth() => c, + _ => self.recreate_http_client_with_oauth(server_name).await?, }; - if !client.has_auth() { - return Err(format!("MCP server '{}' does not use OAuth", server_name)); - } if !client.force_reauth(true).await { return Err(format!( "Authentication failed for MCP server '{}'", @@ -437,6 +431,7 @@ impl SessionActor { .map_err(|e| format!("Failed to get tools after auth: {}", e))?; let mut mcp_state = self.mcp_state.lock().await; mcp_state.auth_required.remove(server_name); + mcp_state.init_failed.remove(server_name); let mut ui_tools: std::collections::HashMap< String, Vec, @@ -455,6 +450,73 @@ impl SessionActor { ); Ok(()) } + /// Rebuild an HTTP MCP client with Interactive OAuth discovery and swap it + /// into session state. Used when auth is requested for a client that was + /// previously started without an `AuthorizationManager`. + async fn recreate_http_client_with_oauth( + &self, + server_name: &str, + ) -> Result, String> { + let (server_config, meta_config, event_tx) = { + let mcp_state = self.mcp_state.lock().await; + let server_config = mcp_state + .configs + .iter() + .find(|c| crate::session::mcp_servers::mcp_server_name(c) == server_name) + .cloned() + .ok_or_else(|| format!("MCP server '{}' not found in config", server_name))?; + match &server_config { + acp::McpServer::Http(_) | acp::McpServer::Sse(_) => {} + _ => { + return Err(format!("MCP server '{}' does not use OAuth", server_name)); + } + } + let meta_config = mcp_state.meta_config_map.get(server_name).cloned(); + let event_tx = mcp_state.client_event_tx(); + (server_config, meta_config, event_tx) + }; + let cwd = std::path::Path::new(&self.session_info.cwd); + let session_id = self.session_info.id.0.as_ref(); + let (_, oauth_config_map) = + crate::util::config::load_mcp_servers_with_oauth(cwd, &self.rebuild_spec.compat); + let byo_config = oauth_config_map.get(server_name).cloned(); + let event_writer = self.events.writer(); + let mode = crate::session::mcp_servers::OauthInteractivity::Interactive; + let new_client = crate::session::mcp_servers::start_mcp_server( + server_config, + Some(session_id), + Some(cwd), + meta_config.as_ref(), + byo_config.as_ref(), + &event_writer, + mode, + ) + .await + .map_err(|e| format!("Failed to prepare OAuth for '{}': {}", server_name, e))?; + if !new_client.has_auth() { + return Err(format!( + "MCP server '{}' does not support OAuth (discovery found no authorization support)", + server_name + )); + } + if let Some(tx) = event_tx { + new_client.set_event_tx(Some(tx)); + } + let arc = std::sync::Arc::new(new_client); + { + let mut mcp_state = self.mcp_state.lock().await; + mcp_state + .owned_clients + .insert(server_name.to_string(), arc.clone()); + mcp_state.auth_required.insert(server_name.to_string()); + mcp_state.init_failed.remove(server_name); + } + tracing::info!( + server = server_name, + "Rebuilt MCP HTTP client with OAuth manager for auth_trigger" + ); + Ok(arc) + } /// Attempt to re-initialize MCP servers stuck in `auth_required`. /// /// For each server, tries `try_reauth_from_disk` which checks the credential diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs index cc1ca22..a7a8f5b 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/run_loop.rs @@ -536,12 +536,13 @@ pub(super) async fn run_session( respond_to } => { session.events .emit(xai_file_utils::events::Event::McpServerToggled { server_name : server_name.clone(), enabled, }); let mut mcp_state = session.mcp_state - .lock(). await; let mut configs = mcp_state.configs.clone(); if enabled { let - already_present = configs.iter().any(| c | crate - ::session::mcp_servers::mcp_server_name(c) == server_name); if + .lock(). await; let mut configs = mcp_state.configs.clone(); if enabled { if + let Some(config) = server_config { configs.retain(| c | { crate + ::session::mcp_servers::mcp_server_name(c) != server_name }); configs + .push(config); } else { let already_present = configs.iter().any(| c | { + crate ::session::mcp_servers::mcp_server_name(c) == server_name }); if already_present { drop(mcp_state); let _ = respond_to.send(Ok(())); continue; - } if let Some(config) = server_config { configs.push(config); } else { - drop(mcp_state); let _ = respond_to.send(Err(acp::Error::invalid_params() + } drop(mcp_state); let _ = respond_to.send(Err(acp::Error::invalid_params() .data(format!("server '{}' not found in config", server_name)))); continue; } } else { configs.retain(| c | crate ::session::mcp_servers::mcp_server_name(c) != server_name); } let diff = diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs index 81d4f77..3e79253 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs @@ -49,7 +49,7 @@ impl SessionTokenAuthGate { is_session_based: auth_method_id .is_some_and(crate::agent::auth_method::is_session_based_method), model_byok, - endpoint_is_first_party: crate::util::is_first_party_xai_url(base_url), + endpoint_is_first_party: crate::util::is_xai_api_url(base_url), } } fn active(self) -> bool { diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs index 82b1f74..14c4a62 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs @@ -545,41 +545,19 @@ pub(crate) async fn spawn_session_actor( grep_ugrep, } }; - let persistent_local_shell = crate::util::config::resolve_persistent_local_shell( - remote_settings - .as_ref() - .and_then(|r| r.persistent_local_shell), - ); - let terminal_backend_kind = select_terminal_backend_kind( - startup_hints.is_subagent, - parent_terminal_backend.is_some(), - client_terminal_capable, - tool_context.gateway.is_some(), - persistent_local_shell, - ); let terminal_backend: std::sync::Arc = - match terminal_backend_kind { - TerminalBackendKind::ReuseParent => parent_terminal_backend - .expect("ReuseParent is only selected when a parent backend is present"), - TerminalBackendKind::AcpClient => { - std::sync::Arc::new(crate::terminal::AcpTerminalAdapter::new( - tool_context.gateway.clone().unwrap(), - tool_context.session_id.clone().unwrap(), - )) - as std::sync::Arc - } - TerminalBackendKind::LocalPersistent => std::sync::Arc::new( - LocalTerminalBackend::new_local_with_persistent_shell(resolve_search_shadows()), - ), - TerminalBackendKind::LocalNonPersistent => { - std::sync::Arc::new(LocalTerminalBackend::new_local(resolve_search_shadows())) - } + if let Some(parent_tb) = parent_terminal_backend.filter(|_| startup_hints.is_subagent) { + parent_tb + } else if client_terminal_capable && tool_context.gateway.is_some() { + std::sync::Arc::new(crate::terminal::AcpTerminalAdapter::new( + tool_context.gateway.clone().unwrap(), + tool_context.session_id.clone().unwrap(), + )) as std::sync::Arc + } else { + let backend: std::sync::Arc = + std::sync::Arc::new(LocalTerminalBackend::new_local(resolve_search_shadows())); + backend }; - if terminal_backend_kind == TerminalBackendKind::LocalPersistent { - terminal_backend - .warm_persistent_shell(tool_context.cwd.as_path()) - .await; - } let fs_backend: std::sync::Arc = if client_fs_capable && tool_context.gateway.is_some() { std::sync::Arc::new(xai_grok_workspace::file_system::AcpFsAdapter::new( @@ -712,6 +690,11 @@ pub(crate) async fn spawn_session_actor( } else { None }; + let embed_credentials = crate::auth::credential_provider::embedding_session_credentials( + &embed_base_url, + auth_manager.as_ref(), + api_key_provider.clone(), + ); let params = crate::session::memory::MemoryBackendParams { session_id: session_info.id.to_string(), embed_config: memory_config.as_ref().map(|mc| mc.embedding.clone()), @@ -723,16 +706,7 @@ pub(crate) async fn spawn_session_actor( watcher, stale_claim_secs: watcher_config.stale_claim_secs, search_source: "tool", - api_key_provider: api_key_provider.clone(), - auth_credentials: auth_manager.as_ref().map(|am| { - std::sync::Arc::new( - crate::auth::credential_provider::ShellAuthCredentialProvider::new( - am.clone(), - None, - None, - ), - ) as std::sync::Arc - }), + embedding_credentials: embed_credentials, }; let backend = crate::session::memory::MemoryBackendImpl::from_session_params( storage.clone(), @@ -2043,78 +2017,3 @@ impl crate::session::mcp_restart::RestartActions for SessionRestartActions { .end_restart(server); } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TerminalBackendKind { - ReuseParent, - AcpClient, - LocalPersistent, - LocalNonPersistent, -} -fn select_terminal_backend_kind( - is_subagent: bool, - has_parent_backend: bool, - client_terminal_capable: bool, - has_gateway: bool, - local_persistent: bool, -) -> TerminalBackendKind { - if is_subagent && has_parent_backend { - TerminalBackendKind::ReuseParent - } else if client_terminal_capable && has_gateway { - TerminalBackendKind::AcpClient - } else if local_persistent { - TerminalBackendKind::LocalPersistent - } else { - TerminalBackendKind::LocalNonPersistent - } -} -#[cfg(test)] -mod terminal_backend_select_tests { - use super::{TerminalBackendKind, select_terminal_backend_kind}; - #[test] - fn subagent_with_parent_reuses_parent() { - assert_eq!( - select_terminal_backend_kind(true, true, true, true, true), - TerminalBackendKind::ReuseParent - ); - } - #[test] - fn subagent_without_parent_falls_through() { - assert_eq!( - select_terminal_backend_kind(true, false, true, true, true), - TerminalBackendKind::AcpClient - ); - assert_eq!( - select_terminal_backend_kind(true, false, false, true, true), - TerminalBackendKind::LocalPersistent - ); - } - #[test] - fn non_subagent_never_reuses_parent() { - assert_eq!( - select_terminal_backend_kind(false, true, false, false, true), - TerminalBackendKind::LocalPersistent - ); - } - #[test] - fn client_terminal_uses_acp_only_with_gateway() { - assert_eq!( - select_terminal_backend_kind(false, false, true, true, true), - TerminalBackendKind::AcpClient - ); - assert_eq!( - select_terminal_backend_kind(false, false, true, false, true), - TerminalBackendKind::LocalPersistent - ); - } - #[test] - fn local_session_persistent_flag_selects_backend() { - assert_eq!( - select_terminal_backend_kind(false, false, false, false, true), - TerminalBackendKind::LocalPersistent - ); - assert_eq!( - select_terminal_backend_kind(false, false, false, false, false), - TerminalBackendKind::LocalNonPersistent - ); - } -} diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs index 79d90c8..8c5ad6f 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/cancel_running_task_tests.rs @@ -555,8 +555,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() watcher: None, stale_claim_secs: 60, search_source: "tool", - api_key_provider: None, - auth_credentials: None, + embedding_credentials: crate::session::memory::EndpointScopedCredentials::none(), }; let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel::(); let actor = Arc::new(SessionActor { diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_reminder_subagent_rules_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_reminder_subagent_rules_tests.rs index 1e301b9..92436b0 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_reminder_subagent_rules_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_reminder_subagent_rules_tests.rs @@ -942,7 +942,10 @@ fn render_goal_rules_substitutes_all_placeholders_in_slim_template() { "slim template must not publish a per-goal verdict file path:\n{body}", ); assert!(body.contains("/tmp/grok-goal-x/implementer")); - assert!(body.contains("`{SCRATCH}` placeholder resolves to")); + assert!(body.contains("`{SCRATCH}` placeholder")); + assert!(body.contains("Use existing\nuser, system, or project defaults")); + assert!(body.contains("`CARGO_HOME`, `RUSTUP_HOME`")); + assert!(body.contains("the scratch dir is deleted when the goal ends")); for placeholder in [ "{OBJECTIVE}", "{GOAL_TOOL}", @@ -1478,6 +1481,9 @@ fn render_goal_continuation_directive_substitutes_all_placeholders() { body.contains("`{SCRATCH}`"), "continuation directive must reference the {{SCRATCH}} placeholder:\n{body}", ); + assert!(body.contains("existing user, system, or project defaults")); + assert!(body.contains("`CARGO_HOME`, `RUSTUP_HOME`")); + assert!(body.contains("deleted when the goal ends")); const AUDIT_CONTRACT: &str = "AUDITS your committed tests"; assert!( GOAL_CONTINUATION_DIRECTIVE_TEMPLATE.contains(AUDIT_CONTRACT), diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs index 2d3e628..0968e20 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs @@ -398,8 +398,7 @@ fn initial_injection_backend_params_use_override_min_score() { watcher: None, stale_claim_secs: 60, search_source: "tool", - api_key_provider: None, - auth_credentials: None, + embedding_credentials: crate::session::memory::EndpointScopedCredentials::none(), }; let initial_injection = crate::config::MemoryInitialInjectionConfig { enabled: true, @@ -427,8 +426,7 @@ fn initial_injection_backend_params_preserve_default_zero_min_score() { watcher: None, stale_claim_secs: 60, search_source: "tool", - api_key_provider: None, - auth_credentials: None, + embedding_credentials: crate::session::memory::EndpointScopedCredentials::none(), }; let (adjusted, effective_min_score) = build_initial_injection_backend_params( ¶ms, diff --git a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs index ce5070e..017f434 100644 --- a/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs +++ b/crates/codegen/xai-grok-shell/src/session/acp_session_tests/memory_config_tests.rs @@ -18,8 +18,7 @@ fn initial_injection_backend_params_use_override_min_score() { watcher: None, stale_claim_secs: 60, search_source: "tool", - api_key_provider: None, - auth_credentials: None, + embedding_credentials: crate::session::memory::EndpointScopedCredentials::none(), }; let initial_injection = crate::config::MemoryInitialInjectionConfig { enabled: true, @@ -47,8 +46,7 @@ fn initial_injection_backend_params_preserve_default_zero_min_score() { watcher: None, stale_claim_secs: 60, search_source: "tool", - api_key_provider: None, - auth_credentials: None, + embedding_credentials: crate::session::memory::EndpointScopedCredentials::none(), }; let (adjusted, effective_min_score) = build_initial_injection_backend_params( ¶ms, @@ -521,8 +519,7 @@ async fn create_injection_ready_actor( watcher: None, stale_claim_secs: 60, search_source: "tool", - api_key_provider: None, - auth_credentials: None, + embedding_credentials: crate::session::memory::EndpointScopedCredentials::none(), }); actor .chat_state_handle diff --git a/crates/codegen/xai-grok-shell/src/session/memory/mod.rs b/crates/codegen/xai-grok-shell/src/session/memory/mod.rs index d9e8a90..f0d7ee8 100644 --- a/crates/codegen/xai-grok-shell/src/session/memory/mod.rs +++ b/crates/codegen/xai-grok-shell/src/session/memory/mod.rs @@ -12,7 +12,7 @@ pub mod hooks; pub use xai_grok_memory::{ - MemoryBackendImpl, MemoryBackendParams, MemoryIndex, MemoryScope, MemoryStorage, archive, - backend, chunker, dream, dream_lock, embed_missing_chunks, embedding, index, init_sqlite_vec, - mmr, query_expansion, schema, search, storage, text_utils, watcher, + EndpointScopedCredentials, MemoryBackendImpl, MemoryBackendParams, MemoryIndex, MemoryScope, + MemoryStorage, archive, backend, chunker, dream, dream_lock, embed_missing_chunks, embedding, + index, init_sqlite_vec, mmr, query_expansion, schema, search, storage, text_utils, watcher, }; diff --git a/crates/codegen/xai-grok-shell/src/session/persistence.rs b/crates/codegen/xai-grok-shell/src/session/persistence.rs index a2473d0..1220f70 100644 --- a/crates/codegen/xai-grok-shell/src/session/persistence.rs +++ b/crates/codegen/xai-grok-shell/src/session/persistence.rs @@ -306,6 +306,10 @@ pub struct SessionStateCopy { pub enum PersistenceMsg { /// A session update (ACP update or xAI extension update) Update(SessionUpdate), + AppendUpdateDurablyAndAck { + update: SessionUpdate, + respond_to: tokio::sync::oneshot::Sender>, + }, ContentChunk(PersistenceContentChunk), Chat(ConversationItem), /// Replace the entire chat history (used for compaction) @@ -1494,32 +1498,69 @@ impl SessionPersistence { } } - async fn write_update(&mut self, update: &SessionUpdate) { - if let Err(e) = self.storage.append_update(&self.info, update).await { - tracing::warn!(?e, "failed to write update"); + async fn write_update( + &self, + update: &SessionUpdate, + ) -> Result<(), crate::session::storage::AppendUpdateError> { + self.storage + .append_update_commit_aware(&self.info, update) + .await + } + + fn queue_acp_sync(&self, notification: acp::SessionNotification) { + if let Some(sync) = &self.remote_sync { + sync.queue(notification.clone()); } + if let Some(relay) = &self.relay_sync { + relay.queue(notification); + } + } + + fn finish_pending_append( + pending: &mut Option, + notification: acp::SessionNotification, + result: Result<(), crate::session::storage::AppendUpdateError>, + ) -> Result { + match result { + Ok(()) => Ok(notification), + Err(crate::session::storage::AppendUpdateError::NotCommitted(error)) => { + *pending = Some(notification); + Err(error) + } + Err(crate::session::storage::AppendUpdateError::Committed(error)) => Err(error), + } + } + + async fn drain_pending(&mut self) -> io::Result<()> { + if let Some(notification) = self.pending_notification.take() { + let result = self + .write_update(&SessionUpdate::Acp(Box::new(notification.clone()))) + .await; + match Self::finish_pending_append( + &mut self.pending_notification, + notification.clone(), + result, + ) { + Ok(notification) => self.queue_acp_sync(notification), + Err(error) => { + if self.pending_notification.is_none() { + self.queue_acp_sync(notification); + } + return Err(error); + } + } + } + Ok(()) } /// Flush any pending merged ACP notification to disk and remote sync. async fn flush_pending(&mut self) { - // Write any pending merged ACP notification - if let Some(notification) = self.pending_notification.take() { - self.write_update(&SessionUpdate::Acp(Box::new(notification.clone()))) - .await; - // HTTP-based remote sync (Writeback mode) - if let Some(sync) = &self.remote_sync { - sync.queue(notification.clone()); - } - // WebSocket-based relay sync (real-time sharing) - if let Some(relay) = &self.relay_sync { - relay.queue(notification); - } + if let Err(error) = self.drain_pending().await { + tracing::warn!(?error, "failed to write pending update"); } - // Flush HTTP sync if let Some(sync) = &self.remote_sync { sync.flush(); } - // Flush WebSocket relay if let Some(relay) = &self.relay_sync { relay.flush(); } @@ -1560,24 +1601,42 @@ impl SessionPersistence { SessionUpdate::Acp(notification) => { // ACP notifications use merging to coalesce consecutive text chunks if let Some(to_write) = self.maybe_merge_notification(¬ification) { - self.write_update(&SessionUpdate::Acp(Box::new(to_write.clone()))) - .await; - // HTTP-based remote sync (Writeback mode) - if let Some(sync) = &self.remote_sync { - sync.queue(to_write.clone()); - } - // WebSocket-based relay sync (real-time sharing) - if let Some(relay) = &self.relay_sync { - relay.queue(to_write); + match self + .write_update(&SessionUpdate::Acp(Box::new(to_write.clone()))) + .await + { + Ok(()) + | Err(crate::session::storage::AppendUpdateError::Committed( + _, + )) => { + self.queue_acp_sync(to_write); + } + Err(error) => tracing::warn!(%error, "failed to write update"), } } } SessionUpdate::Xai(_) => { // xAI notifications are written directly without merging - self.write_update(&update).await; + if let Err(error) = self.write_update(&update).await { + tracing::warn!(%error, "failed to write update"); + } } } } + PersistenceMsg::AppendUpdateDurablyAndAck { update, respond_to } => { + let result = async { + self.drain_pending().await?; + self.storage + .append_update_durable(&self.info, &update) + .await?; + if let SessionUpdate::Acp(notification) = update { + self.queue_acp_sync(*notification); + } + Ok(()) + } + .await; + let _ = respond_to.send(result); + } PersistenceMsg::Chat(chat_msg) => { if let Err(e) = self .storage @@ -2526,6 +2585,10 @@ fn classify_remote_delete( } } +#[cfg(test)] +#[path = "persistence_tests.rs"] +mod durable_update_tests; + #[cfg(test)] mod delete_session_history_tests { use super::{DeleteSessionError, SessionDeletion, classify_remote_delete}; diff --git a/crates/codegen/xai-grok-shell/src/session/persistence_tests.rs b/crates/codegen/xai-grok-shell/src/session/persistence_tests.rs new file mode 100644 index 0000000..a6d15a8 --- /dev/null +++ b/crates/codegen/xai-grok-shell/src/session/persistence_tests.rs @@ -0,0 +1,151 @@ +use super::*; + +struct ActorGuard { + handle: PersistenceHandle, + task: tokio::task::JoinHandle<()>, +} + +impl ActorGuard { + async fn stop(self) { + self.task.abort(); + let _ = self.task.await; + } +} + +fn test_actor(info: Info, storage: Arc) -> ActorGuard { + let (tx, rx) = mpsc::unbounded_channel(); + let summary_tx = tx.clone(); + let sampling_client = OaiCompatClient::new(xai_grok_sampler::SamplerConfig::default()).unwrap(); + let task = tokio::spawn( + SessionPersistence { + info, + storage, + pending_notification: None, + rx, + remote_sync: None, + relay_sync: None, + summary: crate::session::summary::SummaryGenerator::new( + crate::session::summary::SummaryConfig { + sampling_client, + model: String::new(), + persistence_tx: summary_tx, + }, + ), + registry_title_sync: None, + gateway: None, + } + .run(), + ); + ActorGuard { + handle: PersistenceHandle { tx, noop: false }, + task, + } +} + +fn notification(info: &Info, text: &str) -> acp::SessionNotification { + acp::SessionNotification::new( + info.id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text( + acp::TextContent::new(text), + ))), + ) +} + +fn neutral_update(info: &Info, text: &str) -> SessionUpdate { + SessionUpdate::Acp(Box::new(notification(info, text))) +} + +#[test] +fn committed_error_does_not_restore_pending_notification() { + let notification = notification( + &Info { + id: acp::SessionId::new("committed-update"), + cwd: "/test".into(), + }, + "committed", + ); + let mut pending = None; + let result = SessionPersistence::finish_pending_append( + &mut pending, + notification, + Err(crate::session::storage::AppendUpdateError::Committed( + io::Error::other("summary patch failed"), + )), + ); + assert_eq!(result.unwrap_err().to_string(), "summary patch failed"); + assert!(pending.is_none()); +} + +#[test] +fn uncommitted_error_restores_pending_notification() { + let notification = notification( + &Info { + id: acp::SessionId::new("uncommitted-update"), + cwd: "/test".into(), + }, + "pending", + ); + let mut pending = None; + let result = SessionPersistence::finish_pending_append( + &mut pending, + notification, + Err(crate::session::storage::AppendUpdateError::NotCommitted( + io::Error::other("append failed"), + )), + ); + assert!(result.is_err()); + assert!(pending.is_some()); +} + +#[tokio::test] +async fn durable_ack_drains_pending_update_in_fifo_order() { + let dir = tempfile::tempdir().unwrap(); + let info = Info { + id: acp::SessionId::new("durable-update"), + cwd: dir.path().to_string_lossy().into_owned(), + }; + let storage = Arc::new(JsonlStorageAdapter::with_explicit_session_dir( + dir.path().to_path_buf(), + )); + storage + .init_session(&info, default_model_id()) + .await + .unwrap(); + let actor = test_actor(info.clone(), storage.clone()); + actor + .handle + .tx + .send(PersistenceMsg::Update(neutral_update(&info, "before"))) + .unwrap(); + let (respond_to, response) = tokio::sync::oneshot::channel(); + actor + .handle + .tx + .send(PersistenceMsg::AppendUpdateDurablyAndAck { + update: neutral_update(&info, "durable"), + respond_to, + }) + .unwrap(); + response.await.unwrap().unwrap(); + let summary = storage.load_summary(&info).await.unwrap(); + assert_eq!(summary.num_messages, 2); + + let updates = storage.load_session(&info).await.unwrap().updates; + let texts = updates + .iter() + .filter_map(|update| { + let SessionUpdate::Acp(notification) = update else { + return None; + }; + let acp::SessionUpdate::AgentMessageChunk(chunk) = ¬ification.update else { + return None; + }; + let acp::ContentBlock::Text(text) = &chunk.content else { + return None; + }; + Some(text.text.clone()) + }) + .collect::>(); + assert_eq!(texts, ["before", "durable"]); + actor.stop().await; +} diff --git a/crates/codegen/xai-grok-shell/src/session/storage/jsonl/durable_tests.rs b/crates/codegen/xai-grok-shell/src/session/storage/jsonl/durable_tests.rs new file mode 100644 index 0000000..afb6f8a --- /dev/null +++ b/crates/codegen/xai-grok-shell/src/session/storage/jsonl/durable_tests.rs @@ -0,0 +1,173 @@ +use super::*; +use crate::session::info::Info; +use crate::session::persistence::default_model_id; +use crate::session::storage::{SessionUpdate, StorageAdapter}; + +fn info() -> Info { + Info { + id: acp::SessionId::new("durable-jsonl"), + cwd: "/test".into(), + } +} + +fn update(info: &Info, text: String) -> SessionUpdate { + SessionUpdate::Acp(Box::new(acp::SessionNotification::new( + info.id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text( + acp::TextContent::new(text), + ))), + ))) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn ordinary_and_durable_appends_keep_every_physical_line_parseable() { + const N: usize = 100; + let dir = tempfile::tempdir().unwrap(); + let info = info(); + let adapter = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf()); + adapter + .init_session(&info, default_model_id()) + .await + .unwrap(); + let ordinary = adapter.clone(); + let durable = adapter.clone(); + let info_a = info.clone(); + let info_b = info.clone(); + let ordinary = tokio::spawn(async move { + for index in 0..N { + ordinary + .append_update(&info_a, &update(&info_a, format!("ordinary-{index}"))) + .await + .unwrap(); + } + }); + let durable = tokio::spawn(async move { + for index in 0..N { + durable + .append_update_durable(&info_b, &update(&info_b, format!("durable-{index}"))) + .await + .unwrap(); + } + }); + ordinary.await.unwrap(); + durable.await.unwrap(); + + let bytes = std::fs::read(dir.path().join("updates.jsonl")).unwrap(); + let parsed = bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(serde_json::from_slice::) + .collect::, _>>() + .unwrap(); + assert_eq!(parsed.len(), N * 2); +} + +#[tokio::test] +async fn append_commit_is_reported_when_bookkeeping_fails() { + let dir = tempfile::tempdir().unwrap(); + let info = info(); + let adapter = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf()); + adapter + .init_session(&info, default_model_id()) + .await + .unwrap(); + let result = adapter + .append_update_with_bookkeeping(&info, &update(&info, "committed".into()), async { + Err(io::Error::other("summary patch failed")) + }) + .await; + assert!(matches!( + result, + Err(crate::session::storage::AppendUpdateError::Committed(_)) + )); + let bytes = std::fs::read(dir.path().join("updates.jsonl")).unwrap(); + let parsed = bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(serde_json::from_slice::) + .collect::, _>>() + .unwrap(); + assert_eq!(parsed.len(), 1); +} + +#[test] +fn lock_serializes_tail_heal_and_complete_record() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("updates.jsonl"); + std::fs::write(&path, b"torn").unwrap(); + JsonlStorageAdapter::append_jsonl_line_sync( + &path, + b"{\"record\":1}\n".to_vec(), + AppendDurability::Buffered, + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(path).unwrap(), + "torn\n{\"record\":1}\n" + ); +} + +#[test] +fn directory_barrier_failure_is_retried_even_after_file_exists() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static ATTEMPTS: AtomicUsize = AtomicUsize::new(0); + static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = TEST_LOCK.lock().unwrap(); + fn sync_file(file: &std::fs::File) -> io::Result<()> { + file.sync_all() + } + fn flaky_parent(_path: &Path) -> io::Result<()> { + if ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 0 { + Err(io::Error::other("directory barrier failed")) + } else { + Ok(()) + } + } + + ATTEMPTS.store(0, Ordering::SeqCst); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("updates.jsonl"); + assert!( + JsonlStorageAdapter::append_jsonl_line_sync_with( + &path, + b"{\"record\":1}\n".to_vec(), + AppendDurability::Durable, + sync_file, + flaky_parent, + ) + .is_err() + ); + JsonlStorageAdapter::append_jsonl_line_sync_with( + &path, + b"{\"record\":1}\n".to_vec(), + AppendDurability::Durable, + sync_file, + flaky_parent, + ) + .unwrap(); + assert_eq!(ATTEMPTS.load(Ordering::SeqCst), 2); +} + +#[test] +fn file_barrier_error_propagates() { + fn fail(_file: &std::fs::File) -> io::Result<()> { + Err(io::Error::other("file barrier failed")) + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("updates.jsonl"); + let error = JsonlStorageAdapter::append_jsonl_line_sync_with( + &path, + b"{\"record\":1}\n".to_vec(), + AppendDurability::Durable, + fail, + |_| Ok(()), + ) + .unwrap_err(); + assert_eq!(error.to_string(), "file barrier failed"); +} + +#[cfg(target_os = "macos")] +#[test] +fn darwin_fullfsync_seam_reports_invalid_descriptor() { + assert!(JsonlStorageAdapter::fullfsync_raw(-1).is_err()); +} diff --git a/crates/codegen/xai-grok-shell/src/session/storage/jsonl/mod.rs b/crates/codegen/xai-grok-shell/src/session/storage/jsonl/mod.rs index 5b4d23d..c331681 100644 --- a/crates/codegen/xai-grok-shell/src/session/storage/jsonl/mod.rs +++ b/crates/codegen/xai-grok-shell/src/session/storage/jsonl/mod.rs @@ -8,10 +8,12 @@ use crate::session::persistence::{CHAT_FORMAT_VERSION, Summary}; use crate::tools::todo::TodoState; use agent_client_protocol as acp; use async_trait::async_trait; +use fs2::FileExt; use std::fs::OpenOptions; -use std::io::{self, Read}; +use std::io::{self, Read, Seek, Write}; +#[cfg(target_os = "macos")] +use std::os::fd::AsRawFd; use std::path::{Path, PathBuf}; -use tokio::io::AsyncWriteExt; use xai_grok_workspace::session::file_state::RewindPoint; /// How the adapter resolves the session directory on disk. /// @@ -25,6 +27,10 @@ enum SessionDirMode { /// New: use this directory directly (for subagent children). Explicit(PathBuf), } +pub(super) enum AppendDurability { + Buffered, + Durable, +} /// JSONL-based storage adapter (legacy format) /// Stores sessions in {root}/sessions/{url_encoded_cwd}/{session_id}/ #[derive(Clone)] @@ -75,7 +81,7 @@ impl JsonlStorageAdapter { SessionDirMode::Explicit(dir) => dir.clone(), } } - fn updates_file(&self, info: &Info) -> PathBuf { + pub(super) fn updates_file(&self, info: &Info) -> PathBuf { self.session_dir(info).join("updates.jsonl") } fn chat_file(&self, info: &Info) -> PathBuf { @@ -246,33 +252,125 @@ impl JsonlStorageAdapter { /// the torn record is terminated as its own (single) corrupt line. This /// bounds the damage of any torn write to exactly one record, which the /// lenient readers (e.g. [`Self::read_chat_history_sync`]) then skip. - async fn append_jsonl_line(&self, path: PathBuf, mut line: Vec) -> io::Result<()> { + async fn append_jsonl_line(&self, path: PathBuf, line: Vec) -> io::Result<()> { + Self::append_jsonl_line_locked(path, line, AppendDurability::Buffered).await + } + async fn append_jsonl_line_locked( + path: PathBuf, + line: Vec, + durability: AppendDurability, + ) -> io::Result<()> { + tokio::task::spawn_blocking(move || Self::append_jsonl_line_sync(&path, line, durability)) + .await + .map_err(io::Error::other)? + } + fn append_jsonl_line_sync( + path: &Path, + line: Vec, + durability: AppendDurability, + ) -> io::Result<()> { + Self::append_jsonl_line_sync_with( + path, + line, + durability, + Self::sync_file_durable, + Self::sync_parent_directory, + ) + } + fn append_jsonl_line_sync_with( + path: &Path, + mut line: Vec, + durability: AppendDurability, + sync_file: fn(&std::fs::File) -> io::Result<()>, + sync_parent: fn(&Path) -> io::Result<()>, + ) -> io::Result<()> { debug_assert!(line.ends_with(b"\n"), "JSONL record must end with \\n"); - let mut file = tokio::fs::OpenOptions::new() + let lock_path = path.with_extension("jsonl.lock"); + let lock = OpenOptions::new() .read(true) + .write(true) .create(true) - .append(true) - .open(&path) - .await?; - let len = file.metadata().await?.len(); - if len > 0 { - use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _}; - file.seek(io::SeekFrom::Start(len - 1)).await?; - let mut last = [0u8; 1]; - file.read_exact(&mut last).await?; - if last[0] != b'\n' { - tracing::warn!( - path = % path.display(), - "jsonl file has a torn trailing line (previous append crashed \ - mid-write?); terminating it before appending" - ); - line.insert(0, b'\n'); + .truncate(false) + .open(lock_path)?; + lock.lock_exclusive()?; + let result = (|| { + let mut file = OpenOptions::new() + .read(true) + .create(true) + .append(true) + .open(path)?; + let len = file.metadata()?.len(); + if len > 0 { + file.seek(io::SeekFrom::Start(len - 1))?; + let mut last = [0u8; 1]; + file.read_exact(&mut last)?; + if last[0] != b'\n' { + tracing::warn!( + path = % path.display(), "terminating torn jsonl tail" + ); + line.insert(0, b'\n'); + } } + file.write_all(&line)?; + file.flush()?; + if matches!(durability, AppendDurability::Durable) { + sync_file(&file)?; + drop(file); + sync_parent(path)?; + } else { + drop(file); + } + Ok(()) + })(); + let _ = lock.unlock(); + result + } + #[cfg(target_os = "macos")] + fn sync_file_durable(file: &std::fs::File) -> io::Result<()> { + file.sync_all()?; + Self::fullfsync_raw(file.as_raw_fd()) + } + #[cfg(target_os = "macos")] + fn fullfsync_raw(fd: std::os::fd::RawFd) -> io::Result<()> { + let result = unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) }; + if result == -1 { + return Err(io::Error::last_os_error()); } - file.write_all(&line).await?; - file.flush().await?; Ok(()) } + #[cfg(all(unix, not(target_os = "macos")))] + fn sync_file_durable(file: &std::fs::File) -> io::Result<()> { + file.sync_all() + } + #[cfg(windows)] + fn sync_file_durable(file: &std::fs::File) -> io::Result<()> { + file.sync_all() + } + #[cfg(not(any(unix, windows)))] + fn sync_file_durable(_file: &std::fs::File) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "durable file sync is unsupported on this platform", + )) + } + #[cfg(unix)] + fn sync_parent_directory(path: &Path) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "update has no parent"))?; + std::fs::File::open(parent)?.sync_all() + } + #[cfg(windows)] + fn sync_parent_directory(_path: &Path) -> io::Result<()> { + Ok(()) + } + #[cfg(not(any(unix, windows)))] + fn sync_parent_directory(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "durable directory sync is unsupported on this platform", + )) + } /// Write a full JSONL file (rewriting all items), crash-atomically: serialize /// to a temp file then rename over the target, so a crash / `ENOSPC` mid-write /// can't truncate the existing file (e.g. lose `rewind_points.jsonl` history). @@ -307,17 +405,34 @@ impl JsonlStorageAdapter { Ok(items) } /// Append a session update to the updates.jsonl file, wrapping it in an envelope with timestamp. - async fn append_update_to_file( + pub(super) async fn append_update_to_file( &self, path: PathBuf, update: &super::SessionUpdate, + durability: AppendDurability, ) -> io::Result<()> { let envelope = SessionUpdateEnvelope::from_update(update) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; let mut line = serde_json::to_vec(&envelope) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; line.push(b'\n'); - self.append_jsonl_line(path, line).await + Self::append_jsonl_line_locked(path, line, durability).await + } + pub(crate) async fn append_update_with_bookkeeping( + &self, + info: &Info, + update: &super::SessionUpdate, + bookkeeping: F, + ) -> Result<(), super::AppendUpdateError> + where + F: std::future::Future>, + { + self.append_update_to_file(self.updates_file(info), update, AppendDurability::Buffered) + .await + .map_err(super::AppendUpdateError::NotCommitted)?; + bookkeeping + .await + .map_err(super::AppendUpdateError::Committed) } /// Read session updates from an updates.jsonl file, handling both envelope and legacy formats. /// @@ -951,7 +1066,35 @@ impl StorageAdapter for JsonlStorageAdapter { .await } async fn append_update(&self, info: &Info, update: &super::SessionUpdate) -> io::Result<()> { - self.append_update_to_file(self.updates_file(info), update) + self.append_update_commit_aware(info, update) + .await + .map_err(super::AppendUpdateError::into_io_error) + } + async fn append_update_commit_aware( + &self, + info: &Info, + update: &super::SessionUpdate, + ) -> Result<(), super::AppendUpdateError> { + self.append_update_with_bookkeeping( + info, + update, + self.apply_summary_patch( + info, + super::summary_write::SummaryPatch { + record_activity: true, + messages: Some(super::summary_write::CounterOp::Increment(1)), + ..Default::default() + }, + ), + ) + .await + } + async fn append_update_durable( + &self, + info: &Info, + update: &super::SessionUpdate, + ) -> io::Result<()> { + self.append_update_to_file(self.updates_file(info), update, AppendDurability::Durable) .await?; self.apply_summary_patch( info, @@ -1526,4 +1669,6 @@ fn is_valid_data_uri_image(url: &str) -> bool { } } #[cfg(test)] +mod durable_tests; +#[cfg(test)] mod tests; diff --git a/crates/codegen/xai-grok-shell/src/session/storage/mod.rs b/crates/codegen/xai-grok-shell/src/session/storage/mod.rs index f511495..c10914d 100644 --- a/crates/codegen/xai-grok-shell/src/session/storage/mod.rs +++ b/crates/codegen/xai-grok-shell/src/session/storage/mod.rs @@ -484,6 +484,28 @@ pub fn updates_truncate_for_prompt(updates: &[SessionUpdate], target_prompt_inde updates.len() } +#[derive(Debug)] +pub enum AppendUpdateError { + NotCommitted(io::Error), + Committed(io::Error), +} + +impl AppendUpdateError { + pub fn into_io_error(self) -> io::Error { + match self { + Self::NotCommitted(error) | Self::Committed(error) => error, + } + } +} + +impl std::fmt::Display for AppendUpdateError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotCommitted(error) | Self::Committed(error) => error.fmt(formatter), + } + } +} + /// Storage adapter trait for session persistence /// Abstracts over different storage backends (JSONL, SQLite, etc.) #[async_trait] @@ -512,6 +534,28 @@ pub trait StorageAdapter: Send + Sync { /// Append a session update (ACP update or xAI extension update) and increment counter async fn append_update(&self, info: &Info, update: &SessionUpdate) -> io::Result<()>; + /// Append one update and report whether the replay record was committed before an error. + async fn append_update_commit_aware( + &self, + info: &Info, + update: &SessionUpdate, + ) -> Result<(), AppendUpdateError> { + self.append_update(info, update) + .await + .map_err(AppendUpdateError::NotCommitted) + } + + /// Append one update with the ordinary bookkeeping and a durable log barrier. + /// + /// Adapters without this capability return `Unsupported`; callers must tolerate a duplicate + /// record when retrying an error that occurred after the append reached storage. + async fn append_update_durable(&self, _info: &Info, _update: &SessionUpdate) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "durable session update append is unsupported", + )) + } + /// Append a chat message and increment counter async fn append_chat_message(&self, info: &Info, message: &ConversationItem) -> io::Result<()>; diff --git a/crates/codegen/xai-grok-shell/src/session/templates/goal_continuation_directive.md b/crates/codegen/xai-grok-shell/src/session/templates/goal_continuation_directive.md index c88a1c1..1648e7b 100644 --- a/crates/codegen/xai-grok-shell/src/session/templates/goal_continuation_directive.md +++ b/crates/codegen/xai-grok-shell/src/session/templates/goal_continuation_directive.md @@ -12,11 +12,15 @@ Keep your {todo_tool} list current (≥1 `in_progress`, descriptive `activeForm`). Run targeted tests after every change you make, not just at the end. Tests must drive the SHIPPED code on the real path — no hard-coded values, no starting past the thing under test, no -re-implementing it. Save captured test output and artifacts to your -scratch dir {scratch_dir} {scratch_status}, never shared `/tmp/...`; -the plan's `{SCRATCH}` placeholder resolves there. The verifier AUDITS your committed tests and -saved evidence rather than rebuilding them — leave honest proof or you -WILL be refuted. +re-implementing it. Use your scratch dir {scratch_dir} {scratch_status} only for +captured test output, temp scripts, and throwaway artifacts, never shared +`/tmp/...`. Use existing user, system, or project defaults for execution +dependencies and environment state. NEVER set `HOME`, `CARGO_HOME`, `RUSTUP_HOME`, +package-manager homes, virtualenvs, caches, or config dirs to scratch, or persist +references to scratch, which is deleted when the goal ends. +The plan's `{SCRATCH}` placeholder resolves there. The verifier AUDITS your committed tests +and saved evidence rather than rebuilding them — leave honest +proof or you WILL be refuted. Before calling `{goal_tool}(completed: true)`, run the plan's `## Verification plan` steps yourself and confirm the observations it lists hold — the harness re-checks against those SAME steps each attempt diff --git a/crates/codegen/xai-grok-shell/src/session/templates/goal_rules.md b/crates/codegen/xai-grok-shell/src/session/templates/goal_rules.md index dbef565..7511633 100644 --- a/crates/codegen/xai-grok-shell/src/session/templates/goal_rules.md +++ b/crates/codegen/xai-grok-shell/src/session/templates/goal_rules.md @@ -22,11 +22,15 @@ worse than none. VERIFY AS YOU GO: run each change. If output is visual, capture and inspect it; for data/config, validate programmatically. -SCRATCH: write captured test output, temp scripts, and throwaway artifacts to -your private scratch dir {SCRATCH_DIR} — never to shared `/tmp/...` (skeptics and -concurrent goals collide there). {SCRATCH_STATUS} The plan's -`{SCRATCH}` placeholder resolves to it. The verifier AUDITS your committed tests and saved evidence instead of -rebuilding them, so honest, durable proof is what passes. +SCRATCH: use your private scratch dir {SCRATCH_DIR} only for captured test +output, temp scripts, and throwaway artifacts — never shared `/tmp/...` paths +(skeptics and concurrent goals collide there). {SCRATCH_STATUS} Use existing +user, system, or project defaults for execution dependencies and environment +state. NEVER set `HOME`, `CARGO_HOME`, `RUSTUP_HOME`, package-manager homes, +virtualenvs, caches, or config dirs to scratch, or write persistent config that +references scratch; the scratch dir is deleted when the goal ends. The plan's `{SCRATCH}` placeholder +resolves to it. The verifier AUDITS your committed tests and saved evidence +instead of rebuilding them, so honest, durable proof is what passes. TEST PROACTIVELY: run targeted tests after every change, not just at the end. Before calling `{GOAL_TOOL}(completed: true)`, run the test suite relevant to diff --git a/crates/codegen/xai-grok-shell/src/tools/config.rs b/crates/codegen/xai-grok-shell/src/tools/config.rs index dfce291..4330c62 100644 --- a/crates/codegen/xai-grok-shell/src/tools/config.rs +++ b/crates/codegen/xai-grok-shell/src/tools/config.rs @@ -35,9 +35,6 @@ pub struct BashToolConfig { /// Whether to allow a background `&` operator in foreground commands /// (default: `true`). Resolution: config.toml (this) > remote settings > `true`. pub allow_background_operator: Option, - /// Declared so the unknown-key scan accepts `[toolset.bash] persistent_shell`; - /// the effective value is resolved (layered) by `resolve_persistent_local_shell`. - pub persistent_shell: Option, } impl BashToolConfig { diff --git a/crates/codegen/xai-grok-shell/src/util/config/hints.rs b/crates/codegen/xai-grok-shell/src/util/config/hints.rs index 3548486..69ad08a 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/hints.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/hints.rs @@ -108,6 +108,7 @@ pub struct ResolvedContextualHints { pub send_now: bool, pub small_screen: bool, pub word_select: bool, + pub ssh_wrap: bool, } impl Default for ResolvedContextualHints { @@ -119,6 +120,7 @@ impl Default for ResolvedContextualHints { send_now: true, small_screen: true, word_select: true, + ssh_wrap: true, } } } @@ -148,6 +150,7 @@ pub fn resolve_contextual_hints( send_now: resolve_tip(ui.send_now, remote.and_then(|r| r.send_now)), small_screen: resolve_tip(ui.small_screen, remote.and_then(|r| r.small_screen)), word_select: resolve_tip(ui.word_select, remote.and_then(|r| r.word_select)), + ssh_wrap: resolve_tip(ui.ssh_wrap, remote.and_then(|r| r.ssh_wrap)), } } @@ -255,6 +258,7 @@ mod tests { send_now, small_screen: None, word_select, + ssh_wrap: None, } } @@ -268,6 +272,7 @@ mod tests { assert!(resolved.send_now, "send_now defaults ON"); assert!(resolved.small_screen, "small_screen defaults ON"); assert!(resolved.word_select, "word_select defaults ON"); + assert!(resolved.ssh_wrap, "ssh_wrap defaults ON"); } #[test] @@ -285,19 +290,26 @@ mod tests { assert!(resolved.send_now); assert!(resolved.small_screen); assert!(resolved.word_select); + assert!(resolved.ssh_wrap); } #[test] fn contextual_hints_remote_tier_controls_default_per_tip() { let _g = contextual_hints_guard(); - // Remote disables plan_mode; absent tips fall through to default ON. - let r = remote(None, Some(false), None, None, None); + // Remote disables plan_mode + ssh_wrap; absent tips fall through to + // default ON. Setting two distinct fields also catches a cross-wired + // resolver line (reading one remote field into another's gate). + let r = ContextualHintsRemote { + ssh_wrap: Some(false), + ..remote(None, Some(false), None, None, None) + }; let resolved = resolve_contextual_hints(&ContextualHints::default(), Some(&r)); assert!(resolved.undo, "absent remote tip → default ON"); assert!(!resolved.plan_mode, "remote `false` soft-disables"); assert!(resolved.image_input); assert!(resolved.send_now); assert!(resolved.word_select); + assert!(!resolved.ssh_wrap, "remote `false` soft-disables ssh_wrap"); } #[test] @@ -328,6 +340,7 @@ mod tests { send_now: Some(false), small_screen: Some(false), word_select: Some(false), + ssh_wrap: Some(false), }; let r = remote( Some(false), @@ -344,6 +357,7 @@ mod tests { && resolved.send_now && resolved.small_screen && resolved.word_select + && resolved.ssh_wrap ); unsafe { std::env::remove_var(ENV_CONTEXTUAL_HINTS) }; } @@ -360,6 +374,7 @@ mod tests { send_now: Some(true), small_screen: Some(true), word_select: Some(true), + ssh_wrap: Some(true), }; let r = remote(Some(true), Some(true), Some(true), Some(true), Some(true)); let resolved = resolve_contextual_hints(&ui, Some(&r)); @@ -370,6 +385,7 @@ mod tests { && !resolved.send_now && !resolved.small_screen && !resolved.word_select + && !resolved.ssh_wrap ); unsafe { std::env::remove_var(ENV_CONTEXTUAL_HINTS) }; } diff --git a/crates/codegen/xai-grok-shell/src/util/config/mcp.rs b/crates/codegen/xai-grok-shell/src/util/config/mcp.rs index d2e455b..d9019b0 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/mcp.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/mcp.rs @@ -12,7 +12,11 @@ use xai_grok_tools::types::compat::{CompatConfig, CompatConfigToml}; pub use xai_grok_mcp::oauth_config::{McpOAuthConfig, McpOAuthConfigMap}; // MCP server config value types extracted to `xai-grok-config-types` (config // dependency inversion); re-exported so `crate::util::config::*` paths keep working. -pub use xai_grok_config_types::{McpJsonOAuthBlock, McpServerConfig, McpServerTransportConfig}; +pub use xai_grok_config_types::{ + McpJsonOAuthBlock, McpPreferenceSource, McpPreferencesFile, McpServerConfig, + McpServerPreferences, McpServerTransportConfig, McpSetupConfig, McpSetupDerivedValue, + McpSetupField, McpSetupFieldType, McpSetupOption, McpSetupResolution, +}; // Permission-policy value types likewise extracted; re-exported to keep paths stable. pub use xai_grok_config_types::{ PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter, @@ -141,8 +145,17 @@ pub fn load_mcp_servers_with_oauth( let mut oauth_configs = McpOAuthConfigMap::new(); let mut acp_servers = Vec::new(); + let preferences = load_mcp_preferences().file(); let sub = &crate::config::expand_env_vars_in_string; - for (name, mut config) in servers_map { + for (name, config) in servers_map { + let mut config = match config.resolve_setup(preferences.servers.get(&name)) { + McpSetupResolution::Resolved(config) => config, + McpSetupResolution::Required(_) => continue, + McpSetupResolution::Invalid(reason) => { + tracing::warn!(server = %name, error = %reason, "MCP setup config is invalid"); + continue; + } + }; config.expand_strings(sub); if let Some(oauth) = config.oauth_config() { oauth_configs.insert(name.clone(), oauth); @@ -195,10 +208,19 @@ pub fn load_mcp_servers(cwd: &std::path::Path, compat: &CompatConfig) -> Vec Vec { + let preferences = load_mcp_preferences().file(); let sub = &crate::config::expand_env_vars_in_string; load_all_mcp_configs(cwd) .into_iter() - .filter_map(|(name, mut config)| { + .filter_map(|(name, config)| { + let mut config = match config.resolve_setup(preferences.servers.get(&name)) { + McpSetupResolution::Resolved(config) => config, + McpSetupResolution::Required(_) => return None, + McpSetupResolution::Invalid(reason) => { + tracing::warn!(server = %name, error = %reason, "MCP setup config is invalid"); + return None; + } + }; config.expand_strings(sub); config.to_acp_mcp_server(name) }) @@ -267,10 +289,19 @@ pub(crate) fn reload_mcp_servers_merged( servers.entry(name).or_insert(config); } + let preferences = load_mcp_preferences().file(); let sub = &crate::config::expand_env_vars_in_string; servers .into_iter() - .filter_map(|(name, mut config)| { + .filter_map(|(name, config)| { + let mut config = match config.resolve_setup(preferences.servers.get(&name)) { + McpSetupResolution::Resolved(config) => config, + McpSetupResolution::Required(_) => return None, + McpSetupResolution::Invalid(reason) => { + tracing::warn!(server = %name, error = %reason, "MCP setup config is invalid"); + return None; + } + }; config.expand_strings(sub); config.to_acp_mcp_server(name) }) @@ -317,6 +348,230 @@ pub fn all_toml_mcp_server_names(cwd: &std::path::Path) -> std::collections::Has load_all_mcp_configs(cwd).keys().cloned().collect() } +pub fn mcp_preferences_path() -> PathBuf { + xai_grok_config::grok_home().join("mcp_preferences.json") +} + +/// Result of loading prefs. Corrupt files are readable as empty for resolution +/// but must not be overwritten (would clobber other servers). +#[derive(Debug, Clone)] +pub enum McpPreferencesLoad { + Ok(McpPreferencesFile), + Missing, + Corrupt, +} + +impl McpPreferencesLoad { + pub fn file(&self) -> McpPreferencesFile { + match self { + Self::Ok(f) => f.clone(), + Self::Missing | Self::Corrupt => McpPreferencesFile::default(), + } + } + + pub fn is_writable(&self) -> bool { + !matches!(self, Self::Corrupt) + } +} + +pub fn load_mcp_preferences() -> McpPreferencesLoad { + load_mcp_preferences_from(&mcp_preferences_path()) +} + +pub fn load_mcp_preferences_from(path: &std::path::Path) -> McpPreferencesLoad { + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return McpPreferencesLoad::Missing, + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "failed to read MCP preferences"); + return McpPreferencesLoad::Corrupt; + } + }; + match serde_json::from_str(&content) { + Ok(file) => McpPreferencesLoad::Ok(file), + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "failed to parse MCP preferences"); + McpPreferencesLoad::Corrupt + } + } +} + +pub async fn save_mcp_preferences(prefs: &McpPreferencesFile) -> Result<()> { + save_mcp_preferences_to(&mcp_preferences_path(), prefs).await +} + +pub async fn save_mcp_preferences_to( + path: &std::path::Path, + prefs: &McpPreferencesFile, +) -> Result<()> { + if matches!(load_mcp_preferences_from(path), McpPreferencesLoad::Corrupt) { + anyhow::bail!( + "refusing to overwrite unreadable MCP preferences at {}", + path.display() + ); + } + let json = serde_json::to_string_pretty(prefs)?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let tmp = path.with_extension(format!( + "json.tmp.{}{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + tokio::fs::write(&tmp, &json).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) + .await + .map_err(|e| anyhow::anyhow!("failed to set mcp preferences permissions: {e}"))?; + } + tokio::fs::rename(&tmp, path).await?; + Ok(()) +} + +/// Restore a single server key after a failed setup (best-effort). +pub async fn restore_mcp_preference_server( + server_name: &str, + previous: Option, +) -> Result<()> { + let load = load_mcp_preferences(); + if !load.is_writable() { + return Ok(()); + } + let mut prefs = load.file(); + match previous { + Some(entry) => { + prefs.servers.insert(server_name.to_string(), entry); + } + None => { + prefs.servers.remove(server_name); + } + } + save_mcp_preferences(&prefs).await +} + +/// Unresolved setup-bearing MCP config collected for `/mcps` list and auth. +#[derive(Debug, Clone)] +pub struct McpSetupServerEntry { + pub name: String, + pub config: McpServerConfig, + pub source: McpPreferenceSource, +} + +/// Collect MCP configs that declare a `setup` schema from config and plugins. +/// Used to surface setup-required rows and drive `x.ai/mcp/setup`. +pub fn collect_mcp_setup_configs( + cwd: &std::path::Path, + plugin_registry: Option<&xai_grok_agent::plugins::PluginRegistry>, + compat: &CompatConfig, +) -> IndexMap { + let mut result = IndexMap::new(); + for (name, (config, scope)) in load_mcp_server_configs_with_project(cwd) { + if !config.enabled || config.setup.is_none() { + continue; + } + result.insert( + name.clone(), + McpSetupServerEntry { + name, + config, + source: McpPreferenceSource { + kind: "config".to_string(), + plugin: None, + scope: Some(scope.to_string()), + }, + }, + ); + } + if !crate::claude_import::is_claude_import_marked_with_log("collect_mcp_setup_configs") { + for (name, config) in load_claude_json_mcp_servers_as_configs(cwd, compat) { + if !config.enabled || config.setup.is_none() { + continue; + } + result.entry(name.clone()).or_insert(McpSetupServerEntry { + name, + config, + source: McpPreferenceSource { + kind: "config".to_string(), + plugin: None, + scope: Some(MCP_SCOPE_USER.to_string()), + }, + }); + } + for (name, config) in load_cursor_mcp_servers_as_configs(cwd, compat) { + if !config.enabled || config.setup.is_none() { + continue; + } + result.entry(name.clone()).or_insert(McpSetupServerEntry { + name, + config, + source: McpPreferenceSource { + kind: "config".to_string(), + plugin: None, + scope: Some(MCP_SCOPE_USER.to_string()), + }, + }); + } + for (name, config) in load_mcp_json_servers_as_configs(cwd) { + if !config.enabled || config.setup.is_none() { + continue; + } + result.entry(name.clone()).or_insert(McpSetupServerEntry { + name, + config, + source: McpPreferenceSource { + kind: "config".to_string(), + plugin: None, + scope: Some(MCP_SCOPE_PROJECT.to_string()), + }, + }); + } + } + if let Some(registry) = plugin_registry { + let toml_claimed_names = all_toml_mcp_server_names(cwd); + for plugin in registry.active_plugins() { + // File first, then inline; first-wins matches runtime plugin load. + let mut plugin_configs = IndexMap::new(); + if let Some(ref mcp_path) = plugin.mcp_config_path + && let Some(config) = read_mcp_json(mcp_path) + { + for (name, server) in config.mcp_servers { + plugin_configs.entry(name).or_insert(server); + } + } + if let Some(ref inline_value) = plugin.inline_mcp_servers { + let normalized = + xai_grok_agent::plugins::manifest::normalize_inline_mcp_servers(inline_value); + if let Ok(config) = serde_json::from_value::(normalized) { + for (name, server) in config.mcp_servers { + plugin_configs.entry(name).or_insert(server); + } + } + } + for (name, config) in plugin_configs { + if toml_claimed_names.contains(&name) || !config.enabled || config.setup.is_none() { + continue; + } + result.entry(name.clone()).or_insert(McpSetupServerEntry { + name, + config, + source: McpPreferenceSource { + kind: "plugin".to_string(), + plugin: Some(plugin.name.clone()), + scope: None, + }, + }); + } + } + } + result +} + pub const MANAGED_GATEWAY_DISABLED_CONNECTORS_KEY: &str = "__managed_gateway_connectors"; /// Persist `disabled_tools` for a server under `[disabled_mcp_tools]` in config.toml. @@ -681,10 +936,23 @@ pub(crate) fn parse_mcp_config_with_oauth( source_label: &str, sub: &dyn Fn(&str) -> String, ) -> (Vec, McpOAuthConfigMap) { + let preferences = load_mcp_preferences().file(); let mut servers = Vec::new(); let mut oauth_configs = McpOAuthConfigMap::new(); for (name, server_config) in &config.mcp_servers { - let mut server_config = server_config.clone(); + let mut server_config = match server_config.resolve_setup(preferences.servers.get(name)) { + McpSetupResolution::Resolved(config) => config, + McpSetupResolution::Required(_) => continue, + McpSetupResolution::Invalid(reason) => { + tracing::warn!( + source = source_label, + server = %name, + error = %reason, + "MCP setup config is invalid" + ); + continue; + } + }; server_config.expand_strings(sub); if let Some(oauth) = server_config.oauth_config() { oauth_configs.insert(name.clone(), oauth); @@ -1651,5 +1919,52 @@ enabled = false ); } + #[tokio::test] + async fn mcp_preferences_missing_malformed_and_save_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mcp_preferences.json"); + assert!(matches!( + load_mcp_preferences_from(&path), + McpPreferencesLoad::Missing + )); + assert!(load_mcp_preferences_from(&path).file().servers.is_empty()); + + std::fs::write(&path, "not json").unwrap(); + assert!(matches!( + load_mcp_preferences_from(&path), + McpPreferencesLoad::Corrupt + )); + let prefs = McpPreferencesFile { + version: 1, + servers: HashMap::from([( + "acme".to_string(), + McpServerPreferences { + values: HashMap::from([("site".to_string(), "us5".to_string())]), + source: Some(McpPreferenceSource { + kind: "plugin".to_string(), + plugin: Some("acme".to_string()), + scope: None, + }), + updated_at: Some("2026-06-19T00:00:00Z".to_string()), + }, + )]), + }; + assert!(save_mcp_preferences_to(&path, &prefs).await.is_err()); + + std::fs::remove_file(&path).unwrap(); + save_mcp_preferences_to(&path, &prefs).await.unwrap(); + let loaded = load_mcp_preferences_from(&path).file(); + assert_eq!(loaded.servers["acme"].values["site"], "us5"); + assert_eq!( + loaded.servers["acme"] + .source + .as_ref() + .unwrap() + .plugin + .as_deref(), + Some("acme") + ); + } + // === merge_section tests === } diff --git a/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs b/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs index 99c8023..225435d 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/resolve/toolset.rs @@ -62,155 +62,6 @@ fn resolve_search_tool_enabled( env.or(config).or(managed).unwrap_or(true) } -const ENV_PERSISTENT_SHELL: &str = "GROK_PERSISTENT_SHELL"; - -fn persistent_shell_from_toml(v: Option<&TomlValue>) -> Option { - v?.get("toolset")? - .get("bash")? - .get("persistent_shell")? - .as_bool() -} - -pub fn resolve_persistent_local_shell(remote: Option) -> bool { - let requirements = crate::config::load_merged_requirements(); - let layers = match crate::config::ConfigLayers::load() { - Ok(l) => Some(l), - Err(e) => { - tracing::warn!(error = %e, "persistent_local_shell: failed to load config layers"); - None - } - }; - resolve_persistent_local_shell_tiers( - requirements.as_ref(), - layers.as_ref().map(|l| &l.user), - layers.as_ref().map(|l| &l.managed), - layers.as_ref().map(|l| &l.system_managed), - remote, - ) -} - -fn resolve_persistent_local_shell_tiers( - requirements: Option<&TomlValue>, - user: Option<&TomlValue>, - managed: Option<&TomlValue>, - system_managed: Option<&TomlValue>, - remote: Option, -) -> bool { - use crate::agent::config::BoolFlag; - BoolFlag::env(ENV_PERSISTENT_SHELL) - .requirement(persistent_shell_from_toml(requirements)) - .config(persistent_shell_from_toml(user)) - .managed( - persistent_shell_from_toml(managed) - .or_else(|| persistent_shell_from_toml(system_managed)), - ) - .feature_flag(remote) - .default(true) - .resolve() - .value -} - -#[cfg(test)] -mod persistent_local_shell_tests { - use super::{ENV_PERSISTENT_SHELL, resolve_persistent_local_shell_tiers}; - use toml::Value as TomlValue; - - // GROK_PERSISTENT_SHELL is process-global (the documented kill-switch a dev - // may export); serialize and force it unset so these tests can't go flaky. - static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn guard() -> std::sync::MutexGuard<'static, ()> { - let g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - unsafe { std::env::remove_var(ENV_PERSISTENT_SHELL) }; - g - } - - fn cfg(persistent: bool) -> TomlValue { - toml::from_str(&format!( - "[toolset.bash]\npersistent_shell = {persistent}\n" - )) - .unwrap() - } - - #[test] - fn default_is_true() { - let _g = guard(); - assert!(resolve_persistent_local_shell_tiers( - None, None, None, None, None - )); - } - - #[test] - fn remote_false_rolls_back() { - let _g = guard(); - assert!(!resolve_persistent_local_shell_tiers( - None, - None, - None, - None, - Some(false) - )); - } - - #[test] - fn config_false_rolls_back() { - let _g = guard(); - let off = cfg(false); - assert!(!resolve_persistent_local_shell_tiers( - None, - Some(&off), - None, - None, - None - )); - } - - #[test] - fn config_beats_remote() { - let _g = guard(); - let on = cfg(true); - assert!(resolve_persistent_local_shell_tiers( - None, - Some(&on), - None, - None, - Some(false) - )); - } - - #[test] - fn requirement_overrides_remote() { - let _g = guard(); - let on = cfg(true); - assert!(resolve_persistent_local_shell_tiers( - Some(&on), - None, - None, - None, - Some(false) - )); - } - - #[test] - fn managed_and_system_managed_apply_below_config() { - let _g = guard(); - let off = cfg(false); - assert!(!resolve_persistent_local_shell_tiers( - None, - None, - Some(&off), - None, - None - )); - assert!(!resolve_persistent_local_shell_tiers( - None, - None, - None, - Some(&off), - None - )); - } -} - /// Env override for `[toolset.ask_user_question] timeout_enabled` (parsed by /// the shared [`xai_grok_config::env_bool`] via `BoolFlag`). The secs env var /// lives in the tools crate (`RESPONSE_TIMEOUT_ENV`), parsed once there. diff --git a/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs b/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs index f78b3d8..c791945 100644 --- a/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs +++ b/crates/codegen/xai-grok-shell/src/util/config/settings_writes.rs @@ -60,6 +60,11 @@ pub async fn set_contextual_hint_word_select(value: bool) -> Result<()> { update_config(|cfg| cfg.ui.contextual_hints.word_select = Some(value)).await } +/// Persist `[ui.contextual_hints].ssh_wrap` via `update_config`. +pub async fn set_contextual_hint_ssh_wrap(value: bool) -> Result<()> { + update_config(|cfg| cfg.ui.contextual_hints.ssh_wrap = Some(value)).await +} + /// Persist `[ui].theme` via `update_config`. Caller must pass the /// canonical theme name (`groknight`, `tokyonight`, `auto`, etc.). pub async fn set_theme(value: String) -> Result<()> { diff --git a/crates/codegen/xai-grok-shell/tests/test_trusted_local_plugin_refresh_e2e.rs b/crates/codegen/xai-grok-shell/tests/test_trusted_local_plugin_refresh_e2e.rs index a8b3640..1f1b048 100644 --- a/crates/codegen/xai-grok-shell/tests/test_trusted_local_plugin_refresh_e2e.rs +++ b/crates/codegen/xai-grok-shell/tests/test_trusted_local_plugin_refresh_e2e.rs @@ -45,6 +45,7 @@ fn register_local_install(registry: &mut InstallRegistry, source: &Path) -> Inst subdir: None, }, registry, + false, ) .expect("install local plugin"); let plugins = installed diff --git a/crates/codegen/xai-grok-telemetry/src/client.rs b/crates/codegen/xai-grok-telemetry/src/client.rs index c2cd3a7..65101da 100644 --- a/crates/codegen/xai-grok-telemetry/src/client.rs +++ b/crates/codegen/xai-grok-telemetry/src/client.rs @@ -255,6 +255,10 @@ pub async fn track(event_name: &str, request_id: &str, ctx: &UserContext, mut me } /// Sync the user's Mixpanel profile once per init. Fire-and-forget. +/// +/// Only runs in [`TelemetryMode::Enabled`]. SessionMetrics mode may emit +/// lifecycle events via [`track`], but must not write Mixpanel people +/// profiles (`engage`). pub fn sync_profile() { let lock = TELEMETRY_CLIENT.get_or_init(|| Mutex::new(None)); let client = { @@ -265,6 +269,12 @@ pub fn sync_profile() { } }; + // The single profile-sync gate: reads the installed client's mode, so every + // caller (and any init race) resolves against what was actually installed. + if !client.mode.is_enabled() { + return; + } + let Some(mixpanel) = client.mixpanel.clone() else { return; }; @@ -396,6 +406,56 @@ mod tests { assert_eq!(event_value("grok-workspace-turn"), "turn"); } + /// SessionMetrics must not attempt Mixpanel profile engage — sync_profile + /// is a no-op unless mode is fully Enabled. + #[test] + fn sync_profile_is_noop_in_session_metrics_mode() { + // No tokio runtime here BY DESIGN: if the gate wrongly falls through, + // sync_profile's tokio::spawn panics and fails this test. Converting + // this to #[tokio::test] would silently turn it into theater. + assert!( + tokio::runtime::Handle::try_current().is_err(), + "this test must run without a tokio runtime" + ); + // Clear the global client even if an assert below panics. + struct ClearClient; + impl Drop for ClearClient { + fn drop(&mut self) { + let lock = TELEMETRY_CLIENT.get_or_init(|| Mutex::new(None)); + *lock.lock().unwrap_or_else(|err| err.into_inner()) = None; + } + } + let _clear = ClearClient; + + // Mixpanel configured, but no events endpoint: the global must never + // carry a live funnel out of this test. + let cfg = TelemetryConfig { + mixpanel_enabled: true, + mixpanel_token: Some("test-token".into()), + events_url: None, + events_api_key: None, + ..TelemetryConfig::default() + }; + init( + cfg, + TelemetryMode::SessionMetrics, + Some("user-1".into()), + None, + None, + None, + "0.0.0-test".into(), + None, + reqwest::Client::new(), + ); + // Explicit call must no-op too (init already invoked it once). + sync_profile(); + assert!( + is_session_metrics_enabled(), + "client must be live for session metrics" + ); + assert!(!is_enabled(), "product analytics must stay off"); + } + /// Names without a known emitter prefix pass through unchanged (preserves /// the old `unwrap_or(event_name)` fallback). #[test] diff --git a/crates/codegen/xai-grok-telemetry/src/events.rs b/crates/codegen/xai-grok-telemetry/src/events.rs index 68b80f5..c0e4a02 100644 --- a/crates/codegen/xai-grok-telemetry/src/events.rs +++ b/crates/codegen/xai-grok-telemetry/src/events.rs @@ -64,6 +64,8 @@ pub enum ContextualTipKind { SmallScreen, /// Double-click fold/nav path → tip to enable Word select in settings. WordSelect, + /// SSH session without `grok wrap` → tip to wrap the ssh command locally. + SshWrap, } #[derive(Serialize, Clone, Copy)] @@ -1303,10 +1305,15 @@ pub struct ClipboardCopy { pub data_control: bool, pub tmux_ok: bool, pub osc52_ok: bool, - /// `native_ok || tmux_ok || osc52_ok` where tmux/osc52 are real leg outcomes. + /// Evidence classification: `confirmed` | `unverified` | `failed`. + pub delivery: &'static str, + /// An explicit `grok wrap` OSC 52 sink was active. + pub osc52_sink: bool, + /// The process was inside a container without a display server. + pub container_no_display: bool, + /// Historical boolean projection: true unless `delivery == failed`. pub reported_success: bool, - /// UX toast branch (route-shaped, not leg-shaped): `copied` | `copied_tmux` | - /// `copied_osc_remote` | `copied_osc_container` | `failed`. + /// Exact UX toast branch selected by the environment policy. pub toast_kind: &'static str, pub duration_ms: u64, } @@ -1814,6 +1821,64 @@ telemetry_event!( mod tests { use super::*; + fn terminal_telemetry_fixture() -> TerminalTelemetry { + TerminalTelemetry { + brand: "Unknown".into(), + multiplexer: "none".into(), + is_ssh: true, + is_byobu: false, + term_var: "xterm-256color".into(), + tmux_version: "".into(), + xtversion: "".into(), + host_os: "linux".into(), + display_server: "unknown".into(), + modifier_cmd_fate: "unknown".into(), + modifier_opt_fate: "unknown".into(), + enter_modifier_fate: "unknown".into(), + hyperlink_osc8: "unknown".into(), + hyperlink_skip_reason: "none".into(), + clipboard_route: "native+osc52".into(), + clipboard_native_tool: "arboard".into(), + clipboard_data_control: "n/a".into(), + } + } + + #[test] + fn clipboard_copy_serialization_preserves_boolean_and_adds_delivery_evidence() { + for delivery in ["confirmed", "unverified", "failed"] { + let value = serde_json::to_value(ClipboardCopy { + terminal: terminal_telemetry_fixture(), + source: "copy_text", + text_len: 12, + route_native: true, + route_tmux: false, + route_osc52: true, + route_label: "native+osc52".into(), + cli_tools_tried: String::new(), + cli_ok_tools: String::new(), + cli_ok: false, + arboard_ok: false, + data_control: false, + tmux_ok: false, + osc52_ok: true, + delivery, + osc52_sink: false, + container_no_display: false, + reported_success: delivery != "failed", + toast_kind: "unverified_osc_remote", + duration_ms: 1, + }) + .unwrap(); + assert_eq!(value["delivery"], serde_json::json!(delivery)); + assert_eq!( + value["reported_success"], + serde_json::Value::Bool(delivery != "failed") + ); + assert_eq!(value["osc52_sink"], serde_json::json!(false)); + assert_eq!(value["container_no_display"], serde_json::json!(false)); + } + } + #[test] fn manual_auth_name_and_shape() { assert_eq!(ManualAuth::NAME, "manual_auth"); diff --git a/crates/codegen/xai-grok-telemetry/src/external/schema.rs b/crates/codegen/xai-grok-telemetry/src/external/schema.rs index 7931d32..517ba63 100644 --- a/crates/codegen/xai-grok-telemetry/src/external/schema.rs +++ b/crates/codegen/xai-grok-telemetry/src/external/schema.rs @@ -702,6 +702,7 @@ fn contextual_tip_kind_label(t: events::ContextualTipKind) -> &'static str { events::ContextualTipKind::SendNow => "send_now", events::ContextualTipKind::SmallScreen => "small_screen", events::ContextualTipKind::WordSelect => "word_select", + events::ContextualTipKind::SshWrap => "ssh_wrap", } } diff --git a/crates/codegen/xai-grok-telemetry/src/external/tests.rs b/crates/codegen/xai-grok-telemetry/src/external/tests.rs index ccf9ebb..582ae8e 100644 --- a/crates/codegen/xai-grok-telemetry/src/external/tests.rs +++ b/crates/codegen/xai-grok-telemetry/src/external/tests.rs @@ -718,6 +718,8 @@ fn contextual_tip_maps_every_tip_and_action() { (K::SmallScreen, A::Accepted, "small_screen", "accepted"), (K::WordSelect, A::Shown, "word_select", "shown"), (K::WordSelect, A::Accepted, "word_select", "accepted"), + (K::SshWrap, A::Shown, "ssh_wrap", "shown"), + (K::SshWrap, A::Accepted, "ssh_wrap", "accepted"), ]; for (tip, action, tip_label, action_label) in cases { let stream = build(gates_off()); diff --git a/crates/codegen/xai-grok-telemetry/src/id.rs b/crates/codegen/xai-grok-telemetry/src/id.rs index 58d422e..2619058 100644 --- a/crates/codegen/xai-grok-telemetry/src/id.rs +++ b/crates/codegen/xai-grok-telemetry/src/id.rs @@ -38,6 +38,7 @@ fn load_or_compute_agent_id() -> String { if let Ok(cached) = std::fs::read_to_string(&cache_path) { let cached = cached.trim(); if !cached.is_empty() { + tighten_agent_id_cache_perms(&cache_path); return cached.to_string(); } } @@ -60,12 +61,81 @@ fn load_or_compute_agent_id() -> String { }; let id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, machine_hash.as_bytes()).to_string(); - // Save to cache file (best effort, ignore errors) - let _ = std::fs::write(&cache_path, &id); + // Save to cache file with owner-only perms (best effort). + let _ = write_agent_id_cache(&cache_path, &id); id } +/// Write `$GROK_HOME/agent_id` as owner-read/write only (Unix 0o600) — it is a +/// stable device identifier and must not be world-readable. Atomic temp+rename, +/// so overwriting a loose-perms cache from an older build never leaves the id +/// in a world-readable file. +fn write_agent_id_cache(path: &std::path::Path, id: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + xai_grok_config::fs_atomic::write_atomically(path, id, Some(0o600)) +} + +/// Best-effort 0o600 on an existing cache: tightens caches written world-readable +/// by older builds. No-op off Unix or on error (the id itself still loads). +fn tighten_agent_id_cache_perms(path: &std::path::Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + #[cfg(not(unix))] + let _ = path; +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn mode(path: &std::path::Path) -> u32 { + std::fs::metadata(path).expect("meta").permissions().mode() & 0o777 + } + + #[test] + fn agent_id_cache_written_owner_only() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("agent_id"); + write_agent_id_cache(&path, "test-agent-id-value").expect("write"); + assert_eq!(mode(&path), 0o600, "agent_id cache must be 0o600"); + assert_eq!( + std::fs::read_to_string(&path).expect("read").trim(), + "test-agent-id-value" + ); + } + + /// Overwriting an existing loose-perms cache (e.g. an old build's empty or + /// torn write) must still land 0600 — mode-at-create alone would keep 0644. + #[test] + fn rewrite_over_loose_perms_cache_lands_owner_only() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("agent_id"); + std::fs::write(&path, "").expect("write"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod"); + write_agent_id_cache(&path, "fresh-id").expect("rewrite"); + assert_eq!(mode(&path), 0o600, "rewrite must not inherit loose perms"); + assert_eq!(std::fs::read_to_string(&path).expect("read"), "fresh-id"); + } + + #[test] + fn older_world_readable_cache_is_tightened() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("agent_id"); + std::fs::write(&path, "legacy-id").expect("write"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod"); + tighten_agent_id_cache_perms(&path); + assert_eq!(mode(&path), 0o600, "legacy cache must be tightened on read"); + assert_eq!(std::fs::read_to_string(&path).expect("read"), "legacy-id"); + } +} + /// Returns true when workspace marker env vars (`XAI_ROOT` and `XAI_USER`) are set. /// /// Used as a coarse local gate for features that require a full workspace diff --git a/crates/codegen/xai-grok-telemetry/src/otel_layer/redact.rs b/crates/codegen/xai-grok-telemetry/src/otel_layer/redact.rs index 5dfc066..40668ff 100644 --- a/crates/codegen/xai-grok-telemetry/src/otel_layer/redact.rs +++ b/crates/codegen/xai-grok-telemetry/src/otel_layer/redact.rs @@ -99,10 +99,6 @@ pub(super) static ALLOWED_STRING_KEYS: &[&str] = &[ "status", "action", "auth_method", - // auth 401 attribution: fixed enum-ish consumer labels only - // (e.g. "OaiCompatClient.chat_completions_stream"); never user content. - // Key suffix fields stay denied — they are token fingerprints. - "consumer", "to_mode", "trigger", "survey_type", @@ -458,7 +454,6 @@ mod tests { "status", "action", "auth_method", - "consumer", "to_mode", "trigger", "survey_type", @@ -502,7 +497,7 @@ mod tests { assert_eq!( ALLOWED_STRING_KEYS, expected, "ALLOWED_STRING_KEYS changed: adding a key exports a new field — confirm it carries no \ - user content, then update this pin." + user content and get telemetry-owner review, then update this pin." ); } @@ -590,4 +585,52 @@ mod tests { "secret in allowlisted value not scrubbed: {blob}" ); } + + #[test] + fn allowlisted_path_values_are_still_home_scrubbed() { + // Path keys are allowlisted so the field exports, but home/username + // segments must still collapse — allowlist is not a scrub bypass. + let home = dirs::home_dir().expect("home dir for path-scrub test"); + let home_str = home.to_string_lossy(); + // Skip if the home path is too short/generic for the scrubber to match. + if home_str.len() < 4 { + return; + } + let full = format!("{home_str}/secret-project/src/main.rs"); + let mut attrs = vec![ + KeyValue::new("path", full.clone()), + KeyValue::new("file_path", full.clone()), + KeyValue::new("cwd", full.clone()), + ]; + scrub_attributes(&mut attrs); + let blob = format!("{attrs:?}"); + assert!( + !blob.contains(home_str.as_ref()), + "home path survived allowlisted scrub: {blob}" + ); + assert!( + blob.contains("main.rs") || blob.contains("[HOME]") || blob.contains("~"), + "expected redacted path to retain a filename or home marker: {blob}" + ); + } + + #[test] + fn error_key_value_is_secret_and_path_scrubbed() { + // Free-form `error` strings are allowlisted for classification labels; + // any secret/path content that sneaks in must still be scrubbed. + let home = dirs::home_dir().expect("home dir"); + let home_str = home.to_string_lossy(); + let msg = + format!("failed reading {home_str}/.config/creds with sk-CANARYabcdefghij1234567890"); + let mut attrs = vec![KeyValue::new("error", msg)]; + scrub_attributes(&mut attrs); + let blob = format!("{attrs:?}"); + assert!(!blob.contains("CANARY"), "secret survived in error: {blob}"); + if home_str.len() >= 4 { + assert!( + !blob.contains(home_str.as_ref()), + "home path survived in error: {blob}" + ); + } + } } diff --git a/crates/codegen/xai-grok-test-support/README.md b/crates/codegen/xai-grok-test-support/README.md index 1f2ac59..6bd1ef5 100644 --- a/crates/codegen/xai-grok-test-support/README.md +++ b/crates/codegen/xai-grok-test-support/README.md @@ -17,8 +17,9 @@ test-support surface. | Module | What it provides | |--------|------------------| -| `mock_server` | `MockInferenceServer` — `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/models`, `/v1/settings`, `/v1/user` on `127.0.0.1:0`. `/v1/models` entries are `MockModelEntry` (re-exported as `MockModel` for PTY tests): `new(id)` / `with_agent_type(id, ty)` plus chainable `with_api_backend`, `with_supports_backend_search(bool)` → `supportsBackendSearch`, `with_supports_reasoning_effort(bool)` → `supportsReasoningEffort`, `with_reasoning_effort(&str)` → `reasoningEffort`, `with_reasoning_efforts(Vec)` → `reasoningEfforts` (raw option tables/bare strings), all emitted top-level as `parse_remote_model_value` reads them. Three response modes for the inference endpoints, precedence **scripted > required-auth > mode**: (1) **echo** (default) streams `Echo: `, whitespace-collapsing; (2) **fixed** via `set_response(text)`, byte-exact delta reconstruction (newlines preserved — fenced code blocks survive); (3) **scripted** via `enqueue_response`. Constructors (`start`, `start_with_models`, `start_with_required_auth`) return `anyhow::Result`. Settings are 404-until-set (`set_settings(impl Serialize)`, `preset_allow_access()` for the `{"allow_access": true}` gate); scripted `/v1/settings` one-shots (`enqueue_response`) take precedence over the steady-state value (stale-snapshot tests). `/v1/user` serves a minimal `UserInfo` whose `subscriptionTier` is controlled by `set_user_subscription_tier(Option<&str>)` (`None` = free); its log entries keep the query string (e.g. `/v1/user?include=subscription`) so subscription-check cadence is countable. Request log: `requests()` (`LogEntry` with body, `authorization`, full POST headers + `header(name)` accessor), `request_bodies()`, `request_count()`, `has_chat_completion_request()` / `has_responses_request()` (exact, per endpoint), `messages_request_count()`, `last_system_prompt()`, `request_log_summary()`. **Storage:** `POST /v1/storage` with flippable 401 (`set_storage_unauthorized`); accepted uploads via `storage_uploads()` → `StorageUpload { path, size, body, authorization }` (`body` retained up to 256 KiB, empty above; `authorization` is the raw header). Runtime knobs: `set_models`, `set_messages_stop_reason`. Shuts down on drop. | -| `scripted` | Data-only scripting (no axum types in the surface): `SseEvent { event, data }` (`::data`, `::with_event`), `ScriptedBody::{Json, Sse, Raw}` (`Raw` = byte-controllable malformed SSE), `ScriptedResponse { status, headers, body }` (`::sse`, `::json`, `::text`). `enqueue_response(path, response)` queues FIFO **per path**; consumed by the three inference endpoints, falling back to the active mode when empty. Scripted SSE bodies honor the server's `set_chunk_delay` pacing, same as the echo/fixed modes. Validation is eager — bad status/header panics at the enqueue call site. Seed of the phase-2 scriptable-mock format. | +| `inference_override` | Typed request matching and response precedence shared by all inference routes: endpoint + foreground/auxiliary classification, named expectation state, overlapping-duplicate fingerprint replay, per-expectation barriers, compatibility FIFO dispatch, auth rejection, and compatibility completion-gate policy. The module is crate-private; only `InferenceEndpoint`, `InferenceRequestMatcher`, and `InferenceExpectation` are re-exported. | +| `mock_server` | `MockInferenceServer` — `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/models`, `/v1/settings`, `/v1/user` on `127.0.0.1:0`. `/v1/models` entries are `MockModelEntry` (re-exported as `MockModel` for PTY tests): `new(id)` / `with_agent_type(id, ty)` plus chainable `with_api_backend`, `with_supports_backend_search(bool)` → `supportsBackendSearch`, `with_supports_reasoning_effort(bool)` → `supportsReasoningEffort`, `with_reasoning_effort(&str)` → `reasoningEffort`, `with_reasoning_efforts(Vec)` → `reasoningEfforts` (raw option tables/bare strings), all emitted top-level as `parse_remote_model_value` reads them. Inference precedence is **matched expectation > compatibility FIFO > required-auth > echo/fixed mode**. Register a uniquely named response with `expect_response(name, InferenceRequestMatcher::{foreground,auxiliary}(InferenceEndpoint::{ChatCompletions,Responses,Messages}), ScriptedResponse)` or `expect_response_blocked`; duplicate names fail at registration and requests atomically claim one matching expectation. Overlapping duplicate requests replay by a deterministic fingerprint of endpoint, request kind, non-empty `x-grok-req-id`, and serialized request body; tool-result follow-ups reuse the turn id but change the body, so they claim the next expectation. Production exposes no explicit HTTP attempt/model-call identity, so completed sequential retries are intentionally not inferred from timing: after the active shared call settles, an identical request claims the next expectation. A foreground request normally carries a non-empty `x-grok-turn-idx`; a non-turn non-empty `x-grok-req-id` is auxiliary even if it uses tools, and empty headers fall through to the 2+-tool compatibility heuristic. The returned `InferenceExpectation` has watch-backed `wait_received`, `wait_blocked`, `release`, `wait_satisfied`, `is_satisfied`, and `assert_satisfied` lifecycle operations. `release` only opens the barrier; response-body/stream-owned RAII publishes `Satisfied` only when the primary crosses terminal and every active overlapping copy settles. Primary cancellation cleans up without satisfaction or replay retention, and dropping a handle safely releases blocked work. Echo (default) streams `Echo: ` and fixed mode via `set_response(text)` reconstructs bytes exactly. Constructors (`start`, `start_with_models`, `start_with_required_auth`) return `anyhow::Result`. Settings are 404-until-set (`set_settings(impl Serialize)`, `preset_allow_access()` for the `{"allow_access": true}` gate); scripted `/v1/settings` one-shots (`enqueue_response`) take precedence over the steady-state value (stale-snapshot tests). `/v1/user` serves a minimal `UserInfo` whose `subscriptionTier` is controlled by `set_user_subscription_tier(Option<&str>)` (`None` = free); its log entries keep the query string (e.g. `/v1/user?include=subscription`) so subscription-check cadence is countable. Request log: `requests()` (`LogEntry` with body, `authorization`, full POST headers + `header(name)` accessor), `request_bodies()`, `request_count()`, `has_chat_completion_request()` / `has_responses_request()` (exact, per endpoint), `messages_request_count()`, `last_system_prompt()`, `request_log_summary()`. **Storage:** `POST /v1/storage` with flippable 401 (`set_storage_unauthorized`); accepted uploads via `storage_uploads()` → `StorageUpload { path, size, body, authorization }` (`body` retained up to 256 KiB, empty above; `authorization` is the raw header). Runtime knobs: `set_models`, `set_messages_stop_reason`. Shuts down on drop. | +| `scripted` | Data-only response bodies (no axum types in the public surface): `SseEvent { event, data }` (`::data`, `::with_event`), `ScriptedBody::{Json, Sse, Raw}` (`Raw` = byte-controllable malformed SSE), `ScriptedResponse { status, headers, body }` (`::sse`, `::json`, `::text`). Prefer request-matched expectations for inference calls; `enqueue_response(path, response)` remains a compatibility FIFO per path and is still used for non-inference one-shots such as `/v1/settings`. Scripted SSE honors `set_chunk_delay`; matched JSON, raw, SSE, and even empty SSE bodies all honor per-expectation completion barriers. The compatibility `hold_agent_completions` gate also covers foreground scripted SSE on all three inference endpoints. Validation is eager — bad status/header panics at registration. | | `sse` | The three wire formats as event-list builders: `chat_completion_events` / `responses_api_events` / `messages_api_events(text, model, stop_reason)` (echo-style, whitespace-collapsing) plus byte-exact variants `chat_completion_events_exact` / `responses_api_events_exact` (messages is single-delta, byte-exact by construction). The exact/echo split is load-bearing — see the in-module byte-exactness tests. Also the scripted-scenario builders returning `SseEvent`s (for `ScriptedResponse::sse`): `responses_api_reasoning_only_events(reasoning, model)` — reasoning summary deltas completing with a `reasoning` item but no message/output-text, so the shell collector classifies the turn `EmptyReason::ReasoningOnly` (the model-doomloop trigger); `responses_api_reasoning_and_text_events(reasoning, text, model)` — reasoning deltas then a normal text answer (the ordinary reasoning-model turn); `responses_api_reasoning_then_tool_call_events(reasoning, call_id, name, arguments, model)` + its Chat Completions twin `chat_completions_reasoning_then_tool_call_events(...)` — reasoning deltas then one tool call (the think-then-call turn whose tool call finishes the thought and keeps the turn non-empty); the doom-loop check trio: `responses_api_doom_loop_check_events(triggers, reasoning, model)` — a doomed reasoning-only turn with NAMED `response.doom_loop_check` frames re-sent per cumulative prefix of `triggers` plus the terminal `doom_loop_check.triggers` copy on `response.completed`, `responses_api_doom_loop_terminal_only_events(triggers, reasoning, text, model)` — a normal answer whose terminal response alone carries the field, and `responses_api_with_doom_loop_frame(check_frame_data, reasoning, text, model)` — splices one named check frame with a caller-supplied payload (byte-exact `xai_grok_sampling_types::doom_loop::SAMPLE_CHECK_EVENT_DATA{,_CUMULATIVE}` fixtures or malformed variants) into an ordinary turn. | | `acp_client` | `GrokStdioClient` — drives `grok agent stdio` over real pipes through `agent-client-protocol`: spawn variants (`spawn`, `spawn_with_home`, `spawn_with_home_and_env`, `spawn_with_home_env_and_args`), initialize/authenticate, session create/load, prompt, `*_with_timeout` wrappers, captured text + stderr. `RawStdioClient` — raw-wire sibling for bytes the typed `ClientSideConnection` can never produce (escaped-slash methods `"session\/prompt"`, string UUID ids — the Xcode/Foundation shape): `send_line` writes a line verbatim; `response_for_id` matches the response by exact string id (the match IS the id-echo assertion), skips notifications, auto-refuses agent→client requests with `-32601`, and panics on timeout with skipped-traffic diagnostics (count + last lines; `0 other messages` = true silence). Both spawn through one hermetic `spawn_agent_process` (sandbox env + debug-log kill-list exists once) atop `process::spawn_piped_with_stderr_capture` (crate-internal `process` module: pipes, `kill_on_drop`, stderr drain — also used by `leader::LeaderStdioClient`). | | `headless` | `run_headless(server, args, cwd)` / `run_headless_with_cmd(cmd)` → `HeadlessResult { status, stdout, stderr, timed_out }` (60s cap), `assert_headless_success`, `assert_no_crashes` (panic/SIGSEGV/linker patterns), `stderr_tail`. | @@ -56,9 +57,16 @@ that models a specific completion the echo/fixed modes can't express, e.g. `ScriptedResponse::sse`, needs no `mock_server` mode wiring, and ships with an in-module shape test asserting its event shape. +**An expectation matcher** (`inference_override.rs`): keep the public matcher +typed and narrow. Claim under the single expectation-state mutex before +serving, replay only overlapping active duplicates by model-call fingerprint, +and add focused tests for auxiliary non-consumption, concurrent one-claim +behavior, lifecycle barriers, and useful unsatisfied diagnostics. Expectations +and compatibility scripts must remain ahead of required auth and fallback modes. + **A scripted-body kind** (`scripted.rs`): new `ScriptedBody` variant + render arm in `into_response_paced` + eager checks in `validate` if the data can be invalid. Add an in-crate test asserting client-visible bytes (the `Raw` -byte-exactness test is the template) and keep -`scripted_response_takes_precedence_over_required_auth` green — precedence is -part of the contract. +byte-exactness test is the template), exercise terminal gating for the new +body, and keep `scripted_response_takes_precedence_over_required_auth` green — +precedence is part of the contract. diff --git a/crates/codegen/xai-grok-test-support/src/inference_override.rs b/crates/codegen/xai-grok-test-support/src/inference_override.rs new file mode 100644 index 0000000..17d4228 --- /dev/null +++ b/crates/codegen/xai-grok-test-support/src/inference_override.rs @@ -0,0 +1,638 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use axum::Json; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use serde_json::{Value, json}; + +use crate::scripted::{BoxWait, ScriptedResponse, TerminalWait}; + +/// Inference endpoint matched by a scripted expectation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum InferenceEndpoint { + ChatCompletions, + Responses, + Messages, +} + +impl InferenceEndpoint { + pub(crate) fn path(self) -> &'static str { + match self { + Self::ChatCompletions => "/v1/chat/completions", + Self::Responses => "/v1/responses", + Self::Messages => "/v1/messages", + } + } +} + +/// Coarse request kind used to keep auxiliary calls from stealing turn scripts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum InferenceRequestKind { + Foreground, + Auxiliary, +} + +impl InferenceRequestKind { + fn classify(headers: &HeaderMap, body: &Value) -> Self { + if nonempty_header(headers, "x-grok-turn-idx").is_some() { + return Self::Foreground; + } + if nonempty_header(headers, "x-grok-req-id").is_some() { + return Self::Auxiliary; + } + if body + .get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| tools.len() >= 2) + { + Self::Foreground + } else { + Self::Auxiliary + } + } +} + +/// Typed match criteria for one named inference response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InferenceRequestMatcher { + endpoint: InferenceEndpoint, + kind: InferenceRequestKind, +} + +impl InferenceRequestMatcher { + /// Match a user-facing agent turn on the selected endpoint. + pub fn foreground(endpoint: InferenceEndpoint) -> Self { + Self { + endpoint, + kind: InferenceRequestKind::Foreground, + } + } + + /// Match title, classifier, prompt-suggestion, or other side-channel work. + pub fn auxiliary(endpoint: InferenceEndpoint) -> Self { + Self { + endpoint, + kind: InferenceRequestKind::Auxiliary, + } + } + + fn matches(self, endpoint: InferenceEndpoint, kind: InferenceRequestKind) -> bool { + self.endpoint == endpoint && self.kind == kind + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExpectationPhase { + Pending, + Received, + Blocked, + Satisfied, +} + +struct ExpectationControl { + name: String, + phase_tx: tokio::sync::watch::Sender, + claims_tx: tokio::sync::watch::Sender, + release_tx: tokio::sync::watch::Sender, +} + +impl ExpectationControl { + fn set_phase(&self, phase: ExpectationPhase) { + self.phase_tx.send_replace(phase); + } + + fn release(&self) { + self.release_tx.send_replace(true); + } + + fn claim(&self) { + self.claims_tx.send_modify(|claims| *claims += 1); + } + + async fn wait_for_release(&self) { + let mut release_rx = self.release_tx.subscribe(); + if *release_rx.borrow_and_update() { + return; + } + release_rx + .wait_for(|released| *released) + .await + .expect("expectation release sender lives with the claimed response"); + } + + #[cfg(test)] + async fn wait_claims(&self, target: usize) { + let mut claims_rx = self.claims_tx.subscribe(); + claims_rx + .wait_for(|claims| *claims >= target) + .await + .expect("expectation claims sender lives with the control"); + } +} + +/// Deterministic lifecycle handle for one registered inference expectation. +#[must_use = "expectation handles provide synchronization and satisfaction checks"] +pub struct InferenceExpectation { + control: Arc, + phase_rx: tokio::sync::watch::Receiver, +} + +impl InferenceExpectation { + pub fn name(&self) -> &str { + &self.control.name + } + + pub fn is_satisfied(&self) -> bool { + *self.phase_rx.borrow() == ExpectationPhase::Satisfied + } + + /// Wait until one request atomically claims this expectation. + pub async fn wait_received(&mut self) { + self.wait_for(ExpectationPhase::Received).await; + } + + /// Wait until the response reaches its terminal-event barrier. + pub async fn wait_blocked(&mut self) { + self.wait_for(ExpectationPhase::Blocked).await; + } + + /// Wait until the primary response pipeline crosses its terminal boundary. + pub async fn wait_satisfied(&mut self) { + self.wait_for(ExpectationPhase::Satisfied).await; + } + + #[cfg(test)] + pub(crate) async fn wait_claims(&self, target: usize) { + self.control.wait_claims(target).await; + } + + /// Release this expectation's terminal barrier. + pub fn release(&self) { + self.control.release(); + } + + /// Panic with the expectation name and lifecycle state unless satisfied. + pub fn assert_satisfied(&self) { + assert!( + self.is_satisfied(), + "inference expectation `{}` was not satisfied (state: {:?})", + self.name(), + *self.phase_rx.borrow() + ); + } + + /// Describe the expectation for aggregation in test failure output. + pub fn diagnostic(&self) -> String { + format!( + "inference expectation `{}` (state: {:?})", + self.name(), + *self.phase_rx.borrow() + ) + } + + async fn wait_for(&mut self, target: ExpectationPhase) { + if self + .phase_rx + .wait_for(|phase| Self::phase_reached(*phase, target)) + .await + .is_err() + { + panic!( + "inference expectation `{}` closed before reaching {target:?} (state: {:?})", + self.control.name, + *self.phase_rx.borrow() + ); + } + } + + fn phase_reached(current: ExpectationPhase, target: ExpectationPhase) -> bool { + match target { + ExpectationPhase::Pending => true, + ExpectationPhase::Received => current != ExpectationPhase::Pending, + ExpectationPhase::Blocked => matches!( + current, + ExpectationPhase::Blocked | ExpectationPhase::Satisfied + ), + ExpectationPhase::Satisfied => current == ExpectationPhase::Satisfied, + } + } +} + +impl Drop for InferenceExpectation { + fn drop(&mut self) { + self.control.release(); + } +} + +struct PendingExpectation { + matcher: InferenceRequestMatcher, + response: ScriptedResponse, + block_before_terminal: bool, + control: Arc, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ModelCallFingerprint { + endpoint: InferenceEndpoint, + kind: InferenceRequestKind, + request_id: String, + body: String, +} + +struct CallState { + response: ScriptedResponse, + block_before_terminal: bool, + control: Arc, + active: usize, + primary_crossed_terminal: bool, +} + +#[derive(Default)] +struct ExpectationState { + pending: VecDeque, + in_flight: HashMap, +} + +type Expectations = Arc>; +type ScriptQueues = Arc>>>; + +#[derive(Clone)] +pub(crate) struct InferenceOverrides { + expectations: Expectations, + scripted: ScriptQueues, + completion_gate: Arc, + required_token: Option>, +} + +impl InferenceOverrides { + pub(crate) fn new(required_token: Option) -> Self { + Self { + expectations: Arc::new(std::sync::Mutex::new(ExpectationState::default())), + scripted: Arc::new(std::sync::Mutex::new(HashMap::new())), + completion_gate: Arc::new(CompletionGate::default()), + required_token: required_token.map(Arc::from), + } + } + + pub(crate) fn classify( + &self, + endpoint: InferenceEndpoint, + headers: &HeaderMap, + body: &Value, + ) -> ClassifiedInferenceRequest { + let kind = InferenceRequestKind::classify(headers, body); + let fingerprint = + nonempty_header(headers, "x-grok-req-id").map(|request_id| ModelCallFingerprint { + endpoint, + kind, + request_id: request_id.to_owned(), + body: serde_json::to_string(body).expect("serialize inference request fingerprint"), + }); + ClassifiedInferenceRequest { + endpoint, + kind, + fingerprint, + } + } + + pub(crate) async fn response_override( + &self, + request: &ClassifiedInferenceRequest, + headers: &HeaderMap, + delay: Option, + ) -> Option { + if let Some(claimed) = self.claim_expectation(request) { + let (response, wait) = claimed.into_parts(); + return Some(response.into_response_paced(delay, Some(wait)).await); + } + + if let Some(response) = self.pop_scripted(request.endpoint.path()) { + let wait = + (request.is_foreground() && response.is_sse()).then(|| self.global_terminal_wait()); + return Some(response.into_response_paced(delay, wait).await); + } + + self.auth_rejection(headers) + } + + pub(crate) fn register_expectation( + &self, + name: impl Into, + matcher: InferenceRequestMatcher, + response: ScriptedResponse, + block_before_terminal: bool, + ) -> InferenceExpectation { + response.validate(); + let name = name.into(); + let mut expectations = self.expectations.lock().unwrap(); + assert!( + expectations + .pending + .iter() + .all(|expectation| expectation.control.name != name) + && expectations + .in_flight + .values() + .all(|expectation| expectation.control.name != name), + "duplicate inference expectation name `{name}`" + ); + let (phase_tx, phase_rx) = tokio::sync::watch::channel(ExpectationPhase::Pending); + let (claims_tx, _claims_rx) = tokio::sync::watch::channel(0); + let (release_tx, _release_rx) = tokio::sync::watch::channel(!block_before_terminal); + let control = Arc::new(ExpectationControl { + name, + phase_tx, + claims_tx, + release_tx, + }); + expectations.pending.push_back(PendingExpectation { + matcher, + response, + block_before_terminal, + control: control.clone(), + }); + InferenceExpectation { control, phase_rx } + } + + pub(crate) fn enqueue_response(&self, path: impl Into, response: ScriptedResponse) { + response.validate(); + self.scripted + .lock() + .unwrap() + .entry(path.into()) + .or_default() + .push_back(response); + } + + pub(crate) fn pop_scripted(&self, path: &str) -> Option { + self.scripted + .lock() + .unwrap() + .get_mut(path) + .and_then(VecDeque::pop_front) + } + + pub(crate) fn fallback_terminal_wait( + &self, + request: &ClassifiedInferenceRequest, + ) -> Option { + request.is_foreground().then(|| self.global_terminal_wait()) + } + + pub(crate) fn hold_completions(&self) { + self.completion_gate.hold(); + } + + pub(crate) fn release_completions(&self) { + self.completion_gate.release(); + } + + fn claim_expectation( + &self, + request: &ClassifiedInferenceRequest, + ) -> Option { + let mut expectations = self.expectations.lock().unwrap(); + if let Some(fingerprint) = request.fingerprint.as_ref() + && let Some(call) = expectations.in_flight.get_mut(fingerprint) + && call.active > 0 + { + call.active += 1; + call.control.claim(); + return Some(ClaimedExpectation { + response: call.response.clone(), + lease: ClaimLease::new( + self.expectations.clone(), + Some(fingerprint.clone()), + call.control.clone(), + call.block_before_terminal, + ClaimRole::Replay, + ), + }); + } + + let index = expectations + .pending + .iter() + .position(|expectation| expectation.matcher.matches(request.endpoint, request.kind))?; + let expectation = expectations + .pending + .remove(index) + .expect("matched expectation index must remain valid"); + expectation.control.set_phase(ExpectationPhase::Received); + expectation.control.claim(); + let lease = ClaimLease::new( + self.expectations.clone(), + request.fingerprint.clone(), + expectation.control.clone(), + expectation.block_before_terminal, + ClaimRole::Primary, + ); + if let Some(fingerprint) = request.fingerprint.clone() { + let replaced = expectations.in_flight.insert( + fingerprint, + CallState { + response: expectation.response.clone(), + block_before_terminal: expectation.block_before_terminal, + control: expectation.control, + active: 1, + primary_crossed_terminal: false, + }, + ); + assert!( + replaced.is_none(), + "duplicate in-flight model-call fingerprint" + ); + } + Some(ClaimedExpectation { + response: expectation.response, + lease, + }) + } + + fn auth_rejection(&self, headers: &HeaderMap) -> Option { + let expected = self.required_token.as_deref()?; + let valid = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .strip_prefix("Bearer ") + .or_else(|| value.strip_prefix("bearer ")) + .is_some_and(|token| token == expected) + }); + if valid { + return None; + } + Some( + ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "missing API key; set the x-api-key header or Authorization: Bearer header" + })), + ) + .into_response(), + ) + } + + fn global_terminal_wait(&self) -> TerminalWait { + let completion_gate = self.completion_gate.clone(); + Box::new(move || Box::pin(async move { completion_gate.wait_if_held().await })) + } +} + +pub(crate) struct ClassifiedInferenceRequest { + endpoint: InferenceEndpoint, + kind: InferenceRequestKind, + fingerprint: Option, +} + +impl ClassifiedInferenceRequest { + pub(crate) fn is_foreground(&self) -> bool { + self.kind == InferenceRequestKind::Foreground + } +} + +#[derive(Clone)] +enum ClaimRole { + Primary, + Replay, +} + +struct ClaimedExpectation { + response: ScriptedResponse, + lease: ClaimLease, +} + +impl ClaimedExpectation { + fn into_parts(self) -> (ScriptedResponse, TerminalWait) { + let response = self.response; + let mut lease = self.lease; + let wait = Box::new(move || { + Box::pin(async move { + if lease.block_before_terminal { + lease.mark_blocked(); + lease.control.wait_for_release().await; + } + lease.crossed_terminal = true; + lease.finish(); + }) as BoxWait + }); + (response, wait) + } +} + +struct ClaimLease { + expectations: Expectations, + fingerprint: Option, + control: Arc, + block_before_terminal: bool, + role: ClaimRole, + crossed_terminal: bool, + finished: bool, +} + +impl ClaimLease { + fn new( + expectations: Expectations, + fingerprint: Option, + control: Arc, + block_before_terminal: bool, + role: ClaimRole, + ) -> Self { + Self { + expectations, + fingerprint, + control, + block_before_terminal, + role, + crossed_terminal: false, + finished: false, + } + } + + fn mark_blocked(&self) { + if matches!(&self.role, ClaimRole::Primary) + && *self.control.phase_tx.borrow() != ExpectationPhase::Satisfied + { + self.control.set_phase(ExpectationPhase::Blocked); + } + } + + fn finish(&mut self) { + if self.finished { + return; + } + self.finished = true; + self.update_shared_state(); + } + + fn update_shared_state(&self) { + let Some(fingerprint) = self.fingerprint.as_ref() else { + if matches!(&self.role, ClaimRole::Primary) && self.crossed_terminal { + self.control.set_phase(ExpectationPhase::Satisfied); + } + return; + }; + let mut expectations = self.expectations.lock().unwrap(); + let Some(call) = expectations.in_flight.get_mut(fingerprint) else { + return; + }; + assert!(call.active > 0, "claim active count underflow"); + call.active -= 1; + if matches!(&self.role, ClaimRole::Primary) && self.crossed_terminal { + call.primary_crossed_terminal = true; + } + if call.active == 0 { + let control = call.control.clone(); + let satisfied = call.primary_crossed_terminal; + expectations.in_flight.remove(fingerprint); + if satisfied { + control.set_phase(ExpectationPhase::Satisfied); + } + } + } +} + +impl Drop for ClaimLease { + fn drop(&mut self) { + self.finish(); + } +} + +#[derive(Default)] +struct CompletionGate { + held: AtomicBool, + notify: tokio::sync::Notify, +} + +impl CompletionGate { + fn hold(&self) { + self.held.store(true, Ordering::SeqCst); + } + + fn release(&self) { + self.held.store(false, Ordering::SeqCst); + self.notify.notify_waiters(); + } + + async fn wait_if_held(&self) { + loop { + let notified = self.notify.notified(); + if !self.held.load(Ordering::SeqCst) { + return; + } + notified.await; + } + } +} + +fn nonempty_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) +} diff --git a/crates/codegen/xai-grok-test-support/src/lib.rs b/crates/codegen/xai-grok-test-support/src/lib.rs index 6c88e40..0a1d12d 100644 --- a/crates/codegen/xai-grok-test-support/src/lib.rs +++ b/crates/codegen/xai-grok-test-support/src/lib.rs @@ -23,6 +23,7 @@ pub mod acp_client; pub mod counting_server; pub mod env; pub mod headless; +mod inference_override; #[cfg(unix)] pub mod leader; pub mod mock_server; @@ -38,4 +39,5 @@ pub use headless::{ HeadlessResult, assert_headless_success, assert_no_crashes, run_headless, run_headless_with_cmd, stderr_tail, }; +pub use inference_override::{InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher}; pub use mock_server::{MockInferenceServer, MockModelEntry, ScriptedResponse, SseEvent}; diff --git a/crates/codegen/xai-grok-test-support/src/mock_server.rs b/crates/codegen/xai-grok-test-support/src/mock_server.rs index f24c940..6132139 100644 --- a/crates/codegen/xai-grok-test-support/src/mock_server.rs +++ b/crates/codegen/xai-grok-test-support/src/mock_server.rs @@ -3,13 +3,13 @@ //! Serves `/v1/chat/completions`, `/v1/responses`, and `/v1/messages` in one //! of two response modes: echo (default — streams `Echo: `) //! or a fixed text set via [`MockInferenceServer::set_response`] (streamed -//! with byte-exact reconstruction). A per-path FIFO of [`ScriptedResponse`]s -//! (see [`MockInferenceServer::enqueue_response`]) overrides the mode for -//! exact status/body/SSE control. `/v1/models` and `/v1/settings` return +//! with byte-exact reconstruction). Named request-matched expectations take +//! precedence, followed by compatibility per-path [`ScriptedResponse`] FIFOs. +//! `/v1/models` and `/v1/settings` return //! configurable responses (settings is 404 until set). All requests are //! logged — bodies and headers — for assertion in tests. -use std::collections::{HashMap, VecDeque}; +use std::collections::VecDeque; use std::convert::Infallible; use std::net::SocketAddr; use std::sync::Arc; @@ -27,6 +27,11 @@ use serde_json::{Value, json}; use tokio::net::TcpListener; use tokio::sync::oneshot; +use crate::inference_override::{ClassifiedInferenceRequest, InferenceOverrides}; +pub use crate::inference_override::{ + InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher, +}; +use crate::scripted::TerminalWait; pub use crate::scripted::{ScriptedBody, ScriptedResponse, SseEvent}; use crate::sse; @@ -85,8 +90,6 @@ impl RequestLog { } } -type ScriptQueues = Arc>>>; - /// A model entry for the mock `/v1/models` endpoint. #[derive(Debug, Clone)] pub struct MockModelEntry { @@ -193,73 +196,28 @@ enum ResponseMode { Fixed(String), } -/// Opt-in barrier that holds an **agent turn's terminal SSE event** until the -/// test releases it, so the turn stays deterministically "running" while the -/// test interacts with it (queue edits/removals) — eliminating turn-end races. -/// -/// Inert by default (`held == false`): [`wait_if_held`] returns immediately, so -/// every test that never calls [`MockInferenceServer::hold_agent_completions`] -/// is completely unaffected. -/// -/// [`wait_if_held`]: CompletionGate::wait_if_held -#[derive(Default)] -struct CompletionGate { - held: AtomicBool, - notify: tokio::sync::Notify, -} - -impl CompletionGate { - fn hold(&self) { - self.held.store(true, Ordering::SeqCst); - } - - fn release(&self) { - self.held.store(false, Ordering::SeqCst); - self.notify.notify_waiters(); - } - - /// Block while the gate is held. Registers the wake-up interest *before* - /// re-checking `held` so a concurrent `release` can never be missed. - async fn wait_if_held(&self) { - loop { - let notified = self.notify.notified(); - if !self.held.load(Ordering::SeqCst) { - return; - } - notified.await; - } - } -} - -/// Wrap SSE `events` in a stream that emits each one after `delay`. `None` -/// keeps instant emission (the default fast path); `Some(d)` paces the stream -/// so tests can interact with a turn while it is visibly in flight. -/// -/// When `gate` is `Some`, the stream additionally blocks on the gate right -/// before emitting the **final** event (the SSE terminator), so a held gate -/// keeps the turn streaming-but-not-complete until released. +/// Emit each SSE event after `delay` and optionally wait before the final one. fn paced_events( events: Vec, delay: Option, - gate: Option>, + before_terminal: Option, ) -> impl futures_util::Stream> { - use futures_util::StreamExt as _; - let last_idx = events.len().saturating_sub(1); - stream::iter(events.into_iter().enumerate()).then(move |(idx, event)| { - let gate = gate.clone(); - async move { + let last_idx = events.len().checked_sub(1); + stream::unfold( + (events.into_iter().enumerate(), before_terminal), + move |(mut events, mut before_terminal)| async move { + let (idx, event) = events.next()?; if let Some(d) = delay { tokio::time::sleep(d).await; } - // Hold the terminal event until the gate is released. - if idx == last_idx - && let Some(gate) = gate.as_deref() + if Some(idx) == last_idx + && let Some(wait) = before_terminal.take() { - gate.wait_if_held().await; + wait().await; } - Ok::<_, Infallible>(event) - } - }) + Some((Ok::<_, Infallible>(event), (events, before_terminal))) + }, + ) } /// Max body bytes retained on each accepted [`StorageUpload`] (keeps large @@ -297,22 +255,17 @@ pub struct MockInferenceServer { models: Arc>>, settings: Arc>>, response_mode: Arc>, - scripted: ScriptQueues, + overrides: InferenceOverrides, /// Per-agent-turn assistant texts (see [`set_agent_turns`]). /// /// [`set_agent_turns`]: Self::set_agent_turns agent_turns: Arc>>, /// `stop_reason` emitted by the `/v1/messages` terminal `message_delta`. messages_stop_reason: Arc>, - /// Optional per-SSE-event delay on all inference endpoints. `None` - /// (default) streams instantly; `Some(d)` holds the turn "streaming" long - /// enough for tests to interact with it mid-flight (e.g. Esc-cancel). + /// Optional per-SSE-event delay on all inference endpoints. chunk_delay: Arc>>, /// Mock `/v1/storage` 401 gate + accepted-upload record. storage: Arc, - /// Opt-in barrier holding agent turns' terminal event (see - /// [`Self::hold_agent_completions`]). Inert until a test holds it. - completion_gate: Arc, /// See [`Self::set_user_subscription_tier`]. user_tier: Arc>>, } @@ -347,26 +300,23 @@ impl MockInferenceServer { let shared_models = Arc::new(std::sync::RwLock::new(models_json)); let shared_settings = Arc::new(std::sync::RwLock::new(None::)); let response_mode = Arc::new(std::sync::RwLock::new(ResponseMode::Echo)); - let scripted: ScriptQueues = Arc::new(std::sync::Mutex::new(HashMap::new())); + let overrides = InferenceOverrides::new(required_token); let agent_turns = Arc::new(std::sync::Mutex::new(VecDeque::new())); let messages_stop_reason = Arc::new(std::sync::RwLock::new("end_turn".to_string())); let chunk_delay = Arc::new(std::sync::RwLock::new(None::)); let storage = Arc::new(StorageState::default()); - let completion_gate = Arc::new(CompletionGate::default()); let user_tier = Arc::new(std::sync::RwLock::new(None::)); let app = Self::build_router( log.clone(), shared_models.clone(), shared_settings.clone(), response_mode.clone(), - scripted.clone(), + overrides.clone(), agent_turns.clone(), messages_stop_reason.clone(), chunk_delay.clone(), storage.clone(), - completion_gate.clone(), user_tier.clone(), - required_token, ); let listener = TcpListener::bind("127.0.0.1:0") @@ -400,12 +350,11 @@ impl MockInferenceServer { models: shared_models, settings: shared_settings, response_mode, - scripted, + overrides, agent_turns, messages_stop_reason, chunk_delay, storage, - completion_gate, user_tier, }) } @@ -429,20 +378,34 @@ impl MockInferenceServer { /// three inference endpoints; when a path's queue is empty, requests fall /// back to the active response mode (echo/fixed). pub fn enqueue_response(&self, path: impl Into, response: ScriptedResponse) { - // Fail at the call site, not at serve time. - response.validate(); - self.scripted - .lock() - .unwrap() - .entry(path.into()) - .or_default() - .push_back(response); + self.overrides.enqueue_response(path, response); } - /// Queue one byte-exact response per agent turn, consumed FIFO. Only - /// requests carrying 2+ tools count as agent turns, so aux requests - /// (title/classifier) never steal a turn; an empty queue falls back to - /// the active response mode. + /// Register one named response matched atomically by endpoint and request kind. + #[must_use = "keep the handle to synchronize and assert expectation satisfaction"] + pub fn expect_response( + &self, + name: impl Into, + matcher: InferenceRequestMatcher, + response: ScriptedResponse, + ) -> InferenceExpectation { + self.overrides + .register_expectation(name, matcher, response, false) + } + + /// Register a named response that pauses immediately before completion. + #[must_use = "keep the handle to release and assert expectation satisfaction"] + pub fn expect_response_blocked( + &self, + name: impl Into, + matcher: InferenceRequestMatcher, + response: ScriptedResponse, + ) -> InferenceExpectation { + self.overrides + .register_expectation(name, matcher, response, true) + } + + /// Queue one byte-exact response per foreground turn as compatibility sugar. pub fn set_agent_turns(&self, turns: impl IntoIterator) { *self.agent_turns.lock().unwrap() = turns.into_iter().collect(); } @@ -483,16 +446,12 @@ impl MockInferenceServer { *self.chunk_delay.write().unwrap() = delay; } - /// Hold every agent turn's terminal SSE event until - /// [`release_agent_completions`] is called, keeping the turn - /// deterministically "streaming-but-not-complete". Lets a test interact - /// with a running turn (e.g. queue edits/removals) without racing turn - /// end. Content deltas still stream normally; only completion is gated. - /// Inert for tests that never call this. + /// Hold foreground terminal SSE events until [`release_agent_completions`]. + /// Compatibility API; per-expectation blocking gives tighter ownership. /// /// [`release_agent_completions`]: Self::release_agent_completions pub fn hold_agent_completions(&self) { - self.completion_gate.hold(); + self.overrides.hold_completions(); } /// Release a hold set by [`hold_agent_completions`], letting held (and @@ -500,7 +459,7 @@ impl MockInferenceServer { /// /// [`hold_agent_completions`]: Self::hold_agent_completions pub fn release_agent_completions(&self) { - self.completion_gate.release(); + self.overrides.release_completions(); } /// e.g. `http://127.0.0.1:12345/v1` @@ -683,76 +642,41 @@ impl MockInferenceServer { .collect() } - fn pop_scripted(scripted: &ScriptQueues, path: &str) -> Option { - scripted - .lock() - .unwrap() - .get_mut(path) - .and_then(VecDeque::pop_front) - } - - /// Pop the next scripted turn, gated to agent turns (2+ tools) so aux - /// requests don't consume one. fn pop_agent_turn( agent_turns: &Arc>>, - body: &Value, + request: &ClassifiedInferenceRequest, ) -> Option { - let tool_count = body - .get("tools") - .and_then(Value::as_array) - .map_or(0, Vec::len); - if tool_count < 2 { + if !request.is_foreground() { return None; } agent_turns.lock().unwrap().pop_front() } - /// Returns `Some(401)` if auth is required and the Bearer token doesn't match. - fn check_auth(auth: Option<&str>, required_token: Option<&str>) -> Option { - let expected = required_token?; - let valid = auth.is_some_and(|v| { - v.strip_prefix("Bearer ") - .or_else(|| v.strip_prefix("bearer ")) - .is_some_and(|token| token == expected) - }); - if valid { - return None; - } - Some(( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "missing API key; set the x-api-key header or Authorization: Bearer header" - })), - ).into_response()) - } - fn build_router( log: Arc, models: Arc>>, settings: Arc>>, response_mode: Arc>, - scripted: ScriptQueues, + overrides: InferenceOverrides, agent_turns: Arc>>, messages_stop_reason: Arc>, chunk_delay: Arc>>, storage: Arc, - completion_gate: Arc, user_tier: Arc>>, - required_token: Option, ) -> Router { let log_cc = log.clone(); let log_rs = log.clone(); let log_msg = log.clone(); - let token_cc = required_token.clone(); - let token_msg = required_token.clone(); - let token_rs = required_token; let mode_cc = response_mode.clone(); let mode_rs = response_mode.clone(); let mode_msg = response_mode; - let scripted_cc = scripted.clone(); - let scripted_rs = scripted.clone(); - let scripted_settings = scripted.clone(); - let scripted_msg = scripted; + let overrides_cc = overrides.clone(); + let overrides_rs = overrides.clone(); + let overrides_settings = overrides.clone(); + let overrides_msg = overrides; + let agent_turns_cc = agent_turns.clone(); + let agent_turns_rs = agent_turns.clone(); + let agent_turns_msg = agent_turns; let delay_cc = chunk_delay.clone(); let delay_rs = chunk_delay.clone(); let delay_msg = chunk_delay; @@ -762,12 +686,10 @@ impl MockInferenceServer { "/v1/chat/completions", post(move |headers: HeaderMap, Json(body): Json| { let log = log_cc.clone(); - let required = token_cc.clone(); let mode = mode_cc.clone(); - let scripted = scripted_cc.clone(); - let agent_turns = agent_turns.clone(); + let overrides = overrides_cc.clone(); + let agent_turns = agent_turns_cc.clone(); let delay = delay_cc.clone(); - let completion_gate = completion_gate.clone(); async move { let auth = Self::extract_auth(&headers); log.record( @@ -778,14 +700,14 @@ impl MockInferenceServer { Self::headers_vec(&headers), ); - if let Some(s) = Self::pop_scripted(&scripted, "/v1/chat/completions") { - return s.into_response_paced(*delay.read().unwrap()); - } - - if let Some(rejection) = - Self::check_auth(auth.as_deref(), required.as_deref()) + let request = + overrides.classify(InferenceEndpoint::ChatCompletions, &headers, &body); + let chunk_delay = *delay.read().unwrap(); + if let Some(response) = overrides + .response_override(&request, &headers, chunk_delay) + .await { - return rejection; + return response; } let user_msg = body @@ -805,26 +727,18 @@ impl MockInferenceServer { .and_then(Value::as_str) .unwrap_or("test-model"); - // Only agent turns are gate-eligible: aux requests - // (title/classifier) must never block session startup. - let (events, gate) = match Self::pop_agent_turn(&agent_turns, &body) { - Some(text) => ( - sse::chat_completion_events_exact(&text, model), - Some(completion_gate.clone()), - ), - None => { - let events = match &*mode.read().unwrap() { - ResponseMode::Echo => sse::chat_completion_events( - &format!("Echo: {user_msg}"), - model, - ), - ResponseMode::Fixed(text) => { - sse::chat_completion_events_exact(text, model) - } - }; - (events, None) - } + let events = match Self::pop_agent_turn(&agent_turns, &request) { + Some(text) => sse::chat_completion_events_exact(&text, model), + None => match &*mode.read().unwrap() { + ResponseMode::Echo => { + sse::chat_completion_events(&format!("Echo: {user_msg}"), model) + } + ResponseMode::Fixed(text) => { + sse::chat_completion_events_exact(text, model) + } + }, }; + let gate = overrides.fallback_terminal_wait(&request); let stream = paced_events(events, *delay.read().unwrap(), gate); Sse::new(stream) .keep_alive(KeepAlive::default()) @@ -836,9 +750,9 @@ impl MockInferenceServer { "/v1/responses", post(move |headers: HeaderMap, Json(body): Json| { let log = log_rs.clone(); - let required = token_rs.clone(); let mode = mode_rs.clone(); - let scripted = scripted_rs.clone(); + let overrides = overrides_rs.clone(); + let agent_turns = agent_turns_rs.clone(); let delay = delay_rs.clone(); async move { let auth = Self::extract_auth(&headers); @@ -850,14 +764,14 @@ impl MockInferenceServer { Self::headers_vec(&headers), ); - if let Some(s) = Self::pop_scripted(&scripted, "/v1/responses") { - return s.into_response_paced(*delay.read().unwrap()); - } - - if let Some(rejection) = - Self::check_auth(auth.as_deref(), required.as_deref()) + let request = + overrides.classify(InferenceEndpoint::Responses, &headers, &body); + let chunk_delay = *delay.read().unwrap(); + if let Some(response) = overrides + .response_override(&request, &headers, chunk_delay) + .await { - return rejection; + return response; } let user_msg = body @@ -894,15 +808,19 @@ impl MockInferenceServer { .and_then(Value::as_str) .unwrap_or("test-model"); - let events = match &*mode.read().unwrap() { - ResponseMode::Echo => { - sse::responses_api_events(&format!("Echo: {user_msg}"), model) - } - ResponseMode::Fixed(text) => { - sse::responses_api_events_exact(text, model) - } + let events = match Self::pop_agent_turn(&agent_turns, &request) { + Some(text) => sse::responses_api_events_exact(&text, model), + None => match &*mode.read().unwrap() { + ResponseMode::Echo => { + sse::responses_api_events(&format!("Echo: {user_msg}"), model) + } + ResponseMode::Fixed(text) => { + sse::responses_api_events_exact(text, model) + } + }, }; - let stream = paced_events(events, *delay.read().unwrap(), None); + let gate = overrides.fallback_terminal_wait(&request); + let stream = paced_events(events, *delay.read().unwrap(), gate); Sse::new(stream) .keep_alive(KeepAlive::default()) .into_response() @@ -913,9 +831,9 @@ impl MockInferenceServer { "/v1/messages", post(move |headers: HeaderMap, Json(body): Json| { let log = log_msg.clone(); - let required = token_msg.clone(); let mode = mode_msg.clone(); - let scripted = scripted_msg.clone(); + let overrides = overrides_msg.clone(); + let agent_turns = agent_turns_msg.clone(); let stop_reason = messages_stop_reason.clone(); let delay = delay_msg.clone(); async move { @@ -928,14 +846,14 @@ impl MockInferenceServer { Self::headers_vec(&headers), ); - if let Some(s) = Self::pop_scripted(&scripted, "/v1/messages") { - return s.into_response_paced(*delay.read().unwrap()); - } - - if let Some(rejection) = - Self::check_auth(auth.as_deref(), required.as_deref()) + let request = + overrides.classify(InferenceEndpoint::Messages, &headers, &body); + let chunk_delay = *delay.read().unwrap(); + if let Some(response) = overrides + .response_override(&request, &headers, chunk_delay) + .await { - return rejection; + return response; } // Anthropic content is either a plain string or an @@ -973,17 +891,21 @@ impl MockInferenceServer { .unwrap_or("test-model"); let stop = stop_reason.read().unwrap().clone(); - // Messages streams its text as a single delta, so the - // fixed text is byte-exact by construction. - let events = match &*mode.read().unwrap() { - ResponseMode::Echo => { - sse::messages_api_events(&format!("Echo: {user_msg}"), model, &stop) - } - ResponseMode::Fixed(text) => { - sse::messages_api_events(text, model, &stop) - } + let events = match Self::pop_agent_turn(&agent_turns, &request) { + Some(text) => sse::messages_api_events(&text, model, &stop), + None => match &*mode.read().unwrap() { + ResponseMode::Echo => sse::messages_api_events( + &format!("Echo: {user_msg}"), + model, + &stop, + ), + ResponseMode::Fixed(text) => { + sse::messages_api_events(text, model, &stop) + } + }, }; - let stream = paced_events(events, *delay.read().unwrap(), None); + let gate = overrides.fallback_terminal_wait(&request); + let stream = paced_events(events, *delay.read().unwrap(), gate); Sse::new(stream) .keep_alive(KeepAlive::default()) .into_response() @@ -1015,15 +937,15 @@ impl MockInferenceServer { move || { let log = log.clone(); let settings = settings.clone(); - let scripted = scripted_settings.clone(); + let overrides = overrides_settings.clone(); async move { log.record("GET", "/v1/settings", None, None, Vec::new()); // Scripted one-shots take precedence (FIFO), so a // test can serve a transient payload (e.g. one // stale gated snapshot) and fall back to the // steady-state `set_settings` value afterwards. - if let Some(s) = Self::pop_scripted(&scripted, "/v1/settings") { - return s.into_response_paced(None); + if let Some(s) = overrides.pop_scripted("/v1/settings") { + return s.into_response_paced(None, None).await; } let maybe = settings.read().unwrap().clone(); match maybe { @@ -1165,6 +1087,34 @@ mod tests { .collect() } + fn foreground_body(endpoint: InferenceEndpoint, content: &str) -> Value { + let tools = json!([ + { "type": "function", "function": { "name": "read_file" } }, + { "type": "function", "function": { "name": "write" } } + ]); + match endpoint { + InferenceEndpoint::ChatCompletions | InferenceEndpoint::Messages => json!({ + "model": "test-model", + "messages": [{ "role": "user", "content": content }], + "tools": tools, + }), + InferenceEndpoint::Responses => json!({ + "model": "test-model", + "input": [{ "role": "user", "content": content }], + "tools": tools, + }), + } + } + + fn endpoint_url(server: &MockInferenceServer, endpoint: InferenceEndpoint) -> String { + let suffix = match endpoint { + InferenceEndpoint::ChatCompletions => "chat/completions", + InferenceEndpoint::Responses => "responses", + InferenceEndpoint::Messages => "messages", + }; + format!("{}/{suffix}", server.url()) + } + async fn post_chat(server: &MockInferenceServer, content: &str) -> reqwest::Response { reqwest::Client::new() .post(format!("{}/chat/completions", server.url())) @@ -1177,6 +1127,483 @@ mod tests { .expect("POST /v1/chat/completions") } + async fn post_foreground( + server: &MockInferenceServer, + endpoint: InferenceEndpoint, + request_id: &str, + content: &str, + ) -> reqwest::Response { + reqwest::Client::new() + .post(endpoint_url(server, endpoint)) + .header("x-grok-req-id", request_id) + .header("x-grok-turn-idx", "1") + .json(&foreground_body(endpoint, content)) + .send() + .await + .expect("POST foreground inference request") + } + + async fn read_foreground( + server: &MockInferenceServer, + endpoint: InferenceEndpoint, + request_id: &str, + content: &str, + ) -> (reqwest::StatusCode, String) { + let response = post_foreground(server, endpoint, request_id, content).await; + let status = response.status(); + let body = response + .text() + .await + .expect("read foreground response body"); + (status, body) + } + + async fn read_foreground_body( + server: &MockInferenceServer, + endpoint: InferenceEndpoint, + request_id: &str, + body: Value, + ) -> (reqwest::StatusCode, String) { + let response = reqwest::Client::new() + .post(endpoint_url(server, endpoint)) + .header("x-grok-req-id", request_id) + .header("x-grok-turn-idx", "1") + .json(&body) + .send() + .await + .expect("POST foreground inference request"); + let status = response.status(); + let body = response.text().await.expect("read inference response body"); + (status, body) + } + + #[test] + fn explicit_request_headers_override_tool_count_heuristic() { + let overrides = InferenceOverrides::new(None); + let body = foreground_body(InferenceEndpoint::ChatCompletions, "title"); + + let mut headers = HeaderMap::new(); + headers.insert("x-grok-req-id", "title-request".parse().unwrap()); + assert!( + !overrides + .classify(InferenceEndpoint::ChatCompletions, &headers, &body) + .is_foreground() + ); + + headers.insert("x-grok-turn-idx", "1".parse().unwrap()); + assert!( + overrides + .classify(InferenceEndpoint::ChatCompletions, &headers, &body) + .is_foreground() + ); + + headers.insert("x-grok-req-id", "".parse().unwrap()); + headers.insert("x-grok-turn-idx", "".parse().unwrap()); + assert!( + overrides + .classify(InferenceEndpoint::ChatCompletions, &headers, &body) + .is_foreground() + ); + } + + #[tokio::test] + async fn auxiliary_request_does_not_consume_foreground_expectation() { + let server = MockInferenceServer::start().await.unwrap(); + let mut expected = server.expect_response( + "foreground turn", + InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions), + ScriptedResponse::text(209, "foreground"), + ); + + let aux = post_chat(&server, "generate a title").await; + assert_eq!(aux.status(), 200); + assert!(!expected.is_satisfied()); + + let (status, body) = read_foreground( + &server, + InferenceEndpoint::ChatCompletions, + "turn-1", + "run the task", + ) + .await; + assert_eq!(status.as_u16(), 209); + assert_eq!(body, "foreground"); + expected.wait_received().await; + expected.wait_satisfied().await; + } + + #[tokio::test] + async fn concurrent_matching_requests_claim_each_expectation_once() { + let server = MockInferenceServer::start().await.unwrap(); + let mut first = server.expect_response( + "first concurrent turn", + InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions), + ScriptedResponse::text(210, "first"), + ); + let mut second = server.expect_response( + "second concurrent turn", + InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions), + ScriptedResponse::text(211, "second"), + ); + + let (left, right) = tokio::join!( + read_foreground( + &server, + InferenceEndpoint::ChatCompletions, + "concurrent-left", + "left", + ), + read_foreground( + &server, + InferenceEndpoint::ChatCompletions, + "concurrent-right", + "right", + ) + ); + let mut responses = vec![(left.0.as_u16(), left.1), (right.0.as_u16(), right.1)]; + responses.sort_unstable(); + assert_eq!( + responses, + vec![(210, "first".to_owned()), (211, "second".to_owned())] + ); + first.wait_satisfied().await; + second.wait_satisfied().await; + } + + #[tokio::test] + async fn blocked_expectation_reports_lifecycle_and_drop_releases() { + let server = MockInferenceServer::start().await.unwrap(); + let mut expected = server.expect_response_blocked( + "blocked foreground turn", + InferenceRequestMatcher::foreground(InferenceEndpoint::ChatCompletions), + ScriptedResponse::sse(vec![ + SseEvent::data(r#"{"chunk":1}"#), + SseEvent::data("done"), + ]), + ); + let request = read_foreground( + &server, + InferenceEndpoint::ChatCompletions, + "blocked-turn", + "block me", + ); + tokio::pin!(request); + + tokio::select! { + response = &mut request => panic!("blocked expectation completed early: {:?}", response.0), + _ = expected.wait_blocked() => {} + } + assert!(!expected.is_satisfied()); + let diagnostic = expected.diagnostic(); + assert!(diagnostic.contains("blocked foreground turn")); + assert!(diagnostic.contains("Blocked")); + drop(expected); + let (status, _) = tokio::time::timeout(Duration::from_secs(1), request) + .await + .expect("dropping handle releases blocked response"); + assert_eq!(status.as_u16(), 200); + } + + #[tokio::test] + async fn concurrent_retry_obeys_same_barrier_and_primary_owns_satisfaction() { + let server = MockInferenceServer::start().await.unwrap(); + let mut expected = server.expect_response_blocked( + "blocked retry", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::sse(vec![SseEvent::data("chunk"), SseEvent::data("terminal")]), + ); + let first = read_foreground( + &server, + InferenceEndpoint::Responses, + "same-call", + "same body", + ); + tokio::pin!(first); + tokio::select! { + response = &mut first => panic!("primary completed before barrier: {:?}", response.0), + _ = expected.wait_blocked() => {} + } + + let retry = read_foreground( + &server, + InferenceEndpoint::Responses, + "same-call", + "same body", + ); + tokio::pin!(retry); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut retry) + .await + .is_err(), + "retry bypassed the shared release barrier" + ); + assert!(!expected.is_satisfied()); + + expected.release(); + let ((first_status, _), (retry_status, _)) = tokio::join!(first, retry); + assert_eq!(first_status.as_u16(), 200); + assert_eq!(retry_status.as_u16(), 200); + expected.wait_satisfied().await; + } + + #[tokio::test] + async fn release_only_signals_and_late_blocked_waiters_still_succeed() { + let server = MockInferenceServer::start().await.unwrap(); + let mut expected = server.expect_response_blocked( + "release ownership", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::json(200, json!({ "ok": true })), + ); + let request = read_foreground( + &server, + InferenceEndpoint::Responses, + "release-ownership", + "hello", + ); + tokio::pin!(request); + tokio::select! { + response = &mut request => panic!("response completed before barrier: {:?}", response.0), + _ = expected.wait_blocked() => {} + } + + expected.release(); + assert!(!expected.is_satisfied()); + let (status, _) = request.await; + assert_eq!(status.as_u16(), 200); + expected.wait_satisfied().await; + expected.wait_blocked().await; + } + + #[tokio::test] + async fn overlapping_duplicate_replays_but_sequential_identical_request_claims_next() { + let server = MockInferenceServer::start().await.unwrap(); + let mut first = server.expect_response_blocked( + "overlapping call", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::sse(vec![SseEvent::data("first"), SseEvent::data("terminal")]), + ); + let mut second = server.expect_response( + "later identical call", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::text(215, "second expectation"), + ); + + let primary = read_foreground( + &server, + InferenceEndpoint::Responses, + "turn-id", + "same body", + ); + tokio::pin!(primary); + tokio::select! { + response = &mut primary => panic!("primary completed before barrier: {:?}", response.0), + _ = first.wait_blocked() => {} + } + let replay = tokio::spawn({ + let url = endpoint_url(&server, InferenceEndpoint::Responses); + let body = foreground_body(InferenceEndpoint::Responses, "same body"); + async move { + let response = reqwest::Client::new() + .post(url) + .header("x-grok-req-id", "turn-id") + .header("x-grok-turn-idx", "1") + .json(&body) + .send() + .await + .expect("POST overlapping duplicate"); + let status = response.status(); + let body = response.text().await.expect("read overlapping duplicate"); + (status, body) + } + }); + first.wait_claims(2).await; + assert!( + !replay.is_finished(), + "overlapping duplicate bypassed shared barrier" + ); + first.release(); + let (primary_result, replay_result) = tokio::join!(primary, replay); + let (primary_status, _) = primary_result; + let (replay_status, _) = replay_result.expect("replay task"); + assert_eq!(primary_status.as_u16(), 200); + assert_eq!(replay_status.as_u16(), 200); + first.wait_satisfied().await; + + let (status, body) = read_foreground( + &server, + InferenceEndpoint::Responses, + "turn-id", + "same body", + ) + .await; + assert_eq!(status.as_u16(), 215); + assert_eq!(body, "second expectation"); + second.wait_satisfied().await; + } + + #[tokio::test] + async fn changed_body_followup_claims_next_expectation() { + let server = MockInferenceServer::start().await.unwrap(); + let mut first = server.expect_response( + "tool call", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::text(214, "tool-call-script"), + ); + let mut followup = server.expect_response( + "tool follow-up", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::text(215, "follow-up-script"), + ); + let first_body = foreground_body(InferenceEndpoint::Responses, "run tool"); + let (status, body) = + read_foreground_body(&server, InferenceEndpoint::Responses, "turn-id", first_body) + .await; + assert_eq!(status.as_u16(), 214); + assert_eq!(body, "tool-call-script"); + first.wait_satisfied().await; + + let mut followup_body = foreground_body(InferenceEndpoint::Responses, "run tool"); + followup_body["input"] + .as_array_mut() + .unwrap() + .push(json!({ "type": "function_call_output", "call_id": "call_1", "output": "done" })); + let (status, body) = read_foreground_body( + &server, + InferenceEndpoint::Responses, + "turn-id", + followup_body, + ) + .await; + assert_eq!(status.as_u16(), 215); + assert_eq!(body, "follow-up-script"); + followup.wait_satisfied().await; + } + + #[tokio::test] + async fn cancelling_primary_cleans_up_without_satisfying_or_replaying() { + let server = MockInferenceServer::start().await.unwrap(); + let mut cancelled = server.expect_response_blocked( + "cancel primary", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::sse(vec![SseEvent::data("chunk"), SseEvent::data("terminal")]), + ); + let mut next = server.expect_response( + "after cancellation", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::text(216, "next expectation"), + ); + let request = Box::pin(read_foreground( + &server, + InferenceEndpoint::Responses, + "cancel-primary", + "same body", + )); + let mut request = request; + tokio::select! { + response = &mut request => panic!("primary completed before cancellation: {:?}", response.0), + _ = cancelled.wait_blocked() => {} + } + drop(request); + assert!(!cancelled.is_satisfied()); + + let (status, body) = read_foreground( + &server, + InferenceEndpoint::Responses, + "cancel-primary", + "same body", + ) + .await; + assert_eq!(status.as_u16(), 216); + assert_eq!(body, "next expectation"); + next.wait_satisfied().await; + } + + #[tokio::test] + async fn cancelling_replay_waits_for_primary_before_satisfaction() { + let server = MockInferenceServer::start().await.unwrap(); + let mut expected = server.expect_response_blocked( + "cancel replay", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::sse(vec![SseEvent::data("chunk"), SseEvent::data("terminal")]), + ); + let primary = read_foreground( + &server, + InferenceEndpoint::Responses, + "cancel-replay", + "same body", + ); + tokio::pin!(primary); + tokio::select! { + response = &mut primary => panic!("primary completed before barrier: {:?}", response.0), + _ = expected.wait_blocked() => {} + } + + let replay = tokio::spawn({ + let url = endpoint_url(&server, InferenceEndpoint::Responses); + let body = foreground_body(InferenceEndpoint::Responses, "same body"); + async move { + reqwest::Client::new() + .post(url) + .header("x-grok-req-id", "cancel-replay") + .header("x-grok-turn-idx", "1") + .json(&body) + .send() + .await + .expect("POST replay cancellation") + .text() + .await + .expect("read replay cancellation") + } + }); + expected.wait_claims(2).await; + assert!(!replay.is_finished(), "replay bypassed shared barrier"); + replay.abort(); + let _ = replay.await; + assert!(!expected.is_satisfied()); + + expected.release(); + let (status, _) = primary.await; + assert_eq!(status.as_u16(), 200); + expected.wait_satisfied().await; + } + + #[tokio::test] + #[should_panic(expected = "duplicate inference expectation name `duplicate`")] + async fn duplicate_expectation_names_are_rejected() { + let server = MockInferenceServer::start().await.unwrap(); + let _first = server.expect_response( + "duplicate", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::text(200, "first"), + ); + let _second = server.expect_response( + "duplicate", + InferenceRequestMatcher::auxiliary(InferenceEndpoint::Responses), + ScriptedResponse::text(200, "second"), + ); + } + + #[tokio::test] + async fn unsatisfied_expectation_diagnostic_includes_name_and_state() { + let server = MockInferenceServer::start().await.unwrap(); + let expected = server.expect_response( + "must receive a foreground turn", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::text(200, "unused"), + ); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + expected.assert_satisfied(); + })) + .expect_err("unsatisfied expectation must panic"); + let message = panic + .downcast_ref::() + .cloned() + .or_else(|| panic.downcast_ref::<&str>().map(|s| (*s).to_owned())) + .unwrap_or_default(); + assert!(message.contains("must receive a foreground turn")); + assert!(message.contains("Pending")); + } + #[tokio::test] async fn echo_mode_echoes_last_user_message() { let server = MockInferenceServer::start().await.unwrap(); @@ -1393,6 +1820,157 @@ mod tests { assert!(named_then_plain, "events must be served in order:\n{body}"); } + #[tokio::test] + async fn matched_barriers_cover_all_endpoints_and_body_modes() { + for endpoint in [ + InferenceEndpoint::ChatCompletions, + InferenceEndpoint::Responses, + InferenceEndpoint::Messages, + ] { + for (label, response) in [ + ( + "sse", + ScriptedResponse::sse(vec![ + SseEvent::data("chunk"), + SseEvent::data("terminal"), + ]), + ), + ("empty-sse", ScriptedResponse::sse(Vec::new())), + ("json", ScriptedResponse::json(200, json!({ "ok": true }))), + ("raw", ScriptedResponse::text(200, "raw body")), + ] { + let server = MockInferenceServer::start().await.unwrap(); + let mut expected = server.expect_response_blocked( + format!("blocked {endpoint:?} {label}"), + InferenceRequestMatcher::foreground(endpoint), + response, + ); + let request = read_foreground(&server, endpoint, "body-gate", "hello"); + tokio::pin!(request); + tokio::select! { + response = &mut request => panic!("{endpoint:?}/{label} completed before release: {:?}", response.0), + _ = expected.wait_blocked() => {} + } + expected.release(); + let (status, _) = tokio::time::timeout(Duration::from_secs(1), request) + .await + .unwrap_or_else(|_| { + panic!("{endpoint:?}/{label} did not complete after release") + }); + assert_eq!(status.as_u16(), 200); + expected.wait_satisfied().await; + } + } + } + + #[tokio::test] + async fn matched_expectation_precedes_auth_then_auth_resumes() { + let server = MockInferenceServer::start_with_required_auth( + vec![MockModelEntry::new("test-model")], + "secret-token", + ) + .await + .unwrap(); + let mut expected = server.expect_response( + "auth bypass", + InferenceRequestMatcher::foreground(InferenceEndpoint::Responses), + ScriptedResponse::text(218, "matched without auth"), + ); + + let response = post_foreground( + &server, + InferenceEndpoint::Responses, + "auth-bypass", + "first", + ) + .await; + assert_eq!(response.status().as_u16(), 218); + assert_eq!(response.text().await.unwrap(), "matched without auth"); + expected.wait_satisfied().await; + + let response = post_foreground( + &server, + InferenceEndpoint::Responses, + "auth-fallback", + "second", + ) + .await; + assert_eq!(response.status(), 401); + } + + #[tokio::test] + async fn compatibility_completion_gate_covers_fallback_and_scripted_sse() { + for endpoint in [ + InferenceEndpoint::ChatCompletions, + InferenceEndpoint::Responses, + InferenceEndpoint::Messages, + ] { + let server = MockInferenceServer::start().await.unwrap(); + server.hold_agent_completions(); + server.set_agent_turns([format!("{endpoint:?} turn")]); + let request = read_foreground(&server, endpoint, "global-gate", "hello"); + tokio::pin!(request); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut request) + .await + .is_err(), + "{endpoint:?} bypassed the compatibility gate" + ); + server.release_agent_completions(); + tokio::time::timeout(Duration::from_secs(1), request) + .await + .unwrap_or_else(|_| panic!("{endpoint:?} did not complete after release")); + } + + let server = MockInferenceServer::start().await.unwrap(); + server.hold_agent_completions(); + server.enqueue_response( + "/v1/responses", + ScriptedResponse::sse(vec![SseEvent::data("chunk"), SseEvent::data("terminal")]), + ); + let request = read_foreground( + &server, + InferenceEndpoint::Responses, + "scripted-global-gate", + "hello", + ); + tokio::pin!(request); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut request) + .await + .is_err(), + "scripted SSE bypassed the compatibility gate" + ); + server.release_agent_completions(); + tokio::time::timeout(Duration::from_secs(1), request) + .await + .expect("scripted SSE completes after release"); + } + + #[tokio::test] + async fn compatibility_completion_gate_does_not_hold_json_or_raw() { + for response in [ + ScriptedResponse::json(219, json!({ "ok": true })), + ScriptedResponse::text(220, "raw body"), + ] { + let server = MockInferenceServer::start().await.unwrap(); + server.hold_agent_completions(); + server.enqueue_response("/v1/responses", response); + let (status, _) = tokio::time::timeout( + Duration::from_secs(1), + read_foreground( + &server, + InferenceEndpoint::Responses, + "compat-non-sse", + "hello", + ), + ) + .await + .expect("compatibility JSON/raw must not wait for the SSE gate"); + assert!(matches!(status.as_u16(), 219 | 220)); + } + } + #[tokio::test] async fn request_log_captures_arbitrary_headers() { let server = MockInferenceServer::start().await.unwrap(); diff --git a/crates/codegen/xai-grok-test-support/src/scripted.rs b/crates/codegen/xai-grok-test-support/src/scripted.rs index 50d72b7..ddd5223 100644 --- a/crates/codegen/xai-grok-test-support/src/scripted.rs +++ b/crates/codegen/xai-grok-test-support/src/scripted.rs @@ -3,6 +3,8 @@ //! time. Pure data — no router or handler types in the public surface. use std::convert::Infallible; +use std::future::Future; +use std::pin::Pin; use axum::Json; use axum::http::{HeaderName, HeaderValue, StatusCode}; @@ -11,6 +13,9 @@ use axum::response::{IntoResponse, Response}; use futures_util::stream; use serde_json::Value; +pub(crate) type BoxWait = Pin + Send>>; +pub(crate) type TerminalWait = Box BoxWait + Send>; + /// One SSE event as data: optional `event:` name plus the `data:` payload. #[derive(Debug, Clone)] pub struct SseEvent { @@ -45,9 +50,8 @@ pub enum ScriptedBody { Raw(String), } -/// A scripted reply for a single request on one path, consumed FIFO. -/// Takes precedence over the response mode AND the required-auth check — -/// a script is full control over the next reply. +/// A scripted reply served by a matched expectation or compatibility FIFO. +/// Scripted replies take precedence over required auth and fallback modes. #[derive(Debug, Clone)] pub struct ScriptedResponse { pub status: u16, @@ -83,6 +87,10 @@ impl ScriptedResponse { } } + pub(crate) fn is_sse(&self) -> bool { + matches!(self.body, ScriptedBody::Sse(_)) + } + /// Validate status and headers eagerly so a bad script panics at the /// enqueue call site rather than far away at serve time. pub(crate) fn validate(&self) { @@ -93,32 +101,60 @@ impl ScriptedResponse { } } - /// Render to HTTP with SSE events paced by `delay` (sleep before each - /// event, mirroring the fixed/echo `paced_events` pacing) so - /// `set_chunk_delay` also holds scripted turns open. `None` streams - /// instantly. Non-SSE bodies ignore the delay. - pub(crate) fn into_response_paced(self, delay: Option) -> Response { - use futures_util::StreamExt as _; + /// Render to HTTP with SSE events paced by `delay` and optional terminal + /// completion gating. Non-SSE bodies wait before returning so every body + /// mode obeys the same release barrier. + pub(crate) async fn into_response_paced( + self, + delay: Option, + before_terminal: Option, + ) -> Response { let mut resp = match self.body { - ScriptedBody::Json(v) => Json(v).into_response(), - ScriptedBody::Raw(s) => s.into_response(), + ScriptedBody::Json(v) => { + if let Some(wait) = before_terminal { + wait().await; + } + Json(v).into_response() + } + ScriptedBody::Raw(s) => { + if let Some(wait) = before_terminal { + wait().await; + } + s.into_response() + } ScriptedBody::Sse(events) => { - let events: Vec = events - .into_iter() - .map(|e| { - let ev = axum::response::sse::Event::default().data(e.data); - match e.event { - Some(name) => ev.event(name), - None => ev, + let last_idx = events.len().checked_sub(1); + let mut events: Vec<_> = events.into_iter().enumerate().map(Some).collect(); + if events.is_empty() && before_terminal.is_some() { + events.push(None); + } + let stream = stream::unfold( + (events.into_iter(), before_terminal), + move |(mut events, mut before_terminal)| async move { + loop { + let item = events.next()?; + let Some((idx, scripted_event)) = item else { + if let Some(wait) = before_terminal.take() { + wait().await; + } + continue; + }; + if let Some(d) = delay { + tokio::time::sleep(d).await; + } + if Some(idx) == last_idx + && let Some(wait) = before_terminal.take() + { + wait().await; + } + let event = + axum::response::sse::Event::default().data(scripted_event.data); + let event = match scripted_event.event { + Some(name) => event.event(name), + None => event, + }; + return Some((Ok::<_, Infallible>(event), (events, before_terminal))); } - }) - .collect(); - let stream = stream::iter(events.into_iter().map(Ok::<_, Infallible>)).then( - move |event| async move { - if let Some(d) = delay { - tokio::time::sleep(d).await; - } - event }, ); Sse::new(stream) diff --git a/crates/codegen/xai-grok-tools/Cargo.toml b/crates/codegen/xai-grok-tools/Cargo.toml index a362b04..3b5b8f4 100644 --- a/crates/codegen/xai-grok-tools/Cargo.toml +++ b/crates/codegen/xai-grok-tools/Cargo.toml @@ -96,7 +96,7 @@ libc = { workspace = true } # AssignProcessToJobObject, TerminateJobObject) and process creation flags # (CREATE_NO_WINDOW, DETACHED_PROCESS, CREATE_NEW_PROCESS_GROUP). [target.'cfg(windows)'.dependencies] -windows = { workspace = true } +windows = { workspace = true, features = ["Win32_Storage_FileSystem"] } [dev-dependencies] dirs = { workspace = true } diff --git a/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs b/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs index 22481db..b5948b8 100644 --- a/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs +++ b/crates/codegen/xai-grok-tools/src/computer/local/terminal.rs @@ -150,14 +150,8 @@ enum TerminalCommand { reply: oneshot::Sender>, }, - WarmShell { - cwd: PathBuf, - }, - /// Kill all running foreground processes owned by a specific session. - KillForegroundCommandsByOwner { - owner_session_id: String, - }, + KillForegroundCommandsByOwner { owner_session_id: String }, /// Kill all running background tasks owned by a specific session. KillTasksByOwner { @@ -597,25 +591,6 @@ impl LocalTerminalActor { }) } - #[cfg(unix)] - async fn ensure_persistent_shell_initialized(&mut self, cwd: &std::path::Path) { - if self.shell_state.is_some() { - return; - } - let shell = shell_state::ShellKind::detect(); - match shell_state::ShellState::init(shell, cwd).await { - Ok(state) => self.shell_state = Some(state), - Err(e) => { - tracing::warn!("persistent shell init failed, using empty state: {e}"); - self.shell_state = Some(shell_state::ShellState { - cwd: cwd.to_path_buf(), - snapshot: String::new(), - shell, - }); - } - } - } - /// Spawn a command with persistent shell state: restore the prior snapshot /// via fd 3, run the user command, dump the new state to fd 4. #[cfg(unix)] @@ -627,7 +602,20 @@ impl LocalTerminalActor { ) -> Result { use command_fds::CommandFdExt; - self.ensure_persistent_shell_initialized(cwd).await; + if self.shell_state.is_none() { + let shell = shell_state::ShellKind::detect(); + match shell_state::ShellState::init(shell, cwd).await { + Ok(state) => self.shell_state = Some(state), + Err(e) => { + tracing::warn!("persistent shell init failed, using empty state: {e}"); + self.shell_state = Some(shell_state::ShellState { + cwd: cwd.to_path_buf(), + snapshot: String::new(), + shell, + }); + } + } + } let shell_state = self.shell_state.as_ref().unwrap(); // When the persistent shell already tracks a @@ -809,14 +797,6 @@ impl LocalTerminalActor { let cwd = None; let _ = reply.send(cwd); } - TerminalCommand::WarmShell { cwd } => { - #[cfg(unix)] - if self.persistent_shell { - self.ensure_persistent_shell_initialized(&cwd).await; - } - #[cfg(not(unix))] - let _ = cwd; - } TerminalCommand::KillForegroundCommands => { self.kill_foreground_commands().await; } @@ -2281,15 +2261,6 @@ impl TerminalBackend for LocalTerminalBackend { reply_rx.await.ok().flatten() } - async fn warm_persistent_shell(&self, cwd: &std::path::Path) { - let _ = self - .cmd_tx - .send(TerminalCommand::WarmShell { - cwd: cwd.to_path_buf(), - }) - .await; - } - async fn kill_foreground_commands(&self) { let _ = self .cmd_tx diff --git a/crates/codegen/xai-grok-tools/src/computer/types.rs b/crates/codegen/xai-grok-tools/src/computer/types.rs index d1b94a3..ad13267 100644 --- a/crates/codegen/xai-grok-tools/src/computer/types.rs +++ b/crates/codegen/xai-grok-tools/src/computer/types.rs @@ -292,10 +292,6 @@ pub trait TerminalBackend: Send + Sync { /// only the subagent's own tasks are killed — not the parent's. async fn kill_all_background_tasks_by_owner(&self, _owner_session_id: &str) {} - /// Fire-and-forget prewarm of the persistent login shell; default no-op for - /// backends without one (ACP/remote, non-persistent). - async fn warm_persistent_shell(&self, _cwd: &std::path::Path) {} - /// Reparent notification handles for all tasks owned by `old_owner_session_id`. /// Swaps the dead child session's notification handle with the parent's /// live handle so events from surviving processes route correctly. diff --git a/crates/codegen/xai-grok-tools/src/notification/handle.rs b/crates/codegen/xai-grok-tools/src/notification/handle.rs new file mode 100644 index 0000000..a747dd5 --- /dev/null +++ b/crates/codegen/xai-grok-tools/src/notification/handle.rs @@ -0,0 +1,264 @@ +use std::sync::Arc; + +use super::types::{ + BashExecutionBackgrounded, BashExecutionComplete, BashExecutionFailed, BashExecutionTimeout, + BashOutputChunk, FileWritten, LspServerCrashed, LspServerFailed, LspServerReady, + LspServerRetrying, LspServerStarting, MonitorEvent, PlanModeEntered, PlanModeExited, + ScheduledTaskCreated, ScheduledTaskFired, ScheduledTaskRemoved, ToolNotification, + UserQuestionAsked, +}; +use crate::types::TaskSnapshot; + +/// Envelope for consumers that can acknowledge durable notification handling. +pub struct AcknowledgedToolNotification { + /// Notification delivered in the same FIFO as unacknowledged events. + pub notification: ToolNotification, + /// Completion sender present only when the producer requested acknowledgement. + pub acknowledgement: Option>>, +} + +/// Failure reported after all acknowledged notification targets have settled. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum NotificationAcknowledgementError { + #[error("{0} acknowledging notification target(s) closed during dispatch")] + DispatchClosed(usize), + #[error("{0} notification acknowledgement(s) were dropped")] + AcknowledgementDropped(usize), + #[error("notification consumer rejected delivery: {0:?}")] + ConsumerRejected(Vec), + #[error( + "notification acknowledgement failed: {dispatch_closed} dispatch closed, {acknowledgements_dropped} acknowledgement(s) dropped, consumer rejections: {consumer_rejections:?}" + )] + Multiple { + dispatch_closed: usize, + acknowledgements_dropped: usize, + consumer_rejections: Vec, + }, +} + +/// Receipts for one acknowledged fan-out operation. +#[must_use = "acknowledged notification receipts must be awaited"] +pub struct NotificationAcknowledgementBatch { + receipts: Vec>>, + durable_targets: usize, + dispatch_closed: usize, +} + +/// Whether an acknowledged send has configured durable notification targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DurableNotificationTargets { + None, + Present, +} + +impl NotificationAcknowledgementBatch { + /// Whether any target was configured for durable acknowledgement. + pub fn durable_targets(&self) -> DurableNotificationTargets { + if self.durable_targets == 0 { + DurableNotificationTargets::None + } else { + DurableNotificationTargets::Present + } + } + + /// Wait for every live durable target and report all observed failure classes. + pub async fn wait(self) -> Result<(), NotificationAcknowledgementError> { + let mut acknowledgements_dropped = 0; + let mut consumer_rejections = Vec::new(); + for receipt in self.receipts { + match receipt.await { + Ok(Ok(())) => {} + Ok(Err(error)) => consumer_rejections.push(error), + Err(_) => acknowledgements_dropped += 1, + } + } + match ( + self.dispatch_closed, + acknowledgements_dropped, + consumer_rejections.is_empty(), + ) { + (0, 0, true) => Ok(()), + (dispatch_closed, 0, true) => Err(NotificationAcknowledgementError::DispatchClosed( + dispatch_closed, + )), + (0, acknowledgements_dropped, true) => Err( + NotificationAcknowledgementError::AcknowledgementDropped(acknowledgements_dropped), + ), + (0, 0, false) => Err(NotificationAcknowledgementError::ConsumerRejected( + consumer_rejections, + )), + (dispatch_closed, acknowledgements_dropped, _) => { + Err(NotificationAcknowledgementError::Multiple { + dispatch_closed, + acknowledgements_dropped, + consumer_rejections, + }) + } + } + } +} + +#[derive(Clone)] +enum ToolNotificationTarget { + Plain(tokio::sync::mpsc::UnboundedSender), + Acknowledged(tokio::sync::mpsc::UnboundedSender), +} + +/// Cloneable notification fan-out with per-target FIFO ordering. +#[derive(Clone)] +pub struct ToolNotificationHandle { + targets: Arc<[ToolNotificationTarget]>, +} + +impl Default for ToolNotificationHandle { + fn default() -> Self { + Self::noop() + } +} + +macro_rules! convenience_sends { + ($($method:ident, $ty:ty, $variant:ident);+ $(;)?) => { + $(pub fn $method(&self, value: $ty) { self.send(ToolNotification::$variant(value)); })+ + }; +} + +impl ToolNotificationHandle { + pub fn new(sender: tokio::sync::mpsc::UnboundedSender) -> Self { + Self { + targets: Arc::from([ToolNotificationTarget::Plain(sender)]), + } + } + + pub fn from_sender(sender: tokio::sync::mpsc::UnboundedSender) -> Self { + Self::new(sender) + } + + pub fn channel() -> (Self, tokio::sync::mpsc::UnboundedReceiver) { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + (Self::new(sender), receiver) + } + + pub fn acknowledged_channel() -> ( + Self, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + ( + Self { + targets: Arc::from([ToolNotificationTarget::Acknowledged(sender)]), + }, + receiver, + ) + } + + pub fn noop() -> Self { + let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); + Self::new(sender) + } + + /// Combine handles while preserving each target's send order. + pub fn tee(handles: Vec) -> ToolNotificationHandle { + let targets = handles + .iter() + .flat_map(|handle| handle.targets.iter().cloned()) + .collect::>(); + Self { + targets: Arc::from(targets), + } + } + + pub fn send(&self, notification: ToolNotification) { + let last = self.targets.len().saturating_sub(1); + let mut notification = Some(notification); + for (index, target) in self.targets.iter().enumerate() { + let notification = if index == last { + let Some(notification) = notification.take() else { + break; + }; + notification + } else { + let Some(notification) = notification.as_ref() else { + break; + }; + notification.clone() + }; + match target { + ToolNotificationTarget::Plain(target) => { + let _ = target.send(notification); + } + ToolNotificationTarget::Acknowledged(target) => { + let _ = target.send(AcknowledgedToolNotification { + notification, + acknowledgement: None, + }); + } + } + } + } + + /// Send a removal to every target and collect all durable acknowledgements. + pub fn send_scheduled_task_removed_acknowledged( + &self, + removed: ScheduledTaskRemoved, + ) -> NotificationAcknowledgementBatch { + let notification = ToolNotification::ScheduledTaskRemoved(removed); + let mut batch = NotificationAcknowledgementBatch { + receipts: Vec::new(), + durable_targets: 0, + dispatch_closed: 0, + }; + for target in self.targets.iter() { + match target { + ToolNotificationTarget::Plain(target) => { + let _ = target.send(notification.clone()); + } + ToolNotificationTarget::Acknowledged(target) => { + batch.durable_targets += 1; + let (acknowledgement, receipt) = tokio::sync::oneshot::channel(); + if target + .send(AcknowledgedToolNotification { + notification: notification.clone(), + acknowledgement: Some(acknowledgement), + }) + .is_ok() + { + batch.receipts.push(receipt); + } else { + batch.dispatch_closed += 1; + } + } + } + } + batch + } + + convenience_sends! { + send_output_chunk, BashOutputChunk, BashOutputChunk; + send_complete, BashExecutionComplete, BashExecutionComplete; + send_timeout, BashExecutionTimeout, BashExecutionTimeout; + send_backgrounded, BashExecutionBackgrounded, BashExecutionBackgrounded; + send_failed, BashExecutionFailed, BashExecutionFailed; + send_file_written, FileWritten, FileWritten; + send_task_complete, TaskSnapshot, TaskCompleted; + send_plan_mode_entered, PlanModeEntered, PlanModeEntered; + send_plan_mode_exited, PlanModeExited, PlanModeExited; + send_user_question_asked, UserQuestionAsked, UserQuestionAsked; + send_lsp_starting, LspServerStarting, LspServerStarting; + send_lsp_ready, LspServerReady, LspServerReady; + send_lsp_crashed, LspServerCrashed, LspServerCrashed; + send_lsp_retrying, LspServerRetrying, LspServerRetrying; + send_lsp_failed, LspServerFailed, LspServerFailed; + send_scheduled_task_fired, ScheduledTaskFired, ScheduledTaskFired; + send_scheduled_task_removed, ScheduledTaskRemoved, ScheduledTaskRemoved; + send_scheduled_task_created, ScheduledTaskCreated, ScheduledTaskCreated; + send_monitor_event, MonitorEvent, MonitorEvent; + } +} + +/// Per-call notification override applied in addition to the session-wide sink. +#[derive(Clone)] +pub struct PerCallNotificationSink(pub ToolNotificationHandle); + +#[cfg(test)] +#[path = "handle_tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-tools/src/notification/handle_tests.rs b/crates/codegen/xai-grok-tools/src/notification/handle_tests.rs new file mode 100644 index 0000000..bc7f6c7 --- /dev/null +++ b/crates/codegen/xai-grok-tools/src/notification/handle_tests.rs @@ -0,0 +1,117 @@ +use super::*; + +fn removed(task_id: &str) -> ScheduledTaskRemoved { + ScheduledTaskRemoved { + task_id: task_id.into(), + } +} + +fn created(task_id: &str) -> ScheduledTaskCreated { + ScheduledTaskCreated { + task_id: task_id.into(), + prompt: task_id.into(), + human_schedule: "every 5 minutes".into(), + next_fire_at: None, + } +} + +fn task_id(notification: &ToolNotification) -> &str { + match notification { + ToolNotification::ScheduledTaskCreated(value) => &value.task_id, + ToolNotification::ScheduledTaskRemoved(value) => &value.task_id, + other => panic!("unexpected notification: {other:?}"), + } +} + +#[tokio::test] +async fn acknowledged_removal_stays_in_fifo() { + let (handle, mut receiver) = ToolNotificationHandle::acknowledged_channel(); + handle.send_scheduled_task_created(created("before")); + let batch = handle.send_scheduled_task_removed_acknowledged(removed("deleted")); + handle.send_scheduled_task_created(created("after")); + + let first = receiver.recv().await.unwrap(); + assert_eq!(task_id(&first.notification), "before"); + assert!(first.acknowledgement.is_none()); + let second = receiver.recv().await.unwrap(); + assert_eq!(task_id(&second.notification), "deleted"); + second.acknowledgement.unwrap().send(Ok(())).unwrap(); + let third = receiver.recv().await.unwrap(); + assert_eq!(task_id(&third.notification), "after"); + assert!(third.acknowledgement.is_none()); + + assert_eq!(batch.durable_targets(), DurableNotificationTargets::Present); + batch.wait().await.unwrap(); +} + +#[tokio::test] +async fn mixed_fanout_attempts_every_target_before_reporting_closed_dispatch() { + let (closed, closed_rx) = ToolNotificationHandle::acknowledged_channel(); + drop(closed_rx); + let (plain, mut plain_rx) = ToolNotificationHandle::channel(); + let (durable, mut durable_rx) = ToolNotificationHandle::acknowledged_channel(); + let handle = ToolNotificationHandle::tee(vec![closed, plain, durable]); + + handle.send_scheduled_task_created(created("before")); + let batch = handle.send_scheduled_task_removed_acknowledged(removed("deleted")); + handle.send_scheduled_task_created(created("after")); + + assert_eq!(task_id(&plain_rx.recv().await.unwrap()), "before"); + assert_eq!(task_id(&plain_rx.recv().await.unwrap()), "deleted"); + assert_eq!(task_id(&plain_rx.recv().await.unwrap()), "after"); + let durable_before = durable_rx.recv().await.unwrap(); + assert_eq!(task_id(&durable_before.notification), "before"); + let durable_removed = durable_rx.recv().await.unwrap(); + assert_eq!(task_id(&durable_removed.notification), "deleted"); + durable_removed + .acknowledgement + .unwrap() + .send(Ok(())) + .unwrap(); + let durable_after = durable_rx.recv().await.unwrap(); + assert_eq!(task_id(&durable_after.notification), "after"); + + assert_eq!( + batch.wait().await, + Err(NotificationAcknowledgementError::DispatchClosed(1)) + ); +} + +#[tokio::test] +async fn batch_distinguishes_dropped_and_rejected_acknowledgements() { + let (dropped, mut dropped_rx) = ToolNotificationHandle::acknowledged_channel(); + let (rejected, mut rejected_rx) = ToolNotificationHandle::acknowledged_channel(); + let handle = ToolNotificationHandle::tee(vec![dropped, rejected]); + let batch = handle.send_scheduled_task_removed_acknowledged(removed("deleted")); + + drop(dropped_rx.recv().await.unwrap().acknowledgement); + rejected_rx + .recv() + .await + .unwrap() + .acknowledgement + .unwrap() + .send(Err("rejected".into())) + .unwrap(); + + assert_eq!( + batch.wait().await, + Err(NotificationAcknowledgementError::Multiple { + dispatch_closed: 0, + acknowledgements_dropped: 1, + consumer_rejections: vec!["rejected".into()], + }) + ); +} + +#[tokio::test] +async fn plain_and_noop_batches_make_zero_durable_targets_explicit() { + for handle in [ + ToolNotificationHandle::channel().0, + ToolNotificationHandle::noop(), + ] { + let batch = handle.send_scheduled_task_removed_acknowledged(removed("deleted")); + assert_eq!(batch.durable_targets(), DurableNotificationTargets::None); + batch.wait().await.unwrap(); + } +} diff --git a/crates/codegen/xai-grok-tools/src/notification/mod.rs b/crates/codegen/xai-grok-tools/src/notification/mod.rs index 23983a0..4c6ad0c 100644 --- a/crates/codegen/xai-grok-tools/src/notification/mod.rs +++ b/crates/codegen/xai-grok-tools/src/notification/mod.rs @@ -1,5 +1,12 @@ +pub mod handle; pub mod types; +pub use handle::AcknowledgedToolNotification; +pub use handle::DurableNotificationTargets; +pub use handle::NotificationAcknowledgementBatch; +pub use handle::NotificationAcknowledgementError; +pub use handle::PerCallNotificationSink; +pub use handle::ToolNotificationHandle; pub use types::ALL_NOTIFICATION_TAGS; pub use types::BashExecutionBackgrounded; pub use types::BashExecutionComplete; @@ -15,13 +22,11 @@ pub use types::LspServerReady; pub use types::LspServerRetrying; pub use types::LspServerStarting; pub use types::MonitorEvent; -pub use types::PerCallNotificationSink; pub use types::PlanModeEntered; pub use types::PlanModeExited; pub use types::ScheduledTaskCreated; pub use types::ScheduledTaskFired; pub use types::ScheduledTaskRemoved; pub use types::ToolNotification; -pub use types::ToolNotificationHandle; pub use types::UserQuestionAsked; pub use types::notification_schema_catalog; diff --git a/crates/codegen/xai-grok-tools/src/notification/types.rs b/crates/codegen/xai-grok-tools/src/notification/types.rs index 5646fb9..e76bbe6 100644 --- a/crates/codegen/xai-grok-tools/src/notification/types.rs +++ b/crates/codegen/xai-grok-tools/src/notification/types.rs @@ -4,10 +4,11 @@ //! - updates being sent by the tools as they are executing (for example bash tools) use std::path::PathBuf; -use std::sync::Arc; use crate::types::TaskSnapshot; +pub use super::handle::{PerCallNotificationSink, ToolNotificationHandle}; + /// Common fields for all bash execution notifications. /// Extracting these ensures consistent naming and makes refactoring easier. #[derive(Debug, Clone, PartialEq, Eq, schemars::JsonSchema)] @@ -480,236 +481,10 @@ notification_variants! { MonitorEvent => MonitorEvent, } -/// Handle for sending notifications to consumers. -/// Clone-able so it can be passed to multiple tool implementations. -/// -/// Internally holds one-or-many sender targets. Every existing constructor -/// (`new`, `from_sender`, `channel`, `noop`) builds a single-target handle and -/// behaves exactly as before; [`ToolNotificationHandle::tee`] builds a -/// fan-out handle whose [`send`](Self::send) delivers each notification to all -/// targets, in order, preserving per-target ordering. -#[derive(Clone)] -pub struct ToolNotificationHandle { - targets: Arc<[tokio::sync::mpsc::UnboundedSender]>, -} - -impl Default for ToolNotificationHandle { - fn default() -> Self { - Self::noop() - } -} - -impl ToolNotificationHandle { - /// Create a new handle with the given sender - pub fn new(sender: tokio::sync::mpsc::UnboundedSender) -> Self { - Self { - targets: Arc::from([sender]), - } - } - - /// Create a handle from an existing unbounded sender. - /// Alias for `new()` — used by tests and consumers that want to receive notifications. - pub fn from_sender(sender: tokio::sync::mpsc::UnboundedSender) -> Self { - Self::new(sender) - } - - /// Create a channel pair (handle + receiver) - pub fn channel() -> (Self, tokio::sync::mpsc::UnboundedReceiver) { - let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); - (Self::new(sender), receiver) - } - - /// Create a no-op handle (sends are silently dropped) - pub fn noop() -> Self { - let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); - Self::new(sender) - } - - /// Fan-out: build a handle that delivers every notification to all the - /// underlying targets of the given `handles`, in order. - /// - /// Each send is delivered to every target in `handles` (flattened), so a - /// single tool call's notifications can be surfaced on several sinks at - /// once (e.g. the session-wide handle plus a per-call sink). Per-target - /// ordering is preserved: targets observe sends in the same order on the - /// caller's thread. [`ToolNotification`] derives `Clone`, so each extra - /// target receives a clone. - /// - /// An empty input (`tee(vec![])`) yields a handle with no targets whose - /// `send` silently drops every notification — i.e. equivalent to - /// [`noop`](Self::noop). - pub fn tee(handles: Vec) -> ToolNotificationHandle { - let targets: Vec<_> = handles - .iter() - .flat_map(|h| h.targets.iter().cloned()) - .collect(); - Self { - targets: Arc::from(targets), - } - } - - /// Send a notification to all targets, in order. - pub fn send(&self, notification: ToolNotification) { - // Single-target hot path is one send with no clone; for fan-out we - // clone for every target except the last, which takes ownership. - let last = self.targets.len().saturating_sub(1); - for (i, target) in self.targets.iter().enumerate() { - if i == last { - let _ = target.send(notification); - break; - } - let _ = target.send(notification.clone()); - } - } - - // === Convenience methods === - - pub fn send_output_chunk(&self, chunk: BashOutputChunk) { - self.send(ToolNotification::BashOutputChunk(chunk)); - } - - pub fn send_complete(&self, complete: BashExecutionComplete) { - self.send(ToolNotification::BashExecutionComplete(complete)); - } - - pub fn send_timeout(&self, timeout: BashExecutionTimeout) { - self.send(ToolNotification::BashExecutionTimeout(timeout)); - } - - pub fn send_backgrounded(&self, backgrounded: BashExecutionBackgrounded) { - self.send(ToolNotification::BashExecutionBackgrounded(backgrounded)); - } - - pub fn send_failed(&self, failed: BashExecutionFailed) { - self.send(ToolNotification::BashExecutionFailed(failed)); - } - - pub fn send_file_written(&self, written: FileWritten) { - self.send(ToolNotification::FileWritten(written)); - } - - pub fn send_task_complete(&self, task_completed: TaskSnapshot) { - self.send(ToolNotification::TaskCompleted(task_completed)) - } - - pub fn send_plan_mode_entered(&self, entered: PlanModeEntered) { - self.send(ToolNotification::PlanModeEntered(entered)); - } - - pub fn send_plan_mode_exited(&self, exited: PlanModeExited) { - self.send(ToolNotification::PlanModeExited(exited)); - } - - pub fn send_user_question_asked(&self, asked: UserQuestionAsked) { - self.send(ToolNotification::UserQuestionAsked(asked)); - } - - pub fn send_lsp_starting(&self, starting: LspServerStarting) { - self.send(ToolNotification::LspServerStarting(starting)); - } - - pub fn send_lsp_ready(&self, ready: LspServerReady) { - self.send(ToolNotification::LspServerReady(ready)); - } - - pub fn send_lsp_crashed(&self, crashed: LspServerCrashed) { - self.send(ToolNotification::LspServerCrashed(crashed)); - } - - pub fn send_lsp_retrying(&self, retrying: LspServerRetrying) { - self.send(ToolNotification::LspServerRetrying(retrying)); - } - - pub fn send_lsp_failed(&self, failed: LspServerFailed) { - self.send(ToolNotification::LspServerFailed(failed)); - } - - pub fn send_scheduled_task_fired(&self, fired: ScheduledTaskFired) { - self.send(ToolNotification::ScheduledTaskFired(fired)); - } - - pub fn send_scheduled_task_removed(&self, removed: ScheduledTaskRemoved) { - self.send(ToolNotification::ScheduledTaskRemoved(removed)); - } - - pub fn send_scheduled_task_created(&self, created: ScheduledTaskCreated) { - self.send(ToolNotification::ScheduledTaskCreated(created)); - } - - pub fn send_monitor_event(&self, event: MonitorEvent) { - self.send(ToolNotification::MonitorEvent(event)); - } -} - -/// Per-call notification override. -/// -/// When present in `ToolCallContext::extensions`, tools tee their execution -/// notifications here IN ADDITION to the session-wide handle, so a single -/// call's notifications (e.g. bash output chunks) can be surfaced as in-band -/// progress for that one tool call without disturbing the session-wide -/// side-channel. -/// -/// This follows the same per-call ctx-extension pattern as `InnerDispatch` / -/// `Cwd`: a simple clone-able newtype wrapper inserted into and pulled out of -/// `ToolCallContext::extensions`. -#[derive(Clone)] -pub struct PerCallNotificationSink(pub ToolNotificationHandle); - #[cfg(test)] -mod handle_tests { +mod payload_tests { use super::*; - fn chunk(tool_call_id: &str) -> ToolNotification { - ToolNotification::BashOutputChunk(BashOutputChunk { - base: BashNotificationBase { - tool_call_id: tool_call_id.into(), - command: "echo hi".into(), - output: b"hi".to_vec(), - total_bytes: 2, - truncated: false, - cwd: PathBuf::from("/"), - }, - }) - } - - fn tool_call_id(n: &ToolNotification) -> &str { - match n { - ToolNotification::BashOutputChunk(c) => &c.base.tool_call_id, - other => panic!("expected BashOutputChunk, got {other:?}"), - } - } - - #[test] - fn single_target_hot_path_receives_exactly_what_was_sent() { - let (handle, mut rx) = ToolNotificationHandle::channel(); - handle.send(chunk("a")); - handle.send(chunk("b")); - drop(handle); - - assert_eq!(tool_call_id(&rx.try_recv().unwrap()), "a"); - assert_eq!(tool_call_id(&rx.try_recv().unwrap()), "b"); - assert!(rx.try_recv().is_err(), "no extra notifications expected"); - } - - #[test] - fn tee_delivers_to_all_targets_in_order() { - let (h1, mut rx1) = ToolNotificationHandle::channel(); - let (h2, mut rx2) = ToolNotificationHandle::channel(); - let teed = ToolNotificationHandle::tee(vec![h1, h2]); - - teed.send(chunk("a")); - teed.send(chunk("b")); - teed.send(chunk("c")); - drop(teed); - - for rx in [&mut rx1, &mut rx2] { - assert_eq!(tool_call_id(&rx.try_recv().unwrap()), "a"); - assert_eq!(tool_call_id(&rx.try_recv().unwrap()), "b"); - assert_eq!(tool_call_id(&rx.try_recv().unwrap()), "c"); - assert!(rx.try_recv().is_err(), "no extra notifications expected"); - } - } - #[test] fn catalog_has_one_schema_per_variant() { let catalog = notification_schema_catalog(); diff --git a/crates/codegen/xai-grok-tools/src/persistence.rs b/crates/codegen/xai-grok-tools/src/persistence.rs index 0975e3f..87674ce 100644 --- a/crates/codegen/xai-grok-tools/src/persistence.rs +++ b/crates/codegen/xai-grok-tools/src/persistence.rs @@ -3,9 +3,12 @@ //! [`ResourcesPersistence`] persists `Resources` state (the new architecture). //! Old `ToolStatePersistence` and `PersistenceLayer` have been deleted. +use std::io; use std::path::{Path, PathBuf}; use std::time::Duration; +use tokio::io::AsyncWriteExt; + use crate::types::resources::Resources; /// Background persistence for `Resources` state/params. @@ -23,11 +26,16 @@ pub struct ResourcesPersistence { state_path: PathBuf, /// Channel to send serialized state to the background writer tx: tokio::sync::mpsc::UnboundedSender, + noop: bool, } enum ResourcesPersistenceCommand { /// Write this serialized Resources value to disk Save(serde_json::Value), + SaveAndFlush { + snapshot: serde_json::Value, + respond_to: tokio::sync::oneshot::Sender>, + }, /// Flush pending writes and notify when done Flush(tokio::sync::oneshot::Sender<()>), } @@ -39,6 +47,7 @@ impl ResourcesPersistence { Self { state_path: PathBuf::from("/dev/null"), tx, + noop: true, } } @@ -51,7 +60,11 @@ impl ResourcesPersistence { Self::writer_loop(rx, writer_path).await; }); - Self { state_path, tx } + Self { + state_path, + tx, + noop: false, + } } /// Load existing Resources state from disk, if the file exists. @@ -96,10 +109,55 @@ impl ResourcesPersistence { /// Save the current Resources state (non-blocking). /// Sends a serialized snapshot to the background writer. pub fn save(&self, resources: &Resources) { + if self.noop { + return; + } let snapshot = resources.serialize(); let _ = self.tx.send(ResourcesPersistenceCommand::Save(snapshot)); } + /// Replace pending snapshots, write this snapshot, and acknowledge the result. + pub fn enqueue_save_and_flush( + &self, + snapshot: serde_json::Value, + ) -> io::Result>> { + if self.noop { + let (respond_to, response) = tokio::sync::oneshot::channel(); + let _ = respond_to.send(Ok(())); + return Ok(response); + } + let (respond_to, response) = tokio::sync::oneshot::channel(); + self.tx + .send(ResourcesPersistenceCommand::SaveAndFlush { + snapshot, + respond_to, + }) + .map_err(|_| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "resources persistence writer stopped", + ) + })?; + Ok(response) + } + + /// Await an acknowledgement returned by [`Self::enqueue_save_and_flush`]. + pub async fn await_save_and_flush( + response: tokio::sync::oneshot::Receiver>, + ) -> io::Result<()> { + response.await.map_err(|_| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "resources persistence writer dropped acknowledgement", + ) + })? + } + + /// Replace pending snapshots, write this snapshot, and await the result. + pub async fn save_and_flush(&self, snapshot: serde_json::Value) -> io::Result<()> { + Self::await_save_and_flush(self.enqueue_save_and_flush(snapshot)?).await + } + /// Path to the persisted state file. pub fn state_path(&self) -> &std::path::Path { &self.state_path @@ -107,6 +165,9 @@ impl ResourcesPersistence { /// Flush pending writes. Call on graceful shutdown. pub async fn flush(&self) { + if self.noop { + return; + } let (done_tx, done_rx) = tokio::sync::oneshot::channel(); let _ = self.tx.send(ResourcesPersistenceCommand::Flush(done_tx)); let _ = done_rx.await; @@ -147,59 +208,151 @@ impl ResourcesPersistence { Some(ResourcesPersistenceCommand::Save(snapshot)) => { pending = Some(snapshot); } + Some(ResourcesPersistenceCommand::SaveAndFlush { + snapshot, + respond_to, + }) => { + pending = None; + let result = Self::write_json_durable(&state_path, &snapshot).await; + let _ = respond_to.send(result); + } Some(ResourcesPersistenceCommand::Flush(done)) => { - if let Some(snapshot) = pending.take() { - Self::write_json(&state_path, &snapshot).await; + if let Some(snapshot) = pending.take() + && let Err(error) = Self::write_json(&state_path, &snapshot).await + { + tracing::warn!( + ?error, + ?state_path, + "Failed to flush resources state" + ); } let _ = done.send(()); } None => { - if let Some(snapshot) = pending.take() { - Self::write_json(&state_path, &snapshot).await; + if let Some(snapshot) = pending.take() + && let Err(error) = Self::write_json(&state_path, &snapshot).await + { + tracing::warn!( + ?error, + ?state_path, + "Failed to flush resources state" + ); } break; } } } _ = debounce.tick() => { - if let Some(snapshot) = pending.take() { - Self::write_json(&state_path, &snapshot).await; + if let Some(snapshot) = pending.take() + && let Err(error) = Self::write_json(&state_path, &snapshot).await + { + tracing::warn!( + ?error, + ?state_path, + "Failed to save resources state" + ); } } } } } - async fn write_json(path: &Path, value: &serde_json::Value) { - match serde_json::to_string_pretty(value) { - Ok(json) => { - let tmp_path = path.with_extension("json.tmp"); - if let Err(e) = tokio::fs::write(&tmp_path, json.as_bytes()).await { - tracing::warn!("Failed to write resources state to {:?}: {}", tmp_path, e); - return; - } - // Guard: if a previous bug left a directory at `path`, remove it - // so the atomic rename can succeed. - if path.is_dir() { - tracing::warn!( - "Resources state path {:?} is a directory — removing before write", - path - ); - let _ = tokio::fs::remove_dir_all(path).await; - } - if let Err(e) = tokio::fs::rename(&tmp_path, path).await { - tracing::warn!( - "Failed to rename resources state {:?} -> {:?}: {}", - tmp_path, - path, - e - ); - } - } - Err(e) => { - tracing::warn!("Failed to serialize resources state: {}", e); - } + async fn write_json(path: &Path, value: &serde_json::Value) -> io::Result<()> { + let (tmp_path, json) = Self::prepare_write(path, value)?; + tokio::fs::write(&tmp_path, json).await?; + Self::replace_state_path(path, &tmp_path).await + } + + async fn write_json_durable(path: &Path, value: &serde_json::Value) -> io::Result<()> { + let (tmp_path, json) = Self::prepare_write(path, value)?; + let result = async { + let mut file = tokio::fs::File::create(&tmp_path).await?; + file.write_all(&json).await?; + file.sync_all().await?; + drop(file); + Self::publish_durable(path, &tmp_path).await } + .await; + Self::cleanup_temp_on_error(&tmp_path, result).await + } + + async fn cleanup_temp_on_error(tmp_path: &Path, result: io::Result<()>) -> io::Result<()> { + if result.is_err() { + let _ = tokio::fs::remove_file(tmp_path).await; + } + result + } + + fn prepare_write(path: &Path, value: &serde_json::Value) -> io::Result<(PathBuf, Vec)> { + let json = serde_json::to_vec_pretty(value) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + Ok((path.with_extension("json.tmp"), json)) + } + + async fn replace_state_path(path: &Path, tmp_path: &Path) -> io::Result<()> { + if path.is_dir() { + tracing::warn!( + "Resources state path {:?} is a directory — removing before write", + path + ); + tokio::fs::remove_dir_all(path).await?; + } + tokio::fs::rename(tmp_path, path).await + } + + #[cfg(not(windows))] + async fn publish_durable(path: &Path, tmp_path: &Path) -> io::Result<()> { + Self::replace_state_path(path, tmp_path).await?; + let parent = path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "resources state has no parent") + })?; + tokio::fs::File::open(parent).await?.sync_all().await + } + + #[cfg(windows)] + async fn publish_durable(path: &Path, tmp_path: &Path) -> io::Result<()> { + use windows::Win32::Storage::FileSystem::MoveFileExW; + use windows::core::PCWSTR; + if path.is_dir() { + tokio::fs::remove_dir_all(path).await?; + } + let from = Self::windows_extended_path(tmp_path)?; + let to = Self::windows_extended_path(path)?; + unsafe { + MoveFileExW( + PCWSTR(from.as_ptr()), + PCWSTR(to.as_ptr()), + Self::WINDOWS_MOVE_FLAGS, + ) + } + .map_err(io::Error::other) + } + + #[cfg(windows)] + const WINDOWS_MOVE_FLAGS: windows::Win32::Storage::FileSystem::MOVE_FILE_FLAGS = + windows::Win32::Storage::FileSystem::MOVE_FILE_FLAGS(1 | 8); + + #[cfg(windows)] + fn windows_extended_path(path: &Path) -> io::Result> { + use std::os::windows::ffi::OsStrExt; + let path = std::path::absolute(path)?; + let mut wide = path.as_os_str().encode_wide().collect::>(); + if wide.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path contains NUL", + )); + } + let unc = wide.starts_with(&[92, 92]); + let mut result = if unc { r"\\?\UNC\" } else { r"\\?\" } + .encode_utf16() + .collect::>(); + if unc { + wide.drain(..2); + } + result.extend(wide); + result.push(0); + Ok(result) } } @@ -337,4 +490,139 @@ mod tests { // Should have "state" category with "grok_build.WebCitation" key assert!(parsed["state"]["grok_build.WebCitation"].is_object()); } + + #[tokio::test] + async fn save_and_flush_supersedes_older_pending_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let state_path = dir.path().join("resources_state.json"); + let persistence = ResourcesPersistence::new(state_path.clone()); + persistence.flush().await; + + let mut resources = Resources::new(); + resources.register_state::(); + resources + .get_or_default::>() + .counter = 1; + persistence.save(&resources); + + resources + .get_or_default::>() + .counter = 2; + persistence + .save_and_flush(resources.serialize()) + .await + .unwrap(); + persistence.flush().await; + + let content = std::fs::read_to_string(state_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed["state"]["grok_build.WebCitation"]["counter"], 2); + } + + #[tokio::test] + async fn save_and_flush_error_can_be_retried() { + let dir = tempfile::tempdir().unwrap(); + let parent = dir.path().join("missing"); + let state_path = parent.join("resources_state.json"); + let persistence = ResourcesPersistence::new(state_path.clone()); + + let mut resources = Resources::new(); + resources.register_state::(); + resources + .get_or_default::>() + .counter = 7; + let snapshot = resources.serialize(); + + assert!(persistence.save_and_flush(snapshot.clone()).await.is_err()); + + std::fs::create_dir(parent).unwrap(); + persistence.save_and_flush(snapshot).await.unwrap(); + + let content = std::fs::read_to_string(state_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed["state"]["grok_build.WebCitation"]["counter"], 7); + } + + #[tokio::test] + async fn enqueued_acknowledged_save_precedes_a_newer_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let state_path = dir.path().join("resources_state.json"); + let persistence = ResourcesPersistence::new(state_path.clone()); + persistence.flush().await; + + let mut resources = Resources::new(); + resources.register_state::(); + resources + .get_or_default::>() + .counter = 1; + let acknowledgement = persistence + .enqueue_save_and_flush(resources.serialize()) + .unwrap(); + + resources + .get_or_default::>() + .counter = 2; + persistence.save(&resources); + + ResourcesPersistence::await_save_and_flush(acknowledgement) + .await + .unwrap(); + persistence.flush().await; + + let content = std::fs::read_to_string(state_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed["state"]["grok_build.WebCitation"]["counter"], 2); + } + + #[tokio::test] + async fn post_create_failure_cleans_temp_and_allows_retry() { + let dir = tempfile::tempdir().unwrap(); + let tmp_path = dir.path().join("resources_state.json.tmp"); + std::fs::write(&tmp_path, "partial").unwrap(); + let error = io::Error::other("publish failed"); + let returned = ResourcesPersistence::cleanup_temp_on_error(&tmp_path, Err(error)) + .await + .unwrap_err(); + assert_eq!(returned.to_string(), "publish failed"); + assert!(!tmp_path.exists()); + std::fs::write(&tmp_path, "retry").unwrap(); + } + + #[cfg(windows)] + #[tokio::test] + async fn windows_publish_supports_long_paths_and_legacy_directory() { + use windows::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + assert_eq!( + ResourcesPersistence::WINDOWS_MOVE_FLAGS.0, + MOVEFILE_REPLACE_EXISTING.0 | MOVEFILE_WRITE_THROUGH.0 + ); + let long = PathBuf::from(format!(r"C:\{}", "long\\".repeat(60))); + let wide = ResourcesPersistence::windows_extended_path(&long).unwrap(); + assert!(wide.len() > 260 && String::from_utf16_lossy(&wide).starts_with(r"\\?\")); + let unc = + ResourcesPersistence::windows_extended_path(Path::new(r"\\server\share\state.json")) + .unwrap(); + assert!(String::from_utf16_lossy(&unc).starts_with(r"\\?\UNC\")); + assert!(ResourcesPersistence::windows_extended_path(Path::new("bad\0path")).is_err()); + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("state.json"); + std::fs::create_dir(&target).unwrap(); + let temp = dir.path().join("state.json.tmp"); + std::fs::write(&temp, "new").unwrap(); + ResourcesPersistence::publish_durable(&target, &temp) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(target).unwrap(), "new"); + } + + #[tokio::test] + async fn noop_save_and_flush_acknowledges_without_writing() { + ResourcesPersistence::noop() + .save_and_flush(serde_json::json!({"state": {}})) + .await + .unwrap(); + } } diff --git a/crates/codegen/xai-grok-tools/src/registry/types.rs b/crates/codegen/xai-grok-tools/src/registry/types.rs index 5167fc3..c1cd579 100644 --- a/crates/codegen/xai-grok-tools/src/registry/types.rs +++ b/crates/codegen/xai-grok-tools/src/registry/types.rs @@ -2189,6 +2189,7 @@ mod tests { ] .into_iter() .map(|id| ToolConfig::from_id(format!("GrokBuild:{id}"))) + .chain(std::iter::empty::()) .collect(), behavior_preset: None, }; diff --git a/crates/codegen/xai-grok-tools/src/tool_taxonomy.rs b/crates/codegen/xai-grok-tools/src/tool_taxonomy.rs index 2fc4bce..49736ae 100644 --- a/crates/codegen/xai-grok-tools/src/tool_taxonomy.rs +++ b/crates/codegen/xai-grok-tools/src/tool_taxonomy.rs @@ -345,7 +345,16 @@ mod tests { let mut expected: serde_json::Value = serde_json::from_str(tool_meta_json_schema_str()).expect("checked-in schema parses"); if let Some(values) = expected["definitions"]["ToolNamespace"]["enum"].as_array_mut() { - values.retain(|v| v != "cursor"); + use std::collections::HashSet; + use strum::IntoEnumIterator; + let compiled: HashSet = ToolNamespace::iter() + .filter_map(|ns| { + serde_json::to_value(ns) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + }) + .collect(); + values.retain(|v| matches!(v.as_str(), Some(s) if compiled.contains(s))); } let expected = format!("{}\n", serde_json::to_string_pretty(&expected).unwrap()); assert_eq!( diff --git a/crates/codegen/xai-grok-update/src/auto_update.rs b/crates/codegen/xai-grok-update/src/auto_update.rs index 7a3f970..415fae3 100644 --- a/crates/codegen/xai-grok-update/src/auto_update.rs +++ b/crates/codegen/xai-grok-update/src/auto_update.rs @@ -222,6 +222,7 @@ pub async fn ensure_latest_on_disk(update_config: &UpdateConfig) -> Result Background return BackgroundUpdateCheck::none(); }; + heal_managed_install(installer).await; + if is_version_cache_fresh().await { return BackgroundUpdateCheck::none(); } @@ -466,11 +469,12 @@ pub async fn run_update_if_available( interactive: bool, update_config: &UpdateConfig, ) -> Result { - let installer = get_installer().await; - if installer.is_none() { + let Some(inst) = get_installer().await else { // Skip update check if no known installer. return Ok(false); - } + }; + + heal_managed_install(inst).await; if is_version_cache_fresh().await { return Ok(false); @@ -498,8 +502,6 @@ pub async fn run_update_if_available( } let current_version = get_installed_grok_version(); - // installer is guaranteed Some by the guard at the top of this function. - let inst = installer.unwrap(); // Fetch without writing version.json — we only cache after confirming the // update is not needed or after a successful blocking install. This prevents // a failed background download from suppressing retries for the TTL window. @@ -1856,6 +1858,104 @@ async fn cleanup_old_downloads(dir: &std::path::Path, bin_prefix: &str, current_ } } +fn installer_manages_bin_entrypoints(installer: &str) -> bool { + matches!(installer, "internal" | "gh-release") +} + +#[cfg_attr(not(any(unix, windows)), allow(clippy::unused_async))] +async fn heal_managed_install(installer: &str) { + if !installer_manages_bin_entrypoints(installer) { + return; + } + + #[cfg(any(unix, windows))] + { + let bin_dir = grok_home().join("bin"); + + #[cfg(unix)] + reconcile_agent_to_grok(&bin_dir).await; + + #[cfg(windows)] + reconcile_agent_exe_to_grok(&bin_dir).await; + } +} + +#[cfg(unix)] +async fn reconcile_agent_to_grok(bin_dir: &std::path::Path) { + let grok_link = bin_dir.join("grok"); + let agent_link = bin_dir.join("agent"); + + let Ok(grok_target) = tokio::fs::read_link(&grok_link).await else { + return; + }; + if tokio::fs::metadata(&grok_link).await.is_err() { + return; + } + if let Ok(agent_target) = tokio::fs::read_link(&agent_link).await + && agent_target == grok_target + { + return; + } + match atomic_symlink_swap(&grok_target, &agent_link).await { + Ok(()) => tracing::info!( + grok_target = %grok_target.display(), + "reconciled agent bin symlink to grok target" + ), + Err(e) => tracing::warn!("failed to reconcile agent bin symlink: {e:#}"), + } +} + +#[cfg(windows)] +async fn reconcile_agent_exe_to_grok(bin_dir: &std::path::Path) { + let grok_exe = bin_dir.join("grok.exe"); + let agent_exe = bin_dir.join("agent.exe"); + + if tokio::fs::metadata(&grok_exe).await.is_err() { + return; + } + match agent_exe_differs(&grok_exe, &agent_exe).await { + Ok(true) => {} + Ok(false) => return, + Err(e) => { + tracing::debug!("agent.exe reconcile: compare failed: {e:#}"); + return; + } + } + match windows_replace_exe(&grok_exe, &agent_exe).await { + Ok(()) => tracing::info!("reconciled agent.exe to grok.exe"), + Err(e) => tracing::warn!("failed to reconcile agent.exe to grok.exe: {e:#}"), + } +} + +#[cfg(windows)] +async fn agent_exe_differs( + grok: &std::path::Path, + agent: &std::path::Path, +) -> std::io::Result { + use tokio::io::{AsyncReadExt, BufReader}; + let grok_len = tokio::fs::metadata(grok).await?.len(); + match tokio::fs::metadata(agent).await { + Ok(m) if m.len() != grok_len => return Ok(true), + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true), + Err(e) => return Err(e), + } + let mut rg = BufReader::new(tokio::fs::File::open(grok).await?); + let mut ra = BufReader::new(tokio::fs::File::open(agent).await?); + let mut bg = [0u8; 64 * 1024]; + let mut ba = [0u8; 64 * 1024]; + loop { + let n = rg.read(&mut bg).await?; + if n == 0 { + return Ok(false); + } + ra.read_exact(&mut ba[..n]).await?; + if bg[..n] != ba[..n] { + return Ok(true); + } + } +} + /// Download a single asset from a GitHub release via `gh release download`. async fn gh_release_download(tag: &str, pattern: &str, dest: &std::path::Path) -> Result<()> { let pb = ProgressBar::new_spinner(); @@ -2180,6 +2280,8 @@ pub async fn run_update( .await; } + heal_managed_install(installer).await; + let current_version = get_installed_grok_version(); // When --version is given, skip the latest-version check and install directly @@ -2572,6 +2674,138 @@ mod tests { assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2"); } + #[cfg(unix)] + fn managed_layout() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + let downloads = dir.path().join("downloads"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::create_dir_all(&downloads).unwrap(); + (dir, bin, downloads) + } + + #[test] + fn test_installer_manages_bin_entrypoints_gate() { + assert!(installer_manages_bin_entrypoints("internal")); + assert!(installer_manages_bin_entrypoints("gh-release")); + assert!(!installer_manages_bin_entrypoints("npm")); + assert!(!installer_manages_bin_entrypoints("unknown")); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_reconcile_agent_repoints_diverged_agent() { + let (_dir, bin, downloads) = managed_layout(); + std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap(); + std::fs::write(downloads.join("grok-0.1.199-macos-aarch64"), "old").unwrap(); + + std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok")) + .unwrap(); + std::os::unix::fs::symlink("../downloads/grok-0.1.199-macos-aarch64", bin.join("agent")) + .unwrap(); + + reconcile_agent_to_grok(&bin).await; + + assert_eq!( + std::fs::read_link(bin.join("agent")).unwrap(), + std::path::PathBuf::from("../downloads/grok-0.2.101-macos-aarch64"), + ); + assert_eq!(std::fs::read_to_string(bin.join("agent")).unwrap(), "new"); + assert!(downloads.join("grok-0.1.199-macos-aarch64").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_reconcile_agent_heals_legacy_unversioned_agent() { + let (_dir, bin, downloads) = managed_layout(); + std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap(); + std::fs::write(downloads.join("grok-macos-aarch64"), "legacy").unwrap(); + + std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok")) + .unwrap(); + std::os::unix::fs::symlink("../downloads/grok-macos-aarch64", bin.join("agent")).unwrap(); + + reconcile_agent_to_grok(&bin).await; + + assert_eq!( + std::fs::read_link(bin.join("agent")).unwrap(), + std::path::PathBuf::from("../downloads/grok-0.2.101-macos-aarch64"), + ); + assert_eq!(std::fs::read_to_string(bin.join("agent")).unwrap(), "new"); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_reconcile_agent_creates_missing_agent() { + let (_dir, bin, downloads) = managed_layout(); + std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap(); + std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok")) + .unwrap(); + + reconcile_agent_to_grok(&bin).await; + + assert!(bin.join("agent").is_symlink()); + assert_eq!(std::fs::read_to_string(bin.join("agent")).unwrap(), "new"); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_reconcile_agent_noop_when_consistent() { + let (_dir, bin, downloads) = managed_layout(); + std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap(); + let target = "../downloads/grok-0.2.101-macos-aarch64"; + std::os::unix::fs::symlink(target, bin.join("grok")).unwrap(); + std::os::unix::fs::symlink(target, bin.join("agent")).unwrap(); + + reconcile_agent_to_grok(&bin).await; + + assert_eq!( + std::fs::read_link(bin.join("agent")).unwrap(), + std::path::PathBuf::from(target), + ); + let leftovers = std::fs::read_dir(&bin) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains(".tmp-link")) + .count(); + assert_eq!(leftovers, 0, "no temp links from a no-op reconcile"); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_reconcile_agent_skips_when_grok_dangling() { + let (_dir, bin, downloads) = managed_layout(); + std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok")) + .unwrap(); + std::fs::write(downloads.join("grok-0.1.199-macos-aarch64"), "old").unwrap(); + std::os::unix::fs::symlink("../downloads/grok-0.1.199-macos-aarch64", bin.join("agent")) + .unwrap(); + + reconcile_agent_to_grok(&bin).await; + + assert_eq!( + std::fs::read_link(bin.join("agent")).unwrap(), + std::path::PathBuf::from("../downloads/grok-0.1.199-macos-aarch64"), + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_reconcile_agent_skips_when_grok_not_symlink() { + let (_dir, bin, downloads) = managed_layout(); + std::fs::write(bin.join("grok"), "copy-binary").unwrap(); + std::fs::write(downloads.join("grok-0.1.199-macos-aarch64"), "old").unwrap(); + std::os::unix::fs::symlink("../downloads/grok-0.1.199-macos-aarch64", bin.join("agent")) + .unwrap(); + + reconcile_agent_to_grok(&bin).await; + + assert_eq!( + std::fs::read_link(bin.join("agent")).unwrap(), + std::path::PathBuf::from("../downloads/grok-0.1.199-macos-aarch64"), + ); + } + #[cfg(unix)] #[tokio::test] async fn test_sweep_stale_tmp_links_removes_stale_keeps_fresh_and_active() { diff --git a/crates/codegen/xai-grok-version/Cargo.toml b/crates/codegen/xai-grok-version/Cargo.toml index a72c6ac..15e55eb 100644 --- a/crates/codegen/xai-grok-version/Cargo.toml +++ b/crates/codegen/xai-grok-version/Cargo.toml @@ -1,7 +1,7 @@ [package] license = "Apache-2.0" name = "xai-grok-version" -version = "0.2.101" +version = "0.2.102" edition.workspace = true description = "Lockstepped grok CLI version." diff --git a/crates/codegen/xai-grok-voice/src/auth.rs b/crates/codegen/xai-grok-voice/src/auth.rs index 03f146e..34b8127 100644 --- a/crates/codegen/xai-grok-voice/src/auth.rs +++ b/crates/codegen/xai-grok-voice/src/auth.rs @@ -26,7 +26,10 @@ pub type SharedVoiceAuth = Arc; #[cfg(feature = "audio")] pub(crate) async fn require_bearer(auth: &SharedVoiceAuth) -> Result { auth.bearer().await.ok_or_else(|| { - VoiceError::Auth("not signed in — run `grok login` or set XAI_API_KEY".into()) + VoiceError::Auth( + "not signed in — run `grok login`, set XAI_API_KEY, or set a model api_key/env_key" + .into(), + ) }) } diff --git a/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs b/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs index 4a68d54..6ce9ff2 100644 --- a/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs +++ b/crates/codegen/xai-grok-workspace/src/bin/workspace_server.rs @@ -276,7 +276,8 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> { "Workspace server sandbox NOT active" }; tracing::info!( - profile = % profile_name, active, restrict_network = + profile = % profile_name, active, + restrict_network_at_known_linux_launches = xai_grok_sandbox::should_restrict_child_network(), "{status_msg}" ); } diff --git a/crates/codegen/xai-grok-workspace/src/folder_trust.rs b/crates/codegen/xai-grok-workspace/src/folder_trust.rs index 49cd730..0c2b92d 100644 --- a/crates/codegen/xai-grok-workspace/src/folder_trust.rs +++ b/crates/codegen/xai-grok-workspace/src/folder_trust.rs @@ -254,6 +254,14 @@ pub fn repo_config_kinds(cwd: &Path) -> Vec<&'static str> { collect_repo_config_kinds(cwd, false) } +fn path_present_or_uncertain(path: &Path) -> bool { + match std::fs::symlink_metadata(path) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => true, + } +} + /// Shared scanner behind [`repo_configs_present`] and [`repo_config_kinds`]. With /// `first_only` it returns immediately after the first marker (the gate's /// historical short-circuit); otherwise it collects every distinct kind. @@ -346,7 +354,7 @@ fn collect_repo_config_kinds(cwd: &Path, first_only: bool) -> Vec<&'static str> // resolve trusted and run ungated. Presence mirrors discovery's "something to // gate" check. let hook_root = chain.git_root.as_deref().unwrap_or(cwd); - if hook_root.join(".grok").join("hooks").is_dir() + if path_present_or_uncertain(&hook_root.join(".grok").join("hooks")) || hook_root.join(".cursor").join("hooks.json").is_file() { hit!("hooks"); @@ -610,6 +618,29 @@ mod tests { assert!(repo_configs_present(tmp.path())); } + #[test] + fn repo_configs_present_detects_project_hooks_file() { + let tmp = repo_tmp(); + let grok = tmp.path().join(".grok"); + std::fs::create_dir_all(&grok).unwrap(); + std::fs::write(grok.join("hooks"), "{}").unwrap(); + + assert!(repo_configs_present(tmp.path())); + assert!(repo_config_kinds(tmp.path()).contains(&"hooks")); + } + + #[cfg(unix)] + #[test] + fn repo_configs_present_detects_dangling_project_hooks_symlink() { + let tmp = repo_tmp(); + let grok = tmp.path().join(".grok"); + std::fs::create_dir_all(&grok).unwrap(); + std::os::unix::fs::symlink("missing-hooks", grok.join("hooks")).unwrap(); + + assert!(repo_configs_present(tmp.path())); + assert!(repo_config_kinds(tmp.path()).contains(&"hooks")); + } + #[test] fn repo_configs_present_detects_project_hooks_from_subdir() { // Hooks live at the git root but the session is launched from a subdir; diff --git a/crates/codegen/xai-grok-workspace/src/handle.rs b/crates/codegen/xai-grok-workspace/src/handle.rs index 88d8abd..4cf69d3 100644 --- a/crates/codegen/xai-grok-workspace/src/handle.rs +++ b/crates/codegen/xai-grok-workspace/src/handle.rs @@ -702,10 +702,11 @@ impl WorkspaceHandle { TrackingMode::AllDirty, hunk_cancel.clone(), ); - let result = self.create_session_with_tracker_and_viewer_ctx( + let result = self.create_session_with_tracker_inner( session_id, session_cwd, hunk_tracker, + Some(hunk_cancel.clone()), tool_config, capability, viewer_ctx, @@ -738,7 +739,8 @@ impl WorkspaceHandle { ) } /// Variant of [`create_session_with_tracker`](Self::create_session_with_tracker) - /// that carries a session-bind viewer context. + /// that carries a session-bind viewer context. The tracker is externally + /// owned, so the session stores no cancel token for it. pub fn create_session_with_tracker_and_viewer_ctx( &self, session_id: impl Into, @@ -748,6 +750,32 @@ impl WorkspaceHandle { capability: CapabilityMode, viewer_ctx: Option, system_notifications: bool, + ) -> WorkspaceResult> { + self.create_session_with_tracker_inner( + session_id, + cwd, + hunk_tracker, + None, + tool_config, + capability, + viewer_ctx, + system_notifications, + ) + } + /// Shared creation body. `hunk_tracker_cancel` is `Some` only for + /// workspace-spawned trackers, whose actor lifetime the session then + /// owns; externally owned trackers pass `None`. + #[allow(clippy::too_many_arguments)] + fn create_session_with_tracker_inner( + &self, + session_id: impl Into, + cwd: std::path::PathBuf, + hunk_tracker: HunkTrackerHandle, + hunk_tracker_cancel: Option, + tool_config: Option, + capability: CapabilityMode, + viewer_ctx: Option, + system_notifications: bool, ) -> WorkspaceResult> { let session_id = session_id.into(); if session_id.is_empty() { @@ -797,6 +825,7 @@ impl WorkspaceHandle { toolset, terminal_backend, hunk_tracker, + hunk_tracker_cancel, viewer_ctx, system_notifications, system_notify_channel, @@ -2739,7 +2768,7 @@ impl WorkspaceHandle { cwd.clone(), hunk_event_tx, TrackingMode::AllDirty, - hunk_cancel, + hunk_cancel.clone(), ); let session = Arc::new(WorkspaceSession::new( config.agent_id.clone(), @@ -2752,6 +2781,7 @@ impl WorkspaceHandle { toolset, terminal_backend, hunk_tracker, + Some(hunk_cancel), inherited_viewer_ctx, false, None, @@ -2759,9 +2789,11 @@ impl WorkspaceHandle { { let mut sessions = self.shared.sessions.write(); if self.shared.activity_tracker.is_draining() { + session.cancel_hunk_tracker(); return Err(WorkspaceError::ShuttingDown); } if sessions.contains_key(&config.agent_id) { + session.cancel_hunk_tracker(); return Err(WorkspaceError::SessionAlreadyExists(config.agent_id)); } sessions.insert(config.agent_id.clone(), session.clone()); @@ -2785,6 +2817,7 @@ impl WorkspaceHandle { drop(sessions); session.abort_system_notify_forwarder(); session.shutdown_terminal_backend(); + session.cancel_hunk_tracker(); self.shared.tool_defs_last_emit.remove(session_id); Ok(()) } @@ -5726,6 +5759,98 @@ pub(crate) mod tests { assert_backend_stops(&retained_backend).await; drop(retained_toolset); } + async fn assert_hunk_tracker_stops(tracker: &xai_hunk_tracker::HunkTrackerHandle) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !tracker.is_closed() { + assert!( + std::time::Instant::now() < deadline, + "hunk-tracker actor must stop within the deadline despite live \ + handle clones" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + /// `drop_session` cancels the workspace-spawned hunk-tracker actor even + /// while a leaked `HunkTrackerHandle` clone keeps its command channel + /// open. Rationale on `cancel_hunk_tracker`. + #[tokio::test] + async fn drop_session_cancels_workspace_spawned_hunk_tracker() { + let handle = make_handle(); + let session = handle + .create_session_with_config("doomed-ht", None, None, CapabilityMode::All, None, false) + .expect("create session"); + let leaked_tracker = session.hunk_tracker().clone(); + assert!( + !leaked_tracker.is_closed(), + "precondition: the actor is alive while the session exists" + ); + drop(session); + handle.drop_session("doomed-ht", "doomed-ht").expect("drop"); + assert_hunk_tracker_stops(&leaked_tracker).await; + } + /// Same guarantee for the fork spawn site. + #[tokio::test] + async fn drop_session_cancels_forked_session_hunk_tracker() { + let handle = make_handle(); + let child = handle + .fork_session(fork_cfg_with( + "child-ht", + CapabilityMode::ReadWrite, + None, + Some("main"), + )) + .await + .expect("fork should succeed"); + let leaked_tracker = child.hunk_tracker().clone(); + assert!( + !leaked_tracker.is_closed(), + "precondition: the actor is alive while the session exists" + ); + drop(child); + handle.drop_session("child-ht", "child-ht").expect("drop"); + assert_hunk_tracker_stops(&leaked_tracker).await; + } + /// The inverse guarantee: a tracker bound via `create_session_with_tracker` + /// is externally owned, so `drop_session` must NOT cancel it. The agent + /// shares such trackers with the workspace session. + #[tokio::test] + async fn drop_session_leaves_externally_owned_hunk_tracker_alive() { + let handle = make_handle(); + let cwd = handle.shared.root_cwd.clone(); + let (hunk_event_tx, _hunk_event_rx) = tokio::sync::mpsc::unbounded_channel(); + let owner_cancel = tokio_util::sync::CancellationToken::new(); + let tracker = HunkTrackerActor::spawn( + "external-ht".to_string(), + cwd.clone(), + hunk_event_tx, + TrackingMode::AllDirty, + owner_cancel.clone(), + ); + let session = handle + .create_session_with_tracker( + "external-ht", + cwd, + tracker.clone(), + None, + CapabilityMode::All, + ) + .expect("create session"); + assert!( + !tracker.is_closed(), + "precondition: the actor is alive while the session exists" + ); + drop(session); + handle + .drop_session("external-ht", "external-ht") + .expect("drop"); + let _ = tracker.get_all_hunks().await; + assert!( + !tracker.is_closed(), + "drop_session must not cancel an externally owned hunk tracker" + ); + owner_cancel.cancel(); + assert_hunk_tracker_stops(&tracker).await; + } /// Isolation matrix #5: a workspace process restart loses tasks (they are /// process state — physics), and what's pinned here is the recovery UX: /// the same session id recreates cleanly on the fresh process, the task diff --git a/crates/codegen/xai-grok-workspace/src/hub_server.rs b/crates/codegen/xai-grok-workspace/src/hub_server.rs index 4e4f5c0..2c8c23d 100644 --- a/crates/codegen/xai-grok-workspace/src/hub_server.rs +++ b/crates/codegen/xai-grok-workspace/src/hub_server.rs @@ -1078,6 +1078,7 @@ impl ToolServerHandler for WorkspaceRpcHandler { if let Some(session) = sessions.remove(sid) { session.abort_system_notify_forwarder(); session.shutdown_terminal_backend(); + session.cancel_hunk_tracker(); } let empty = sessions.is_empty(); let already_winding_down = self.workspace.activity_tracker().is_draining(); diff --git a/crates/codegen/xai-grok-workspace/src/permission/auto_mode.rs b/crates/codegen/xai-grok-workspace/src/permission/auto_mode.rs index b9d49d4..d413832 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/auto_mode.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/auto_mode.rs @@ -14,7 +14,9 @@ use super::bash_command_splitting::{ PlainCommand, is_wrapper_command, strip_wrapper_command, try_parse_shell, try_parse_word_only_commands_sequence, unwrap_wrappers, }; -use super::shell_access::{command_words_write_paths, command_write_paths_in_tree}; +use super::shell_access::{ + command_words_write_paths, command_write_paths_in_tree, is_safe_write_sink, +}; use super::types::AccessKind; /// Classifier outcome for a single tool authorization. @@ -408,7 +410,7 @@ fn classify_bash(cmd: &str) -> ClassifierVerdict { // (or any `env` option) can change which binary runs / how code resolves. // Read from the PARSED, quote-stripped tree so `env "LD_PRELOAD=..."` can't // hide the key. - if sets_unsafe_env(tree.root_node(), cmd, &cmds) { + if script_sets_unsafe_env(tree.root_node(), cmd, &cmds) { return ClassifierVerdict::Block; } // A routine command can still write an arbitrary destination via a redirect @@ -744,7 +746,7 @@ fn explicit_launch_target<'a>(head: &str, inner: &'a [String]) -> LaunchTarget<' /// whose KEY is not in [`SAFE_ENV_KEYS`], or passes an option to `env` (which can /// run a string, clear, or unset the environment). Reads the PARSED tree so /// quoting (`env "LD_PRELOAD=..."`) can't hide a key from the check. -fn sets_unsafe_env(root: Node<'_>, src: &str, cmds: &[PlainCommand]) -> bool { +pub(crate) fn script_sets_unsafe_env(root: Node<'_>, src: &str, cmds: &[PlainCommand]) -> bool { // (a) Inline `KEY=val cmd` assignments are `variable_assignment` nodes // (stripped from PlainCommand words), so walk the tree for them. let mut stack = vec![root]; @@ -823,12 +825,6 @@ fn is_lone_wrapper(words: &[String]) -> bool { words.len() == 1 && is_wrapper_command(words) } -/// Safe write sinks: writing to these discards/echoes output rather than -/// touching a real file. Exact match. -fn is_safe_write_sink(path: &str) -> bool { - matches!(path, "/dev/null" | "/dev/stdout" | "/dev/stderr") -} - /// `find` is routine ONLY when it has no action primary that deletes, executes, /// or writes files. Operates on the already-unwrapped command words. fn find_is_read_only(words: &[String]) -> bool { diff --git a/crates/codegen/xai-grok-workspace/src/permission/bash_command_splitting.rs b/crates/codegen/xai-grok-workspace/src/permission/bash_command_splitting.rs index 09ef3c1..dc1e7b9 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/bash_command_splitting.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/bash_command_splitting.rs @@ -152,7 +152,7 @@ pub(crate) fn is_setup_command(cmd: &[String]) -> bool { matches!( cmd[0].as_str(), - "cd" | "pushd" | "popd" | "export" | "unset" | "set" | "source" | "." | "sleep" | "timeout" + "cd" | "pushd" | "popd" | "export" | "unset" | "set" | "sleep" | "timeout" ) } diff --git a/crates/codegen/xai-grok-workspace/src/permission/manager.rs b/crates/codegen/xai-grok-workspace/src/permission/manager.rs index bedae67..b8e2b18 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/manager.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/manager.rs @@ -7,12 +7,15 @@ use chrono::Utc; use tokio::sync::{mpsc, oneshot}; use xai_acp_lib::AcpAgentGatewaySender as GatewaySender; +use crate::permission::auto_mode::script_sets_unsafe_env; use crate::permission::bash_command_splitting::{ - all_commands_from_script, is_setup_command, unwrap_wrappers, + is_setup_command, try_parse_shell, try_parse_word_only_commands_sequence, unwrap_wrappers, }; use crate::permission::policy::CompiledPolicy; use crate::permission::prompter::{AcpPrompter, PromptOutcome}; -use crate::permission::shell_access::combine_decisions; +use crate::permission::shell_access::{ + combine_decisions, command_write_paths_in_tree, is_safe_write_sink, +}; use crate::permission::state::{PermissionState, load_state_from_disk, persist_state}; use crate::permission::types::{ AccessKind, ClientType, Decision, EditPolicy, PermissionCommand, PermissionEvent, PromptPolicy, @@ -339,8 +342,7 @@ fn matches_whitelist_prefix(segment_str: &str, allowed_prefix: &str) -> bool { matches_command_prefix(segment_str, allowed_prefix) } -/// Result of evaluating a bash script's segments against the current -/// permission state. +/// Ordinary command-segment outcome, before script-level effect floors. #[derive(Debug)] pub(crate) enum SegmentEvaluation { /// All non-setup segments safe/always-safe or on an allow-prefix. @@ -348,7 +350,7 @@ pub(crate) enum SegmentEvaluation { AutoAllow { via_session_grant: bool }, /// Disallow-prefix matched; reject without prompting. Reject(String), - /// Needs a user decision (manager prompts once for the full script). + /// One or more segments need a user decision. NeedsPrompts { #[allow(dead_code)] segments: Vec, @@ -360,34 +362,51 @@ pub(crate) enum SegmentEvaluation { Unparseable, } -/// Walk every command in a chained script and classify the script as a whole -/// against the current permission state. -/// -/// This is the per-segment replacement for the previous primary-only check, -/// closing the auto-allow bypass where a safe / whitelisted primary command -/// could smuggle a dangerous follow-on through `&&`, `||`, `;`, or `|`. -pub(crate) fn evaluate_bash_segments(cmd: &str, state: &PermissionState) -> SegmentEvaluation { - evaluate_bash_segments_inner(cmd, state, true) +/// One request's parsed Bash authorization facts. +#[derive(Debug)] +struct BashEvaluation { + segments: SegmentEvaluation, + writes_real_file: bool, + sets_unsafe_env: bool, + exact_grant: bool, + all_segments_granted: bool, } -/// Core of [`evaluate_bash_segments`]. When `honor_safe_lists` is `false`, the -/// built-in safe / always-safe command lists are ignored, so only the user's -/// explicit `allowed_bash_commands` grants auto-allow a segment. That mode lets -/// an explicit "Always allow" grant satisfy an `ask` policy floor (ask once, -/// then remember) without letting the floor be bypassed for commands the user -/// never personally approved. Disallow and dangerous handling are identical in -/// both modes. -pub(crate) fn evaluate_bash_segments_inner( - cmd: &str, - state: &PermissionState, - honor_safe_lists: bool, -) -> SegmentEvaluation { - let Some(segments) = all_commands_from_script(cmd) else { - return SegmentEvaluation::Unparseable; +/// Parse and classify one Bash request once, keeping ordinary segment outcome +/// separate from the script-level real-file-write and unsafe-environment floors. +fn evaluate_bash(cmd: &str, state: &PermissionState, honor_safe_lists: bool) -> BashEvaluation { + let exact_grant = state.allowed_bash_commands.contains(cmd); + let Some(tree) = try_parse_shell(cmd) else { + return BashEvaluation { + segments: SegmentEvaluation::Unparseable, + writes_real_file: false, + sets_unsafe_env: false, + exact_grant, + all_segments_granted: false, + }; + }; + let writes_real_file = command_write_paths_in_tree(tree.root_node(), cmd) + .into_iter() + .any(|path| !is_safe_write_sink(&path)); + let segments = try_parse_word_only_commands_sequence(&tree, cmd); + let sets_unsafe_env = script_sets_unsafe_env( + tree.root_node(), + cmd, + segments.as_deref().unwrap_or_default(), + ); + let Some(segments) = segments else { + return BashEvaluation { + segments: SegmentEvaluation::Unparseable, + writes_real_file, + sets_unsafe_env, + exact_grant, + all_segments_granted: false, + }; }; let mut needs_prompt: Vec = Vec::new(); let mut any_dangerous = false; let mut via_session_grant = false; + let mut all_segments_granted = true; for parsed in segments { let raw_words = parsed.words(); // Peel wrapper commands like `timeout 30 …`, `env FOO=1 …`, `nice -n 5 …` @@ -406,11 +425,23 @@ pub(crate) fn evaluate_bash_segments_inner( .iter() .find(|d| matches_whitelist_prefix(&s, d)) { - return SegmentEvaluation::Reject(format!( - "User previously rejected `{d}` for this session" - )); + return BashEvaluation { + segments: SegmentEvaluation::Reject(format!( + "User previously rejected `{d}` for this session" + )), + writes_real_file, + sets_unsafe_env, + exact_grant, + all_segments_granted, + }; } + let matched_grant = state + .allowed_bash_commands + .iter() + .any(|a| matches_whitelist_prefix(&s, a)); + all_segments_granted &= matched_grant; + // 2. Dangerous commands must be prompted even if a whitelist prefix // would otherwise match. This preserves the historical invariant // that `is_dangerous_command` took precedence over auto-allow. @@ -422,10 +453,6 @@ pub(crate) fn evaluate_bash_segments_inner( // 3. Auto-allow conditions. Built-in safe lists count only when // `honor_safe_lists` is set; an explicit user grant always counts. - let matched_grant = state - .allowed_bash_commands - .iter() - .any(|a| matches_whitelist_prefix(&s, a)); let matched_safe = honor_safe_lists && (is_safe_command_words(words) || is_always_safe_command_words(words)); if matched_grant || matched_safe { @@ -438,16 +465,37 @@ pub(crate) fn evaluate_bash_segments_inner( // 4. Otherwise: prompt for this segment. needs_prompt.push(s); } - if needs_prompt.is_empty() { + let segments = if needs_prompt.is_empty() { SegmentEvaluation::AutoAllow { via_session_grant } } else { SegmentEvaluation::NeedsPrompts { segments: needs_prompt, any_dangerous, } + }; + BashEvaluation { + segments, + writes_real_file, + sets_unsafe_env, + exact_grant, + all_segments_granted, } } +#[cfg(test)] +pub(crate) fn evaluate_bash_segments(cmd: &str, state: &PermissionState) -> SegmentEvaluation { + evaluate_bash(cmd, state, true).segments +} + +#[cfg(test)] +pub(crate) fn evaluate_bash_segments_inner( + cmd: &str, + state: &PermissionState, + honor_safe_lists: bool, +) -> SegmentEvaluation { + evaluate_bash(cmd, state, honor_safe_lists).segments +} + impl PermissionHandle { pub fn allow_all() -> Self { PermissionHandle::AllowAll @@ -691,32 +739,42 @@ fn persisted_bash_auto_allows( (state.allow_bash_execute && yolo_pin.is_none()) || state.allowed_bash_commands.contains(cmd) } +fn bash_write_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool { + evaluation.is_some_and(|evaluation| evaluation.writes_real_file && !evaluation.exact_grant) +} + +fn bash_unsafe_env_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool { + evaluation.is_some_and(|evaluation| evaluation.sets_unsafe_env && !evaluation.exact_grant) +} + +fn bash_request_floor_requires_prompt(evaluation: Option<&BashEvaluation>) -> bool { + bash_write_floor_requires_prompt(evaluation) + || bash_unsafe_env_floor_requires_prompt(evaluation) +} + +fn sandbox_may_auto_allow_bash(evaluation: Option<&BashEvaluation>, sandbox_active: bool) -> bool { + sandbox_active && !bash_request_floor_requires_prompt(evaluation) +} + /// Policy knobs for [`bash_grant_pre_decision`]. #[derive(Clone, Copy)] struct BashGrantOpts { - /// When false, only explicit `allowed_bash_commands` count as AutoAllow. honor_safe_lists: bool, - /// When false, NeedsPrompts / Unparseable never auto-allow via grants. allow_blanket: bool, - /// When true (pre-classifier), refuse approve-all on dangerous / unparseable - /// scripts so the classifier still sees them. Exact full-script grants stick. conservative_blanket: bool, } impl BashGrantOpts { - /// Early short-circuit before auto classify. const PRE_CLASSIFIER: Self = Self { honor_safe_lists: true, allow_blanket: true, conservative_blanket: true, }; - /// Managed Ask floor with remember_tool_approvals (explicit grants only). const ASK_FLOOR_REMEMBER: Self = Self { honor_safe_lists: false, allow_blanket: false, conservative_blanket: false, }; - /// Post-classify bash arm; blanket only when auto did not force a prompt. fn post_classify(auto_forced_prompt: bool) -> Self { Self { honor_safe_lists: true, @@ -730,54 +788,50 @@ fn grant_allow(reason: &'static str) -> Option<(Decision, &'static str)> { Some((Decision::Allow, reason)) } -/// Shared bash grant path for pre-classifier short-circuit and post-classify arm. fn bash_grant_pre_decision( cmd: &str, + evaluation: &BashEvaluation, state: &PermissionState, yolo_pin: Option<&'static str>, opts: BashGrantOpts, ) -> Option<(Decision, &'static str)> { - let eval = if opts.honor_safe_lists { - evaluate_bash_segments(cmd, state) - } else { - evaluate_bash_segments_inner(cmd, state, false) - }; - match eval { - SegmentEvaluation::Reject(reason) => { - Some((Decision::Reject(reason), reasons::SESSION_DENY)) - } - SegmentEvaluation::AutoAllow { via_session_grant } => grant_allow(if via_session_grant { - reasons::SESSION_GRANT - } else { - reasons::SAFE_COMMAND - }), - SegmentEvaluation::NeedsPrompts { any_dangerous, .. } => { - if !opts.allow_blanket { - return None; - } - if any_dangerous && opts.conservative_blanket { - return None; - } - if persisted_bash_auto_allows(state, cmd, yolo_pin) { - grant_allow(reasons::SESSION_GRANT) - } else { + if let SegmentEvaluation::Reject(reason) = &evaluation.segments { + return Some((Decision::Reject(reason.to_owned()), reasons::SESSION_DENY)); + } + if bash_request_floor_requires_prompt(Some(evaluation)) { + return None; + } + match &evaluation.segments { + SegmentEvaluation::Reject(_) => unreachable!(), + SegmentEvaluation::AutoAllow { via_session_grant } => { + if !opts.honor_safe_lists && !evaluation.all_segments_granted { None + } else { + grant_allow(if *via_session_grant { + reasons::SESSION_GRANT + } else { + reasons::SAFE_COMMAND + }) + } + } + SegmentEvaluation::NeedsPrompts { any_dangerous, .. } => { + if !opts.allow_blanket || (*any_dangerous && opts.conservative_blanket) { + None + } else { + persisted_bash_auto_allows(state, cmd, yolo_pin) + .then_some((Decision::Allow, reasons::SESSION_GRANT)) } } SegmentEvaluation::Unparseable => { if !opts.allow_blanket { - return None; - } - // Conservative path: approve-all must not cover undecomposable scripts. - let allowed = if opts.conservative_blanket { - state.allowed_bash_commands.contains(cmd) - } else { - persisted_bash_auto_allows(state, cmd, yolo_pin) - }; - if allowed { - grant_allow(reasons::SESSION_GRANT) - } else { None + } else { + let allowed = if opts.conservative_blanket { + evaluation.exact_grant + } else { + persisted_bash_auto_allows(state, cmd, yolo_pin) + }; + allowed.then_some((Decision::Allow, reasons::SESSION_GRANT)) } } } @@ -787,6 +841,7 @@ fn bash_grant_pre_decision( /// Caller must skip under policy/shell Ask floors. fn session_grant_pre_decision( access: &AccessKind, + bash_evaluation: Option<&BashEvaluation>, state: &PermissionState, allow_edits_for_session: bool, static_domain_matcher: &DomainMatcher, @@ -811,9 +866,13 @@ fn session_grant_pre_decision( } } AccessKind::Edit(_) if allow_edits_for_session => grant_allow(reasons::SESSION_GRANT), - AccessKind::Bash(cmd) => { - bash_grant_pre_decision(cmd, state, yolo_pin, BashGrantOpts::PRE_CLASSIFIER) - } + AccessKind::Bash(cmd) => bash_grant_pre_decision( + cmd, + bash_evaluation?, + state, + yolo_pin, + BashGrantOpts::PRE_CLASSIFIER, + ), AccessKind::Read(_) | AccessKind::Grep { .. } | AccessKind::WebSearch(_) @@ -1150,6 +1209,11 @@ fn spawn_permission_manager_with_pin( continue; } + let bash_evaluation = match &access { + AccessKind::Bash(cmd) => Some(evaluate_bash(cmd, &state, true)), + _ => None, + }; + // Evaluate managed policy (direct access + per-segment Bash command // rules + Bash shell-file args) up front so the YOLO/sandbox fast // paths below honor a deny or forced prompt. @@ -1212,6 +1276,7 @@ fn spawn_permission_manager_with_pin( && !shell_forced_prompt && let Some((decision, reason)) = session_grant_pre_decision( &access, + bash_evaluation.as_ref(), &state, allow_edits_for_session, &static_domain_matcher, @@ -1232,8 +1297,12 @@ fn spawn_permission_manager_with_pin( // Policy deny already handled; forced Ask falls through unless // fast-path/classifier allows. Policy Ask still prompts below // unless auto fast-path/classifier decides first for non-forced - // paths — we skip auto entirely when policy_forced_prompt. - if auto_mode && !policy_forced_prompt && !shell_forced_prompt { + // paths; policy and Bash request floors skip auto entirely. + if auto_mode + && !policy_forced_prompt + && !shell_forced_prompt + && !bash_request_floor_requires_prompt(bash_evaluation.as_ref()) + { use crate::permission::auto_mode::{ AutoFastPath, ClassifierVerdict, access_requires_user_interaction, auto_mode_fast_path, @@ -1331,7 +1400,10 @@ fn spawn_permission_manager_with_pin( } if matches!(&access, AccessKind::Bash(_)) - && xai_grok_sandbox::should_auto_allow_bash() + && sandbox_may_auto_allow_bash( + bash_evaluation.as_ref(), + xai_grok_sandbox::should_auto_allow_bash(), + ) && !policy_forced_prompt && !auto_forced_prompt { @@ -1348,8 +1420,8 @@ fn spawn_permission_manager_with_pin( // `policy_forced_prompt` is consumed by the MCP arm of the // pre-decision match: a policy `Ask` rule on an MCP tool // overrides the session allowlist and forces a re-prompt. - // Other access kinds (Bash / Edit / WebFetch) keep their - // legacy fall-through behavior. + // Other access kinds keep their legacy fall-through behavior, + // subject to Bash request floors. match policy_decision { Some(Decision::Ask) => { tracing::info!( @@ -1358,6 +1430,15 @@ fn spawn_permission_manager_with_pin( "permission policy: ask rule matched, prompting user" ); } + Some(Decision::Allow) + if bash_request_floor_requires_prompt(bash_evaluation.as_ref()) => + { + tracing::info!( + tool = ?tool_name, + source = "policy", + "permission policy allow deferred to Bash prompt floor" + ); + } Some(decision) => { tracing::info!( tool = ?tool_name, @@ -1426,7 +1507,9 @@ fn spawn_permission_manager_with_pin( } } AccessKind::Bash(cmd) => { - if policy_forced_prompt { + if bash_request_floor_requires_prompt(bash_evaluation.as_ref()) { + None + } else if policy_forced_prompt { // Ask floor: only explicit grants with remember on. // `!shell_file_forced_prompt` blocks bash grants from // satisfying a Read/Edit ask escalated from shell-file access. @@ -1436,6 +1519,9 @@ fn spawn_permission_manager_with_pin( { bash_grant_pre_decision( cmd, + bash_evaluation + .as_ref() + .expect("Bash access has evaluation"), &state, yolo_pin, BashGrantOpts::ASK_FLOOR_REMEMBER, @@ -1446,6 +1532,9 @@ fn spawn_permission_manager_with_pin( } else { bash_grant_pre_decision( cmd, + bash_evaluation + .as_ref() + .expect("Bash access has evaluation"), &state, yolo_pin, BashGrantOpts::post_classify(auto_forced_prompt), @@ -1803,6 +1892,10 @@ mod tests { // ── Managed-policy pin: yolo clamp + persisted bash clamp ── const PIN: &str = crate::permission::resolution::YOLO_PIN_REASON_REQUIREMENTS; + const UNSAFE_GIT_STATUS: &str = concat!( + "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor ", + "GIT_CONFIG_VALUE_0=/tmp/pwn git status" + ); #[test] fn clamp_yolo_respects_pin() { @@ -3196,6 +3289,67 @@ mod tests { .await; } + #[tokio::test] + async fn sourced_script_prompts_once_in_ask_mode() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, _e) = + manager_with_recording_client(&cwd, None, client, ClientType::Generic); + + let d = tokio::time::timeout( + std::time::Duration::from_secs(5), + mgr.request( + AccessKind::Bash("source ./setup.sh".into()), + tool_call(), + None, + None, + None, + ), + ) + .await + .expect("permission request must resolve, not hang"); + + assert_eq!(prompts.borrow().len(), 1, "sourced script must prompt once"); + assert!(matches!(d, Decision::Reject(_)), "got {d:?}"); + }) + .await; + } + + #[tokio::test] + async fn sourced_script_dont_ask_denies_without_prompt() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let mut config = crate::permission::types::PermissionConfig::new(vec![]); + config.prompt_policy = PromptPolicy::Deny; + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, _e) = + manager_with_recording_client(&cwd, Some(config), client, ClientType::Generic); + + let d = mgr + .request( + AccessKind::Bash("source ./setup.sh".into()), + tool_call(), + None, + None, + None, + ) + .await; + + assert!(matches!(d, Decision::PolicyDeny(_)), "got {d:?}"); + assert!(prompts.borrow().is_empty(), "dontAsk must not prompt"); + }) + .await; + } + /// Chained unsafe segments must produce **one** permission prompt for the /// full script, not one prompt per segment. `evaluate_bash_segments` still /// decomposes for auto-allow/reject, but the interactive path no longer @@ -3235,9 +3389,122 @@ mod tests { .await; } - /// Negative direction: with no policy rule, the same bash-safe `ls` - /// auto-allows via bash-safety and is never prompted — proving the fix did - /// not over-correct into prompting on plain auto-allowed commands. + async fn run_bash_request(cmd: &str, policy: PromptPolicy) -> (Decision, usize) { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let mut config = crate::permission::types::PermissionConfig::new(vec![]); + config.prompt_policy = policy; + let (mgr, _events) = + manager_with_recording_client(&cwd, Some(config), client, ClientType::Generic); + let decision = mgr + .request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None) + .await; + let count = prompts.borrow().len(); + (decision, count) + } + + async fn run_write_request(policy: PromptPolicy) -> (Decision, usize) { + run_bash_request("cat payload > out", policy).await + } + + #[tokio::test] + async fn real_file_write_prompts_once() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (decision, prompts) = run_write_request(PromptPolicy::Ask).await; + assert!(matches!(decision, Decision::Reject(_))); + assert_eq!(prompts, 1); + }) + .await; + } + + #[tokio::test] + async fn configured_bash_allow_does_not_cross_write_floor() { + use crate::permission::types::{PatternMode, PermissionRule, RuleAction, ToolFilter}; + + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let tmp = tempfile::tempdir().unwrap(); + let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap(); + let config = + crate::permission::types::PermissionConfig::new(vec![PermissionRule { + action: RuleAction::Allow, + tool: ToolFilter::Bash, + pattern: Some("*".to_owned()), + pattern_mode: PatternMode::Glob, + }]); + let client = RecordingClient::default(); + let prompts = client.prompts.clone(); + let (mgr, _events) = + manager_with_recording_client(&cwd, Some(config), client, ClientType::Generic); + for cmd in ["cat payload > out", UNSAFE_GIT_STATUS] { + let decision = mgr + .request(AccessKind::Bash(cmd.into()), tool_call(), None, None, None) + .await; + assert!(matches!(decision, Decision::Reject(_)), "{cmd}"); + } + assert_eq!(prompts.borrow().len(), 2); + }) + .await; + } + + #[tokio::test] + async fn real_file_write_dont_ask_rejects_without_prompt() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (decision, prompts) = run_write_request(PromptPolicy::Deny).await; + assert!(matches!(decision, Decision::PolicyDeny(_))); + assert_eq!(prompts, 0); + }) + .await; + } + + #[tokio::test] + async fn unsafe_environment_ask_and_dont_ask() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (decision, prompts) = + run_bash_request(UNSAFE_GIT_STATUS, PromptPolicy::Ask).await; + assert!(matches!(decision, Decision::Reject(_))); + assert_eq!(prompts, 1); + + let (decision, prompts) = + run_bash_request(UNSAFE_GIT_STATUS, PromptPolicy::Deny).await; + assert!(matches!(decision, Decision::PolicyDeny(_))); + assert_eq!(prompts, 0); + }) + .await; + } + + #[test] + fn sandbox_auto_allow_respects_real_file_write_floor() { + let state = PermissionState::default(); + for cmd in ["cat payload > out", UNSAFE_GIT_STATUS] { + assert!(!sandbox_may_auto_allow_bash( + Some(&evaluate_bash(cmd, &state, true)), + true, + )); + } + for cmd in [ + "cargo build > /dev/null", + "cargo build 2>&1", + "RUST_LOG=debug git status", + ] { + assert!( + sandbox_may_auto_allow_bash(Some(&evaluate_bash(cmd, &state, true)), true), + "sandbox control: {cmd}" + ); + } + } + + /// Negative direction: with no policy rule, bash-safe `ls` auto-allows + /// without a prompt. #[tokio::test] async fn bash_safe_command_without_policy_auto_allows_without_prompt() { let local = tokio::task::LocalSet::new(); @@ -4434,6 +4701,29 @@ mod tests { } } + #[test] + fn evaluate_sourced_scripts_need_prompt() { + let state = PermissionState::default(); + for (cmd, expected) in [ + ("source ./setup.sh", "source ./setup.sh"), + (". ./setup.sh", ". ./setup.sh"), + ("cd repo && source ./setup.sh", "source ./setup.sh"), + ("timeout 5 source ./setup.sh", "source ./setup.sh"), + ] { + match evaluate_bash_segments(cmd, &state) { + SegmentEvaluation::NeedsPrompts { segments, .. } => { + assert_eq!(segments, vec![expected.to_owned()]); + } + other => panic!("expected NeedsPrompts for `{cmd}`, got {other:?}"), + } + } + + assert!(matches!( + evaluate_bash_segments("cd repo && git status", &state), + SegmentEvaluation::AutoAllow { .. } + )); + } + #[test] fn evaluate_all_safe_chain_auto_allows() { let state = PermissionState::default(); @@ -4456,6 +4746,152 @@ mod tests { } } + #[test] + fn real_file_writes_need_prompt() { + let state = PermissionState::default(); + for cmd in [ + "cat payload > ~/.zshrc", + "cat payload >> out", + "sort -o out input", + "cat payload > 3", + "> out", + ] { + assert!( + evaluate_bash(cmd, &state, true).writes_real_file, + "real-file write must set the floor: {cmd}" + ); + } + } + + #[test] + fn unsafe_environment_detection_covers_script_forms() { + let state = PermissionState::default(); + for (cmd, unsafe_env) in [ + (UNSAFE_GIT_STATUS, true), + ( + concat!( + "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor ", + "GIT_CONFIG_VALUE_0=/tmp/pwn git status" + ), + true, + ), + ( + concat!( + "set -a; GIT_CONFIG_COUNT=1; GIT_CONFIG_KEY_0=core.fsmonitor; ", + "GIT_CONFIG_VALUE_0=/tmp/pwn; git status" + ), + true, + ), + ("RUST_LOG=debug git status", false), + ] { + let evaluation = evaluate_bash(cmd, &state, true); + assert_eq!(evaluation.sets_unsafe_env, unsafe_env, "{cmd}"); + assert_eq!( + bash_unsafe_env_floor_requires_prompt(Some(&evaluation)), + unsafe_env, + "{cmd}" + ); + } + } + + #[test] + fn unsafe_env_floor_blocks_broad_grants_but_preserves_exact_decisions() { + let cmd = UNSAFE_GIT_STATUS; + for (grants, blanket, allowed) in [ + (vec!["git status"], false, false), + (vec![], true, false), + (vec![cmd], false, true), + ] { + let state = PermissionState { + allowed_bash_commands: grants.into_iter().map(str::to_owned).collect(), + allow_bash_execute: blanket, + ..Default::default() + }; + let evaluation = evaluate_bash(cmd, &state, true); + assert!(evaluation.sets_unsafe_env); + assert_eq!( + bash_grant_pre_decision( + cmd, + &evaluation, + &state, + None, + BashGrantOpts::PRE_CLASSIFIER, + ) + .is_some(), + allowed + ); + } + } + + #[test] + fn write_floor_preserves_sinks_fd_dups_and_exact_decisions() { + let state = PermissionState::default(); + for cmd in ["grep text file 2>/dev/null", "cargo check 2>&1"] { + assert!(!evaluate_bash(cmd, &state, true).writes_real_file); + } + + let cmd = "cat payload > another-file"; + for (state, allowed) in [ + ( + PermissionState { + allowed_bash_commands: HashSet::from(["cat".to_owned()]), + ..Default::default() + }, + false, + ), + ( + PermissionState { + allow_bash_execute: true, + ..Default::default() + }, + false, + ), + ( + PermissionState { + allowed_bash_commands: HashSet::from([cmd.to_owned()]), + ..Default::default() + }, + true, + ), + ] { + let evaluation = evaluate_bash(cmd, &state, true); + assert_eq!( + bash_grant_pre_decision( + cmd, + &evaluation, + &state, + None, + BashGrantOpts::PRE_CLASSIFIER, + ) + .is_some(), + allowed + ); + } + } + + #[test] + fn ask_floor_requires_every_segment_to_be_granted() { + let cmd = "cat README && git status"; + for (grants, allowed) in [(["cat", "unused"], false), (["cat", "git status"], true)] { + let state = PermissionState { + allowed_bash_commands: grants.into_iter().map(str::to_owned).collect(), + ..Default::default() + }; + let evaluation = evaluate_bash(cmd, &state, true); + assert_eq!( + bash_grant_pre_decision( + cmd, + &evaluation, + &state, + None, + BashGrantOpts::ASK_FLOOR_REMEMBER, + ) + .is_some(), + allowed + ); + } + } + #[test] fn evaluate_inner_without_safe_lists_ignores_builtin_safe_commands() { // `honor_safe_lists = false` (the `ask`-floor escape mode): a built-in diff --git a/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs b/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs index d8a3c80..e50f1a8 100644 --- a/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs +++ b/crates/codegen/xai-grok-workspace/src/permission/shell_access.rs @@ -225,6 +225,11 @@ pub(crate) fn command_write_paths_in_tree(root: Node<'_>, src: &str) -> Vec bool { + matches!(path, "/dev/null" | "/dev/stdout" | "/dev/stderr") +} + #[derive(Clone, Copy)] pub(crate) enum ShellFileMode { Read, @@ -432,28 +437,32 @@ pub(crate) fn shell_redirect_targets( } fn shell_redirect_one(node: Node<'_>, src: &str) -> Option<(Option, ShellFileMode, bool)> { - let mut mode = None; + let mut redirect = None; for i in 0..node.child_count() { let kind = node.child(i)?.kind(); // `<<`/`<<<` read from inline text, not a file. if kind.contains("<<") { return None; } - if kind.contains('>') { - mode = Some(ShellFileMode::Write); - break; - } - if kind.contains('<') { - mode = Some(ShellFileMode::Read); + if kind.contains('>') || kind.contains('<') { + redirect = Some(kind); break; } } - let mode = mode?; + let redirect = redirect?; + let mode = if redirect.contains('>') { + ShellFileMode::Write + } else { + ShellFileMode::Read + }; + let duplicates_fd = matches!(redirect, ">&" | "<&"); let dest = node.child_by_field_name("destination")?; match shell_node_arg(dest, src)? { ArgText::Literal(s) => { - // Skip fd duplications (`>&1`) and empty targets. - if s.is_empty() || s.starts_with('&') || s.bytes().all(|b| b.is_ascii_digit()) { + if s.is_empty() + || s.starts_with('&') + || (duplicates_fd && (s == "-" || s.bytes().all(|b| b.is_ascii_digit()))) + { None } else { let ambiguous = shell_arg_is_ambiguous(&s); @@ -1485,6 +1494,18 @@ mod tests { } } + #[test] + fn adversarial_fd_duplication_and_numeric_filenames() { + let parsed = |cmd: &str| { + let tree = try_parse_shell(cmd).expect("shell parses"); + command_write_paths_in_tree(tree.root_node(), cmd) + }; + assert!(parsed("cat payload 2>&1").is_empty()); + assert!(parsed("cat payload 1>&-").is_empty()); + assert!(parsed("cat payload 0<&3").is_empty()); + assert_eq!(parsed("cat payload > 3"), vec!["3"]); + } + /// An outer reader fed a substitution can't pin its operand (Ask); an inner /// literal read (incl. inside `<(…)`) is a hard deny. #[test] diff --git a/crates/codegen/xai-grok-workspace/src/session/mod.rs b/crates/codegen/xai-grok-workspace/src/session/mod.rs index 4e9ac2c..54f9e82 100644 --- a/crates/codegen/xai-grok-workspace/src/session/mod.rs +++ b/crates/codegen/xai-grok-workspace/src/session/mod.rs @@ -57,6 +57,13 @@ pub struct WorkspaceSession { pub(crate) depth: u32, pub(crate) fork_budget: u32, pub(crate) hunk_tracker: HunkTrackerHandle, + /// Cancel token for the workspace-spawned [`HunkTrackerActor`] backing + /// [`Self::hunk_tracker`], fired on session teardown by + /// [`Self::cancel_hunk_tracker`]. `None` when the tracker is externally + /// owned (e.g. `create_session_with_tracker` / local shell mode). + /// + /// [`HunkTrackerActor`]: xai_hunk_tracker::HunkTrackerActor + pub(crate) hunk_tracker_cancel: Option, pub(crate) file_state_tracker: Arc, /// Per-turn hunk deltas keyed by `prompt_index`, captured at finalize and /// replayed on rewind (only when `workspace_rewind_hunks` is on). The live @@ -154,6 +161,7 @@ impl WorkspaceSession { toolset: Arc, terminal_backend: crate::config::SessionTerminalBackend, hunk_tracker: HunkTrackerHandle, + hunk_tracker_cancel: Option, viewer_ctx: Option, #[allow(dead_code)] system_notifications: bool, system_notify_channel: Option<( @@ -177,6 +185,7 @@ impl WorkspaceSession { depth, fork_budget, hunk_tracker, + hunk_tracker_cancel, file_state_tracker, hunk_checkpoints: Arc::new(tokio::sync::Mutex::new(HashMap::new())), git_checkpoints: crate::session::git::GitCheckpointStore::new(), @@ -299,6 +308,15 @@ impl WorkspaceSession { pub(crate) fn shutdown_terminal_backend(&self) { self.terminal_backend.shutdown(); } + /// Cancel the workspace-spawned hunk-tracker actor, if this session owns + /// one. Runs at the session drop chokepoints so the actor (which pins file + /// contents in `file_states`) stops even while leaked handle clones hold + /// its channel open. + pub(crate) fn cancel_hunk_tracker(&self) { + if let Some(token) = &self.hunk_tracker_cancel { + token.cancel(); + } + } /// Return the current resolved toolset (snapshot). pub fn toolset(&self) -> Arc { self.inner.read().toolset.clone() diff --git a/crates/codegen/xai-grok-workspace/src/workspace_ops.rs b/crates/codegen/xai-grok-workspace/src/workspace_ops.rs index 66bae41..8f0a38d 100644 --- a/crates/codegen/xai-grok-workspace/src/workspace_ops.rs +++ b/crates/codegen/xai-grok-workspace/src/workspace_ops.rs @@ -867,14 +867,17 @@ fn hook_registry_to_wire( serde_json::to_value(registry).map_err(|e| WorkspaceError::HubError(e.to_string()))?; serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string())) } -/// Inverse of [`hook_registry_to_wire`]. The compiled `matcher` is absent from -/// the wire (and from this result); callers recompile it via -/// `HookRegistry::recompile_matchers`, exactly as the proxy path already did. +/// Inverse of [`hook_registry_to_wire`]. Rebuilds compiled matchers via +/// [`HookRegistry::recompile_matchers`] so invalid patterns fail closed +/// (match nothing) rather than widening to match-all after the wire hop. fn wire_to_hook_registry( wire: &HookRegistryWire, ) -> WorkspaceResult { let value = serde_json::to_value(wire).map_err(|e| WorkspaceError::HubError(e.to_string()))?; - serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string())) + let mut registry: xai_grok_hooks::discovery::HookRegistry = + serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string()))?; + registry.recompile_matchers(); + Ok(registry) } #[async_trait] impl WorkspaceOp for HookRegistryReq { diff --git a/crates/codegen/xai-hunk-tracker/src/handle.rs b/crates/codegen/xai-hunk-tracker/src/handle.rs index 7e4f4fb..6d1fb05 100644 --- a/crates/codegen/xai-hunk-tracker/src/handle.rs +++ b/crates/codegen/xai-hunk-tracker/src/handle.rs @@ -33,6 +33,12 @@ impl HunkTrackerHandle { Self { cmd_tx } } + /// Whether the backing actor has exited (its command receiver was + /// dropped), observable even while handle clones are alive. + pub fn is_closed(&self) -> bool { + self.cmd_tx.is_closed() + } + /// Record that an agent tool wrote to a file. /// This is fire-and-forget - doesn't wait for processing. /// diff --git a/crates/codegen/xai-ratatui-textarea/src/editor.rs b/crates/codegen/xai-ratatui-textarea/src/editor.rs new file mode 100644 index 0000000..2344c18 --- /dev/null +++ b/crates/codegen/xai-ratatui-textarea/src/editor.rs @@ -0,0 +1,903 @@ +use std::ops::Range; +use std::sync::Arc; + +use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation as _}; +use unicode_width::UnicodeWidthStr as _; + +#[path = "editor_keys.rs"] +mod keys; + +pub use keys::classify_key_event; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WordStyle { + Small, + WhitespaceDelimited, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditCommand { + Insert(char), + MoveGraphemeLeft, + MoveGraphemeRight, + MoveWordLeft(WordStyle), + MoveWordRight(WordStyle), + MoveLogicalLineStart, + MoveLogicalLineEnd, + DeleteGraphemeBackward, + DeleteGraphemeForward, + DeleteWordBackward(WordStyle), + DeleteWordForward(WordStyle), + DeleteToLineStart, + DeleteToLineEnd, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EditDelta { + pub replaced_byte_range: Range, + pub inserted_byte_range: Range, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EditOutcome { + Unchanged, + CursorOnly, + TextOnly(EditDelta), + TextAndCursor(EditDelta), +} + +impl EditOutcome { + fn from_changes(delta: Option, cursor_changed: bool) -> Self { + match (delta, cursor_changed) { + (None, false) => Self::Unchanged, + (None, true) => Self::CursorOnly, + (Some(delta), false) => Self::TextOnly(delta), + (Some(delta), true) => Self::TextAndCursor(delta), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PostEditCursorAffinity { + Exact, + Right, +} + +#[derive(Debug, Clone)] +pub struct EditPlan { + replaced_byte_range: Range, + replacement: String, + removed_text: String, + cursor_byte: usize, + cursor_affinity: PostEditCursorAffinity, + source_identity: Arc, + source_generation: u64, +} + +impl EditPlan { + pub fn replaced_byte_range(&self) -> Range { + self.replaced_byte_range.clone() + } + + pub fn replacement(&self) -> &str { + &self.replacement + } + + pub fn removed_text(&self) -> &str { + &self.removed_text + } + + pub fn cursor_byte(&self) -> usize { + self.cursor_byte + } + + pub fn cursor_affinity(&self) -> PostEditCursorAffinity { + self.cursor_affinity + } + + pub fn into_removed_text(self) -> String { + self.removed_text + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyEditPlanError { + StalePlan, + InvalidRange, + RemovedTextMismatch, + InvalidCursor, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SingleLineViewport { + pub visible_byte_range: Range, + pub cursor_display_column: usize, +} + +#[derive(Debug)] +struct BufferIdentity; + +#[derive(Debug)] +pub struct EditBuffer { + text: String, + cursor_byte: usize, + identity: Arc, + generation: u64, +} + +impl Default for EditBuffer { + fn default() -> Self { + Self { + text: String::new(), + cursor_byte: 0, + identity: Arc::new(BufferIdentity), + generation: 0, + } + } +} + +impl Clone for EditBuffer { + fn clone(&self) -> Self { + Self { + text: self.text.clone(), + cursor_byte: self.cursor_byte, + identity: Arc::new(BufferIdentity), + generation: 0, + } + } +} + +impl PartialEq for EditBuffer { + fn eq(&self, other: &Self) -> bool { + self.text == other.text && self.cursor_byte == other.cursor_byte + } +} + +impl Eq for EditBuffer {} + +impl EditBuffer { + pub fn new() -> Self { + Self::default() + } + + pub fn from_text(text: impl Into) -> Self { + let text = text.into(); + let cursor_byte = text.len(); + Self { + text, + cursor_byte, + identity: Arc::new(BufferIdentity), + generation: 0, + } + } + + /// External cursor requests use nearest grapheme boundaries; ties go left for determinism. + pub fn from_parts(text: impl Into, cursor_byte: usize) -> Self { + let text = text.into(); + let cursor_byte = normalize_external_cursor(&text, cursor_byte); + Self { + text, + cursor_byte, + identity: Arc::new(BufferIdentity), + generation: 0, + } + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn into_text(self) -> String { + self.text + } + + pub fn cursor_byte(&self) -> usize { + self.cursor_byte + } + + /// External cursor requests use nearest grapheme boundaries; ties go left for determinism. + #[must_use] + pub fn set_cursor_byte(&mut self, cursor_byte: usize) -> EditOutcome { + let old_cursor = self.cursor_byte; + self.cursor_byte = normalize_external_cursor(&self.text, cursor_byte); + let cursor_changed = self.cursor_byte != old_cursor; + if cursor_changed { + self.advance_generation(); + } + EditOutcome::from_changes(None, cursor_changed) + } + + #[must_use] + pub fn insert_str(&mut self, text: &str) -> EditOutcome { + let plan = self.plan_replace_byte_range(self.cursor_byte..self.cursor_byte, text, &[]); + self.apply_valid_plan(&plan) + } + + /// Edit-result cursors keep right affinity when adjacent text merges into one grapheme. + #[must_use] + pub fn replace_byte_range(&mut self, range: Range, replacement: &str) -> EditOutcome { + let plan = self.plan_replace_byte_range(range, replacement, &[]); + self.apply_valid_plan(&plan) + } + + pub fn plan_replace_byte_range( + &self, + range: Range, + replacement: &str, + atomic_byte_ranges: &[Range], + ) -> EditPlan { + let atomic_byte_ranges = normalize_atomic_ranges(&self.text, atomic_byte_ranges); + let range = normalize_replacement_range(&self.text, range, &atomic_byte_ranges); + let cursor_byte = normalize_cursor_for_atomic_ranges(self.cursor_byte, &atomic_byte_ranges); + let next_cursor = if cursor_byte < range.start { + cursor_byte + } else if cursor_byte <= range.end { + range.start + replacement.len() + } else { + cursor_byte - (range.end - range.start) + replacement.len() + }; + self.make_plan( + range, + replacement.to_owned(), + next_cursor, + PostEditCursorAffinity::Right, + ) + } + + pub fn plan_command( + &self, + command: EditCommand, + atomic_byte_ranges: &[Range], + ) -> EditPlan { + let atomic_byte_ranges = normalize_atomic_ranges(&self.text, atomic_byte_ranges); + let cursor_byte = normalize_cursor_for_atomic_ranges(self.cursor_byte, &atomic_byte_ranges); + match command { + EditCommand::Insert(character) => { + let replacement = character.to_string(); + self.make_plan( + cursor_byte..cursor_byte, + replacement, + cursor_byte + character.len_utf8(), + PostEditCursorAffinity::Right, + ) + } + EditCommand::MoveGraphemeLeft => self.make_plan( + cursor_byte..cursor_byte, + String::new(), + previous_atomic_boundary(&self.text, cursor_byte, &atomic_byte_ranges), + PostEditCursorAffinity::Exact, + ), + EditCommand::MoveGraphemeRight => self.make_plan( + cursor_byte..cursor_byte, + String::new(), + next_atomic_boundary(&self.text, cursor_byte, &atomic_byte_ranges), + PostEditCursorAffinity::Exact, + ), + EditCommand::MoveWordLeft(style) => { + let target = self.previous_word_boundary(style, cursor_byte, &atomic_byte_ranges); + self.make_plan( + cursor_byte..cursor_byte, + String::new(), + target, + PostEditCursorAffinity::Exact, + ) + } + EditCommand::MoveWordRight(style) => { + let target = self.next_word_boundary(style, cursor_byte, &atomic_byte_ranges); + self.make_plan( + cursor_byte..cursor_byte, + String::new(), + target, + PostEditCursorAffinity::Exact, + ) + } + EditCommand::MoveLogicalLineStart => { + let target = self.logical_line_start_target(cursor_byte, &atomic_byte_ranges); + self.make_plan( + cursor_byte..cursor_byte, + String::new(), + target, + PostEditCursorAffinity::Exact, + ) + } + EditCommand::MoveLogicalLineEnd => { + let target = self.logical_line_end_target(cursor_byte, &atomic_byte_ranges); + self.make_plan( + cursor_byte..cursor_byte, + String::new(), + target, + PostEditCursorAffinity::Exact, + ) + } + EditCommand::DeleteGraphemeBackward => { + let start = previous_atomic_boundary(&self.text, cursor_byte, &atomic_byte_ranges); + self.make_plan( + start..cursor_byte, + String::new(), + start, + PostEditCursorAffinity::Right, + ) + } + EditCommand::DeleteGraphemeForward => { + let end = next_atomic_boundary(&self.text, cursor_byte, &atomic_byte_ranges); + self.make_plan( + cursor_byte..end, + String::new(), + cursor_byte, + PostEditCursorAffinity::Right, + ) + } + EditCommand::DeleteWordBackward(style) => { + let start = self.previous_word_boundary(style, cursor_byte, &atomic_byte_ranges); + self.make_plan( + start..cursor_byte, + String::new(), + start, + PostEditCursorAffinity::Right, + ) + } + EditCommand::DeleteWordForward(style) => { + let end = self.next_word_boundary(style, cursor_byte, &atomic_byte_ranges); + self.make_plan( + cursor_byte..end, + String::new(), + cursor_byte, + PostEditCursorAffinity::Right, + ) + } + EditCommand::DeleteToLineStart => { + let line_start = self.line_start_at(cursor_byte, &atomic_byte_ranges); + let start = if cursor_byte == line_start { + previous_atomic_boundary(&self.text, line_start, &atomic_byte_ranges) + } else { + line_start + }; + self.make_plan( + start..cursor_byte, + String::new(), + start, + PostEditCursorAffinity::Right, + ) + } + EditCommand::DeleteToLineEnd => { + let line_end = self.line_end_from(cursor_byte, &atomic_byte_ranges); + let start = cursor_byte.min(line_end); + let end = if cursor_byte >= line_end { + self.line_ending_at(line_end) + .map_or(line_end, |range| range.end) + } else { + line_end + }; + self.make_plan( + start..end, + String::new(), + start, + PostEditCursorAffinity::Right, + ) + } + } + } + + pub fn apply_plan(&mut self, plan: &EditPlan) -> Result { + self.validate_plan(plan)?; + Ok(self.apply_valid_plan(plan)) + } + + #[must_use] + pub fn apply(&mut self, command: EditCommand) -> EditOutcome { + let plan = self.plan_command(command, &[]); + self.apply_valid_plan(&plan) + } + + fn make_plan( + &self, + replaced_byte_range: Range, + replacement: String, + cursor_byte: usize, + cursor_affinity: PostEditCursorAffinity, + ) -> EditPlan { + let removed_text = self.text[replaced_byte_range.clone()].to_owned(); + EditPlan { + replaced_byte_range, + replacement, + removed_text, + cursor_byte, + cursor_affinity, + source_identity: Arc::clone(&self.identity), + source_generation: self.generation, + } + } + + fn validate_plan(&self, plan: &EditPlan) -> Result<(), ApplyEditPlanError> { + if !Arc::ptr_eq(&plan.source_identity, &self.identity) + || plan.source_generation != self.generation + { + return Err(ApplyEditPlanError::StalePlan); + } + let range = &plan.replaced_byte_range; + if range.start > range.end + || range.end > self.text.len() + || !self.text.is_char_boundary(range.start) + || !self.text.is_char_boundary(range.end) + || !is_grapheme_boundary(&self.text, range.start) + || !is_grapheme_boundary(&self.text, range.end) + { + return Err(ApplyEditPlanError::InvalidRange); + } + if self.text.get(range.clone()) != Some(plan.removed_text.as_str()) { + return Err(ApplyEditPlanError::RemovedTextMismatch); + } + let Some(resulting_len) = self + .text + .len() + .checked_sub(range.end - range.start) + .and_then(|len| len.checked_add(plan.replacement.len())) + else { + return Err(ApplyEditPlanError::InvalidCursor); + }; + if plan.cursor_byte > resulting_len { + return Err(ApplyEditPlanError::InvalidCursor); + } + if plan.cursor_affinity == PostEditCursorAffinity::Exact + && (plan.replacement != plan.removed_text + || !is_grapheme_boundary(&self.text, plan.cursor_byte)) + { + return Err(ApplyEditPlanError::InvalidCursor); + } + Ok(()) + } + + fn apply_valid_plan(&mut self, plan: &EditPlan) -> EditOutcome { + let old_cursor = self.cursor_byte; + let text_changed = plan.removed_text != plan.replacement; + let inserted_len = plan.replacement.len(); + if text_changed { + self.text + .replace_range(plan.replaced_byte_range.clone(), &plan.replacement); + } + self.cursor_byte = match plan.cursor_affinity { + PostEditCursorAffinity::Exact => plan.cursor_byte, + PostEditCursorAffinity::Right => ceil_grapheme_boundary(&self.text, plan.cursor_byte), + }; + let cursor_changed = self.cursor_byte != old_cursor; + if text_changed || cursor_changed { + self.advance_generation(); + } + let delta = text_changed.then_some(EditDelta { + inserted_byte_range: plan.replaced_byte_range.start + ..(plan.replaced_byte_range.start + inserted_len), + replaced_byte_range: plan.replaced_byte_range.clone(), + }); + EditOutcome::from_changes(delta, cursor_changed) + } + + fn advance_generation(&mut self) { + if let Some(generation) = self.generation.checked_add(1) { + self.generation = generation; + } else { + self.identity = Arc::new(BufferIdentity); + self.generation = 0; + } + } + + pub fn single_line_viewport(&self, display_width: usize) -> SingleLineViewport { + self.single_line_viewport_with_atomic_ranges(display_width, &[]) + } + + pub fn single_line_viewport_with_atomic_ranges( + &self, + display_width: usize, + atomic_byte_ranges: &[Range], + ) -> SingleLineViewport { + let atomic_byte_ranges = normalize_atomic_ranges(&self.text, atomic_byte_ranges); + let cursor_byte = self.cursor_byte; + if display_width == 0 { + return SingleLineViewport { + visible_byte_range: cursor_byte..cursor_byte, + cursor_display_column: 0, + }; + } + + let line_start = self.line_start_at(cursor_byte, &atomic_byte_ranges); + let line_end = self.line_end_from(cursor_byte, &atomic_byte_ranges); + let left_budget = display_width - 1; + let mut start = cursor_byte; + let mut left_width = 0usize; + while start > line_start { + let previous = previous_atomic_boundary(&self.text, start, &atomic_byte_ranges); + let grapheme_width = self.text[previous..start].width(); + let next_width = left_width.saturating_add(grapheme_width); + if next_width > left_budget { + break; + } + start = previous; + left_width = next_width; + } + + let mut end = start; + let mut visible_width = 0usize; + while end < line_end { + let next = next_atomic_boundary(&self.text, end, &atomic_byte_ranges); + let grapheme_width = self.text[end..next].width(); + let next_width = visible_width.saturating_add(grapheme_width); + if next_width > display_width { + if end < cursor_byte { + end = next; + } + break; + } + end = next; + visible_width = next_width; + } + + SingleLineViewport { + visible_byte_range: start..end, + cursor_display_column: self.text[start..cursor_byte].width(), + } + } + + fn previous_word_boundary( + &self, + style: WordStyle, + cursor_byte: usize, + atomic_byte_ranges: &[Range], + ) -> usize { + let mut position = cursor_byte; + while position > 0 { + let previous = previous_atomic_boundary(&self.text, position, atomic_byte_ranges); + if atomic_word_class(&self.text, previous, position, style, atomic_byte_ranges) + == Some(WordClass::Whitespace) + { + position = previous; + } else { + break; + } + } + + if position == 0 { + return 0; + } + + let previous = previous_atomic_boundary(&self.text, position, atomic_byte_ranges); + let target_class = + atomic_word_class(&self.text, previous, position, style, atomic_byte_ranges); + while position > 0 { + let previous = previous_atomic_boundary(&self.text, position, atomic_byte_ranges); + if atomic_word_class(&self.text, previous, position, style, atomic_byte_ranges) + != target_class + { + break; + } + position = previous; + } + position + } + + fn next_word_boundary( + &self, + style: WordStyle, + cursor_byte: usize, + atomic_byte_ranges: &[Range], + ) -> usize { + let mut position = cursor_byte; + while position < self.text.len() { + let next = next_atomic_boundary(&self.text, position, atomic_byte_ranges); + if atomic_word_class(&self.text, position, next, style, atomic_byte_ranges) + == Some(WordClass::Whitespace) + { + position = next; + } else { + break; + } + } + + if position == self.text.len() { + return position; + } + + let next = next_atomic_boundary(&self.text, position, atomic_byte_ranges); + let target_class = atomic_word_class(&self.text, position, next, style, atomic_byte_ranges); + while position < self.text.len() { + let next = next_atomic_boundary(&self.text, position, atomic_byte_ranges); + if atomic_word_class(&self.text, position, next, style, atomic_byte_ranges) + != target_class + { + break; + } + position = next; + } + position + } + + fn logical_line_start_target( + &self, + cursor_byte: usize, + atomic_byte_ranges: &[Range], + ) -> usize { + let line_start = self.line_start_at(cursor_byte, atomic_byte_ranges); + if cursor_byte == line_start && line_start > 0 { + let previous_line_end = + previous_atomic_boundary(&self.text, line_start, atomic_byte_ranges); + self.line_start_at(previous_line_end, atomic_byte_ranges) + } else { + line_start + } + } + + fn logical_line_end_target( + &self, + cursor_byte: usize, + atomic_byte_ranges: &[Range], + ) -> usize { + let line_end = self.line_end_from(cursor_byte, atomic_byte_ranges); + if cursor_byte == line_end { + self.line_ending_at(line_end).map_or(line_end, |range| { + self.line_end_from(range.end, atomic_byte_ranges) + }) + } else { + line_end + } + } + + fn line_start_at(&self, cursor_byte: usize, atomic_byte_ranges: &[Range]) -> usize { + let cursor_byte = cursor_byte.min(self.text.len()); + (0..cursor_byte) + .rev() + .find(|position| { + self.text.as_bytes()[*position] == b'\n' + && !byte_is_inside_atomic_range(*position, atomic_byte_ranges) + }) + .map_or(0, |position| position + 1) + } + + fn line_end_from(&self, cursor_byte: usize, atomic_byte_ranges: &[Range]) -> usize { + let cursor_byte = cursor_byte.min(self.text.len()); + (cursor_byte..self.text.len()) + .find(|position| { + self.text.as_bytes()[*position] == b'\n' + && !byte_is_inside_atomic_range(*position, atomic_byte_ranges) + }) + .map_or(self.text.len(), |line_feed| { + if line_feed > 0 && self.text.as_bytes()[line_feed - 1] == b'\r' { + line_feed - 1 + } else { + line_feed + } + }) + } + + fn line_ending_at(&self, line_end: usize) -> Option> { + let remaining = self.text.get(line_end..)?; + if remaining.starts_with("\r\n") { + Some(line_end..line_end + 2) + } else if remaining.starts_with('\n') { + Some(line_end..line_end + 1) + } else { + None + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WordClass { + Whitespace, + Word, + Punctuation, + Atomic(usize), +} + +fn word_class(grapheme: &str, style: WordStyle) -> Option { + let character = grapheme.chars().next()?; + if character.is_whitespace() { + Some(WordClass::Whitespace) + } else if style == WordStyle::WhitespaceDelimited + || character.is_alphanumeric() + || character == '_' + { + Some(WordClass::Word) + } else { + Some(WordClass::Punctuation) + } +} + +fn atomic_word_class( + text: &str, + start: usize, + end: usize, + style: WordStyle, + atomic_byte_ranges: &[Range], +) -> Option { + if let Some(index) = atomic_byte_ranges + .iter() + .position(|range| range.start == start && range.end == end) + { + match style { + WordStyle::Small => Some(WordClass::Atomic(index)), + WordStyle::WhitespaceDelimited => Some(WordClass::Word), + } + } else { + word_class(&text[start..end], style) + } +} + +fn normalize_atomic_ranges(text: &str, ranges: &[Range]) -> Vec> { + let mut normalized = ranges + .iter() + .filter_map(|range| { + let raw_start = range.start.min(range.end).min(text.len()); + let raw_end = range.start.max(range.end).min(text.len()); + if raw_start == raw_end { + return None; + } + let start = floor_grapheme_boundary(text, raw_start); + let end = ceil_grapheme_boundary(text, raw_end); + (start < end).then_some(start..end) + }) + .collect::>(); + normalized.sort_by_key(|range| (range.start, range.end)); + + let mut merged: Vec> = Vec::with_capacity(normalized.len()); + for range in normalized { + if let Some(previous) = merged.last_mut() + && range.start < previous.end + { + previous.end = previous.end.max(range.end); + } else { + merged.push(range); + } + } + merged +} + +fn normalize_replacement_range( + text: &str, + range: Range, + atomic_byte_ranges: &[Range], +) -> Range { + let raw_start = range.start.min(range.end).min(text.len()); + let raw_end = range.start.max(range.end).min(text.len()); + if raw_start == raw_end { + let cursor = normalize_external_cursor(text, raw_start); + let cursor = normalize_cursor_for_atomic_ranges(cursor, atomic_byte_ranges); + return cursor..cursor; + } + + let mut normalized = + floor_grapheme_boundary(text, raw_start)..ceil_grapheme_boundary(text, raw_end); + loop { + let mut changed = false; + for atomic in atomic_byte_ranges { + if atomic.start < normalized.end && atomic.end > normalized.start { + let start = normalized.start.min(atomic.start); + let end = normalized.end.max(atomic.end); + changed |= start != normalized.start || end != normalized.end; + normalized = start..end; + } + } + if !changed { + return normalized; + } + } +} + +fn normalize_cursor_for_atomic_ranges( + cursor_byte: usize, + atomic_byte_ranges: &[Range], +) -> usize { + let Some(range) = atomic_byte_ranges + .iter() + .find(|range| cursor_byte > range.start && cursor_byte < range.end) + else { + return cursor_byte; + }; + if cursor_byte - range.start <= range.end - cursor_byte { + range.start + } else { + range.end + } +} + +fn previous_atomic_boundary(text: &str, byte: usize, atomic_byte_ranges: &[Range]) -> usize { + if let Some(range) = atomic_byte_ranges + .iter() + .find(|range| byte > range.start && byte <= range.end) + { + return range.start; + } + let boundary = previous_grapheme_boundary(text, byte); + atomic_byte_ranges + .iter() + .find(|range| boundary > range.start && boundary < range.end) + .map_or(boundary, |range| range.start) +} + +fn next_atomic_boundary(text: &str, byte: usize, atomic_byte_ranges: &[Range]) -> usize { + if let Some(range) = atomic_byte_ranges + .iter() + .find(|range| byte >= range.start && byte < range.end) + { + return range.end; + } + let boundary = next_grapheme_boundary(text, byte); + atomic_byte_ranges + .iter() + .find(|range| boundary > range.start && boundary < range.end) + .map_or(boundary, |range| range.end) +} + +fn byte_is_inside_atomic_range(byte: usize, atomic_byte_ranges: &[Range]) -> bool { + atomic_byte_ranges + .iter() + .any(|range| byte >= range.start && byte < range.end) +} + +fn is_grapheme_boundary(text: &str, byte: usize) -> bool { + byte == text.len() + || text + .grapheme_indices(true) + .any(|(boundary, _)| boundary == byte) +} + +fn floor_grapheme_boundary(text: &str, byte: usize) -> usize { + let byte = byte.min(text.len()); + if byte == text.len() { + return byte; + } + text.grapheme_indices(true) + .map(|(index, _)| index) + .take_while(|index| *index <= byte) + .last() + .unwrap_or(0) +} + +fn ceil_grapheme_boundary(text: &str, byte: usize) -> usize { + let byte = byte.min(text.len()); + if byte == text.len() { + return byte; + } + text.grapheme_indices(true) + .map(|(index, _)| index) + .find(|index| *index >= byte) + .unwrap_or(text.len()) +} + +fn normalize_external_cursor(text: &str, byte: usize) -> usize { + let byte = byte.min(text.len()); + let before = floor_grapheme_boundary(text, byte); + let after = ceil_grapheme_boundary(text, byte); + if byte - before <= after - byte { + before + } else { + after + } +} + +fn previous_grapheme_boundary(text: &str, byte: usize) -> usize { + let byte = byte.min(text.len()); + if byte == 0 { + return 0; + } + let mut cursor = GraphemeCursor::new(byte, text.len(), true); + match cursor.prev_boundary(text, 0) { + Ok(Some(boundary)) => boundary, + Ok(None) => 0, + Err(_) => floor_grapheme_boundary(text, byte.saturating_sub(1)), + } +} + +fn next_grapheme_boundary(text: &str, byte: usize) -> usize { + let byte = byte.min(text.len()); + if byte == text.len() { + return byte; + } + let mut cursor = GraphemeCursor::new(byte, text.len(), true); + match cursor.next_boundary(text, 0) { + Ok(Some(boundary)) => boundary, + Ok(None) => text.len(), + Err(_) => ceil_grapheme_boundary(text, byte.saturating_add(1)), + } +} + +#[cfg(test)] +#[path = "editor_tests/mod.rs"] +mod tests; diff --git a/crates/codegen/xai-ratatui-textarea/src/editor_keys.rs b/crates/codegen/xai-ratatui-textarea/src/editor_keys.rs new file mode 100644 index 0000000..12875f7 --- /dev/null +++ b/crates/codegen/xai-ratatui-textarea/src/editor_keys.rs @@ -0,0 +1,159 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::{EditCommand, WordStyle}; + +pub fn classify_key_event(event: &KeyEvent) -> Option { + match event { + KeyEvent { + code: KeyCode::Char('\u{0002}'), + modifiers: KeyModifiers::NONE, + .. + } => Some(EditCommand::MoveGraphemeLeft), + KeyEvent { + code: KeyCode::Char('\u{0006}'), + modifiers: KeyModifiers::NONE, + .. + } => Some(EditCommand::MoveGraphemeRight), + KeyEvent { + code: KeyCode::Char('h'), + modifiers, + .. + } if *modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { + Some(EditCommand::DeleteWordBackward(WordStyle::Small)) + } + KeyEvent { + code: KeyCode::Backspace | KeyCode::Char('\u{0008}' | '\u{007f}'), + modifiers, + .. + } => Some(backspace_command(*modifiers)), + KeyEvent { + code: KeyCode::Delete, + modifiers: KeyModifiers::ALT | KeyModifiers::CONTROL, + .. + } => Some(EditCommand::DeleteWordForward(WordStyle::Small)), + KeyEvent { + code: KeyCode::Delete, + .. + } => Some(EditCommand::DeleteGraphemeForward), + KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::CONTROL, + .. + } => Some(EditCommand::DeleteWordBackward( + WordStyle::WhitespaceDelimited, + )), + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::ALT | KeyModifiers::CONTROL, + .. + } => Some(EditCommand::MoveWordLeft(WordStyle::Small)), + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::ALT | KeyModifiers::CONTROL, + .. + } => Some(EditCommand::MoveWordRight(WordStyle::Small)), + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + .. + } => Some(EditCommand::MoveLogicalLineStart), + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::CONTROL, + .. + } => Some(EditCommand::MoveLogicalLineEnd), + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::CONTROL, + .. + } => Some(EditCommand::MoveGraphemeLeft), + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::CONTROL, + .. + } => Some(EditCommand::MoveGraphemeRight), + KeyEvent { + code: KeyCode::Char('b'), + modifiers: KeyModifiers::ALT, + .. + } => Some(EditCommand::MoveWordLeft(WordStyle::Small)), + KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::ALT, + .. + } => Some(EditCommand::MoveWordRight(WordStyle::Small)), + KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::CONTROL, + .. + } => Some(EditCommand::DeleteToLineStart), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + .. + } => Some(EditCommand::DeleteToLineEnd), + KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::CONTROL, + .. + } => Some(EditCommand::DeleteGraphemeBackward), + KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::CONTROL, + .. + } => Some(EditCommand::DeleteGraphemeForward), + KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::ALT | KeyModifiers::SUPER, + .. + } => Some(EditCommand::DeleteWordForward(WordStyle::Small)), + KeyEvent { + code: KeyCode::Char(character), + modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, + .. + } if !character.is_control() => { + let character = if event.modifiers.contains(KeyModifiers::SHIFT) { + shifted_char(*character) + } else { + *character + }; + Some(EditCommand::Insert(character)) + } + KeyEvent { + code: KeyCode::Char(character), + modifiers, + .. + } if crate::is_altgr(*modifiers) && !character.is_control() => { + Some(EditCommand::Insert(*character)) + } + _ => None, + } +} + +fn shifted_char(character: char) -> char { + if character.is_ascii_lowercase() { + character.to_ascii_uppercase() + } else { + character + } +} + +fn backspace_command(modifiers: KeyModifiers) -> EditCommand { + match modifiers { + KeyModifiers::ALT | KeyModifiers::CONTROL => { + EditCommand::DeleteWordBackward(WordStyle::Small) + } + KeyModifiers::SUPER => EditCommand::DeleteToLineStart, + _ => EditCommand::DeleteGraphemeBackward, + } +} diff --git a/crates/codegen/xai-ratatui-textarea/src/editor_tests/editing.rs b/crates/codegen/xai-ratatui-textarea/src/editor_tests/editing.rs new file mode 100644 index 0000000..6a2c92c --- /dev/null +++ b/crates/codegen/xai-ratatui-textarea/src/editor_tests/editing.rs @@ -0,0 +1,278 @@ +use unicode_segmentation::UnicodeSegmentation as _; + +use super::super::*; +use super::{delta, is_extended_grapheme_boundary}; + +#[test] +fn edit_outcome_is_closed_over_cursor_and_text_changes() { + let mut buffer = EditBuffer::new(); + assert_eq!( + buffer.apply(EditCommand::MoveGraphemeLeft), + EditOutcome::Unchanged + ); + + assert_eq!( + buffer.apply(EditCommand::Insert('é')), + EditOutcome::TextAndCursor(delta(0..0, 0.."é".len())) + ); + assert_eq!(buffer.text(), "é"); + assert_eq!(buffer.cursor_byte(), "é".len()); + + assert_eq!( + buffer.apply(EditCommand::MoveGraphemeLeft), + EditOutcome::CursorOnly + ); + assert_eq!(buffer.cursor_byte(), 0); + + assert_eq!( + buffer.apply(EditCommand::DeleteGraphemeForward), + EditOutcome::TextOnly(delta(0.."é".len(), 0..0)) + ); + assert_eq!(buffer.text(), ""); +} + +#[test] +fn grapheme_motion_treats_combining_zwj_flags_and_cjk_atomically() { + let graphemes = ["e\u{301}", "👩🏽\u{200d}💻", "🇺🇸", "界"]; + let text = graphemes.concat(); + let mut boundaries = vec![0]; + for grapheme in graphemes { + boundaries.push(boundaries.last().copied().unwrap_or(0) + grapheme.len()); + } + + let mut buffer = EditBuffer::from_parts(text, usize::MAX); + for expected in boundaries.iter().rev().skip(1) { + let _ = buffer.apply(EditCommand::MoveGraphemeLeft); + assert_eq!(buffer.cursor_byte(), *expected); + } + for expected in boundaries.iter().skip(1) { + let _ = buffer.apply(EditCommand::MoveGraphemeRight); + assert_eq!(buffer.cursor_byte(), *expected); + } +} + +#[test] +fn grapheme_deletion_and_replacement_never_split_clusters() { + let combining = "e\u{301}"; + let zwj = "👩🏽\u{200d}💻"; + let flag = "🇺🇸"; + let text = format!("{combining}{zwj}{flag}"); + let mut buffer = EditBuffer::from_parts(text.as_str(), combining.len() + zwj.len()); + + let _ = buffer.apply(EditCommand::DeleteGraphemeBackward); + let expected = format!("{combining}{flag}"); + assert_eq!(buffer.text(), expected.as_str()); + assert_eq!(buffer.cursor_byte(), combining.len()); + + let _ = buffer.apply(EditCommand::DeleteGraphemeForward); + assert_eq!(buffer.text(), combining); + assert_eq!(buffer.cursor_byte(), combining.len()); + + let text = format!("a{zwj}b"); + let mut buffer = EditBuffer::from_parts(text.as_str(), text.len()); + let outcome = buffer.replace_byte_range(2..(1 + zwj.len() - 1), "X"); + assert_eq!(buffer.text(), "aXb"); + assert_eq!(buffer.cursor_byte(), 3); + assert_eq!( + outcome, + EditOutcome::TextAndCursor(delta(1..(1 + zwj.len()), 1..2)) + ); + + let mut combining_insert = EditBuffer::from_text("e"); + let _ = combining_insert.insert_str("\u{301}"); + assert_eq!(combining_insert.text(), combining); + let _ = combining_insert.apply(EditCommand::DeleteGraphemeBackward); + assert_eq!(combining_insert.text(), ""); + + let base = "👩🏽"; + let laptop = "💻"; + let text = format!("{base}{laptop}"); + let mut zwj_insert = EditBuffer::from_parts(text, base.len()); + let _ = zwj_insert.insert_str("\u{200d}"); + assert_eq!(zwj_insert.text(), zwj); + assert_eq!(zwj_insert.cursor_byte(), zwj.len()); +} + +#[test] +fn edit_created_grapheme_merges_keep_right_cursor_affinity() { + let woman = "👩"; + let tail = "👩🏽\u{200d}💻"; + let text = format!("{woman}{tail}"); + let mut zwj_insert = EditBuffer::from_parts(text, woman.len()); + let outcome = zwj_insert.insert_str("\u{200d}"); + let inserted_end = woman.len() + "\u{200d}".len(); + assert_eq!(zwj_insert.text().graphemes(true).count(), 1); + assert_eq!(zwj_insert.cursor_byte(), zwj_insert.text().len()); + assert_eq!( + outcome, + EditOutcome::TextAndCursor(delta(woman.len()..woman.len(), woman.len()..inserted_end,)) + ); + + let mut flag_insert = EditBuffer::from_parts("🇺", 0); + let outcome = flag_insert.insert_str("🇨"); + assert_eq!(flag_insert.text(), "🇨🇺"); + assert_eq!(flag_insert.cursor_byte(), flag_insert.text().len()); + assert_eq!( + outcome, + EditOutcome::TextAndCursor(delta(0..0, 0.."🇨".len())) + ); + + let mut flag_replace = EditBuffer::from_parts("x🇺", 0); + let outcome = flag_replace.replace_byte_range(0..1, "🇨"); + assert_eq!(flag_replace.text(), "🇨🇺"); + assert_eq!(flag_replace.cursor_byte(), flag_replace.text().len()); + assert_eq!( + outcome, + EditOutcome::TextAndCursor(delta(0..1, 0.."🇨".len())) + ); + + let regional_indicator_len = "🇨".len(); + let mut flag_delete = EditBuffer::from_parts("🇨x🇺", regional_indicator_len + "x".len()); + let outcome = flag_delete.apply(EditCommand::DeleteGraphemeBackward); + assert_eq!(flag_delete.text(), "🇨🇺"); + assert_eq!(flag_delete.cursor_byte(), flag_delete.text().len()); + assert_eq!( + outcome, + EditOutcome::TextAndCursor(delta( + regional_indicator_len..(regional_indicator_len + "x".len()), + regional_indicator_len..regional_indicator_len, + )) + ); +} + +#[test] +fn invalid_cursor_bytes_normalize_to_the_nearest_grapheme_boundary() { + let mut buffer = EditBuffer::from_parts("e\u{301}x", 1); + assert_eq!(buffer.cursor_byte(), 0); + + let outcome = buffer.set_cursor_byte(2); + assert_eq!(buffer.cursor_byte(), "e\u{301}".len()); + assert_eq!(outcome, EditOutcome::CursorOnly); + + let _ = buffer.set_cursor_byte(usize::MAX); + assert_eq!(buffer.cursor_byte(), buffer.text().len()); + assert!(is_extended_grapheme_boundary( + buffer.text(), + buffer.cursor_byte() + )); + + let tied = EditBuffer::from_parts("🇨🇺", "🇨".len()); + assert_eq!(tied.cursor_byte(), 0); +} + +#[test] +fn range_replacement_tracks_a_cursor_before_inside_or_after_the_edit() { + let mut before = EditBuffer::from_parts("alpha beta", 1); + let outcome = before.replace_byte_range(6..10, "B"); + assert_eq!(before.text(), "alpha B"); + assert_eq!(before.cursor_byte(), 1); + assert_eq!(outcome, EditOutcome::TextOnly(delta(6..10, 6..7))); + + let mut inside = EditBuffer::from_parts("alpha beta", 8); + let outcome = inside.replace_byte_range(6..10, "B"); + assert_eq!(inside.cursor_byte(), 7); + assert_eq!(outcome, EditOutcome::TextAndCursor(delta(6..10, 6..7))); + + let mut after = EditBuffer::from_parts("alpha beta", 10); + let outcome = after.replace_byte_range(0..5, "A"); + assert_eq!(after.text(), "A beta"); + assert_eq!(after.cursor_byte(), 6); + assert_eq!(outcome, EditOutcome::TextAndCursor(delta(0..5, 0..1))); +} + +#[test] +fn small_words_keep_textarea_punctuation_classes() { + let mut buffer = EditBuffer::from_parts("hello-world", 0); + for expected in [5, 6, 11] { + let _ = buffer.apply(EditCommand::MoveWordRight(WordStyle::Small)); + assert_eq!(buffer.cursor_byte(), expected); + } + for expected in [6, 5, 0] { + let _ = buffer.apply(EditCommand::MoveWordLeft(WordStyle::Small)); + assert_eq!(buffer.cursor_byte(), expected); + } + + let mut buffer = EditBuffer::from_text("hello-world"); + let _ = buffer.apply(EditCommand::DeleteWordBackward(WordStyle::Small)); + assert_eq!(buffer.text(), "hello-"); + + let mut buffer = EditBuffer::from_parts("hello-world", 0); + let _ = buffer.apply(EditCommand::DeleteWordForward(WordStyle::Small)); + assert_eq!(buffer.text(), "-world"); + + let mut buffer = EditBuffer::from_parts("hello-world", 0); + let _ = buffer.apply(EditCommand::MoveWordRight(WordStyle::WhitespaceDelimited)); + assert_eq!(buffer.cursor_byte(), buffer.text().len()); +} + +#[test] +fn logical_line_commands_chain_at_line_boundaries() { + let mut buffer = EditBuffer::from_parts("one\ntwo\nthree", 6); + let _ = buffer.apply(EditCommand::MoveLogicalLineStart); + assert_eq!(buffer.cursor_byte(), 4); + let _ = buffer.apply(EditCommand::MoveLogicalLineEnd); + assert_eq!(buffer.cursor_byte(), 7); + + let _ = buffer.set_cursor_byte(4); + let _ = buffer.apply(EditCommand::MoveLogicalLineStart); + assert_eq!(buffer.cursor_byte(), 0); + + let _ = buffer.set_cursor_byte(3); + let _ = buffer.apply(EditCommand::MoveLogicalLineEnd); + assert_eq!(buffer.cursor_byte(), 7); + + let _ = buffer.set_cursor_byte(6); + let _ = buffer.apply(EditCommand::DeleteToLineStart); + assert_eq!(buffer.text(), "one\no\nthree"); + assert_eq!(buffer.cursor_byte(), 4); + + let _ = buffer.apply(EditCommand::DeleteToLineStart); + assert_eq!(buffer.text(), "oneo\nthree"); + assert_eq!(buffer.cursor_byte(), 3); + + let mut buffer = EditBuffer::from_parts("one\ntwo\nthree", 7); + let _ = buffer.apply(EditCommand::DeleteToLineEnd); + assert_eq!(buffer.text(), "one\ntwothree"); + assert_eq!(buffer.cursor_byte(), 7); +} + +#[test] +fn crlf_line_motion_and_deletion_keep_the_line_ending_atomic() { + let mut motion = EditBuffer::from_parts("ab\r\ncd", 1); + let _ = motion.apply(EditCommand::MoveLogicalLineEnd); + assert_eq!(motion.cursor_byte(), 2); + let _ = motion.apply(EditCommand::MoveLogicalLineEnd); + assert_eq!(motion.cursor_byte(), 6); + let _ = motion.set_cursor_byte(4); + let _ = motion.apply(EditCommand::MoveLogicalLineStart); + assert_eq!(motion.cursor_byte(), 0); + + let mut midline = EditBuffer::from_parts("ab\r\ncd", 1); + let outcome = midline.apply(EditCommand::DeleteToLineEnd); + assert_eq!(midline.text(), "a\r\ncd"); + assert_eq!(midline.cursor_byte(), 1); + assert_eq!(outcome, EditOutcome::TextOnly(delta(1..2, 1..1))); + + let mut at_eol = EditBuffer::from_parts("ab\r\ncd", 2); + let outcome = at_eol.apply(EditCommand::DeleteToLineEnd); + assert_eq!(at_eol.text(), "abcd"); + assert_eq!(at_eol.cursor_byte(), 2); + assert_eq!(outcome, EditOutcome::TextOnly(delta(2..4, 2..2))); + + let mut on_lf = EditBuffer::from_parts("ab\r\ncd", 3); + let outcome = on_lf.apply(EditCommand::DeleteToLineEnd); + assert_eq!(on_lf.text(), "abcd"); + assert_eq!(outcome, EditOutcome::TextOnly(delta(2..4, 2..2))); + + let mut second_line = EditBuffer::from_parts("ab\r\ncd", 5); + let outcome = second_line.apply(EditCommand::DeleteToLineStart); + assert_eq!(second_line.text(), "ab\r\nd"); + assert_eq!(second_line.cursor_byte(), 4); + assert_eq!(outcome, EditOutcome::TextAndCursor(delta(4..5, 4..4))); + + let mut at_bol = EditBuffer::from_parts("ab\r\ncd", 4); + let outcome = at_bol.apply(EditCommand::DeleteToLineStart); + assert_eq!(at_bol.text(), "abcd"); + assert_eq!(at_bol.cursor_byte(), 2); + assert_eq!(outcome, EditOutcome::TextAndCursor(delta(2..4, 2..2))); +} diff --git a/crates/codegen/xai-ratatui-textarea/src/editor_tests/keys.rs b/crates/codegen/xai-ratatui-textarea/src/editor_tests/keys.rs new file mode 100644 index 0000000..065b273 --- /dev/null +++ b/crates/codegen/xai-ratatui-textarea/src/editor_tests/keys.rs @@ -0,0 +1,313 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::super::*; + +fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) +} + +#[test] +fn ctrl_w_uses_whitespace_delimited_word_deletion() { + let command = classify_key_event(&key(KeyCode::Char('w'), KeyModifiers::CONTROL)) + .expect("Ctrl+W must classify"); + assert_eq!( + command, + EditCommand::DeleteWordBackward(WordStyle::WhitespaceDelimited) + ); + + let mut buffer = EditBuffer::from_text("git commit -m hello-world"); + let _ = buffer.apply(command); + assert_eq!(buffer.text(), "git commit -m "); + + let mut small = EditBuffer::from_text("git commit -m hello-world"); + let _ = small.apply(EditCommand::DeleteWordBackward(WordStyle::Small)); + assert_eq!(small.text(), "git commit -m hello-"); +} + +#[test] +fn common_editing_keys_classify_to_semantic_commands() { + let cases = [ + ( + key(KeyCode::Char('a'), KeyModifiers::NONE), + EditCommand::Insert('a'), + ), + ( + key(KeyCode::Char('a'), KeyModifiers::SHIFT), + EditCommand::Insert('A'), + ), + ( + key(KeyCode::Left, KeyModifiers::NONE), + EditCommand::MoveGraphemeLeft, + ), + ( + key(KeyCode::Right, KeyModifiers::NONE), + EditCommand::MoveGraphemeRight, + ), + ( + key(KeyCode::Left, KeyModifiers::ALT), + EditCommand::MoveWordLeft(WordStyle::Small), + ), + ( + key(KeyCode::Left, KeyModifiers::CONTROL), + EditCommand::MoveWordLeft(WordStyle::Small), + ), + ( + key(KeyCode::Right, KeyModifiers::ALT), + EditCommand::MoveWordRight(WordStyle::Small), + ), + ( + key(KeyCode::Right, KeyModifiers::CONTROL), + EditCommand::MoveWordRight(WordStyle::Small), + ), + ( + key(KeyCode::Char('a'), KeyModifiers::CONTROL), + EditCommand::MoveLogicalLineStart, + ), + ( + key(KeyCode::Char('e'), KeyModifiers::CONTROL), + EditCommand::MoveLogicalLineEnd, + ), + ( + key(KeyCode::Char('u'), KeyModifiers::CONTROL), + EditCommand::DeleteToLineStart, + ), + ( + key(KeyCode::Char('k'), KeyModifiers::CONTROL), + EditCommand::DeleteToLineEnd, + ), + ( + key(KeyCode::Char('b'), KeyModifiers::CONTROL), + EditCommand::MoveGraphemeLeft, + ), + ( + key(KeyCode::Char('f'), KeyModifiers::CONTROL), + EditCommand::MoveGraphemeRight, + ), + ( + key(KeyCode::Char('d'), KeyModifiers::CONTROL), + EditCommand::DeleteGraphemeForward, + ), + ( + key(KeyCode::Char('h'), KeyModifiers::CONTROL), + EditCommand::DeleteGraphemeBackward, + ), + ( + key(KeyCode::Char('b'), KeyModifiers::ALT), + EditCommand::MoveWordLeft(WordStyle::Small), + ), + ( + key(KeyCode::Char('f'), KeyModifiers::ALT), + EditCommand::MoveWordRight(WordStyle::Small), + ), + ( + key(KeyCode::Char('d'), KeyModifiers::ALT), + EditCommand::DeleteWordForward(WordStyle::Small), + ), + ( + key(KeyCode::Char('d'), KeyModifiers::SUPER), + EditCommand::DeleteWordForward(WordStyle::Small), + ), + ( + key(KeyCode::Char('\u{0002}'), KeyModifiers::NONE), + EditCommand::MoveGraphemeLeft, + ), + ( + key(KeyCode::Char('\u{0006}'), KeyModifiers::NONE), + EditCommand::MoveGraphemeRight, + ), + ]; + + for (event, expected) in cases { + assert_eq!(classify_key_event(&event), Some(expected), "{event:?}"); + } +} + +#[test] +fn visual_row_keys_and_modified_home_end_remain_adapter_owned() { + let events = [ + key(KeyCode::Home, KeyModifiers::NONE), + key(KeyCode::Home, KeyModifiers::SHIFT), + key(KeyCode::Home, KeyModifiers::CONTROL), + key(KeyCode::Home, KeyModifiers::ALT), + key(KeyCode::Home, KeyModifiers::SUPER), + key(KeyCode::Home, KeyModifiers::CONTROL | KeyModifiers::SHIFT), + key(KeyCode::End, KeyModifiers::NONE), + key(KeyCode::End, KeyModifiers::SHIFT), + key(KeyCode::End, KeyModifiers::CONTROL), + key(KeyCode::End, KeyModifiers::ALT), + key(KeyCode::End, KeyModifiers::SUPER), + key(KeyCode::End, KeyModifiers::ALT | KeyModifiers::SUPER), + key(KeyCode::Left, KeyModifiers::SUPER), + key(KeyCode::Right, KeyModifiers::SUPER), + ]; + + for event in events { + assert_eq!(classify_key_event(&event), None, "{event:?}"); + } + + assert_eq!( + classify_key_event(&key(KeyCode::Char('a'), KeyModifiers::CONTROL)), + Some(EditCommand::MoveLogicalLineStart) + ); + assert_eq!( + classify_key_event(&key(KeyCode::Char('e'), KeyModifiers::CONTROL)), + Some(EditCommand::MoveLogicalLineEnd) + ); + assert_eq!( + classify_key_event(&key( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )), + None + ); +} + +#[test] +fn lifecycle_and_host_owned_keys_remain_unclassified() { + let events = [ + key(KeyCode::Esc, KeyModifiers::NONE), + key(KeyCode::Enter, KeyModifiers::NONE), + key(KeyCode::Tab, KeyModifiers::NONE), + key(KeyCode::BackTab, KeyModifiers::SHIFT), + key(KeyCode::Up, KeyModifiers::NONE), + key(KeyCode::Down, KeyModifiers::NONE), + key(KeyCode::Char('j'), KeyModifiers::CONTROL), + key(KeyCode::Char('m'), KeyModifiers::CONTROL), + key(KeyCode::Char('v'), KeyModifiers::CONTROL), + key(KeyCode::Char('z'), KeyModifiers::CONTROL), + key(KeyCode::Char('\n'), KeyModifiers::NONE), + ]; + + for event in events { + assert_eq!(classify_key_event(&event), None, "{event:?}"); + } +} + +#[test] +fn backspace_delete_and_raw_encodings_have_modifier_parity() { + let cases = [ + ( + KeyModifiers::NONE, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::SHIFT, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::ALT, + EditCommand::DeleteWordBackward(WordStyle::Small), + EditCommand::DeleteWordForward(WordStyle::Small), + ), + ( + KeyModifiers::CONTROL, + EditCommand::DeleteWordBackward(WordStyle::Small), + EditCommand::DeleteWordForward(WordStyle::Small), + ), + ( + KeyModifiers::SUPER, + EditCommand::DeleteToLineStart, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::ALT | KeyModifiers::SHIFT, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::SUPER | KeyModifiers::SHIFT, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::CONTROL | KeyModifiers::ALT, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::CONTROL | KeyModifiers::SUPER, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::ALT | KeyModifiers::SUPER, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::META, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::META | KeyModifiers::SHIFT, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ( + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, + EditCommand::DeleteGraphemeBackward, + EditCommand::DeleteGraphemeForward, + ), + ]; + + for (modifiers, expected_backspace, expected_delete) in cases { + let backspace = key(KeyCode::Backspace, modifiers); + let delete = key(KeyCode::Delete, modifiers); + let raw_bs = key(KeyCode::Char('\u{0008}'), modifiers); + let raw_del = key(KeyCode::Char('\u{007f}'), modifiers); + let backspace_command = classify_key_event(&backspace); + assert_eq!(backspace_command, Some(expected_backspace), "{backspace:?}"); + assert_eq!( + classify_key_event(&delete), + Some(expected_delete), + "{delete:?}" + ); + assert_eq!(classify_key_event(&raw_bs), backspace_command, "{raw_bs:?}"); + assert_eq!( + classify_key_event(&raw_del), + backspace_command, + "{raw_del:?}" + ); + } +} + +#[test] +fn altgr_insertion_and_ctrl_alt_h_precedence_follow_platform_encoding() { + let ctrl_alt = KeyModifiers::CONTROL | KeyModifiers::ALT; + assert_eq!( + classify_key_event(&key(KeyCode::Char('h'), ctrl_alt)), + Some(EditCommand::DeleteWordBackward(WordStyle::Small)) + ); + + for (character, modifiers) in [ + ('q', ctrl_alt), + ('€', ctrl_alt | KeyModifiers::SHIFT), + ('h', ctrl_alt | KeyModifiers::SHIFT), + ] { + let expected = if cfg!(target_os = "windows") { + Some(EditCommand::Insert(character)) + } else { + None + }; + assert_eq!( + classify_key_event(&key(KeyCode::Char(character), modifiers)), + expected + ); + } + + if cfg!(target_os = "windows") { + let mut buffer = EditBuffer::new(); + let command = classify_key_event(&key(KeyCode::Char('€'), ctrl_alt | KeyModifiers::SHIFT)) + .expect("shifted AltGr must classify on Windows"); + let _ = buffer.apply(command); + assert_eq!(buffer.text(), "€"); + } +} diff --git a/crates/codegen/xai-ratatui-textarea/src/editor_tests/mod.rs b/crates/codegen/xai-ratatui-textarea/src/editor_tests/mod.rs new file mode 100644 index 0000000..59d0d41 --- /dev/null +++ b/crates/codegen/xai-ratatui-textarea/src/editor_tests/mod.rs @@ -0,0 +1,21 @@ +use std::ops::Range; + +use unicode_segmentation::UnicodeSegmentation as _; + +use super::EditDelta; + +mod editing; +mod keys; +mod planning; +mod viewport; + +fn delta(replaced_byte_range: Range, inserted_byte_range: Range) -> EditDelta { + EditDelta { + replaced_byte_range, + inserted_byte_range, + } +} + +fn is_extended_grapheme_boundary(text: &str, byte: usize) -> bool { + byte == text.len() || text.grapheme_indices(true).any(|(index, _)| index == byte) +} diff --git a/crates/codegen/xai-ratatui-textarea/src/editor_tests/planning.rs b/crates/codegen/xai-ratatui-textarea/src/editor_tests/planning.rs new file mode 100644 index 0000000..2d6dc6c --- /dev/null +++ b/crates/codegen/xai-ratatui-textarea/src/editor_tests/planning.rs @@ -0,0 +1,278 @@ +use std::sync::Arc; + +use super::super::*; +use super::delta; + +#[test] +fn edit_plan_exposes_removed_text_before_apply_and_delta_matches() { + let mut buffer = EditBuffer::from_text("say hello-world"); + let plan = buffer.plan_command( + EditCommand::DeleteWordBackward(WordStyle::WhitespaceDelimited), + &[], + ); + let replaced = "say ".len()..buffer.text().len(); + assert_eq!(plan.replaced_byte_range(), replaced); + assert_eq!(plan.replacement(), ""); + assert_eq!(plan.removed_text(), "hello-world"); + assert_eq!(plan.cursor_byte(), replaced.start); + assert_eq!(plan.cursor_affinity(), PostEditCursorAffinity::Right); + assert_eq!(buffer.text(), "say hello-world"); + + let expected_delta = delta(replaced.clone(), replaced.start..replaced.start); + let outcome = buffer.apply_plan(&plan); + assert_eq!(outcome, Ok(EditOutcome::TextAndCursor(expected_delta))); + assert_eq!(buffer.text(), "say "); + assert_eq!(buffer.cursor_byte(), replaced.start); + assert_eq!(buffer.apply_plan(&plan), Err(ApplyEditPlanError::StalePlan)); + assert_eq!(plan.into_removed_text(), "hello-world"); +} + +#[test] +fn stale_edit_plan_is_rejected_without_mutation() { + let mut buffer = EditBuffer::from_text("abc"); + let plan = buffer.plan_command(EditCommand::DeleteGraphemeBackward, &[]); + let _ = buffer.set_cursor_byte(0); + assert_eq!(buffer.apply_plan(&plan), Err(ApplyEditPlanError::StalePlan)); + assert_eq!(buffer.text(), "abc"); + assert_eq!(buffer.cursor_byte(), 0); +} + +#[test] +fn edit_plans_are_bound_to_buffer_identity_and_generation() { + let source = EditBuffer::from_text("x"); + let plan = source.plan_command(EditCommand::DeleteGraphemeBackward, &[]); + + let mut same_value = EditBuffer::from_text("x"); + assert_eq!(same_value, source); + assert_eq!( + same_value.apply_plan(&plan), + Err(ApplyEditPlanError::StalePlan) + ); + + let mut other = EditBuffer::from_text("x\u{301}"); + assert_eq!(other.apply_plan(&plan), Err(ApplyEditPlanError::StalePlan)); + assert_eq!(other.text(), "x\u{301}"); + + let mut cloned = source.clone(); + assert_eq!(cloned, source); + assert_eq!(cloned.apply_plan(&plan), Err(ApplyEditPlanError::StalePlan)); + assert_eq!(cloned, source); + + let mut changed_text = EditBuffer::from_text("x"); + let stale = changed_text.plan_command(EditCommand::DeleteGraphemeBackward, &[]); + let _ = changed_text.insert_str("y"); + assert_eq!( + changed_text.apply_plan(&stale), + Err(ApplyEditPlanError::StalePlan) + ); + assert_eq!(changed_text.text(), "xy"); +} + +#[test] +fn normal_state_changes_advance_generation_without_rotating_identity() { + let mut buffer = EditBuffer::from_text("ab"); + let identity = Arc::clone(&buffer.identity); + assert_eq!(buffer.generation, 0); + + let _ = buffer.apply(EditCommand::MoveGraphemeLeft); + assert_eq!(buffer.generation, 1); + assert!(Arc::ptr_eq(&identity, &buffer.identity)); + + let _ = buffer.insert_str("x"); + assert_eq!(buffer.generation, 2); + assert!(Arc::ptr_eq(&identity, &buffer.identity)); +} + +#[test] +fn generation_overflow_rotates_identity_and_invalidates_old_plans() { + let mut buffer = EditBuffer::from_text("ab"); + buffer.generation = u64::MAX; + let identity = Arc::clone(&buffer.identity); + let plan = buffer.plan_command(EditCommand::DeleteGraphemeBackward, &[]); + + assert_eq!( + buffer.apply_plan(&plan), + Ok(EditOutcome::TextAndCursor(delta(1..2, 1..1))) + ); + assert_eq!(buffer.generation, 0); + assert!(!Arc::ptr_eq(&identity, &buffer.identity)); + assert_eq!(buffer.apply_plan(&plan), Err(ApplyEditPlanError::StalePlan)); +} + +#[test] +fn edit_plan_validation_rejects_grapheme_splitting_ranges() { + let mut buffer = EditBuffer::from_text("x\u{301}"); + let mut plan = buffer.plan_command(EditCommand::DeleteGraphemeBackward, &[]); + plan.replaced_byte_range = 0..1; + plan.removed_text = "x".to_owned(); + plan.cursor_byte = 0; + + assert_eq!( + buffer.apply_plan(&plan), + Err(ApplyEditPlanError::InvalidRange) + ); + assert_eq!(buffer.text(), "x\u{301}"); + assert_eq!(buffer.cursor_byte(), "x\u{301}".len()); +} + +#[test] +fn atomic_ranges_are_indivisible_for_grapheme_plans() { + let atomic = 1..6; + + let mut backward = EditBuffer::from_parts("aTOKENb", atomic.end); + let plan = backward.plan_command( + EditCommand::DeleteGraphemeBackward, + std::slice::from_ref(&atomic), + ); + assert_eq!(plan.replaced_byte_range(), atomic); + assert_eq!(plan.removed_text(), "TOKEN"); + assert_eq!( + backward.apply_plan(&plan), + Ok(EditOutcome::TextAndCursor(delta(1..6, 1..1))) + ); + assert_eq!(backward.text(), "ab"); + assert_eq!(backward.cursor_byte(), 1); + + let mut forward = EditBuffer::from_parts("aTOKENb", atomic.start); + let plan = forward.plan_command( + EditCommand::DeleteGraphemeForward, + std::slice::from_ref(&atomic), + ); + assert_eq!(plan.replaced_byte_range(), atomic); + assert_eq!( + forward.apply_plan(&plan), + Ok(EditOutcome::TextOnly(delta(1..6, 1..1))) + ); + assert_eq!(forward.text(), "ab"); + assert_eq!(forward.cursor_byte(), 1); + + let mut motion = EditBuffer::from_parts("aTOKENb", atomic.end); + let left = motion.plan_command(EditCommand::MoveGraphemeLeft, std::slice::from_ref(&atomic)); + assert_eq!(left.cursor_byte(), atomic.start); + assert_eq!(motion.apply_plan(&left), Ok(EditOutcome::CursorOnly)); + let right = motion.plan_command(EditCommand::MoveGraphemeRight, &[atomic]); + assert_eq!(right.cursor_byte(), 6); + assert_eq!(motion.apply_plan(&right), Ok(EditOutcome::CursorOnly)); + + let mut replacement = EditBuffer::from_parts("aTOKENb", 3); + let plan = replacement.plan_replace_byte_range(3..4, "x", &[1..6]); + assert_eq!(plan.replaced_byte_range(), 1..6); + assert_eq!(plan.replacement(), "x"); + assert_eq!(plan.removed_text(), "TOKEN"); + assert_eq!( + replacement.apply_plan(&plan), + Ok(EditOutcome::TextAndCursor(delta(1..6, 1..2))) + ); + assert_eq!(replacement.text(), "axb"); +} + +#[test] +fn atomic_word_classes_follow_the_selected_word_style() { + let text = "fooTOKENbar"; + let atomic = 3..8; + + let small_forward = EditBuffer::from_parts(text, 0).plan_command( + EditCommand::MoveWordRight(WordStyle::Small), + std::slice::from_ref(&atomic), + ); + assert_eq!(small_forward.cursor_byte(), atomic.start); + let small_backward = EditBuffer::from_text(text).plan_command( + EditCommand::MoveWordLeft(WordStyle::Small), + std::slice::from_ref(&atomic), + ); + assert_eq!(small_backward.cursor_byte(), atomic.end); + + let mut small_delete_forward = EditBuffer::from_parts(text, atomic.start); + let plan = small_delete_forward.plan_command( + EditCommand::DeleteWordForward(WordStyle::Small), + std::slice::from_ref(&atomic), + ); + assert_eq!(plan.replaced_byte_range(), atomic); + assert_eq!( + small_delete_forward.apply_plan(&plan), + Ok(EditOutcome::TextOnly(delta(3..8, 3..3))) + ); + assert_eq!(small_delete_forward.text(), "foobar"); + + let mut small_delete_backward = EditBuffer::from_parts(text, atomic.end); + let plan = small_delete_backward.plan_command( + EditCommand::DeleteWordBackward(WordStyle::Small), + std::slice::from_ref(&atomic), + ); + assert_eq!(plan.replaced_byte_range(), atomic); + assert_eq!( + small_delete_backward.apply_plan(&plan), + Ok(EditOutcome::TextAndCursor(delta(3..8, 3..3))) + ); + assert_eq!(small_delete_backward.text(), "foobar"); + + let word_forward = EditBuffer::from_parts(text, 0).plan_command( + EditCommand::MoveWordRight(WordStyle::WhitespaceDelimited), + std::slice::from_ref(&atomic), + ); + assert_eq!(word_forward.cursor_byte(), text.len()); + let word_backward = EditBuffer::from_text(text).plan_command( + EditCommand::MoveWordLeft(WordStyle::WhitespaceDelimited), + std::slice::from_ref(&atomic), + ); + assert_eq!(word_backward.cursor_byte(), 0); + + let mut word_delete_forward = EditBuffer::from_parts(text, 0); + let plan = word_delete_forward.plan_command( + EditCommand::DeleteWordForward(WordStyle::WhitespaceDelimited), + std::slice::from_ref(&atomic), + ); + assert_eq!(plan.replaced_byte_range(), 0..text.len()); + assert_eq!( + word_delete_forward.apply_plan(&plan), + Ok(EditOutcome::TextOnly(delta(0..text.len(), 0..0))) + ); + assert_eq!(word_delete_forward.text(), ""); + + let mut word_delete_backward = EditBuffer::from_text(text); + let plan = word_delete_backward.plan_command( + EditCommand::DeleteWordBackward(WordStyle::WhitespaceDelimited), + &[atomic], + ); + assert_eq!(plan.replaced_byte_range(), 0..text.len()); + assert_eq!( + word_delete_backward.apply_plan(&plan), + Ok(EditOutcome::TextAndCursor(delta(0..text.len(), 0..0))) + ); + assert_eq!(word_delete_backward.text(), ""); +} + +#[test] +fn logical_line_plans_ignore_newlines_inside_atomic_ranges() { + let atomic = 1..4; + let text = "aX\nYb\nc"; + + let motion = EditBuffer::from_parts(text, 0); + let to_end = motion.plan_command( + EditCommand::MoveLogicalLineEnd, + std::slice::from_ref(&atomic), + ); + assert_eq!(to_end.cursor_byte(), 5); + + let mut deletion = EditBuffer::from_parts(text, 0); + let to_end = deletion.plan_command(EditCommand::DeleteToLineEnd, std::slice::from_ref(&atomic)); + assert_eq!(to_end.replaced_byte_range(), 0..5); + assert_eq!(to_end.removed_text(), "aX\nYb"); + assert_eq!( + deletion.apply_plan(&to_end), + Ok(EditOutcome::TextOnly(delta(0..5, 0..0))) + ); + assert_eq!(deletion.text(), "\nc"); + + let from_bol = EditBuffer::from_parts(text, 6); + let to_start = from_bol.plan_command( + EditCommand::MoveLogicalLineStart, + std::slice::from_ref(&atomic), + ); + assert_eq!(to_start.cursor_byte(), 0); + + let deletion = EditBuffer::from_parts(text, atomic.end); + let to_start = deletion.plan_command(EditCommand::DeleteToLineStart, &[atomic]); + assert_eq!(to_start.replaced_byte_range(), 0..4); + assert_eq!(to_start.removed_text(), "aX\nY"); +} diff --git a/crates/codegen/xai-ratatui-textarea/src/editor_tests/viewport.rs b/crates/codegen/xai-ratatui-textarea/src/editor_tests/viewport.rs new file mode 100644 index 0000000..08a4701 --- /dev/null +++ b/crates/codegen/xai-ratatui-textarea/src/editor_tests/viewport.rs @@ -0,0 +1,247 @@ +use rand::{Rng as _, SeedableRng as _}; +use unicode_width::UnicodeWidthStr as _; + +use super::super::*; +use super::is_extended_grapheme_boundary; + +fn assert_viewport_invariants( + buffer: &EditBuffer, + viewport: &SingleLineViewport, + display_width: usize, +) { + let visible = &buffer.text()[viewport.visible_byte_range.clone()]; + let prefix = &buffer.text()[viewport.visible_byte_range.start..buffer.cursor_byte()]; + assert!(!visible.contains('\n')); + assert!(!visible.contains('\r')); + assert!(visible.width() <= display_width); + assert_eq!(prefix.width(), viewport.cursor_display_column); + if display_width == 0 { + assert_eq!(viewport.cursor_display_column, 0); + } else { + assert!(viewport.cursor_display_column < display_width); + } +} + +#[test] +fn single_line_viewport_clips_only_at_grapheme_boundaries() { + let zwj = "👩🏽\u{200d}💻"; + let flag = "🇺🇸"; + let text = format!("a{zwj}b{flag}界"); + let after_b = 1 + zwj.len() + 1; + let mut buffer = EditBuffer::from_parts(text.as_str(), after_b); + + let viewport = buffer.single_line_viewport(4); + let expected = format!("{zwj}b"); + assert_eq!( + &buffer.text()[viewport.visible_byte_range.clone()], + expected.as_str() + ); + assert_eq!(viewport.cursor_display_column, 3); + assert_viewport_invariants(&buffer, &viewport, 4); + + let _ = buffer.set_cursor_byte(0); + let viewport = buffer.single_line_viewport(4); + let expected = format!("a{zwj}b"); + assert_eq!( + &buffer.text()[viewport.visible_byte_range.clone()], + expected.as_str() + ); + assert_eq!(viewport.cursor_display_column, 0); + assert_viewport_invariants(&buffer, &viewport, 4); + + let _ = buffer.set_cursor_byte(buffer.text().len()); + let viewport = buffer.single_line_viewport(4); + assert_eq!(&buffer.text()[viewport.visible_byte_range.clone()], "界"); + assert_eq!(viewport.cursor_display_column, 2); + assert_viewport_invariants(&buffer, &viewport, 4); + + let viewport = buffer.single_line_viewport(0); + assert_eq!( + viewport.visible_byte_range, + buffer.cursor_byte()..buffer.cursor_byte() + ); + assert_eq!(viewport.cursor_display_column, 0); + assert_viewport_invariants(&buffer, &viewport, 0); + + let combining = EditBuffer::from_parts("e\u{301}x", 0); + let viewport = combining.single_line_viewport(1); + assert_eq!( + &combining.text()[viewport.visible_byte_range.clone()], + "e\u{301}" + ); + assert_viewport_invariants(&combining, &viewport, 1); + + let narrow_zwj = EditBuffer::from_parts(zwj, 0); + let viewport = narrow_zwj.single_line_viewport(1); + assert!(viewport.visible_byte_range.is_empty()); + assert_viewport_invariants(&narrow_zwj, &viewport, 1); + + let zero_width = "\u{200b}"; + assert_eq!(zero_width.width(), 0); + let text = format!("a{zero_width}b"); + let zero_width_buffer = EditBuffer::from_parts(text.as_str(), 1 + zero_width.len()); + let viewport = zero_width_buffer.single_line_viewport(2); + assert_eq!( + &zero_width_buffer.text()[viewport.visible_byte_range.clone()], + text.as_str() + ); + assert_eq!(viewport.cursor_display_column, 1); + assert_viewport_invariants(&zero_width_buffer, &viewport, 2); +} + +#[test] +fn single_line_viewport_stays_within_lf_and_crlf_logical_lines() { + for (text, cursor_byte, expected) in [ + ("a\nb", "a\nb".len(), "b"), + ("ab\r\ncd", "ab\r\ncd".len(), "cd"), + ("ab\r\ncd", 2, "ab"), + ] { + let buffer = EditBuffer::from_parts(text, cursor_byte); + let viewport = buffer.single_line_viewport(4); + assert_eq!( + &buffer.text()[viewport.visible_byte_range.clone()], + expected + ); + assert_viewport_invariants(&buffer, &viewport, 4); + } +} + +#[test] +fn atomic_line_break_stays_inside_single_line_viewport() { + let text = "aaX\nYbb"; + let cursor = text.find('Y').expect("Y") + 1; + let buffer = EditBuffer::from_parts(text, cursor); + let atomic = text.find('X').expect("X")..text.find('b').expect("b"); + + let physical = buffer.single_line_viewport(16); + assert_eq!(&buffer.text()[physical.visible_byte_range], "Ybb"); + + let logical = buffer.single_line_viewport_with_atomic_ranges(16, &[atomic]); + assert_eq!(&buffer.text()[logical.visible_byte_range], text); + assert_eq!(logical.cursor_display_column, 5); +} + +#[test] +fn atomic_viewport_preserves_raw_cursor_and_whole_spans() { + let text = "aaTOKENbb"; + let atom = 2..7; + let buffer = EditBuffer { + text: text.to_string(), + cursor_byte: 5, + ..EditBuffer::default() + }; + + let viewport = buffer.single_line_viewport_with_atomic_ranges(4, std::slice::from_ref(&atom)); + assert!(viewport.visible_byte_range.start <= buffer.cursor_byte()); + assert!(buffer.cursor_byte() <= viewport.visible_byte_range.end); + assert!( + viewport.visible_byte_range.is_empty() + || viewport.visible_byte_range.start <= atom.start + || viewport.visible_byte_range.start >= atom.end + ); + assert!( + viewport.visible_byte_range.is_empty() + || viewport.visible_byte_range.end <= atom.start + || viewport.visible_byte_range.end >= atom.end + ); + assert!(viewport.visible_byte_range.start <= buffer.cursor_byte()); + assert!(buffer.cursor_byte() <= viewport.visible_byte_range.end); +} + +#[test] +fn fixed_seed_edit_sequence_preserves_cursor_and_viewport_invariants() { + let atoms = [ + "", + "a", + "_", + "-", + " ", + "\n", + "\r\n", + "\u{200b}", + "e\u{301}", + "👩🏽\u{200d}💻", + "🇺🇸", + "界", + ]; + let mut rng = rand::rngs::StdRng::seed_from_u64(0x5eed_ed17); + let mut buffer = EditBuffer::new(); + + for _ in 0..2_000 { + match rng.random_range(0..13) { + 0 => { + let atom = atoms[rng.random_range(0..atoms.len())]; + let _ = buffer.insert_str(atom); + } + 1 => { + let _ = buffer.apply(EditCommand::MoveGraphemeLeft); + } + 2 => { + let _ = buffer.apply(EditCommand::MoveGraphemeRight); + } + 3 => { + let _ = buffer.apply(EditCommand::DeleteGraphemeBackward); + } + 4 => { + let _ = buffer.apply(EditCommand::DeleteGraphemeForward); + } + 5 => { + let _ = buffer.apply(EditCommand::DeleteWordBackward(WordStyle::Small)); + } + 6 => { + let _ = buffer.apply(EditCommand::DeleteWordBackward( + WordStyle::WhitespaceDelimited, + )); + } + 7 => { + let _ = buffer.apply(EditCommand::DeleteWordForward(WordStyle::Small)); + } + 8 => { + let _ = buffer.apply(EditCommand::MoveWordLeft(WordStyle::Small)); + } + 9 => { + let _ = buffer.apply(EditCommand::MoveWordRight(WordStyle::Small)); + } + 10 => { + let byte = rng.random_range(0..=buffer.text().len().saturating_add(3)); + let _ = buffer.set_cursor_byte(byte); + } + 11 => { + let max = buffer.text().len().saturating_add(2); + let start = rng.random_range(0..=max); + let end = rng.random_range(0..=max); + let replacement = atoms[rng.random_range(0..atoms.len())]; + let _ = buffer.replace_byte_range(start..end, replacement); + } + 12 => { + let command = if rng.random() { + EditCommand::MoveLogicalLineStart + } else { + EditCommand::MoveLogicalLineEnd + }; + let _ = buffer.apply(command); + } + _ => unreachable!(), + } + + assert!(buffer.cursor_byte() <= buffer.text().len()); + assert!(is_extended_grapheme_boundary( + buffer.text(), + buffer.cursor_byte() + )); + + let width = rng.random_range(0..8); + let viewport = buffer.single_line_viewport(width); + assert!(viewport.visible_byte_range.start <= buffer.cursor_byte()); + assert!(buffer.cursor_byte() <= viewport.visible_byte_range.end); + assert!(is_extended_grapheme_boundary( + buffer.text(), + viewport.visible_byte_range.start + )); + assert!(is_extended_grapheme_boundary( + buffer.text(), + viewport.visible_byte_range.end + )); + assert_viewport_invariants(&buffer, &viewport, width); + } +} diff --git a/crates/codegen/xai-ratatui-textarea/src/lib.rs b/crates/codegen/xai-ratatui-textarea/src/lib.rs index 3fc86c2..b4cfede 100644 --- a/crates/codegen/xai-ratatui-textarea/src/lib.rs +++ b/crates/codegen/xai-ratatui-textarea/src/lib.rs @@ -1,9 +1,14 @@ #![allow(clippy::new_without_default)] +pub mod editor; pub mod render; pub mod textarea; pub mod wrapping; +pub use editor::{ + ApplyEditPlanError, EditBuffer, EditCommand, EditDelta, EditOutcome, EditPlan, + PostEditCursorAffinity, SingleLineViewport, WordStyle, classify_key_event, +}; pub use textarea::{ ClipboardProvider, ElementId, ElementKind, InternalClipboard, MouseAction, TextArea, TextAreaState, TextElement, TextElementEvent, TextElementEventKind, is_undo_input, diff --git a/prod/mc/cli-chat-proxy-types/src/deployment_config_types.rs b/prod/mc/cli-chat-proxy-types/src/deployment_config_types.rs index eb52aaa..1e7c298 100644 --- a/prod/mc/cli-chat-proxy-types/src/deployment_config_types.rs +++ b/prod/mc/cli-chat-proxy-types/src/deployment_config_types.rs @@ -9,12 +9,21 @@ use serde::{Deserialize, Serialize}; /// verifiers can distinguish generations; `0` means a pre-versioned payload. pub const SIGNED_PAYLOAD_VERSION: u32 = 1; +/// Domain-separation tags inside the signed bytes: both message types share one +/// signing key, so each verifier requires its own tag (no cross-substitution). +pub const MANAGED_POLICY_TYP: &str = "grok.managed_policy.v1"; +pub const MANAGED_IDENTITY_TYP: &str = "grok.managed_identity.v1"; + /// The exact bytes the server signs: the served policy, the principal it is /// bound to, and an expiry. Serialized once on the server and shipped verbatim /// as `signed_payload`, so the client verifies the received bytes directly /// instead of re-canonicalizing (no cross-language serialization drift). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SignedPayload { + /// Domain-separation tag; the verifier requires [`MANAGED_POLICY_TYP`]. + /// `default` so untagged JSON parses — verification still rejects it. + #[serde(default)] + pub typ: String, /// Payload format version ([`SIGNED_PAYLOAD_VERSION`]); `default` 0 so /// pre-versioned sidecars parse and verify unchanged. #[serde(default)] @@ -37,6 +46,26 @@ pub struct SignedPayload { pub key_id: String, } +/// Server-signed claim that a principal is managed (+ fail-closed), persisted by +/// the client as its OWN sidecar — so deleting the policy sidecar alone cannot +/// downgrade the load-time gate to the forgeable marker. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ManagedIdentityClaim { + /// Domain-separation tag — the verifier requires [`MANAGED_IDENTITY_TYP`]. + #[serde(default)] + pub typ: String, + /// The managed principal (deployment or team id) this claim is bound to. + pub principal: String, + /// Strict opt-in, same server source as the policy's; `default` false so an + /// older/partial claim never imposes. + #[serde(default)] + pub fail_closed: bool, + /// Unix seconds after which the claim is no longer trusted. + pub expires_at: u64, + /// Signing key id (same rotation set as the policy envelope). + pub key_id: String, +} + /// One signed envelope carried alongside the legacy policy fields in the /// deployment-config response (additive: old clients ignore it). Also the /// shape the client persists as its on-disk signature sidecar. @@ -61,17 +90,41 @@ pub fn now_unix() -> u64 { .unwrap_or(0) } -/// The `requirements.toml` opt-in key for strict (fail-closed) enforcement. +/// `requirements.toml` key for strict (fail-closed) enforcement. pub const FAIL_CLOSED_KEY: &str = "fail_closed"; -/// Read the `fail_closed` opt-in from a requirements-TOML string — THE canonical parse, -/// shared by the cli-chat-proxy signer and the client so the two sides can't drift. -/// Invalid TOML or a non-bool value → `false`. -pub fn fail_closed_flag_from_str(requirements: &str) -> bool { - toml::from_str::(requirements) - .ok() - .and_then(|v| v.get(FAIL_CLOSED_KEY).and_then(toml::Value::as_bool)) - .unwrap_or(false) +/// Parse result for `fail_closed`. [`Invalid`] = key present but not a bool. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailClosedFlag { + True, + False, + Invalid, +} + +impl FailClosedFlag { + pub fn is_enabled(self) -> bool { + matches!(self, Self::True) + } +} + +/// Shared `fail_closed` parse (signer + client). Bad TOML → False; non-bool key → Invalid. +pub fn fail_closed_flag_status(requirements: &str) -> FailClosedFlag { + let Ok(v) = toml::from_str::(requirements) else { + return FailClosedFlag::False; + }; + fail_closed_flag_status_from_value(&v) +} + +/// [`fail_closed_flag_status`] for an already-parsed table. +pub fn fail_closed_flag_status_from_value(requirements: &toml::Value) -> FailClosedFlag { + match requirements.get(FAIL_CLOSED_KEY) { + None => FailClosedFlag::False, + Some(val) => match val.as_bool() { + Some(true) => FailClosedFlag::True, + Some(false) => FailClosedFlag::False, + None => FailClosedFlag::Invalid, + }, + } } #[cfg(test)] @@ -83,6 +136,7 @@ mod tests { #[test] fn signed_payload_version_round_trips_and_defaults() { let versioned = SignedPayload { + typ: MANAGED_POLICY_TYP.to_owned(), version: SIGNED_PAYLOAD_VERSION, deployment_id: None, team_id: Some("team-007".into()), @@ -101,5 +155,61 @@ mod tests { let legacy: SignedPayload = serde_json::from_str(r#"{"expires_at": 1, "key_id": "v1"}"#).unwrap(); assert_eq!(legacy.version, 0, "pre-versioned payloads default to 0"); + assert_eq!( + legacy.typ, "", + "an untagged payload parses (verifiers reject it)" + ); + } + + /// The claim round-trips; `fail_closed` is additive (absent → permissive). + #[test] + fn managed_identity_claim_round_trips_and_defaults() { + let claim = ManagedIdentityClaim { + typ: MANAGED_IDENTITY_TYP.to_owned(), + principal: "team-007".into(), + fail_closed: true, + expires_at: 4_000_000_000, + key_id: "v1".into(), + }; + let json = serde_json::to_string(&claim).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + claim + ); + + let partial: ManagedIdentityClaim = serde_json::from_str( + r#"{"typ":"grok.managed_identity.v1","principal":"team-007","expires_at":1,"key_id":"v1"}"#, + ) + .unwrap(); + assert!(!partial.fail_closed, "a partial claim parses permissive"); + } + + #[test] + fn fail_closed_flag_status_distinguishes_invalid() { + assert_eq!( + fail_closed_flag_status("fail_closed = true\n"), + FailClosedFlag::True + ); + assert_eq!( + fail_closed_flag_status("fail_closed = false\n"), + FailClosedFlag::False + ); + assert_eq!( + fail_closed_flag_status("[features]\n"), + FailClosedFlag::False + ); + assert_eq!( + fail_closed_flag_status("fail_closed = \"true\"\n"), + FailClosedFlag::Invalid + ); + assert_eq!( + fail_closed_flag_status("fail_closed = 1\n"), + FailClosedFlag::Invalid + ); + // Unparseable TOML: no key to warn about. + assert_eq!( + fail_closed_flag_status("not = = valid"), + FailClosedFlag::False + ); } }