Synced from monorepo
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
This commit is contained in:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
367
crates/codegen/xai-grok-shell/src/util/subprocess.rs
Normal file
367
crates/codegen/xai-grok-shell/src/util/subprocess.rs
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
//! Shared subprocess helpers: a TTY-detached async runner with a wall-clock
|
||||
//! timeout and concurrent pipe draining, plus the hermetic `git` binary path.
|
||||
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Output;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use tokio::io::AsyncRead;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
use tracing::warn;
|
||||
use xai_tty_utils::ProcessGroup;
|
||||
|
||||
const MAX_CAPTURE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
const READ_CHUNK_SIZE: usize = 8192;
|
||||
|
||||
/// Quiet lull ending the post-exit drain (backgrounded grandchildren may hold
|
||||
/// the pipes open).
|
||||
const POST_EXIT_QUIET: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Hard cap on the whole post-exit drain (`POST_EXIT_QUIET` is per-chunk, so a
|
||||
/// grandchild that keeps writing could otherwise reach the deadline).
|
||||
const POST_EXIT_BUDGET: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Grace between SIGTERM and SIGKILL when tearing down a timed-out process
|
||||
/// group, so a signal-aware child can exit cleanly before it is force-killed.
|
||||
const TERM_GRACE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Resolve the `git` binary: `GIT_BIN_PATH` (Bazel's hermetic-git data dep;
|
||||
/// runfiles-relative, so resolved against the cwd) or bare `git` on `PATH`.
|
||||
pub(crate) fn git_bin() -> OsString {
|
||||
let Some(raw) = env::var_os("GIT_BIN_PATH") else {
|
||||
return OsString::from("git");
|
||||
};
|
||||
let path = PathBuf::from(&raw);
|
||||
if path.is_relative() {
|
||||
match env::current_dir() {
|
||||
Ok(cwd) => cwd.join(&path).into_os_string(),
|
||||
Err(_) => raw,
|
||||
}
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
/// A `sh -c <script>` command: the portable shell escape hatch shared by the
|
||||
/// identity and auth providers.
|
||||
pub(crate) fn sh_c(script: &str) -> Command {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.args(["-c", script]);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Whether the command text may appear in spawn-failure/timeout logs.
|
||||
/// `Redacted` (the safe default) keeps it out for commands whose text may
|
||||
/// embed secrets; `Shown` includes it for diagnostics.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum CommandLog<'a> {
|
||||
Redacted,
|
||||
Shown(&'a str),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CommandLog<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match *self {
|
||||
Self::Shown(cmd) => f.write_str(cmd),
|
||||
Self::Redacted => f.write_str("<redacted>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RunOptions<'a> {
|
||||
pub label: &'a str,
|
||||
pub command_log: CommandLog<'a>,
|
||||
}
|
||||
|
||||
/// Why [`run_detached_with_timeout`] produced no `Output`. A nonzero exit is
|
||||
/// not one of these; it comes back as `Ok`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RunError {
|
||||
SpawnFailed,
|
||||
TimedOut,
|
||||
WaitFailed,
|
||||
}
|
||||
|
||||
/// Run `cmd` detached from the TTY with a wall-clock `timeout`, killing the
|
||||
/// whole process group on timeout and capping capture per stream. A nonzero
|
||||
/// exit comes back as `Ok`; [`RunError`] covers the no-output cases.
|
||||
pub(crate) async fn run_detached_with_timeout(
|
||||
mut cmd: Command,
|
||||
timeout: Duration,
|
||||
opts: RunOptions<'_>,
|
||||
) -> Result<Output, RunError> {
|
||||
let RunOptions { label, command_log } = opts;
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
// Pipe stderr; inherit would corrupt the TUI alternate screen.
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
xai_grok_tools::util::detach_command(&mut cmd);
|
||||
cmd.envs(xai_grok_tools::util::pager_env());
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
warn!(error = %e, label, cmd = %command_log, "failed to start the command");
|
||||
return Err(RunError::SpawnFailed);
|
||||
}
|
||||
};
|
||||
|
||||
// Enroll the child's process group so a timeout kill reaches grandchildren;
|
||||
// on failure only the direct child is killed.
|
||||
let group = ProcessGroup::new()
|
||||
.and_then(|mut group| group.attach(&child).map(|()| group))
|
||||
.inspect_err(|e| {
|
||||
debug!(error = %e, label, "process-group enrollment failed; a timeout kill covers the direct child only");
|
||||
})
|
||||
.ok();
|
||||
|
||||
let stdout_rx = child.stdout.take().map(spawn_pipe_reader);
|
||||
let stderr_rx = child.stderr.take().map(spawn_pipe_reader);
|
||||
|
||||
let status = match tokio::time::timeout(timeout, child.wait()).await {
|
||||
Ok(Ok(status)) => status,
|
||||
Ok(Err(e)) => {
|
||||
warn!(error = %e, label, "error while waiting for the command");
|
||||
terminate_child(&mut child, group.as_ref()).await;
|
||||
return Err(RunError::WaitFailed);
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
warn!(
|
||||
label,
|
||||
cmd = %command_log,
|
||||
timeout_secs = timeout.as_secs(),
|
||||
"command timed out; terminating"
|
||||
);
|
||||
terminate_child(&mut child, group.as_ref()).await;
|
||||
return Err(RunError::TimedOut);
|
||||
}
|
||||
};
|
||||
|
||||
// Both streams drain concurrently under one budget measured fresh from the
|
||||
// child's exit, so a near-deadline exit still captures its buffered output
|
||||
// in full.
|
||||
let drain_deadline = Instant::now() + POST_EXIT_BUDGET;
|
||||
let (stdout, stderr) = tokio::join!(
|
||||
drain_reader(stdout_rx, drain_deadline),
|
||||
drain_reader(stderr_rx, drain_deadline),
|
||||
);
|
||||
if !status.success() {
|
||||
// Debug, not warn: some callers run secret-bearing commands (auth
|
||||
// tokens, resolved identities), so stderr stays out of routine logs;
|
||||
// callers that need louder reporting inspect the returned stderr.
|
||||
debug!(status = %status, label, "command exited with a nonzero status");
|
||||
}
|
||||
Ok(Output {
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tear down a timed-out or errored run: SIGTERM the process group, wait
|
||||
/// [`TERM_GRACE`], then SIGKILL survivors. Always reaps the direct child too,
|
||||
/// so an absent group signal can't leave it unwaited.
|
||||
async fn terminate_child(child: &mut Child, group: Option<&ProcessGroup>) {
|
||||
if let Some(group) = group {
|
||||
let _ = group.terminate();
|
||||
let _ = tokio::time::timeout(TERM_GRACE, child.wait()).await;
|
||||
let _ = group.kill();
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
// Bounded reap: a wedged (uninterruptible) child must not park the runner;
|
||||
// `kill_on_drop` reaps it later if this wait gives up.
|
||||
let _ = tokio::time::timeout(TERM_GRACE, child.wait()).await;
|
||||
}
|
||||
|
||||
/// Stream a child pipe over an unbounded channel, capped at
|
||||
/// [`MAX_CAPTURE_BYTES`]; a closed receiver (the drain gave up) stops the
|
||||
/// reader so it never parks in `read()` forever.
|
||||
fn spawn_pipe_reader<R>(mut pipe: R) -> mpsc::UnboundedReceiver<Vec<u8>>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
let mut sent = 0usize;
|
||||
let mut chunk = [0u8; READ_CHUNK_SIZE];
|
||||
loop {
|
||||
let n = tokio::select! {
|
||||
biased;
|
||||
() = tx.closed() => break,
|
||||
result = pipe.read(&mut chunk) => match result {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
},
|
||||
};
|
||||
let keep = n.min(MAX_CAPTURE_BYTES.saturating_sub(sent));
|
||||
if keep > 0 {
|
||||
if tx.send(chunk[..keep].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
sent += keep;
|
||||
}
|
||||
// Past the cap: keep reading to drain the pipe (never block the child),
|
||||
// discarding the excess.
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
async fn drain_reader(
|
||||
rx: Option<mpsc::UnboundedReceiver<Vec<u8>>>,
|
||||
drain_deadline: Instant,
|
||||
) -> Vec<u8> {
|
||||
let Some(mut rx) = rx else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let Some(remaining) = drain_deadline.checked_duration_since(Instant::now()) else {
|
||||
break;
|
||||
};
|
||||
let wait = POST_EXIT_QUIET.min(remaining);
|
||||
match tokio::time::timeout(wait, rx.recv()).await {
|
||||
Ok(Some(chunk)) => buf.extend_from_slice(&chunk),
|
||||
// A closed channel is EOF; an elapsed wait is a quiet lull (a
|
||||
// grandchild may still hold the pipe).
|
||||
Ok(None) | Err(_) => break,
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sh(script: &str) -> Command {
|
||||
sh_c(script)
|
||||
}
|
||||
|
||||
fn opts(label: &str) -> RunOptions<'_> {
|
||||
RunOptions {
|
||||
label,
|
||||
command_log: CommandLog::Redacted,
|
||||
}
|
||||
}
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn large_stderr_is_streamed_and_capped() {
|
||||
let out = run_detached_with_timeout(
|
||||
sh("yes 0123456789abcdef | head -c 2097152 >&2; echo done"),
|
||||
TIMEOUT,
|
||||
opts("test large stderr"),
|
||||
)
|
||||
.await
|
||||
.expect("must complete without hitting the timeout");
|
||||
assert!(out.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "done");
|
||||
assert_eq!(out.stderr.len(), MAX_CAPTURE_BYTES);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backgrounded_grandchild_does_not_block_the_drain() {
|
||||
let start = Instant::now();
|
||||
let out = run_detached_with_timeout(
|
||||
sh("sleep 5 & echo hi"),
|
||||
TIMEOUT,
|
||||
opts("test grandchild drain"),
|
||||
)
|
||||
.await
|
||||
.expect("must return promptly, not at the grandchild's exit");
|
||||
assert!(out.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hi");
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(3),
|
||||
"drain must not wait for the grandchild (took {:?})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nonzero_exit_returns_output_to_caller() {
|
||||
let out = run_detached_with_timeout(
|
||||
sh("echo partial; echo diagnostics >&2; exit 3"),
|
||||
TIMEOUT,
|
||||
opts("test nonzero"),
|
||||
)
|
||||
.await
|
||||
.expect("nonzero exit is the caller's call, not a runner failure");
|
||||
assert_eq!(out.status.code(), Some(3));
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "partial");
|
||||
assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "diagnostics");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_with_grandchild_reports_timeout_promptly() {
|
||||
let start = Instant::now();
|
||||
let out = run_detached_with_timeout(
|
||||
sh("sleep 30 & sleep 30"),
|
||||
Duration::from_secs(1),
|
||||
opts("test group kill"),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(out, Err(RunError::TimedOut)));
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"group kill must not wait for the grandchild (took {:?})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_escalates_past_a_sigterm_trap() {
|
||||
let start = Instant::now();
|
||||
let out = run_detached_with_timeout(
|
||||
sh("trap '' TERM; while :; do sleep 0.2; done"),
|
||||
Duration::from_secs(1),
|
||||
opts("test sigterm trap"),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(out, Err(RunError::TimedOut)));
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"escalation must bound teardown of a SIGTERM-ignoring child (took {:?})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_failure_reports_spawn_error() {
|
||||
let cmd = Command::new("/nonexistent/grok-test-binary");
|
||||
let out = run_detached_with_timeout(cmd, TIMEOUT, opts("test spawn failure")).await;
|
||||
assert!(matches!(out, Err(RunError::SpawnFailed)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_exit_budget_is_shared_across_streams() {
|
||||
let start = Instant::now();
|
||||
let out = run_detached_with_timeout(
|
||||
sh("(i=0; while [ $i -lt 100 ]; do echo out; echo err >&2; sleep 0.1; i=$((i+1)); done) & echo hi"),
|
||||
TIMEOUT,
|
||||
opts("test shared drain budget"),
|
||||
)
|
||||
.await
|
||||
.expect("must return at the shared budget, not the timeout");
|
||||
assert!(out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("hi"));
|
||||
assert!(
|
||||
start.elapsed() < POST_EXIT_BUDGET + Duration::from_millis(1500),
|
||||
"drain must be bounded by ONE shared budget (took {:?})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue