Synced from monorepo Changes: - Shell: accept target response id on rewind execute - Shell: stamp response id on chat user message chunks - Worktree: optional rebuild and stale git registration cleanup in auto-GC - Worktree: kind-aware auto-GC TTLs and config knobs - Worktree: macOS process CWD scan and Unix PID liveness for GC guards - Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only) - Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups - Shell: stop overwriting user skills - Tools: read markdown in `skills/` directories untruncated - `/usage` shows per-session token and dollar usage in the TUI - Security: prompt on environment-dumping `ps` variants - Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission - Tools: make scheduler deletion durable - Shell: add relocation storage primitives - Shell: give side model calls their own conversation ids - Fix five workflow-runtime bugs (budget, pause, cancel, reconnect) - Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask) - Pager: expose doctor in the TUI - Security: block unauthorized RCE via abused safe commands - Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent" - Security: block `rg --pre` arbitrary code execution in auto-mode - Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section - App builder deployer: `allow_forking` and `show_built_with_grok` - Pager: stop stacking duplicate "Worked for" markers on parked turns - Shell: support `max` as a distinct reasoning effort tier - Tools: serialize background `/loop` fires on the whole work unit - Shell: add working-directory relocation state primitives - Proto: `ClientToolResult` and `ChatConfig` client-side tools - Shell: model providers - Chat: select App Builder product on the Build path - Shell: attach author identity to feedback when the deployment opts in - Doctor: fix for SSH wrap setup - Workflow authoring skills: create-workflow and import-claude-workflow docs - Add read-only grok doctor - Sandbox: apply Landlock without a controlling TTY - Pager: recover image paste over grok wrap on headless remotes - Pager: make actions screen-mode aware - Shell: resume sessions when the working directory moves - Pager: centralize terminal diagnostics - Workspace: gate inline shell file access - Pager: centralize terminal probes - Pager: edit minimal prompts in an external editor - Pager: standardize backgrounding on Ctrl+B - Shell: recap rides the parent turn's prompt cache - Tools: add scheduler lifecycle version clock Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
274 lines
9 KiB
Rust
274 lines
9 KiB
Rust
//! Shared tmux command protocol and result parsing.
|
|
|
|
use std::process::{Command, Stdio};
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum TmuxCommand<'a> {
|
|
Version,
|
|
OptionValue(&'a str),
|
|
OptionSupport(&'a str),
|
|
ControlMode,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct TmuxCommandOutput {
|
|
status_success: bool,
|
|
stdout: Vec<u8>,
|
|
stderr: Vec<u8>,
|
|
}
|
|
|
|
trait TmuxCommandRunner {
|
|
fn run(&self, command: TmuxCommand<'_>) -> Result<TmuxCommandOutput, String>;
|
|
}
|
|
|
|
struct LiveTmuxCommandRunner;
|
|
|
|
impl TmuxCommandRunner for LiveTmuxCommandRunner {
|
|
fn run(&self, command: TmuxCommand<'_>) -> Result<TmuxCommandOutput, String> {
|
|
let output = build_tmux_command(command)
|
|
.output()
|
|
.map_err(|error| format!("failed to run tmux: {error}"))?;
|
|
Ok(TmuxCommandOutput {
|
|
status_success: output.status.success(),
|
|
stdout: output.stdout,
|
|
stderr: output.stderr,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum TmuxQueryResult<T> {
|
|
Available(T),
|
|
Unsupported,
|
|
Unavailable,
|
|
Error(String),
|
|
}
|
|
|
|
impl<T> TmuxQueryResult<T> {
|
|
pub fn into_option(self) -> Option<T> {
|
|
match self {
|
|
Self::Available(value) => Some(value),
|
|
Self::Unsupported | Self::Unavailable | Self::Error(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn query_version() -> TmuxQueryResult<String> {
|
|
query_version_with(&LiveTmuxCommandRunner)
|
|
}
|
|
|
|
fn query_version_with(runner: &dyn TmuxCommandRunner) -> TmuxQueryResult<String> {
|
|
parse_value(runner.run(TmuxCommand::Version))
|
|
}
|
|
|
|
pub fn query_option(option: &str) -> TmuxQueryResult<String> {
|
|
query_option_with(&LiveTmuxCommandRunner, option)
|
|
}
|
|
|
|
fn query_option_with(runner: &dyn TmuxCommandRunner, option: &str) -> TmuxQueryResult<String> {
|
|
parse_value(runner.run(TmuxCommand::OptionValue(option)))
|
|
}
|
|
|
|
pub fn query_option_support(option: &str) -> TmuxQueryResult<()> {
|
|
query_option_support_with(&LiveTmuxCommandRunner, option)
|
|
}
|
|
|
|
fn query_option_support_with(runner: &dyn TmuxCommandRunner, option: &str) -> TmuxQueryResult<()> {
|
|
match runner.run(TmuxCommand::OptionSupport(option)) {
|
|
Ok(output) if output.status_success => TmuxQueryResult::Available(()),
|
|
Ok(output) if stderr_identifies_unknown_option(&output.stderr, option) => {
|
|
TmuxQueryResult::Unsupported
|
|
}
|
|
Ok(_) => TmuxQueryResult::Unavailable,
|
|
Err(error) => TmuxQueryResult::Error(error),
|
|
}
|
|
}
|
|
|
|
pub fn query_control_mode() -> TmuxQueryResult<bool> {
|
|
query_control_mode_with(&LiveTmuxCommandRunner)
|
|
}
|
|
|
|
fn query_control_mode_with(runner: &dyn TmuxCommandRunner) -> TmuxQueryResult<bool> {
|
|
match runner.run(TmuxCommand::ControlMode) {
|
|
Ok(output) if output.status_success => TmuxQueryResult::Available(
|
|
String::from_utf8_lossy(&output.stdout).contains("control-mode"),
|
|
),
|
|
Ok(_) => TmuxQueryResult::Unavailable,
|
|
Err(error) => TmuxQueryResult::Error(error),
|
|
}
|
|
}
|
|
|
|
fn build_tmux_command(command: TmuxCommand<'_>) -> Command {
|
|
let mut cmd = Command::new("tmux");
|
|
match command {
|
|
TmuxCommand::Version => {
|
|
cmd.arg("-V").stdout(Stdio::piped()).stderr(Stdio::null());
|
|
}
|
|
TmuxCommand::OptionValue(option) => {
|
|
cmd.args(["show-option", "-gqv", option])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null());
|
|
}
|
|
TmuxCommand::OptionSupport(option) => {
|
|
cmd.args(["show-option", "-gv", option])
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped());
|
|
}
|
|
TmuxCommand::ControlMode => {
|
|
cmd.args(["display-message", "-p", "#{client_flags}"])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null());
|
|
}
|
|
}
|
|
cmd.stdin(Stdio::null()).envs(xai_tty_utils::pager_env());
|
|
xai_tty_utils::detach_std_command(&mut cmd);
|
|
cmd
|
|
}
|
|
|
|
fn parse_value(output: Result<TmuxCommandOutput, String>) -> TmuxQueryResult<String> {
|
|
match output {
|
|
Ok(output) if output.status_success => {
|
|
let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
|
if value.is_empty() {
|
|
TmuxQueryResult::Unavailable
|
|
} else {
|
|
TmuxQueryResult::Available(value)
|
|
}
|
|
}
|
|
Ok(_) => TmuxQueryResult::Unavailable,
|
|
Err(error) => TmuxQueryResult::Error(error),
|
|
}
|
|
}
|
|
|
|
fn stderr_identifies_unknown_option(stderr: &[u8], option: &str) -> bool {
|
|
let invalid = format!("invalid option: {option}");
|
|
let unknown = format!("unknown option: {option}");
|
|
String::from_utf8_lossy(stderr)
|
|
.lines()
|
|
.any(|line| matches!(line.trim(), value if value == invalid || value == unknown))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::cell::RefCell;
|
|
use std::collections::HashMap;
|
|
use std::ffi::{OsStr, OsString};
|
|
|
|
use super::*;
|
|
|
|
struct FakeRunner {
|
|
output: Result<TmuxCommandOutput, String>,
|
|
calls: RefCell<Vec<String>>,
|
|
}
|
|
|
|
impl FakeRunner {
|
|
fn output(status_success: bool, stdout: &[u8], stderr: &[u8]) -> Self {
|
|
Self {
|
|
output: Ok(TmuxCommandOutput {
|
|
status_success,
|
|
stdout: stdout.to_vec(),
|
|
stderr: stderr.to_vec(),
|
|
}),
|
|
calls: RefCell::new(Vec::new()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TmuxCommandRunner for FakeRunner {
|
|
fn run(&self, command: TmuxCommand<'_>) -> Result<TmuxCommandOutput, String> {
|
|
self.calls.borrow_mut().push(match command {
|
|
TmuxCommand::Version => "version".to_owned(),
|
|
TmuxCommand::OptionValue(option) => format!("value:{option}"),
|
|
TmuxCommand::OptionSupport(option) => format!("support:{option}"),
|
|
TmuxCommand::ControlMode => "control-mode".to_owned(),
|
|
});
|
|
self.output.clone()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn command_protocol_uses_exact_argv_and_pager_env() {
|
|
let cases = [
|
|
(TmuxCommand::Version, vec!["-V"]),
|
|
(
|
|
TmuxCommand::OptionValue("set-clipboard"),
|
|
vec!["show-option", "-gqv", "set-clipboard"],
|
|
),
|
|
(
|
|
TmuxCommand::OptionSupport("allow-passthrough"),
|
|
vec!["show-option", "-gv", "allow-passthrough"],
|
|
),
|
|
(
|
|
TmuxCommand::ControlMode,
|
|
vec!["display-message", "-p", "#{client_flags}"],
|
|
),
|
|
];
|
|
for (request, args) in cases {
|
|
let cmd = build_tmux_command(request);
|
|
assert_eq!(cmd.get_program(), OsStr::new("tmux"));
|
|
assert_eq!(cmd.get_args().collect::<Vec<_>>(), args);
|
|
let actual: HashMap<OsString, Option<OsString>> = cmd
|
|
.get_envs()
|
|
.map(|(key, value)| (key.to_owned(), value.map(OsStr::to_owned)))
|
|
.collect();
|
|
let expected: HashMap<OsString, Option<OsString>> = xai_tty_utils::pager_env()
|
|
.into_iter()
|
|
.map(|(key, value)| (key.into(), Some(value.into())))
|
|
.collect();
|
|
assert_eq!(actual, expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn value_and_control_queries_use_the_injected_runner() {
|
|
let runner = FakeRunner::output(true, b" on\n", b"");
|
|
assert_eq!(
|
|
query_option_with(&runner, "set-clipboard"),
|
|
TmuxQueryResult::Available("on".to_owned())
|
|
);
|
|
assert_eq!(runner.calls.into_inner(), ["value:set-clipboard"]);
|
|
|
|
let runner = FakeRunner::output(true, b"control-mode,utf8\n", b"");
|
|
assert_eq!(
|
|
query_control_mode_with(&runner),
|
|
TmuxQueryResult::Available(true)
|
|
);
|
|
assert_eq!(runner.calls.into_inner(), ["control-mode"]);
|
|
}
|
|
|
|
#[test]
|
|
fn nonzero_and_execution_failure_remain_fail_open_facts() {
|
|
let runner = FakeRunner::output(false, b"on", b"server unavailable");
|
|
assert_eq!(
|
|
query_option_with(&runner, "set-clipboard"),
|
|
TmuxQueryResult::Unavailable
|
|
);
|
|
let runner = FakeRunner {
|
|
output: Err("spawn failed".to_owned()),
|
|
calls: RefCell::new(Vec::new()),
|
|
};
|
|
assert_eq!(
|
|
query_version_with(&runner),
|
|
TmuxQueryResult::Error("spawn failed".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn support_query_accepts_both_known_spellings_only() {
|
|
for stderr in [
|
|
b"invalid option: allow-passthrough\n".as_slice(),
|
|
b"unknown option: allow-passthrough\n".as_slice(),
|
|
] {
|
|
let runner = FakeRunner::output(false, b"", stderr);
|
|
assert_eq!(
|
|
query_option_support_with(&runner, "allow-passthrough"),
|
|
TmuxQueryResult::Unsupported
|
|
);
|
|
}
|
|
let runner = FakeRunner::output(false, b"", b"no server running\n");
|
|
assert_eq!(
|
|
query_option_support_with(&runner, "allow-passthrough"),
|
|
TmuxQueryResult::Unavailable
|
|
);
|
|
}
|
|
}
|