Synced from monorepo

Changes:
- Non-blocking coding-data sharing upsell banner
- Consolidate remediation in Doctor
- Auto mode defers fail-closed gate asks to the classifier
- Coalesce marketplace list fetches
- Allow removing a marketplace source by name
- Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal)
- Label failed workspace RPCs with error_kind
- Drop redundant explicit tonic/prost deps from xai-grok-shell
- Report real exit codes for completed background shells
- Narrow the date-rollover reminder to date-bearing templates
- Wire toolOverrides through the session and agent
- Security: Bash(git:*) allowlist matches whole command chain by prefix
- Split prompt-trigger telemetry and record classifier provenance
- Raise connectors-manager timeout to 60s
- Auto classifier honors recorded approvals for repeat actions
- Apply doctor fixes in the TUI
- Auto-mode classifier timeouts prompt instead of silently denying
- Scope subagent completion drains to the owning session
- Add the toolOverrides wire types
- Set client_identifier=grok-agent-sdk
- Accept both spellings of the workspace-teleport kill switch
- Persist one-shot occurrence journal
- Stop turns that poll the exact same tool call 16x in a row
- Copy compaction checkpoint files when forking sessions
- Auto-focus permission prompt from scrollback
- Esc cancels the running turn in non-vim and minimal modes
- List Ctrl+Z undo and redo in keyboard shortcuts
- Out-of-process macOS mic capture
- Show active auth mode on session-info
- Install the npm binary under $GROK_HOME
- Remove hover/click dead zones between dashboard items
- Route startup warnings to doctor
- Document [feedback.user] author identity config
- Extend bang command timeout
- Close combine-queued edit-hold race
- Integrate relocation recovery
- Expose privacy notice rollout flag
- Break harness discovery ref cycle so connections can idle-evict
- Shift/Alt+Enter inserts newline when editing a queued prompt
- Gate project Claude permissions on folder trust
- Echo response.create.event_id on response.created
- Toast when session creation fails from disk full
- Add shared test process lifecycle
- Enable dynamic workflows by default
- Add relocation transaction state machine
- Add shared test sandbox
- Surface auth failures on model-switch compact
- Persist durable scheduler expiry
- Confirm before removing extensions-modal items
- Re-run compact and prompt after login when compact hit expired auth
- Recap sends hosted tools under backend search
This commit is contained in:
grokkybara[bot] 2026-07-22 19:18:53 +01:00
commit a5727c5960
482 changed files with 37627 additions and 13402 deletions

View file

@ -59,6 +59,10 @@ reqwest = { workspace = true }
tracing-subscriber = { workspace = true }
tracing = { workspace = true }
[[test]]
name = "env_op_compile"
path = "tests/env_op_compile.rs"
[[bin]]
name = "pty-scenario"
path = "src/bin/pty_scenario.rs"

View file

@ -12,7 +12,7 @@
use std::path::Path;
use anyhow::{Context, Result};
use xai_grok_test_support::MockInferenceServer;
use xai_grok_test_support::{MockInferenceServer, TestSandbox};
pub use xai_grok_test_support::mock_server::LogEntry;
pub use xai_grok_test_support::mock_server::MockModelEntry as MockModel;
@ -107,7 +107,7 @@ impl AgentTurnExpectation {
/// Shuts the server down on drop (the inner server's `Drop`).
pub struct ContentController {
server: MockInferenceServer,
home: tempfile::TempDir,
sandbox: TestSandbox,
}
impl ContentController {
@ -132,9 +132,11 @@ impl ContentController {
server.preset_allow_access();
server.set_response(default_response_text());
let home = tempfile::tempdir().context("create temp HOME")?;
let mut sandbox = TestSandbox::builder().mock_url(server.url()).build();
// Keep unrelated autocomplete work out of PTY timing assertions.
sandbox.set_env("GROK_PROMPT_SUGGESTIONS", "false");
Ok(Self { server, home })
Ok(Self { server, sandbox })
}
/// Base URL of the mock server, e.g. `http://127.0.0.1:41823/v1`.
@ -145,36 +147,12 @@ impl ContentController {
/// Isolated `$HOME` directory that the pager should use (keeps its ~/.grok
/// cache/state out of the real home during tests).
pub fn home(&self) -> &Path {
self.home.path()
self.sandbox.home()
}
/// Env vars to pass to the pager process so it hits the mock server
/// with telemetry / feedback disabled.
///
/// Mirrors `xai_grok_test_support::env::test_env_cmd_tokio`.
pub fn env_for_pager(&self) -> Vec<(String, String)> {
let home = self.home.path().to_string_lossy().into_owned();
let grok_home = self
.home
.path()
.join(".grok")
.to_string_lossy()
.into_owned();
vec![
("HOME".into(), home),
// Explicit GROK_HOME prevents leaking the real user's
// config.toml when $HOME alone isn't sufficient (e.g. if
// GROK_HOME is set in the test runner's env).
("GROK_HOME".into(), grok_home),
("GROK_CLI_CHAT_PROXY_BASE_URL".into(), self.url()),
("GROK_XAI_API_BASE_URL".into(), self.url()),
("XAI_API_KEY".into(), "test-key-for-ci".into()),
("GROK_TELEMETRY_ENABLED".into(), "false".into()),
("GROK_FEEDBACK_ENABLED".into(), "false".into()),
("GROK_TRACE_UPLOAD".into(), "false".into()),
// Keep unrelated autocomplete work out of PTY timing assertions.
("GROK_PROMPT_SUGGESTIONS".into(), "false".into()),
]
/// Filesystem and environment used by content-backed spawns.
pub fn sandbox(&self) -> &TestSandbox {
&self.sandbox
}
/// Replace the mocked assistant response. All subsequent chat completion
@ -538,32 +516,4 @@ mod tests {
"unused logical turn must fail one-of-two contract"
);
}
/// `env_for_pager` keeps the exact sandbox + endpoint env contract the
/// pager spawn path depends on.
#[tokio::test]
async fn env_for_pager_shape() {
let content = ContentController::start().await.unwrap();
let env = content.env_for_pager();
let get = |k: &str| {
env.iter()
.find(|(key, _)| key.as_str() == k)
.map(|(_, v)| v.clone())
};
assert_eq!(get("HOME").as_deref(), content.home().to_str());
assert_eq!(
get("GROK_HOME").as_deref(),
content.home().join(".grok").to_str()
);
assert_eq!(get("GROK_CLI_CHAT_PROXY_BASE_URL"), Some(content.url()));
assert_eq!(get("GROK_XAI_API_BASE_URL"), Some(content.url()));
assert_eq!(get("XAI_API_KEY").as_deref(), Some("test-key-for-ci"));
assert_eq!(get("GROK_TELEMETRY_ENABLED").as_deref(), Some("false"));
assert_eq!(get("GROK_FEEDBACK_ENABLED").as_deref(), Some("false"));
assert_eq!(get("GROK_TRACE_UPLOAD").as_deref(), Some("false"));
assert_eq!(get("GROK_PROMPT_SUGGESTIONS").as_deref(), Some("false"));
assert_eq!(get("GROK_MAX_RETRIES"), None);
assert_eq!(env.len(), 9, "env list must not silently grow or shrink");
}
}

View file

@ -76,7 +76,7 @@ pub fn inference_request_count(content: &ContentController) -> usize {
/// e2es (e.g. storage park-on-401) still enqueue traces — missing that field
/// now deserializes as opted-out via
/// `default_coding_data_retention_opt_out()`. The mock server accepts any
/// bearer. Pair with [`oauth_env_for_pager`].
/// bearer. Pair with [`oauth_credential_ops`].
pub fn seed_fake_oauth(content: &ContentController, user: &str) {
let grok_home = content.home().join(".grok");
std::fs::create_dir_all(&grok_home).expect("create temp .grok");
@ -102,12 +102,10 @@ pub fn seed_fake_oauth(content: &ContentController, user: &str) {
.expect("seed fake oauth auth.json");
}
/// [`ContentController::env_for_pager`] minus `XAI_API_KEY`, so the entry
/// written by [`seed_fake_oauth`] is the active credential.
pub fn oauth_env_for_pager(content: &ContentController) -> Vec<(String, String)> {
let mut env = content.env_for_pager();
env.retain(|(k, _)| k != "XAI_API_KEY");
env
/// Remove only the sandbox's fake API-key credential, allowing the `auth.json`
/// entry written by [`seed_fake_oauth`] to determine the advertised auth method.
pub fn oauth_credential_ops() -> [crate::EnvOp<'static>; 1] {
[crate::EnvOp::remove("XAI_API_KEY")]
}
/// Drive `/new` until `model` shows on screen. Campaigns apply to **new

View file

@ -41,13 +41,13 @@ pub use content::{
};
pub use env::pager_binary;
pub use flows::{
inference_request_count, oauth_env_for_pager, seed_fake_oauth, submit_turn,
inference_request_count, oauth_credential_ops, seed_fake_oauth, submit_turn,
wait_for_labels_absent, wait_for_model_via_new_sessions,
};
pub use host_clipboard::HostClipboardTextGuard;
pub use leader::LeaderCluster;
use pty::PtyRead;
pub use pty::{PtyController, keys};
pub use pty::{EnvOp, PtyController, PtyExitPoll, keys};
pub use results::{BenchResults, compare_baseline};
pub use scenarios::Scenario;
pub use screen::ScreenTracker;
@ -107,21 +107,10 @@ pub struct PtyHarness {
}
impl PtyHarness {
/// Spawn the pager in a PTY and create a new harness.
///
/// Both `rows` and `cols` follow terminal convention: `(rows, cols)`.
pub fn new(
binary: &Path,
rows: u16,
cols: u16,
args: &[&str],
env: &[(&str, &str)],
) -> Result<Self> {
Self::new_in_dir(binary, rows, cols, args, env, None)
}
/// Like [`new`](Self::new), with an explicit working directory (`None` inherits).
pub fn new_in_dir(
/// Inherit the parent environment for terminal/shell behavior tests
/// (XTVERSION probes and grok wrap). Content-backed launches must use
/// [`Self::new_in_sandbox`].
pub fn new_inherited_env(
binary: &Path,
rows: u16,
cols: u16,
@ -135,10 +124,52 @@ impl PtyHarness {
pixel_width: 0,
pixel_height: 0,
};
let pty = PtyController::spawn_in_dir(binary, size, args, env, cwd)
let pty = PtyController::spawn_inherited_env(binary, size, args, env, cwd)
.context("failed to spawn pager in PTY")?;
Ok(Self::from_pty(pty, rows, cols))
}
Ok(Self {
/// Spawn from a canonical [`xai_grok_test_support::TestSandbox`] baseline
/// plus Set-only convenience overrides.
pub fn new_in_sandbox(
binary: &Path,
rows: u16,
cols: u16,
args: &[&str],
sandbox: &xai_grok_test_support::TestSandbox,
env: &[(&str, &str)],
cwd: Option<&Path>,
) -> Result<Self> {
let operations: Vec<_> = env
.iter()
.map(|(key, value)| EnvOp::set(key, value))
.collect();
Self::new_in_sandbox_ops(binary, rows, cols, args, sandbox, &operations, cwd)
}
/// Spawn from a canonical sandbox baseline plus typed Set/Remove operations.
pub fn new_in_sandbox_ops(
binary: &Path,
rows: u16,
cols: u16,
args: &[&str],
sandbox: &xai_grok_test_support::TestSandbox,
operations: &[EnvOp<'_>],
cwd: Option<&Path>,
) -> Result<Self> {
let size = PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
};
let pty = PtyController::spawn_in_sandbox(binary, size, args, sandbox, operations, cwd)
.context("failed to spawn pager in PTY")?;
Ok(Self::from_pty(pty, rows, cols))
}
fn from_pty(pty: PtyController, rows: u16, cols: u16) -> Self {
Self {
pty,
screen: ScreenTracker::new(rows, cols),
timing: FrameTimingParser::new(),
@ -147,7 +178,7 @@ impl PtyHarness {
cast_events: Vec::new(),
cast_size: (cols, rows),
respond_to_queries: false,
})
}
}
/// Enable (or disable) forwarding terminal-generated replies back to the
@ -185,7 +216,7 @@ impl PtyHarness {
content: &ContentController,
extra_args: &[&str],
) -> Result<Self> {
Self::spawn_with_content_in_dir(binary, rows, cols, content, extra_args, None)
Self::spawn_with_content_env_in_dir(binary, rows, cols, content, extra_args, &[], None)
}
/// Like [`spawn_with_content`](Self::spawn_with_content), with an explicit working directory.
@ -197,10 +228,80 @@ impl PtyHarness {
extra_args: &[&str],
cwd: Option<&Path>,
) -> Result<Self> {
let env = content.env_for_pager();
let env_refs: Vec<(&str, &str)> =
env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
Self::new_in_dir(binary, rows, cols, extra_args, &env_refs, cwd)
Self::spawn_with_content_env_in_dir(binary, rows, cols, content, extra_args, &[], cwd)
}
/// Content-backed spawn with Set-only convenience overrides applied after
/// the sandbox baseline. Duplicate keys are last-wins.
pub fn spawn_with_content_env(
binary: &Path,
rows: u16,
cols: u16,
content: &ContentController,
extra_args: &[&str],
overrides: &[(&str, &str)],
) -> Result<Self> {
Self::spawn_with_content_env_in_dir(
binary, rows, cols, content, extra_args, overrides, None,
)
}
pub fn spawn_with_content_env_in_dir(
binary: &Path,
rows: u16,
cols: u16,
content: &ContentController,
extra_args: &[&str],
overrides: &[(&str, &str)],
cwd: Option<&Path>,
) -> Result<Self> {
let operations: Vec<_> = overrides
.iter()
.map(|(key, value)| EnvOp::set(key, value))
.collect();
Self::spawn_with_content_env_ops_in_dir(
binary,
rows,
cols,
content,
extra_args,
&operations,
cwd,
)
}
/// Content-backed spawn with typed Set/Remove operations.
pub fn spawn_with_content_env_ops(
binary: &Path,
rows: u16,
cols: u16,
content: &ContentController,
extra_args: &[&str],
operations: &[EnvOp<'_>],
) -> Result<Self> {
Self::spawn_with_content_env_ops_in_dir(
binary, rows, cols, content, extra_args, operations, None,
)
}
pub fn spawn_with_content_env_ops_in_dir(
binary: &Path,
rows: u16,
cols: u16,
content: &ContentController,
extra_args: &[&str],
operations: &[EnvOp<'_>],
cwd: Option<&Path>,
) -> Result<Self> {
Self::new_in_sandbox_ops(
binary,
rows,
cols,
extra_args,
content.sandbox(),
operations,
cwd,
)
}
// ── PTY control ──────────────────────────────────────────────────
@ -271,8 +372,8 @@ impl PtyHarness {
self.screen.feed(bytes);
}
/// Check whether the child process is still running.
pub fn is_running(&mut self) -> bool {
/// Return true only while the child is live; pending status is non-running.
pub fn is_running(&mut self) -> Result<bool> {
self.pty.is_running()
}
@ -322,8 +423,9 @@ impl PtyHarness {
if remaining.is_zero() {
anyhow::bail!(
"timed out after {timeout:?} waiting for {description}\n\
process running: {}\nscreen contents:\n{}",
self.pty.is_running(),
process running: {}\nprocess tree: {}\nscreen contents:\n{}",
self.pty.is_running()?,
self.pty.process_tree_diagnostics(),
self.screen.contents()
);
}
@ -368,8 +470,9 @@ impl PtyHarness {
if remaining.is_zero() {
anyhow::bail!(
"timed out after {timeout:?} waiting for {description} to remain true for \
{hold:?}\nprocess running: {}\nscreen contents:\n{}",
self.pty.is_running(),
{hold:?}\nprocess running: {}\nprocess tree: {}\nscreen contents:\n{}",
self.pty.is_running()?,
self.pty.process_tree_diagnostics(),
self.screen.contents()
);
}
@ -574,10 +677,10 @@ impl PtyHarness {
self.pty.quit()
}
/// Wait up to `timeout` for the child to exit, returning its exit code
/// (`None` if it's still running at the deadline). Call once and cache the
/// result — the underlying `try_wait` reaps the child.
pub fn wait_exit_code(&mut self, timeout: Duration) -> Option<u32> {
/// Wait without collapsing exit, pending-status, liveness, or poll errors.
/// Returns [`PtyExitPoll::PendingStatus`] immediately for an already-exited
/// child and [`PtyExitPoll::Running`] only when the live-child deadline expires.
pub fn wait_exit_code(&mut self, timeout: Duration) -> Result<PtyExitPoll<u32>> {
self.pty.wait_exit_code(timeout)
}
@ -592,14 +695,25 @@ impl PtyHarness {
) -> Result<u32> {
let exit_deadline = Instant::now() + exit_timeout;
let exit_code = loop {
if let Some(code) = self.pty.try_exit_code()? {
let exit = self.pty.poll_exit_code()?;
if let PtyExitPoll::Exited(code) = exit {
break code;
}
let remaining = exit_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
if exit == PtyExitPoll::PendingStatus {
anyhow::bail!(
"exit observed but status unavailable after {exit_timeout:?}\n\
process tree: {}\nscreen contents:\n{}\nraw output:\n{}",
self.pty.process_tree_diagnostics(),
self.screen.contents(),
String::from_utf8_lossy(&self.raw_output)
);
}
anyhow::bail!(
"timed out after {exit_timeout:?} waiting for child exit\n\
process running: true\nscreen contents:\n{}\nraw output:\n{}",
process running: true\nprocess tree: {}\nscreen contents:\n{}\nraw output:\n{}",
self.pty.process_tree_diagnostics(),
self.screen.contents(),
String::from_utf8_lossy(&self.raw_output)
);
@ -630,6 +744,11 @@ impl PtyHarness {
self.pty.child_pid()
}
/// Process-group/job enrollment state for failure diagnostics.
pub fn process_tree_diagnostics(&self) -> String {
self.pty.process_tree_diagnostics()
}
/// Deliver a signal to the child (unix). See [`PtyController::send_signal`].
#[cfg(unix)]
pub fn send_signal(&self, signal: i32) -> Result<()> {

View file

@ -1,12 +1,18 @@
//! Layer 1: PTY management — spawn, inject keys, resize, drain output.
use std::io::{Read, Write};
use std::ffi::OsStr;
use std::io::{self, Read, Write};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use portable_pty::{CommandBuilder, ExitStatus, PtySize, native_pty_system};
use xai_grok_test_support::{TestProcessTree, TestSandbox, process_has_exited_without_reap};
const PTY_DROP_REAP_TIMEOUT: Duration = Duration::from_millis(250);
const PTY_REAP_POLL: Duration = Duration::from_millis(10);
const PENDING_STATUS_ERROR: &str = "exit observed but status unavailable";
/// Raw key byte constants for terminal input injection.
pub mod keys {
@ -24,6 +30,31 @@ pub mod keys {
pub const ESC: &[u8] = b"\x1b";
}
/// One explicit environment mutation applied after the TestSandbox baseline.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EnvOp<'a> {
Set(&'a OsStr, &'a OsStr),
Remove(&'a OsStr),
}
impl<'a> EnvOp<'a> {
pub fn set(key: &'a str, value: &'a str) -> Self {
Self::Set(OsStr::new(key), OsStr::new(value))
}
pub const fn set_os(key: &'a OsStr, value: &'a OsStr) -> Self {
Self::Set(key, value)
}
pub fn remove(key: &'a str) -> Self {
Self::Remove(OsStr::new(key))
}
pub const fn remove_os(key: &'a OsStr) -> Self {
Self::Remove(key)
}
}
#[derive(Debug)]
pub(crate) enum PtyRead {
Chunk(Vec<u8>),
@ -35,6 +66,17 @@ pub(crate) enum PtyRead {
/// methods to inject input, resize, and drain output.
pub struct PtyController {
child: Box<dyn portable_pty::Child + Send>,
process_tree: Option<TestProcessTree>,
exit_status: Option<ExitStatus>,
exit_observed: bool,
spawn_pid: Option<u32>,
// portable-pty's Unix kill may reap and cache status through Child::try_wait.
#[cfg(unix)]
portable_kill_may_have_reaped: bool,
#[cfg(test)]
status_cache_count: usize,
#[cfg(test)]
tree_release_count: usize,
writer: Box<dyn Write + Send>,
reader_rx: mpsc::Receiver<Vec<u8>>,
#[allow(dead_code)] // Kept alive to hold the PTY open; used by resize().
@ -42,25 +84,40 @@ pub struct PtyController {
}
impl PtyController {
/// Spawn a binary inside a PTY with the given terminal size.
///
/// `env` is a list of `(key, value)` pairs to set on the child process.
pub fn spawn(
binary: &Path,
size: PtySize,
args: &[&str],
env: &[(&str, &str)],
) -> Result<Self> {
Self::spawn_in_dir(binary, size, args, env, None)
}
/// Like [`spawn`](Self::spawn), with an optional child working directory.
pub fn spawn_in_dir(
/// Inherit the parent environment for terminal-brand probes, grok-wrap
/// tests, and other fixtures that test inherited host env. Content-backed
/// pager launches must use [`Self::spawn_in_sandbox`].
pub fn spawn_inherited_env(
binary: &Path,
size: PtySize,
args: &[&str],
env: &[(&str, &str)],
cwd: Option<&Path>,
) -> Result<Self> {
let operations = set_operations(env);
Self::spawn_inner(binary, size, args, &operations, cwd, None)
}
/// Spawn from a [`TestSandbox`] baseline plus typed per-process Set/Remove
/// operations. The sandbox remains owned by the caller.
pub fn spawn_in_sandbox(
binary: &Path,
size: PtySize,
args: &[&str],
sandbox: &TestSandbox,
env: &[EnvOp<'_>],
cwd: Option<&Path>,
) -> Result<Self> {
Self::spawn_inner(binary, size, args, env, cwd, Some(sandbox))
}
fn spawn_inner(
binary: &Path,
size: PtySize,
args: &[&str],
env: &[EnvOp<'_>],
cwd: Option<&Path>,
sandbox: Option<&TestSandbox>,
) -> Result<Self> {
let pty_system = native_pty_system();
let pair = pty_system.openpty(size)?;
@ -72,9 +129,21 @@ impl PtyController {
if let Some(dir) = cwd {
cmd.cwd(dir);
}
apply_child_env(&mut cmd, env);
apply_child_env(&mut cmd, sandbox, env);
// portable-pty calls setsid on Unix. Windows Job enrollment is a
// best-effort post-spawn attachment, so a very short-lived descendant
// may escape before enrollment; diagnostics preserve that downgrade.
let child = pair.slave.spawn_command(cmd)?;
#[cfg(unix)]
let process_pid = child
.process_id()
.or_else(|| pair.master.process_group_leader().map(|pid| pid as u32));
#[cfg(windows)]
let process_pid = child.process_id();
let process_tree = process_pid.map(|pid| TestProcessTree::attach(pid, "grok PTY child"));
// Attachment failures remain recorded by TestProcessTree and are
// surfaced through process_tree_diagnostics() on every harness timeout.
// Drop the slave so we get EOF when the child exits.
drop(pair.slave);
@ -84,6 +153,16 @@ impl PtyController {
Ok(Self {
child,
process_tree,
exit_status: None,
exit_observed: false,
spawn_pid: process_pid,
#[cfg(unix)]
portable_kill_may_have_reaped: false,
#[cfg(test)]
status_cache_count: 0,
#[cfg(test)]
tree_release_count: 0,
writer,
reader_rx,
master: pair.master,
@ -137,17 +216,18 @@ impl PtyController {
let _ = self.inject_keys(keys::Q);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
match self.child.try_wait()? {
Some(_) => return Ok(()),
None if std::time::Instant::now() >= deadline => {
self.child.kill()?;
self.child
.wait()
.context("failed to wait for pager child after kill")?;
return Ok(());
}
None => std::thread::sleep(Duration::from_millis(50)),
if is_quit_complete(self.poll_exit_code())? {
return Ok(());
}
if std::time::Instant::now() >= deadline {
self.cleanup_descendants();
self.kill_portable_child()?;
self.wait_child_bounded(Duration::from_secs(1))
.context("failed to wait for pager child after kill")?
.context("pager child did not exit within 1s after kill")?;
return Ok(());
}
std::thread::sleep(Duration::from_millis(50));
}
}
@ -162,46 +242,43 @@ impl PtyController {
}
}
/// Check whether the child process is still running.
pub fn is_running(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
/// Return true only while the child is live; pending status is non-running.
pub fn is_running(&mut self) -> Result<bool> {
self.poll_exit_code()
.map(|state| state == PtyExitPoll::Running)
}
/// Poll child status once, preserving process-query errors.
pub(crate) fn try_exit_code(&mut self) -> Result<Option<u32>> {
self.child
.try_wait()
.map(|status| status.map(|status| status.exit_code()))
.context("failed to query PTY child status")
/// Poll once without collapsing pending status, liveness, or query errors.
/// Repeated calls return cached exit status without querying a reaped child.
pub fn poll_exit_code(&mut self) -> Result<PtyExitPoll<u32>> {
let poll = self
.poll_exit_status()
.map(|status| status.map(|status| status.exit_code()));
classify_exit_poll(poll, self.exit_observed)
}
/// Wait up to `timeout` for the child to exit, returning its exit code
/// (`None` if it's still running at the deadline). Call once and cache the
/// result — `try_wait` reaps the child, so the status isn't re-readable.
pub fn wait_exit_code(&mut self, timeout: Duration) -> Option<u32> {
/// Poll until exit or `timeout` without collapsing lifecycle states.
/// Returns [`PtyExitPoll::PendingStatus`] immediately because the child is
/// already non-running; [`PtyExitPoll::Running`] is returned only when the
/// deadline expires while the child remains live.
pub fn wait_exit_code(&mut self, timeout: Duration) -> Result<PtyExitPoll<u32>> {
let deadline = std::time::Instant::now() + timeout;
loop {
match self.child.try_wait() {
Ok(Some(status)) => return Some(status.exit_code()),
Ok(None) if std::time::Instant::now() >= deadline => return None,
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
Err(_) => return None,
if let Some(state) =
resolve_wait_poll(self.poll_exit_code(), std::time::Instant::now() >= deadline)?
{
return Ok(state);
}
std::thread::sleep(Duration::from_millis(50));
}
}
/// Child PID, falling back to the PTY's foreground process group.
#[cfg(unix)]
/// Child PID while the direct child is live. Once reaped, returns `None` so
/// callers cannot signal a recycled PID.
pub fn child_pid(&self) -> Option<u32> {
self.child
.process_id()
.or_else(|| self.master.process_group_leader().map(|p| p as u32))
}
/// Child PID (no foreground-group fallback — ConPTY has no process groups).
#[cfg(windows)]
pub fn child_pid(&self) -> Option<u32> {
self.child.process_id()
(!self.exit_observed && self.exit_status.is_none())
.then_some(self.spawn_pid)
.flatten()
}
/// Deliver a signal directly to the child (unix), bypassing the PTY line
@ -219,15 +296,235 @@ impl PtyController {
}
Ok(())
}
fn poll_exit_status(&mut self) -> Result<Option<ExitStatus>> {
if let Some(status) = self.exit_status.clone() {
return Ok(Some(status));
}
#[cfg(unix)]
{
if let Some(pid) = self.spawn_pid {
match observe_exit_before_reap(
process_has_exited_without_reap(pid, "PTY child"),
self.exit_observed,
self.portable_kill_may_have_reaped,
) {
Ok(ExitObservation::Running) => return Ok(None),
Ok(ExitObservation::Exited) => self.observe_exit_and_cleanup_tree(),
Ok(ExitObservation::StatusAlreadyConsumed) => {
self.observe_exit_and_cleanup_tree();
return self.recover_consumed_status();
}
Err(error) => {
return Err(error).context("failed to observe PTY child exit");
}
}
}
}
self.try_wait_and_cache()
}
fn try_wait_and_cache(&mut self) -> Result<Option<ExitStatus>> {
let status = self
.child
.try_wait()
.context("failed to query PTY child status")?;
if let Some(status) = status {
#[cfg(windows)]
self.cleanup_descendants();
self.cache_reaped_status(status.clone());
return Ok(Some(status));
}
Ok(None)
}
fn cache_reaped_status(&mut self, status: ExitStatus) {
if self.exit_status.is_none() {
self.release_process_tree();
cache_exit_status(
&mut self.exit_status,
&mut self.exit_observed,
&mut self.spawn_pid,
status,
);
#[cfg(test)]
{
self.status_cache_count += 1;
}
}
}
#[cfg(unix)]
fn observe_exit_and_cleanup_tree(&mut self) {
if !self.exit_observed {
self.exit_observed = true;
self.cleanup_descendants();
}
}
#[cfg(unix)]
fn recover_consumed_status(&mut self) -> Result<Option<ExitStatus>> {
let status = recover_consumed_status(self.child.try_wait())
.context("failed to recover PTY child status after it was consumed")?;
self.cache_reaped_status(status.clone());
Ok(Some(status))
}
fn kill_portable_child(&mut self) -> io::Result<()> {
#[cfg(unix)]
{
self.portable_kill_may_have_reaped = true;
}
self.child.kill()
}
/// Process-group/job enrollment state.
pub fn process_tree_diagnostics(&self) -> String {
self.process_tree
.as_ref()
.map(TestProcessTree::diagnostic_summary)
.unwrap_or_else(|| "tree_unavailable=true".to_owned())
}
fn kill_tree_best_effort(&self) {
if let Some(tree) = &self.process_tree {
let _ = tree.kill();
}
}
fn release_process_tree(&mut self) {
if let Some(mut tree) = self.process_tree.take() {
tree.release();
#[cfg(test)]
{
self.tree_release_count += 1;
}
}
}
fn cleanup_descendants(&mut self) {
self.kill_tree_best_effort();
self.release_process_tree();
}
fn wait_child_bounded(&mut self, timeout: Duration) -> Result<Option<ExitStatus>> {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(status) = self.poll_exit_status()? {
return Ok(Some(status));
}
if std::time::Instant::now() >= deadline {
if self.exit_observed {
anyhow::bail!(PENDING_STATUS_ERROR);
}
return Ok(None);
}
std::thread::sleep(PTY_REAP_POLL);
}
}
}
impl Drop for PtyController {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
if self.exit_status.is_none() {
self.cleanup_descendants();
let _ = self.kill_portable_child();
let _ = self.wait_child_bounded(PTY_DROP_REAP_TIMEOUT);
}
self.release_process_tree();
}
}
#[cfg(unix)]
#[derive(Debug, Eq, PartialEq)]
enum ExitObservation {
Running,
Exited,
StatusAlreadyConsumed,
}
#[cfg(unix)]
fn observe_exit_before_reap(
observation: io::Result<bool>,
exit_observed: bool,
portable_kill_may_have_reaped: bool,
) -> io::Result<ExitObservation> {
match observation {
Ok(false) => Ok(ExitObservation::Running),
Ok(true) => Ok(ExitObservation::Exited),
Err(error)
if error.raw_os_error() == Some(libc::ECHILD)
&& (exit_observed || portable_kill_may_have_reaped) =>
{
Ok(ExitObservation::StatusAlreadyConsumed)
}
Err(error) => Err(error),
}
}
#[cfg(unix)]
fn recover_consumed_status(status: io::Result<Option<ExitStatus>>) -> io::Result<ExitStatus> {
status?.ok_or_else(|| io::Error::other("PTY child status was consumed without being cached"))
}
/// Typed result of polling a PTY child's lifecycle.
///
/// Only [`Self::Running`] means the process is live. [`Self::PendingStatus`]
/// means exit was already observed, descendants were cleaned, and the PID was
/// hidden, but portable-pty has not yet yielded the final status.
#[must_use = "PTY exit state and poll errors must be handled explicitly"]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PtyExitPoll<T> {
/// The child exited and its cached terminal status is available.
Exited(T),
/// The child is non-running, but its portable-pty status is not yet available.
PendingStatus,
/// The child is still live.
Running,
}
fn classify_exit_poll<T, E>(
poll: std::result::Result<Option<T>, E>,
exit_observed: bool,
) -> std::result::Result<PtyExitPoll<T>, E> {
match poll {
Ok(Some(status)) => Ok(PtyExitPoll::Exited(status)),
Ok(None) if exit_observed => Ok(PtyExitPoll::PendingStatus),
Ok(None) => Ok(PtyExitPoll::Running),
Err(error) => Err(error),
}
}
fn resolve_wait_poll<T, E>(
poll: std::result::Result<PtyExitPoll<T>, E>,
deadline_reached: bool,
) -> std::result::Result<Option<PtyExitPoll<T>>, E> {
match poll? {
PtyExitPoll::Running if !deadline_reached => Ok(None),
state => Ok(Some(state)),
}
}
fn is_quit_complete<T, E>(
poll: std::result::Result<PtyExitPoll<T>, E>,
) -> std::result::Result<bool, E> {
match poll? {
PtyExitPoll::Exited(_) | PtyExitPoll::PendingStatus => Ok(true),
PtyExitPoll::Running => Ok(false),
}
}
fn cache_exit_status(
exit_status: &mut Option<ExitStatus>,
exit_observed: &mut bool,
spawn_pid: &mut Option<u32>,
status: ExitStatus,
) {
*exit_status = Some(status);
*exit_observed = true;
*spawn_pid = None;
}
const CLIPBOARD_SINK_ENV_VARS: &[&str] = &["GROK_OSC52_SINK", "LC_GROK_OSC52_SINK"];
/// Host terminal identity markers stripped from the child environment.
@ -281,14 +578,21 @@ const HOST_TERMINAL_ENV_VARS: &[&str] = &[
"INSIDE_EMACS",
];
/// Prepare the child environment: fixed `TERM`, color and host-terminal
/// hygiene strips, then the caller's `env` pairs.
///
/// Strips run BEFORE the caller env is applied, preserving the contract
/// that tests may re-inject any marker (e.g. `TERM_PROGRAM=vscode`, or a
/// fake `NVIM` socket) to simulate that host — see
/// `tests/pty_e2e/doubled_lines_out_of_band_repro.rs` in the pager crate.
fn apply_child_env(cmd: &mut CommandBuilder, env: &[(&str, &str)]) {
fn set_operations<'a>(env: &'a [(&'a str, &'a str)]) -> Vec<EnvOp<'a>> {
env.iter()
.map(|(key, value)| EnvOp::set(key, value))
.collect()
}
/// Prepare the child environment. Content-backed callers provide a
/// [`xai_grok_test_support::TestSandbox`], which always clears inheritance.
/// The explicitly named inherited-env path is reserved for terminal probing and
/// grok-wrap fixtures. Caller overrides are always applied last.
fn apply_child_env(cmd: &mut CommandBuilder, sandbox: Option<&TestSandbox>, env: &[EnvOp<'_>]) {
if let Some(sandbox) = sandbox {
cmd.env_clear();
sandbox.apply_to_command_builder(cmd);
}
// Set TERM so the pager renders with full color support.
cmd.env("TERM", "xterm-256color");
// Strip inherited color opt-outs/overrides for the same reason: a
@ -320,8 +624,11 @@ fn apply_child_env(cmd: &mut CommandBuilder, env: &[(&str, &str)]) {
for term_var in HOST_TERMINAL_ENV_VARS {
cmd.env_remove(term_var);
}
for &(key, val) in env {
cmd.env(key, val);
for operation in env {
match operation {
EnvOp::Set(key, value) => cmd.env(key, value),
EnvOp::Remove(key) => cmd.env_remove(key),
}
}
}
@ -353,6 +660,270 @@ fn spawn_reader(mut reader: Box<dyn Read + Send>) -> mpsc::Receiver<Vec<u8>> {
mod tests {
use super::*;
#[test]
fn exit_poll_distinguishes_pending_running_and_errors() {
assert_eq!(
classify_exit_poll::<u32, &'static str>(Ok(None), true),
Ok(PtyExitPoll::PendingStatus)
);
assert_eq!(
classify_exit_poll::<u32, &'static str>(Ok(None), false),
Ok(PtyExitPoll::Running)
);
assert_eq!(
classify_exit_poll::<u32, &'static str>(Err("poll failed"), false),
Err("poll failed")
);
}
#[test]
fn wait_deadline_preserves_pending_running_and_errors() {
assert_eq!(
resolve_wait_poll::<u32, &'static str>(Ok(PtyExitPoll::PendingStatus), false),
Ok(Some(PtyExitPoll::PendingStatus))
);
assert_eq!(
resolve_wait_poll::<u32, &'static str>(Ok(PtyExitPoll::PendingStatus), true),
Ok(Some(PtyExitPoll::PendingStatus))
);
assert_eq!(
resolve_wait_poll::<u32, &'static str>(Ok(PtyExitPoll::Running), false),
Ok(None)
);
assert_eq!(
resolve_wait_poll::<u32, &'static str>(Ok(PtyExitPoll::Running), true),
Ok(Some(PtyExitPoll::Running))
);
assert_eq!(
resolve_wait_poll::<u32, &'static str>(Err("poll failed"), true),
Err("poll failed")
);
}
#[test]
fn quit_completion_accepts_non_running_states_and_propagates_errors() {
assert_eq!(
is_quit_complete::<u32, &'static str>(Ok(PtyExitPoll::Exited(0))),
Ok(true)
);
assert_eq!(
is_quit_complete::<u32, &'static str>(Ok(PtyExitPoll::PendingStatus)),
Ok(true)
);
assert_eq!(
is_quit_complete::<u32, &'static str>(Ok(PtyExitPoll::Running)),
Ok(false)
);
assert_eq!(
is_quit_complete::<u32, &'static str>(Err("poll failed")),
Err("poll failed")
);
}
#[cfg(unix)]
#[test]
fn observed_exit_echild_is_typed_only_after_portable_reap_capability() {
let echild = || io::Error::from_raw_os_error(libc::ECHILD);
let unrelated = io::Error::other("unrelated poll failure");
assert_eq!(
observe_exit_before_reap(Err(echild()), false, true).unwrap(),
ExitObservation::StatusAlreadyConsumed
);
assert_eq!(
observe_exit_before_reap(Err(echild()), true, false).unwrap(),
ExitObservation::StatusAlreadyConsumed
);
assert_eq!(
observe_exit_before_reap(Err(echild()), false, false)
.unwrap_err()
.raw_os_error(),
Some(libc::ECHILD)
);
assert_eq!(
observe_exit_before_reap(Err(unrelated), true, true)
.unwrap_err()
.to_string(),
"unrelated poll failure"
);
}
#[cfg(unix)]
#[test]
fn consumed_status_recovery_requires_a_cached_status() {
let status = ExitStatus::with_exit_code(0);
assert_eq!(
recover_consumed_status(Ok(Some(status.clone())))
.unwrap()
.exit_code(),
0
);
assert!(recover_consumed_status(Ok(None)).is_err());
assert_eq!(
recover_consumed_status(Err(io::Error::other("real status failure")))
.unwrap_err()
.to_string(),
"real status failure"
);
}
#[cfg(unix)]
#[test]
fn observed_exit_then_echild_recovers_cached_status_once() {
let sandbox = TestSandbox::new();
let mut controller = PtyController::spawn_in_sandbox(
Path::new("/bin/sh"),
PtySize {
rows: 8,
cols: 40,
pixel_width: 0,
pixel_height: 0,
},
&["-c", "exit 7"],
&sandbox,
&[],
None,
)
.expect("spawn PTY exit fixture");
let pid = controller.child_pid().expect("live child pid");
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while !process_has_exited_without_reap(pid, "PTY exit fixture").expect("observe child exit")
&& std::time::Instant::now() < deadline
{
std::thread::sleep(Duration::from_millis(10));
}
controller.observe_exit_and_cleanup_tree();
controller
.kill_portable_child()
.expect("portable kill consumes the exited child status");
assert!(controller.portable_kill_may_have_reaped);
assert_eq!(controller.tree_release_count, 1);
assert_eq!(
process_has_exited_without_reap(pid, "PTY exit fixture")
.expect_err("consumed status must produce ECHILD")
.raw_os_error(),
Some(libc::ECHILD)
);
assert_eq!(
controller
.poll_exit_status()
.expect("recover cached portable status")
.expect("cached status")
.exit_code(),
7
);
assert_eq!(controller.status_cache_count, 1);
assert_eq!(controller.tree_release_count, 1);
assert_eq!(
controller.poll_exit_status().unwrap().unwrap().exit_code(),
7
);
assert_eq!(controller.status_cache_count, 1);
assert_eq!(controller.tree_release_count, 1);
assert_eq!(controller.child_pid(), None);
}
#[cfg(unix)]
#[test]
fn pty_waits_are_idempotent_and_pid_is_hidden_after_reap() {
let sandbox = TestSandbox::new();
let mut controller = PtyController::spawn_in_sandbox(
Path::new("/bin/sh"),
PtySize {
rows: 8,
cols: 40,
pixel_width: 0,
pixel_height: 0,
},
&["-c", "exit 7"],
&sandbox,
&[],
None,
)
.expect("spawn PTY exit fixture");
assert!(controller.child_pid().is_some());
assert_eq!(
controller.wait_exit_code(Duration::from_secs(2)).unwrap(),
PtyExitPoll::Exited(7)
);
assert_eq!(
controller.wait_exit_code(Duration::ZERO).unwrap(),
PtyExitPoll::Exited(7)
);
assert_eq!(controller.poll_exit_code().unwrap(), PtyExitPoll::Exited(7));
assert!(!controller.is_running().unwrap());
assert_eq!(controller.status_cache_count, 1);
assert_eq!(controller.tree_release_count, 1);
assert_eq!(controller.child_pid(), None);
assert!(controller.send_signal(libc::SIGTERM).is_err());
}
#[cfg(unix)]
fn pid_is_alive(pid: u32) -> bool {
// SAFETY: signal 0 performs an existence/permission check only.
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(unix)]
#[test]
fn pty_drop_tree_cleanup_is_bounded_and_reaps_grandchild() {
let sandbox = TestSandbox::new();
let pid_file = sandbox.temp_dir().join("pty-grandchild.pid");
let pid_path = pid_file.to_string_lossy().into_owned();
let controller = PtyController::spawn_in_sandbox(
Path::new("/bin/sh"),
PtySize {
rows: 8,
cols: 40,
pixel_width: 0,
pixel_height: 0,
},
&["-c", "sleep 1000 & echo $! > \"$PID_FILE\"; wait"],
&sandbox,
&[EnvOp::set("PID_FILE", &pid_path)],
None,
)
.expect("spawn PTY tree fixture");
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let grandchild_pid = loop {
if let Ok(raw) = std::fs::read_to_string(&pid_file)
&& let Ok(pid) = raw.trim().parse::<u32>()
{
break pid;
}
assert!(std::time::Instant::now() < deadline, "pid file timeout");
std::thread::sleep(Duration::from_millis(10));
};
let started = std::time::Instant::now();
drop(controller);
assert!(
started.elapsed() < Duration::from_secs(1),
"PTY Drop exceeded its bounded wait"
);
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while pid_is_alive(grandchild_pid) && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!pid_is_alive(grandchild_pid),
"PTY grandchild leaked after controller Drop"
);
}
#[cfg(unix)]
#[test]
fn pty_tree_diagnostics_surface_enrollment_state() {
let tree = TestProcessTree::attach(u32::MAX, "invalid PTY fixture");
let diagnostics = tree.diagnostic_summary();
assert!(diagnostics.contains("tree_label=\"invalid PTY fixture\""));
assert!(diagnostics.contains("tree_attached=false"));
assert!(diagnostics.contains("tree_attach_error=Some"));
}
/// Every host-terminal marker the pager's detection chain reads must be
/// stripped from the child env — polluted entries are seeded via
/// `cmd.env` (same `CommandBuilder` map that inherited base-env entries
@ -373,10 +944,12 @@ mod tests {
for sink_var in CLIPBOARD_SINK_ENV_VARS {
cmd.env(sink_var, "polluted");
}
// Unrelated vars must survive the hygiene pass untouched.
// Sandboxed launches remove unrelated inherited variables before
// re-applying the baseline and explicit overrides.
cmd.env("GROK_SCROLL_LOG", "/tmp/scroll.jsonl");
let sandbox = TestSandbox::new();
apply_child_env(&mut cmd, &[]);
apply_child_env(&mut cmd, Some(&sandbox), &[]);
for var in HOST_TERMINAL_ENV_VARS {
assert!(
@ -408,14 +981,71 @@ mod tests {
);
assert_eq!(
cmd.get_env("GROK_SCROLL_LOG").and_then(|v| v.to_str()),
Some("/tmp/scroll.jsonl"),
"hygiene must not touch unrelated vars"
None,
"hermetic baseline must remove unrelated inherited vars"
);
assert_eq!(
cmd.get_env("GROK_HOME").and_then(|v| v.to_str()),
sandbox.grok_home().to_str()
);
}
#[test]
fn apply_child_env_uses_sandbox_baseline() {
let sandbox = TestSandbox::new();
let mut cmd = CommandBuilder::new("true");
apply_child_env(&mut cmd, Some(&sandbox), &[]);
assert_eq!(
cmd.get_env("HOME").and_then(|v| v.to_str()),
sandbox.home().to_str()
);
assert_eq!(
cmd.get_env("GROK_HOME").and_then(|v| v.to_str()),
sandbox.grok_home().to_str()
);
assert_eq!(cmd.get_env("GROK_LEADER_SOCKET"), None);
}
#[test]
fn apply_child_env_remove_deletes_sandbox_credential() {
let sandbox = TestSandbox::builder()
.mock_url("http://127.0.0.1:43123/v1")
.build();
let mut cmd = CommandBuilder::new("true");
apply_child_env(&mut cmd, Some(&sandbox), &[EnvOp::remove("XAI_API_KEY")]);
assert_eq!(cmd.get_env("XAI_API_KEY"), None);
assert_eq!(
cmd.get_env("GROK_XAI_API_BASE_URL")
.and_then(|v| v.to_str()),
Some("http://127.0.0.1:43123/v1")
);
}
#[test]
fn inherited_env_projection_is_set_only_and_preserves_unrelated_ambient_vars() {
let operations = set_operations(&[("EXPLICIT_MARKER", "set")]);
assert_eq!(operations, [EnvOp::set("EXPLICIT_MARKER", "set")]);
let mut cmd = CommandBuilder::new("true");
cmd.env("AMBIENT_MARKER", "inherited");
apply_child_env(&mut cmd, None, &operations);
assert_eq!(
cmd.get_env("AMBIENT_MARKER")
.and_then(|value| value.to_str()),
Some("inherited")
);
assert_eq!(
cmd.get_env("EXPLICIT_MARKER")
.and_then(|value| value.to_str()),
Some("set")
);
}
/// The documented override contract: strips run BEFORE the caller env,
/// so tests can re-inject any marker to simulate a specific host
/// (e.g. the fake-nvim wrapper repro or the xtversion brand fixtures).
#[test]
fn apply_child_env_caller_env_overrides_survive_strips() {
let mut cmd = CommandBuilder::new("true");
@ -424,11 +1054,12 @@ mod tests {
apply_child_env(
&mut cmd,
None,
&[
("TERM_PROGRAM", "vscode"),
("NVIM", "/tmp/fake-nvim.sock"),
("TERM", "xterm-kitty"),
("GROK_OSC52_SINK", "1"),
EnvOp::set("TERM_PROGRAM", "vscode"),
EnvOp::set("NVIM", "/tmp/fake-nvim.sock"),
EnvOp::set("TERM", "xterm-kitty"),
EnvOp::set("GROK_OSC52_SINK", "1"),
],
);

View file

@ -20,7 +20,7 @@ pub async fn run(harness: &mut PtyHarness, _content: &ContentController) -> Resu
let start = Instant::now();
while start.elapsed() < IDLE_WINDOW {
harness.update(Duration::from_millis(100));
if !harness.is_running() {
if !harness.is_running()? {
break;
}
}

View file

@ -28,7 +28,7 @@ pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Resul
for _ in 0..SCROLL_KEYS {
harness.inject_keys(keys::J)?;
harness.update(KEY_INTERVAL);
if !harness.is_running() {
if !harness.is_running()? {
break;
}
}

View file

@ -35,7 +35,7 @@ pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Resul
for _ in 0..SCROLL_KEYS {
harness.inject_keys(keys::J)?;
harness.update(KEY_INTERVAL);
if !harness.is_running() {
if !harness.is_running()? {
break;
}
}

View file

@ -23,7 +23,7 @@ pub async fn run(harness: &mut PtyHarness, _content: &ContentController) -> Resu
let (rows, cols) = if i % 2 == 0 { (35, 100) } else { (55, 160) };
harness.resize(rows, cols)?;
harness.update(RESIZE_INTERVAL);
if !harness.is_running() {
if !harness.is_running()? {
return Err(anyhow!("pager exited during resize_storm at iter {i}"));
}
}

View file

@ -40,7 +40,7 @@ pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Resul
for _ in 0..SCROLL_KEYS {
harness.inject_keys(keys::J)?;
harness.update(KEY_INTERVAL);
if !harness.is_running() {
if !harness.is_running()? {
break;
}
}

View file

@ -30,7 +30,7 @@ pub async fn run(harness: &mut PtyHarness, content: &ContentController) -> Resul
let start = Instant::now();
while start.elapsed() < STREAM_WINDOW {
harness.update(Duration::from_millis(100));
if !harness.is_running() {
if !harness.is_running()? {
break;
}
}

View file

@ -553,17 +553,11 @@ impl ScriptedScenarioRunner {
.context("write scenario config.toml")?;
}
let mut env = content.env_for_pager();
env.extend(
scenario
.environment
.env
.iter()
.map(|v| (v.key.clone(), v.value.clone())),
);
let env_refs: Vec<(&str, &str)> = env
let env_refs: Vec<(&str, &str)> = scenario
.environment
.env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.map(|v| (v.key.as_str(), v.value.as_str()))
.collect();
let args: Vec<&str> = scenario
.environment
@ -576,16 +570,17 @@ impl ScriptedScenarioRunner {
// init) and run the pager there. Bound for the whole run so the dir
// outlives the pager process; `None` inherits the test process cwd.
let workspace_dir = match scenario.workspace.as_ref() {
Some(ws) => Some(materialize_workspace(ws)?),
Some(ws) => Some(materialize_workspace(ws, content.sandbox())?),
None => None,
};
let workspace_cwd = workspace_dir.as_ref().map(|dir| dir.path());
let mut harness = PtyHarness::new_in_dir(
let mut harness = PtyHarness::new_in_sandbox(
&self.config.binary,
scenario.terminal.rows,
scenario.terminal.cols,
&args,
content.sandbox(),
&env_refs,
workspace_cwd,
)
@ -636,7 +631,7 @@ impl ScriptedScenarioRunner {
}
}
if !harness.is_running() {
if !harness.is_running()? {
report.bugs.push(BugFinding {
step: scenario.steps.len(),
severity: BugSeverity::Bug,
@ -690,7 +685,10 @@ impl ScriptedScenarioRunner {
/// Create a temp dir for a scenario [`WorkspaceConfig`]: write its files
/// (creating parent dirs) and optionally `git init` it. The returned `TempDir`
/// must be held for the whole run so the directory outlives the pager process.
fn materialize_workspace(workspace: &WorkspaceConfig) -> Result<tempfile::TempDir> {
fn materialize_workspace(
workspace: &WorkspaceConfig,
sandbox: &xai_grok_test_support::TestSandbox,
) -> Result<tempfile::TempDir> {
let dir = tempfile::tempdir().context("create scenario workspace temp dir")?;
for (rel_path, contents) in &workspace.files {
// Fail closed: a `files` key must be a relative path that stays inside
@ -718,20 +716,22 @@ fn materialize_workspace(workspace: &WorkspaceConfig) -> Result<tempfile::TempDi
.with_context(|| format!("write workspace file {}", path.display()))?;
}
if workspace.git_init {
// A real repo root makes `workspace_key` / repo-local discovery
// deterministic regardless of where the system temp dir lives. Shelled
// out (not `git2`) because this harness crate has no `git2` dependency;
// the subprocess is careful — nulled streams + `status.success()` check
// + `bail!` on failure.
let status = std::process::Command::new("git")
// A real repo root keeps repo-local discovery independent of the
// system temp path.
let mut cmd = sandbox.git_command();
let output = cmd
.args(["init", "-q"])
.current_dir(dir.path())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.stderr(std::process::Stdio::piped())
.output()
.context("run `git init` for scenario workspace")?;
if !status.success() {
bail!("`git init` for scenario workspace failed: {status}");
if !output.status.success() {
bail!(
"`git init` for scenario workspace failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
}
Ok(dir)
@ -1057,7 +1057,7 @@ fn run_step(
));
}
ScenarioStep::AssertRunning => {
if !harness.is_running() {
if !harness.is_running()? {
bail!("pager process is not running");
}
}
@ -2062,7 +2062,8 @@ mod tests {
git_init: false,
files: BTreeMap::from([(".mcp.json".to_string(), "{}".to_string())]),
};
assert!(materialize_workspace(&ok).is_ok());
let sandbox = xai_grok_test_support::TestSandbox::new();
assert!(materialize_workspace(&ok, &sandbox).is_ok());
// Absolute and `..`-traversing keys are rejected before any write.
for bad in ["/etc/evil", "../escape", "sub/../../escape"] {
@ -2070,7 +2071,9 @@ mod tests {
git_init: false,
files: BTreeMap::from([(bad.to_string(), "x".to_string())]),
};
let err = materialize_workspace(&ws).unwrap_err().to_string();
let err = materialize_workspace(&ws, &sandbox)
.unwrap_err()
.to_string();
assert!(
err.contains("must be relative and within the workspace"),
"path {bad:?} must be rejected, got: {err}"
@ -2078,6 +2081,36 @@ mod tests {
}
}
#[test]
fn workspace_git_init_materializes_a_real_repository() {
let workspace = WorkspaceConfig {
git_init: true,
files: std::collections::BTreeMap::from([(
"nested/fixture.txt".to_string(),
"fixture\n".to_string(),
)]),
};
let sandbox = xai_grok_test_support::TestSandbox::new();
let dir = materialize_workspace(&workspace, &sandbox).expect("materialize git workspace");
assert!(dir.path().join(".git").is_dir());
assert_eq!(
std::fs::read_to_string(dir.path().join("nested/fixture.txt")).unwrap(),
"fixture\n"
);
let mut cmd = sandbox.git_command();
let output = cmd
.args(["rev-parse", "--show-toplevel"])
.current_dir(dir.path())
.output()
.expect("query materialized repository");
assert!(
output.status.success(),
"git rev-parse failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn image_fixture_defaults_to_standard_kind() {
let f: ImageFixture =

View file

@ -162,14 +162,16 @@ fn spawn_pager(
content: &ContentController,
extra_env: &[(&str, &str)],
) -> PtyHarness {
let content_env = content.env_for_pager();
let mut env: Vec<(&str, &str)> = content_env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
env.extend_from_slice(extra_env);
let mut harness = PtyHarness::new(binary, SESSION_ROWS, SESSION_COLS, &[], &env)
.expect("spawn pager with content");
let mut harness = PtyHarness::new_in_sandbox(
binary,
SESSION_ROWS,
SESSION_COLS,
&[],
content.sandbox(),
extra_env,
None,
)
.expect("spawn pager with content");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");

View file

@ -0,0 +1,25 @@
use std::ffi::OsStr;
use xai_grok_pager_pty_harness::{EnvOp, oauth_credential_ops};
#[test]
fn set_and_remove_operations_have_one_typed_surface() {
let key = OsStr::new("FEATURE_FLAG");
let value = OsStr::new("enabled");
let operations: [EnvOp<'_>; 4] = [
EnvOp::set("FEATURE_FLAG", "enabled"),
EnvOp::remove("XAI_API_KEY"),
EnvOp::set_os(key, value),
EnvOp::remove_os(key),
];
assert!(matches!(operations[0], EnvOp::Set(_, _)));
assert!(matches!(operations[1], EnvOp::Remove(_)));
assert!(matches!(operations[2], EnvOp::Set(_, _)));
assert!(matches!(operations[3], EnvOp::Remove(_)));
}
#[test]
fn oauth_credential_operations_remove_the_api_key() {
assert_eq!(oauth_credential_ops(), [EnvOp::remove("XAI_API_KEY")],);
}

View file

@ -24,7 +24,7 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use xai_grok_pager_pty_harness::{ContentController, PtyHarness, keys, pager_binary};
use xai_grok_pager_pty_harness::{ContentController, PtyExitPoll, PtyHarness, keys, pager_binary};
const ROWS: u16 = 50;
const COLS: u16 = 120;
@ -75,11 +75,13 @@ async fn run() -> Result<()> {
// graceful teardown (incl. the show-cursor restore) for the assertions below.
first.update(Duration::from_secs(10));
let code = first.wait_exit_code(Duration::from_secs(10));
let exit = first
.wait_exit_code(Duration::from_secs(10))
.context("wait for double-Ctrl+C exit")?;
assert_eq!(
code,
Some(0),
"double Ctrl+C should exit via the graceful quit (exit 0), got {code:?}"
exit,
PtyExitPoll::Exited(0),
"double Ctrl+C should exit via the graceful quit (exit 0), got {exit:?}"
);
assert!(
terminal_restored(&first, pre),
@ -156,11 +158,13 @@ async fn run_sigint() -> Result<()> {
// Pre-fix the SIGINT handler called std::process::exit(130); routing it
// through the graceful quit exits 0 — the deterministic Part-B regression catch.
let code = first.wait_exit_code(Duration::from_secs(10));
let exit = first
.wait_exit_code(Duration::from_secs(10))
.context("wait for SIGINT exit")?;
assert_eq!(
code,
Some(0),
"real SIGINT should route through the graceful quit (exit 0), got {code:?}"
exit,
PtyExitPoll::Exited(0),
"real SIGINT should route through the graceful quit (exit 0), got {exit:?}"
);
assert!(
terminal_restored(&first, pre),

View file

@ -97,7 +97,7 @@ async fn scroll_up_from_follow_bottom_then_back_down() -> Result<()> {
for _ in 0..30 {
harness.inject_keys(keys::PGUP)?;
harness.update(Duration::from_millis(35));
if !harness.is_running() {
if !harness.is_running()? {
bail!("pager exited while PageUp scrolling");
}
}
@ -126,7 +126,7 @@ async fn scroll_up_from_follow_bottom_then_back_down() -> Result<()> {
for _ in 0..35 {
harness.inject_keys(keys::PGDN)?;
harness.update(Duration::from_millis(35));
if !harness.is_running() {
if !harness.is_running()? {
bail!("pager exited while PageDown scrolling");
}
}