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

@ -13,6 +13,7 @@ async-trait = { workspace = true }
axum = { workspace = true }
clap = { workspace = true }
futures-util = { workspace = true }
portable-pty = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
@ -21,7 +22,9 @@ tokio-tungstenite = { workspace = true }
tokio-util = { workspace = true, features = ["compat"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["fmt"] }
url = { workspace = true }
xai-acp-lib = { workspace = true }
xai-tty-utils = { workspace = true }
[dev-dependencies]
reqwest = { workspace = true }

View file

@ -1,10 +1,12 @@
# xai-grok-test-support
Shared test infrastructure for the grok-build crates: mock inference server,
SSE wire-format generators, ACP stdio clients, headless
runner, and sandboxed process env. Consumed by `xai-grok-shell` integration
tests, `xai-grok-pager-pty-harness` (`ContentController`), and `xai-grok-sampler`
tests.
SSE wire-format generators, ACP stdio clients, headless runner, and the shared
`TestSandbox` filesystem/environment plus `TestProcess` subprocess owners. PR3
owns test subprocesses only; production spawning, leader protocol, and startup
behavior are unchanged. Consumed by `xai-grok-shell`
integration tests, `xai-grok-pager-pty-harness` (`ContentController`), and
`xai-grok-sampler` tests.
> **Freshness rule:** update this README in the same PR that changes `src/`
> reviewers should treat a `src/` diff without a README diff as incomplete.
@ -21,20 +23,36 @@ test-support surface.
| `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<Value>)``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: <last user message>` 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 axum variants `chat_completion_events_exact` / `responses_api_events_exact` and matching public scripted variants `chat_completion_script_exact` / `responses_api_script_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_env(server, args, cwd, env)` (extra env applied after the defaults, so it overrides them) / `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`. |
| `env` | `grok_binary()` (`GROK_BINARY` env → `CARGO_BIN_EXE` → local debug build of `xai-grok-pager`), `git_workdir()` (temp git repo, forces full libgit2 init), `test_env_cmd_tokio(cmd, mock_url, home)` (sandboxed HOME **and GROK_HOME** — Windows resolves `~` via USERPROFILE, so HOME alone doesn't sandbox — + mock endpoints + telemetry kill-switches). |
| `leader` | Unix-only `LeaderStdioClient` (`grok agent --leader stdio`, `env_clear`-hermetic, sandboxed `GROK_LEADER_SOCKET`; `spawn_with_binary` runs an explicit binary for version-skew lanes, per-role resolution via `leader_binary()` / `client_binary()` honoring `GROK_BINARY_LEADER` / `GROK_BINARY_CLIENT`) + lock-file helpers: `leader_lock_path`, `read_leader_pid`, `pid_alive`, `wait_for_live_leader`, `wait_for_new_leader`, `wait_for_replay_notifications`, `leader_log`. |
| `sandbox` | `TestSandbox` — one owner for a temp root, isolated `HOME`/`USERPROFILE`, explicit `GROK_HOME`, workspace, and `TMPDIR`/`TMP`/`TEMP`. Child commands use `env_clear()` plus a minimal platform allowlist, loopback `NO_PROXY`, interactive-git suppression, telemetry/feedback/trace/instrumentation/updater kill switches, and no ambient leader socket or proxy variables. Unix preserves the host `SHELL` when set and falls back to `/bin/sh`; explicit overrides still win. `TestSandbox::builder().mock_url(url)` wires grok API/models/auxiliary endpoints plus a fake CI key; `.git()` initializes and commits the owned workspace. Bazel test targets that execute Git directly provide `@git_hermetic` runfiles and `GIT_BIN_PATH`; at construction, `TestSandbox` resolves that path against the parent cwd while it is still the Bazel execroot, stores absolute `GIT_BIN_PATH`/`GIT_EXEC_PATH`, and prepends the binary parent to its baseline `PATH`. `TestSandbox::git_command()` applies that cleared environment plus detached, non-interactive Git settings. Without `GIT_BIN_PATH`, ordinary baseline `PATH` is preserved and no special binary/exec vars are added. `set_env`/`extend_env` and `remove_env` are the narrow post-baseline override seam. `diagnostic_summary()` redacts credential-key segments/suffixes and all malformed/non-loopback endpoints; loopback URLs are parsed and stripped of userinfo/query/fragment. |
| `process` | `TestProcess` — canonical Tokio child owner stacked over `TestSandbox`: clears/reapplies the sandbox env, applies `pager_env`, enforces null/piped stdin policy, TTY-detaches, owns the pre-PR3 `xai_tty_utils::ProcessGroup`, and captures bounded stdout/stderr tails. Unix detachment establishes the child session/process group before exec; Windows preserves `CREATE_NO_WINDOW` and uses the existing best-effort post-spawn Job attachment without claiming atomic descendant containment. Private Unix `waitid(WNOWAIT)` observes exit so descendants are cleaned before PID/PGID reuse. `wait_with_deadline` is non-destructive; Unix `close` sends SIGTERM then escalates, while Windows uses immediate Job hard-kill policy; Drop synchronously kills and performs a bounded best-effort reap. PID becomes unavailable after reap; status/reason, truncation counters, read/lifecycle errors, and secret-sanitized tails remain cached. `TestProcessTree` is the process-tree adapter for dependencies that retain their concrete child. All lifecycle policy is test-only; production utility behavior and APIs are unchanged. |
| `acp_client` | `GrokStdioClient` drives `grok agent stdio` over real pipes through `agent-client-protocol`: `spawn` creates a sandbox, `spawn_with_sandbox` reuses one across restarts, and `spawn_with_sandbox_env_and_args` adds explicit env/global-argument overrides. It exposes initialize/authenticate, session create/load, prompt, `*_with_timeout` wrappers, child PID, captured text/stderr, process diagnostics, explicit close/kill signalling, and `take_sandbox`. `RawStdioClient` is the raw-wire sibling for escaped-slash methods and string UUID ids: exact-id response matching skips notifications, auto-refuses agent→client requests with `-32601`, and reports skipped traffic on timeout. Both keep the sandbox alive while `TestProcess` owns the child tree and pipe-tail diagnostics. |
| `headless` | `run_headless[_with_env]` runs grok with an owned canonical `TestSandbox`; `run_headless_in_sandbox[_with_env]` owns a supplied sandbox, while `run_headless_in_sandbox_borrowed[_with_env]` keeps it available for artifact inspection. `_with_env` variants apply explicit last-wins overrides after the hermetic baseline. `TestProcess` owns lifecycle and timeout tree-kill; the scaled 60s process deadline is followed by a separate bounded 2s pipe-drain budget, with retained-pipe or read-task failures returning the bounded partial tail. All variants return `HeadlessResult { status, stdout, stderr, timed_out, elapsed }`. Assertion helpers are `assert_headless_success`, `assert_no_crashes`, and `stderr_tail`. |
| `env` | Binary resolution (`grok_binary()`: `GROK_BINARY``CARGO_BIN_EXE` → local debug build) and `git_workdir()`, which returns a git-initialized `TestSandbox`; use `.workspace()` for the cwd. |
| `leader` | Unix-only `LeaderFixture` is mandatory for every `LeaderStdioClient`. It owns exactly one concrete initial leader and the client objects it directly spawns. Callers close/drop clients first; `LeaderFixture::close` rejects active clients and performs bounded TERM→KILL→reap only on the initial owned child/group. If both graceful and hard client cleanup fail, the test-only unwind containment path requests hard kills and intentionally leaks the retained client/leader owners after signaling; this preserves ownership through panic unwind and is bounded by the test-process lifetime. Lock-file PIDs are observations only: detached replacement generations are never adopted or signaled. Death/re-election and version-skew cases that produce detached replacements remain `leader-acceptance` ignored/manual with tracking language until OS containment or a test-only leader binary can own the whole generation chain. No production marker/protocol/bootstrap behavior is required. |
| `uds_proxy` | Unix-only `UdsProxy` — frame-aware (4-byte BE length prefix) man-in-the-middle for leader IPC sockets. `UdsProxy::spawn(proxy_path, upstream_path, FaultPlan)`; `FaultPlan { direction, drop_frame, sever_mid_frame, delay, duplicate_frame }` (1-based frame index, per connection per direction); runtime `FaultHandle::sever_now()` + `forwarded(direction)` counters; frame bodies capped at 64 MiB (leader-transport parity — corrupt lengths error instead of allocating). Zero production changes: point `LeaderClient::connect` / `GROK_LEADER_SOCKET` at the proxy path. |
## Consumer matrix
| Consumer | Uses | Notes |
|----------|------|-------|
| `xai-grok-shell` `tests/*.rs` | Everything | Direct imports (`use xai_grok_test_support::*` or module paths); no local shim. |
| `xai-grok-pager-pty-harness` `src/content.rs` | `MockInferenceServer`, `MockModelEntry` (re-exported as `MockModel`) | `ContentController` wraps the server and **keeps the HOME-sandbox `TempDir` + `env_for_pager()` harness-side**; presets `allow_access` + a fixed default response at construction. |
| `xai-grok-shell` `tests/*.rs` | `TestSandbox`, `TestProcess` through ACP/leader/headless wrappers, mock server | Binary-driving tests share the same path/env owner; multi-process restart and leader fixtures retain one sandbox across clients. Raw Tokio child ownership is centralized in the wrappers. |
| `xai-grok-pager-pty-harness` | `TestSandbox`, `TestProcessTree`, `MockInferenceServer`, `MockModelEntry` | `ContentController` owns the sandbox and server. `spawn_with_content[_env][_in_dir]` applies that sandbox followed by explicit last-wins overrides. OAuth tests use `EnvOp::Remove` for `XAI_API_KEY`; ordinary overrides use `EnvOp::Set`. `portable-pty` remains the concrete child/wait/signal owner; `TestProcessTree` attaches by PID. Unix gets process-group teardown; Windows attachment is best effort, non-atomic, and reported in diagnostics. PTY exit status is cached so every wait is idempotent; PID/signals disappear after reap; Drop uses a bounded direct-child reap wait. |
| `xai-grok-sampler` `tests/test_actor.rs` | `sse` generators | Happy-path payloads only; the actor keeps its own router for stall/conditional fixtures. |
## Sandbox contract
- Keep the `TestSandbox` alive at least as long as every child using its paths.
- Use the builder only for construction-time endpoint/git choices. Use
`set_env`/`extend_env` for test-specific flags and terminal brands; the last
explicit override wins. Use `remove_env` to test absence.
- Do not add process-global env mutation. Keep filesystem/environment ownership
in `TestSandbox`; process groups, jobs, output tails, and kill-tree ownership
stay in the separate `TestProcess`/`TestProcessTree` harness.
- Diagnostics may name sandbox paths and sanitized HTTP(S)/WS(S) loopback URLs.
URL parsing fails closed: userinfo/query/fragment are stripped, while malformed
or non-loopback values are redacted. Credential-like key segments/suffixes are
always redacted.
## Adding a capability
**A response mode** (`mock_server.rs`): extend the private `ResponseMode` enum

View file

@ -2,8 +2,8 @@
//! [`GrokStdioClient`] (`agent-client-protocol::ClientSideConnection` —
//! authentication, session lifecycle, permissions, notification streaming) and
//! the raw-wire [`RawStdioClient`] (verbatim JSON-RPC lines for shapes the
//! typed client can't produce), plus the shared subprocess spawn/stderr-capture
//! plumbing used by every harness in this crate.
//! typed client can't produce), all backed by the shared [`TestProcess`]
//! lifecycle owner.
use std::path::Path;
use std::sync::Arc;
@ -13,51 +13,50 @@ use std::time::Duration;
use crate::scaled;
use agent_client_protocol::{self as acp, Agent as _};
use tempfile::TempDir;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use xai_acp_lib::LineBufferedRead;
use crate::env::{grok_binary, test_env_cmd_tokio};
use crate::env::grok_binary;
use crate::headless::stderr_tail;
use crate::mock_server::MockInferenceServer;
use crate::process::spawn_piped_with_stderr_capture;
use crate::process::{TestOutput, TestProcess, TestProcessConfig, TestStdin};
use crate::sandbox::TestSandbox;
/// Spawn `grok agent stdio` with the canonical hermetic test env: the sandbox
/// from [`test_env_cmd_tokio`] plus the debug-logging kill-list, so the
/// hermeticity setup exists exactly once for the typed ([`GrokStdioClient`])
/// and raw ([`RawStdioClient`]) harnesses. `leading_args` go before the
/// `agent stdio` subcommand (global flags); `extra_env` is applied after the
/// kill-list so a test can still set e.g. `GROK_DEBUG_LOG=1` explicitly.
/// Spawn `grok agent stdio` with the sandbox's canonical hermetic environment.
/// `leading_args` go before the `agent stdio` subcommand (global flags).
fn spawn_agent_process(
sandbox: &mut TestSandbox,
server: &MockInferenceServer,
cwd: &Path,
home: &Path,
extra_env: &[(&str, &str)],
leading_args: &[&str],
) -> (tokio::process::Child, Arc<std::sync::Mutex<Vec<u8>>>) {
let binary = grok_binary();
) -> TestProcess {
sandbox.set_mock_url(server.url());
for (key, value) in extra_env {
sandbox.set_env(*key, *value);
}
let binary = grok_binary();
let mut cmd = tokio::process::Command::new(&binary);
cmd.args(leading_args)
.args(["agent", "stdio"])
.current_dir(cwd);
test_env_cmd_tokio(&mut cmd, &server.url(), home);
// Hermetic firehose env: clear inherited debug-logging knobs so a test
// controls logging only via `extra_env` / `leading_args` (mirrors the
// headless `debug_cmd`).
for k in [
"GROK_DEBUG_LOG",
"GROK_LOG_FILE",
"GROK_LOG_SAMPLING",
"GROK_HOOKS_LOG",
] {
cmd.env_remove(k);
}
for (k, v) in extra_env {
cmd.env(k, v);
}
spawn_piped_with_stderr_capture(cmd)
TestProcess::spawn(
cmd,
sandbox,
TestProcessConfig::new()
.label("grok agent stdio")
.stdin(TestStdin::Piped)
.stdout(TestOutput::Piped),
)
.unwrap_or_else(|error| {
panic!(
"failed to spawn ACP test client at {}: {error}\n{}",
binary.display(),
sandbox.diagnostic_summary(),
)
})
}
#[derive(Default)]
@ -115,49 +114,41 @@ impl acp::Client for TestAcpClient {
/// Child process is killed on drop.
pub struct GrokStdioClient {
conn: acp::ClientSideConnection,
_child: tokio::process::Child,
home: Option<TempDir>,
process: TestProcess,
sandbox: Option<TestSandbox>,
capture: Arc<TextCapture>,
stderr: Arc<std::sync::Mutex<Vec<u8>>>,
}
impl GrokStdioClient {
pub async fn spawn(server: &MockInferenceServer, cwd: &Path) -> Self {
let home = TempDir::new().expect("create temp home");
Self::spawn_with_home(server, cwd, home).await
Self::spawn_with_sandbox(server, cwd, TestSandbox::new()).await
}
pub async fn spawn_with_home(server: &MockInferenceServer, cwd: &Path, home: TempDir) -> Self {
Self::spawn_with_home_and_env(server, cwd, home, &[]).await
}
/// Like [`spawn_with_home`] but applies extra environment variables to the
/// child process (after the standard test env). Used by tests that toggle
/// behavior via env vars (e.g. the vendor-compat suite).
pub async fn spawn_with_home_and_env(
pub async fn spawn_with_sandbox(
server: &MockInferenceServer,
cwd: &Path,
home: TempDir,
extra_env: &[(&str, &str)],
sandbox: TestSandbox,
) -> Self {
Self::spawn_with_home_env_and_args(server, cwd, home, extra_env, &[]).await
Self::spawn_with_sandbox_env_and_args(server, cwd, sandbox, &[], &[]).await
}
/// Like [`spawn_with_home_and_env`] but also prepends `leading_args` before
/// the `agent stdio` subcommand. Used to drive top-level global flags (e.g.
/// `--debug`) so a test can exercise the flag's master switch, not just env.
pub async fn spawn_with_home_env_and_args(
pub async fn spawn_with_sandbox_env_and_args(
server: &MockInferenceServer,
cwd: &Path,
home: TempDir,
mut sandbox: TestSandbox,
extra_env: &[(&str, &str)],
leading_args: &[&str],
) -> Self {
let (mut child, stderr) =
spawn_agent_process(server, cwd, home.path(), extra_env, leading_args);
let mut process = spawn_agent_process(&mut sandbox, server, cwd, extra_env, leading_args);
let outgoing = child.stdin.take().unwrap().compat_write();
let incoming = child.stdout.take().unwrap().compat();
let outgoing = process
.take_stdin()
.expect("child stdin missing")
.compat_write();
let incoming = process
.take_stdout()
.expect("child stdout missing")
.compat();
let capture = Arc::new(TextCapture::default());
let client = TestAcpClient {
@ -172,10 +163,9 @@ impl GrokStdioClient {
Self {
conn,
_child: child,
home: Some(home),
process,
sandbox: Some(sandbox),
capture,
stderr,
}
}
@ -296,16 +286,35 @@ impl GrokStdioClient {
}
pub fn stderr(&self) -> String {
String::from_utf8_lossy(&self.stderr.lock().unwrap()).into_owned()
self.process.stderr_tail().text
}
pub fn take_home(&mut self) -> TempDir {
self.home.take().expect("test home already taken")
pub fn child_pid(&self) -> Option<u32> {
self.process.pid()
}
/// Return the home directory path (for cache invalidation between phases).
pub fn home_path(&self) -> &std::path::Path {
self.home.as_ref().expect("test home already taken").path()
pub fn process_diagnostics(&self) -> String {
self.process.diagnostic_summary()
}
pub fn start_terminate(&mut self) -> std::io::Result<()> {
self.process.start_terminate()
}
pub fn start_kill(&mut self) {
self.process.start_kill();
}
pub async fn close(&mut self) -> std::io::Result<std::process::ExitStatus> {
self.process.close().await
}
pub fn take_sandbox(&mut self) -> TestSandbox {
self.sandbox.take().expect("test sandbox already taken")
}
pub fn sandbox(&self) -> &TestSandbox {
self.sandbox.as_ref().expect("test sandbox already taken")
}
/// Timing breadcrumb for tuning CI timeout budgets (visible with --nocapture).
@ -423,31 +432,49 @@ impl GrokStdioClient {
/// ids. Child process is killed on drop.
pub struct RawStdioClient {
stdin: tokio::process::ChildStdin,
stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
stderr: Arc<std::sync::Mutex<Vec<u8>>>,
_child: tokio::process::Child,
_home: TempDir,
stdout: tokio::io::BufReader<crate::process::TestProcessStdout>,
process: TestProcess,
_sandbox: TestSandbox,
}
impl RawStdioClient {
pub async fn spawn(server: &MockInferenceServer, cwd: &Path) -> Self {
let home = TempDir::new().expect("create temp home");
let (mut child, stderr) = spawn_agent_process(server, cwd, home.path(), &[], &[]);
let mut sandbox = TestSandbox::new();
let mut process = spawn_agent_process(&mut sandbox, server, cwd, &[], &[]);
let stdin = child.stdin.take().expect("child stdin missing");
let child_stdout = child.stdout.take().expect("child stdout missing");
let stdin = process.take_stdin().expect("child stdin missing");
let child_stdout = process.take_stdout().expect("child stdout missing");
Self {
stdin,
stdout: tokio::io::BufReader::new(child_stdout),
stderr,
_child: child,
_home: home,
process,
_sandbox: sandbox,
}
}
pub fn stderr(&self) -> String {
String::from_utf8_lossy(&self.stderr.lock().unwrap()).into_owned()
self.process.stderr_tail().text
}
pub fn child_pid(&self) -> Option<u32> {
self.process.pid()
}
pub fn process_diagnostics(&self) -> String {
self.process.diagnostic_summary()
}
pub fn start_terminate(&mut self) -> std::io::Result<()> {
self.process.start_terminate()
}
pub fn start_kill(&mut self) {
self.process.start_kill();
}
pub async fn close(&mut self) -> std::io::Result<std::process::ExitStatus> {
self.process.close().await
}
/// Write `line` verbatim followed by `\n`, and flush.

View file

@ -1,10 +1,10 @@
//! Shared environment helpers: binary resolution, git workdirs, env var setup.
//! Binary resolution, serial env guards, and git sandbox creation.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
use crate::sandbox::TestSandbox;
/// RAII guard for a single environment variable in `#[serial]` tests: snapshots
/// the prior value on construction, applies the change, then restores the prior
@ -75,8 +75,8 @@ fn ensure_local_grok_binary(binary: &Path) {
}
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let output = Command::new(&cargo)
.current_dir(workspace_root())
let mut cmd = Command::new(&cargo);
cmd.current_dir(workspace_root())
.args([
"build",
"-p",
@ -84,6 +84,10 @@ fn ensure_local_grok_binary(binary: &Path) {
"--bin",
"xai-grok-pager",
])
.stdin(std::process::Stdio::null())
.envs(xai_tty_utils::pager_env());
xai_tty_utils::detach_std_command(&mut cmd);
let output = cmd
.output()
.unwrap_or_else(|e| panic!("failed to spawn {cargo} to build xai-grok-pager: {e}"));
@ -121,63 +125,7 @@ pub fn grok_binary() -> PathBuf {
binary
}
/// Temp dir with a git repo + one committed file.
/// Forces libgit2 to fully init (the codepath that breaks with bad OpenSSL linking).
pub fn git_workdir() -> TempDir {
let dir = TempDir::new().expect("create temp dir");
let path = dir.path();
fn run_git(args: &[&str], dir: &Path) {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("failed to spawn git {}: {e}", args.join(" ")));
assert!(
output.status.success(),
"git {} failed (exit {:?}):\n{}",
args.join(" "),
output.status.code(),
String::from_utf8_lossy(&output.stderr),
);
}
run_git(&["init"], path);
// Configure git user for commits (required in CI where no global config exists)
run_git(&["config", "user.email", "test@test.com"], path);
run_git(&["config", "user.name", "Test"], path);
std::fs::write(path.join("README.md"), "test file\n").expect("write test file");
run_git(&["add", "-A"], path);
run_git(&["commit", "-m", "init", "--no-gpg-sign"], path);
dir
}
/// Point grok at the mock server with a fake API key and telemetry disabled.
pub fn test_env_cmd_tokio(
cmd: &mut tokio::process::Command,
mock_url: &str,
home: &std::path::Path,
) {
cmd.env("HOME", home)
// HOME alone does not sandbox grok on Windows: the product resolves
// `~` via `USERPROFILE`/Known Folders (`std::env::home_dir()`), so
// without an explicit GROK_HOME every spawned child shares the real
// `%USERPROFILE%\.grok` — test 1's models_cache.json (which embeds
// its per-test mock-server URL) then poisons every later test's
// prompt (the windows-x86_64 lifecycle "prompt timed out" failure).
// Mirrors `leader.rs` and the pty-harness `env_for_pager`.
.env("GROK_HOME", home.join(".grok"))
.env("GROK_CLI_CHAT_PROXY_BASE_URL", mock_url)
.env("GROK_XAI_API_BASE_URL", mock_url)
.env("XAI_API_KEY", "test-key-for-ci")
.env("GROK_TELEMETRY_ENABLED", "false")
.env("GROK_FEEDBACK_ENABLED", "false")
.env("GROK_TRACE_UPLOAD", "false")
.env("GROK_INSTRUMENTATION", "disabled")
// Release binaries (CI lifecycle tests) otherwise spawn a background
// update check that hits the network and can add latency under Rosetta.
.env("GROK_DISABLE_AUTOUPDATER", "1");
/// Create an owned, git-initialized [`TestSandbox`].
pub fn git_workdir() -> TestSandbox {
TestSandbox::builder().git().build()
}

View file

@ -2,23 +2,24 @@
//!
//! Runs the grok binary as a subprocess with the mock server, captures output.
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::time::Duration;
use tempfile::TempDir;
use tokio::io::AsyncReadExt as _;
use crate::env::{grok_binary, test_env_cmd_tokio};
use crate::env::grok_binary;
use crate::mock_server::MockInferenceServer;
use crate::process::{TestOutput, TestProcess, TestProcessConfig};
use crate::sandbox::TestSandbox;
pub struct HeadlessResult {
pub status: ExitStatus,
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
/// Wall time of the grok invocation; logged so CI timeout budgets can be
/// tuned against observed durations.
/// Wall time of the headless command invocation; logged so CI timeout
/// budgets can be tuned against observed durations.
pub elapsed: Duration,
}
@ -28,8 +29,10 @@ fn headless_timeout() -> Duration {
crate::scaled(Duration::from_secs(60))
}
/// Run `grok` with the given args against the mock server, bounded by
/// [`headless_timeout_secs`]. Uses an isolated HOME and disables telemetry.
const HEADLESS_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
/// Run `grok` with the given args against the mock server, bounded by the
/// scaled headless timeout. Uses an isolated HOME and disables telemetry.
pub async fn run_headless(
server: &MockInferenceServer,
args: &[&str],
@ -39,94 +42,188 @@ pub async fn run_headless(
}
/// Like [`run_headless`], but with extra environment variables applied after the
/// shared defaults so they take precedence — e.g. to re-enable a feature the
/// defaults turn off.
/// sandbox baseline so they take precedence — e.g. to re-enable a feature the
/// baseline turns off.
pub async fn run_headless_with_env(
server: &MockInferenceServer,
args: &[&str],
cwd: &Path,
env: &[(&str, &str)],
) -> HeadlessResult {
let home = TempDir::new().expect("create temp home");
let sandbox = TestSandbox::builder().mock_url(server.url()).build();
let mut cmd = tokio::process::Command::new(grok_binary());
cmd.args(args)
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
test_env_cmd_tokio(&mut cmd, &server.url(), home.path());
cmd.envs(env.iter().copied());
run_headless_with_cmd(cmd).await
cmd.args(args).current_dir(cwd);
run_headless_with_cmd_and_sandbox(cmd, &sandbox, env).await
}
pub async fn run_headless_with_cmd(mut cmd: tokio::process::Command) -> HeadlessResult {
let binary = grok_binary();
/// Apply and retain one [`TestSandbox`] while running a custom headless command.
pub async fn run_headless_in_sandbox(
cmd: tokio::process::Command,
sandbox: TestSandbox,
) -> HeadlessResult {
run_headless_in_sandbox_with_env(cmd, sandbox, &[]).await
}
pub async fn run_headless_in_sandbox_with_env(
cmd: tokio::process::Command,
sandbox: TestSandbox,
overrides: &[(&str, &str)],
) -> HeadlessResult {
run_headless_in_sandbox_borrowed_with_env(cmd, &sandbox, overrides).await
}
/// Run a custom headless command while leaving the caller's sandbox available
/// for post-run artifact inspection.
pub async fn run_headless_in_sandbox_borrowed(
cmd: tokio::process::Command,
sandbox: &TestSandbox,
) -> HeadlessResult {
run_headless_in_sandbox_borrowed_with_env(cmd, sandbox, &[]).await
}
pub async fn run_headless_in_sandbox_borrowed_with_env(
cmd: tokio::process::Command,
sandbox: &TestSandbox,
overrides: &[(&str, &str)],
) -> HeadlessResult {
run_headless_with_cmd_and_sandbox(cmd, sandbox, overrides).await
}
async fn run_headless_with_cmd_and_sandbox(
cmd: tokio::process::Command,
sandbox: &TestSandbox,
overrides: &[(&str, &str)],
) -> HeadlessResult {
let program = PathBuf::from(cmd.as_std().get_program());
let started = std::time::Instant::now();
let mut child = cmd
.spawn()
.unwrap_or_else(|e| panic!("failed to spawn grok binary at {}: {e}", binary.display()));
let stdout = child.stdout.take().expect("child stdout missing");
let stderr = child.stderr.take().expect("child stderr missing");
let mut process = TestProcess::spawn(
cmd,
sandbox,
TestProcessConfig::new()
.label(format!("headless command {}", program.display()))
.stdout(TestOutput::Piped)
.stderr(TestOutput::Piped)
.envs(overrides.iter().copied()),
)
.unwrap_or_else(|error| {
panic!(
"failed to spawn headless command at {}: {error}\n{}",
program.display(),
sandbox.diagnostic_summary(),
)
});
let mut stdout = process.take_stdout().expect("child stdout missing");
let stdout_handle = tokio::spawn(async move {
let mut stdout = stdout;
let mut stdout_buf = Vec::new();
stdout.read_to_end(&mut stdout_buf).await?;
Ok::<Vec<u8>, std::io::Error>(stdout_buf)
let mut bytes = Vec::new();
stdout.read_to_end(&mut bytes).await?;
Ok::<Vec<u8>, std::io::Error>(bytes)
});
let mut stderr = process.take_stderr().expect("child stderr missing");
let stderr_handle = tokio::spawn(async move {
let mut stderr = stderr;
let mut stderr_buf = Vec::new();
stderr.read_to_end(&mut stderr_buf).await?;
Ok::<Vec<u8>, std::io::Error>(stderr_buf)
let mut bytes = Vec::new();
stderr.read_to_end(&mut bytes).await?;
Ok::<Vec<u8>, std::io::Error>(bytes)
});
let (status, timed_out) = match tokio::time::timeout(headless_timeout(), child.wait()).await {
Ok(result) => (
result.unwrap_or_else(|e| {
panic!("failed to wait for grok binary {}: {e}", binary.display())
}),
false,
),
Err(_) => {
let _ = child.kill().await;
let status = child.wait().await.unwrap_or_else(|e| {
let (status, timed_out) = match process
.wait_with_deadline(headless_timeout())
.await
.unwrap_or_else(|error| {
panic!(
"failed to wait for headless command {}: {error}\n{}",
program.display(),
process.diagnostic_summary(),
)
}) {
Some(status) => (status, false),
None => {
let status = process.kill().await.unwrap_or_else(|error| {
panic!(
"failed to kill timed out grok binary {}: {e}",
binary.display()
"failed to kill timed out headless command {}: {error}\n{}",
program.display(),
process.diagnostic_summary(),
)
});
(status, true)
}
};
let stdout_bytes = match stdout_handle.await {
Ok(Ok(bytes)) => bytes,
Ok(Err(err)) => panic!("failed to read stdout from {}: {err}", binary.display()),
Err(err) => panic!("stdout task join failed for {}: {err}", binary.display()),
};
let stderr_bytes = match stderr_handle.await {
Ok(Ok(bytes)) => bytes,
Ok(Err(err)) => panic!("failed to read stderr from {}: {err}", binary.display()),
Err(err) => panic!("stderr task join failed for {}: {err}", binary.display()),
};
let stdout = finish_output_drain(
stdout_handle,
process.stdout_tail().text,
"stdout",
&program,
&process,
)
.await;
let stderr = finish_output_drain(
stderr_handle,
process.stderr_tail().text,
"stderr",
&program,
&process,
)
.await;
let elapsed = started.elapsed();
// Timing breadcrumb for tuning CI timeout budgets against observed
// durations (visible with --nocapture).
eprintln!("[harness-timing] headless grok run: {elapsed:?} (timed_out={timed_out})");
eprintln!(
"[harness-timing] headless command {}: {elapsed:?} (timed_out={timed_out})",
program.display()
);
HeadlessResult {
status,
stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(),
stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
stdout,
stderr,
timed_out,
elapsed,
}
}
async fn finish_output_drain(
mut handle: tokio::task::JoinHandle<std::io::Result<Vec<u8>>>,
partial_tail: String,
stream: &str,
program: &Path,
process: &TestProcess,
) -> String {
match tokio::time::timeout(HEADLESS_DRAIN_TIMEOUT, &mut handle).await {
Ok(Ok(Ok(bytes))) => String::from_utf8_lossy(&bytes).into_owned(),
Ok(Ok(Err(error))) => {
tracing::warn!(
%stream,
program = %program.display(),
%error,
"headless output drain failed; returning captured partial tail"
);
partial_tail
}
Ok(Err(error)) => {
tracing::warn!(
%stream,
program = %program.display(),
%error,
"headless output task failed; returning captured partial tail"
);
partial_tail
}
Err(_) => {
handle.abort();
let _ = handle.await;
tracing::warn!(
%stream,
program = %program.display(),
diagnostics = %process.diagnostic_summary(),
"headless output drain timed out; returning captured partial tail"
);
partial_tail
}
}
}
const CRASH_PATTERNS: &[&str] = &[
"panicked at",
"SIGSEGV",
@ -175,3 +272,90 @@ pub fn assert_no_crashes(stderr: &str) {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[tokio::test]
async fn borrowed_runner_keeps_sandbox_artifacts_available() {
let sandbox = TestSandbox::new();
let artifact = sandbox.temp_dir().join("borrowed-headless.txt");
let script = format!("printf kept > '{}'", artifact.display());
let mut cmd = tokio::process::Command::new("/bin/sh");
cmd.args(["-c", &script]);
let result = run_headless_in_sandbox_borrowed(cmd, &sandbox).await;
assert!(result.status.success(), "stderr: {}", result.stderr);
assert_eq!(std::fs::read_to_string(artifact).unwrap(), "kept");
}
#[cfg(unix)]
#[tokio::test]
async fn output_drain_timeout_returns_partial_capture() {
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let _keep_open = tx;
let _ = rx.await;
Ok::<Vec<u8>, std::io::Error>(b"complete".to_vec())
});
let sandbox = TestSandbox::new();
let mut command = tokio::process::Command::new("/bin/sh");
command.args(["-c", "exit 0"]);
let mut process = TestProcess::spawn(
command,
&sandbox,
TestProcessConfig::new().label("headless-drain-test"),
)
.expect("spawn drain fixture");
process
.wait_with_deadline(Duration::from_secs(2))
.await
.expect("wait fixture")
.expect("fixture exits");
let output = finish_output_drain(
handle,
"partial".to_owned(),
"stdout",
Path::new("fixture"),
&process,
)
.await;
assert_eq!(output, "partial");
}
#[cfg(unix)]
#[tokio::test]
async fn custom_headless_env_is_explicit_and_wins_after_sandbox_baseline() {
let sandbox = TestSandbox::new();
let mut cmd = tokio::process::Command::new("/bin/sh");
cmd.args([
"-c",
"printf '%s|%s|%s|%s' \"${AMBIENT_ONLY-unset}\" \"$GROK_PROMPT_SUGGESTIONS\" \"$FEATURE_TEST_VAR\" \"$HOME\"",
])
.env("AMBIENT_ONLY", "discarded")
.env("GROK_PROMPT_SUGGESTIONS", "command-level-discarded");
let result = run_headless_in_sandbox_borrowed_with_env(
cmd,
&sandbox,
&[
("GROK_PROMPT_SUGGESTIONS", "explicit-override"),
("FEATURE_TEST_VAR", "enabled"),
],
)
.await;
assert!(result.status.success(), "stderr: {}", result.stderr);
assert_eq!(
result.stdout,
format!(
"unset|explicit-override|enabled|{}",
sandbox.home().display()
)
);
}
}

View file

@ -1,13 +1,13 @@
//! Leader-mode (`grok agent --leader stdio`) test harness.
//!
//! Spawns the real binary as a stdio client whose bridge elects a leader
//! subprocess hosting the actual sessions, speaks ACP over pipes, and
//! exposes lock-file helpers for leader-lifecycle assertions. Unix-only:
//! the leader transport is a unix socket.
//! The fixture owns only subprocess handles it created: one initial persistent
//! leader and each returned stdio client. Lock-file PIDs are observations only;
//! detached replacement generations are never adopted or signaled.
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use agent_client_protocol::{self as acp, Agent as _};
@ -16,7 +16,8 @@ use xai_acp_lib::LineBufferedRead;
use crate::env::grok_binary;
use crate::mock_server::MockInferenceServer;
use crate::process::spawn_piped_with_stderr_capture;
use crate::process::{TestOutput, TestProcess, TestProcessConfig, TestProcessTree, TestStdin};
use crate::sandbox::TestSandbox;
/// Env var naming the binary that elects/hosts the leader in a two-binary
/// (version-skew) test. Falls back to [`grok_binary`]'s resolution.
@ -28,26 +29,28 @@ pub const CLIENT_BINARY_ENV: &str = "GROK_BINARY_CLIENT";
fn role_binary(env_key: &str) -> PathBuf {
if let Ok(path) = std::env::var(env_key) {
let p = PathBuf::from(path);
assert!(p.exists(), "{env_key} does not exist: {}", p.display());
return p;
let path = PathBuf::from(path);
assert!(
path.exists(),
"{env_key} does not exist: {}",
path.display()
);
return path;
}
grok_binary()
}
/// Binary for the leader-electing side of a version-skew test
/// (`GROK_BINARY_LEADER`, else the shared [`grok_binary`] resolution).
/// Binary for the leader-electing side of a version-skew test.
pub fn leader_binary() -> PathBuf {
role_binary(LEADER_BINARY_ENV)
}
/// Binary for the client side of a version-skew test (`GROK_BINARY_CLIENT`,
/// else the shared [`grok_binary`] resolution).
/// Binary for the client side of a version-skew test.
pub fn client_binary() -> PathBuf {
role_binary(CLIENT_BINARY_ENV)
}
/// Capture for notifications + reconnect signals.
/// Capture for notifications and reconnect signals.
#[derive(Default)]
pub struct Capture {
chunks: std::sync::Mutex<Vec<String>>,
@ -68,11 +71,11 @@ impl acp::Client for LeaderAcpClient {
let outcome = args
.options
.iter()
.find(|o| o.kind == acp::PermissionOptionKind::AllowOnce)
.find(|option| option.kind == acp::PermissionOptionKind::AllowOnce)
.or(args.options.first())
.map(|o| {
.map(|option| {
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
o.option_id.clone(),
option.option_id.clone(),
))
})
.unwrap_or(acp::RequestPermissionOutcome::Cancelled);
@ -85,9 +88,9 @@ impl acp::Client for LeaderAcpClient {
.fetch_add(1, Ordering::SeqCst);
if let acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { content, .. }) =
args.update
&& let acp::ContentBlock::Text(t) = content
&& let acp::ContentBlock::Text(text) = content
{
self.capture.chunks.lock().unwrap().push(t.text);
self.capture.chunks.lock().unwrap().push(text.text);
}
Ok(())
}
@ -102,81 +105,550 @@ impl acp::Client for LeaderAcpClient {
}
}
/// A `grok agent --leader stdio` client subprocess speaking ACP over pipes.
/// The leader subprocess it elects hosts the actual sessions.
pub struct LeaderStdioClient {
pub conn: acp::ClientSideConnection,
// Exposed for PID assertions.
pub child: tokio::process::Child,
capture: Arc<Capture>,
stderr: Arc<std::sync::Mutex<Vec<u8>>>,
/// Owns the concrete initial persistent leader shared by a test's clients.
///
/// Production-created replacement generations are outside this fixture's
/// ownership. [`Self::wait_for_new_leader`] may observe one for assertions but
/// never turns its lock-file PID into signal authority.
pub struct LeaderFixture {
inner: Arc<Mutex<LeaderFixtureState>>,
}
impl LeaderStdioClient {
pub async fn spawn(server: &MockInferenceServer, cwd: &Path, home: &Path) -> Self {
Self::spawn_with_binary(&grok_binary(), server, cwd, home).await
struct LeaderFixtureState {
binary: PathBuf,
socket: PathBuf,
lock: PathBuf,
active_clients: usize,
leader: Option<PersistentLeader>,
}
struct PersistentLeader {
child: std::process::Child,
tree: TestProcessTree,
pid: u32,
}
struct FixtureClientRegistration {
fixture: Weak<Mutex<LeaderFixtureState>>,
}
impl FixtureClientRegistration {
fn new(fixture: &Arc<Mutex<LeaderFixtureState>>) -> Self {
fixture
.lock()
.unwrap_or_else(|error| error.into_inner())
.active_clients += 1;
Self {
fixture: Arc::downgrade(fixture),
}
}
}
impl Drop for FixtureClientRegistration {
fn drop(&mut self) {
if let Some(fixture) = self.fixture.upgrade() {
let mut fixture = fixture.lock().unwrap_or_else(|error| error.into_inner());
fixture.active_clients = fixture.active_clients.saturating_sub(1);
}
}
}
/// A `grok agent --leader stdio` client subprocess speaking ACP over pipes.
pub struct LeaderStdioClient {
pub conn: acp::ClientSideConnection,
process: TestProcess,
capture: Arc<Capture>,
registration: Option<FixtureClientRegistration>,
}
impl LeaderFixture {
/// Start one concrete persistent leader under the shared sandbox.
pub async fn start(
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
) -> io::Result<Self> {
Self::start_with_binary(&grok_binary(), server, cwd, sandbox).await
}
/// [`Self::spawn`] with an explicit binary, for two-binary version-skew
/// tests (pair with [`leader_binary`] / [`client_binary`]).
pub async fn spawn_with_binary(
pub async fn start_with_binary(
binary: &Path,
server: &MockInferenceServer,
cwd: &Path,
home: &Path,
) -> Self {
let mut cmd = tokio::process::Command::new(binary);
cmd.args(["agent", "--leader", "stdio"])
.current_dir(cwd)
// Hermetic env: the developer's shell may export GROK_* vars
// (e.g. GROK_LEADER_SOCKET pointing at a REAL leader on this
// machine). env_clear + explicit allowlist guarantees the test
// can never touch a leader outside its sandbox home.
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", home)
.env("GROK_HOME", home.join(".grok"))
// Pin the socket inside the sandbox. The lock file is the
// sibling `.lock` (leader.sock -> leader.lock), and the spawned
// leader subprocess inherits/forwards this env var, so every
// (re-)elected leader binds the same sandboxed path.
.env("GROK_LEADER_SOCKET", home.join(".grok").join("leader.sock"))
sandbox: &TestSandbox,
) -> io::Result<Self> {
Self::start_with_binary_timeout(binary, server, cwd, sandbox, Duration::from_secs(30)).await
}
async fn start_with_binary_timeout(
binary: &Path,
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
readiness_timeout: Duration,
) -> io::Result<Self> {
let socket = sandbox.grok_home().join("leader.sock");
let lock = sandbox.grok_home().join("leader.lock");
let mut cmd = std::process::Command::new(binary);
cmd.args([
"agent",
"leader",
"--no-exit-on-disconnect",
"--relay-on-demand",
"--no-auto-update",
])
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null());
sandbox.apply_to_std_command(&mut cmd);
cmd.envs(xai_tty_utils::pager_env())
.env("GROK_CLI_CHAT_PROXY_BASE_URL", server.url())
.env("GROK_XAI_API_BASE_URL", server.url())
.env("GROK_MODELS_BASE_URL", server.url())
.env("GROK_FEEDBACK_BASE_URL", server.url())
.env("GROK_TRACE_UPLOAD_URL", server.url())
.env("XAI_API_KEY", "test-key-for-ci")
.env("GROK_TELEMETRY_ENABLED", "false")
.env("GROK_FEEDBACK_ENABLED", "false")
.env("GROK_TRACE_UPLOAD", "false")
.env("GROK_INSTRUMENTATION", "disabled")
// Inherited by the spawned leader, whose stderr goes to
// ~/.grok/leader.log — keep it chatty for diagnosis.
.env("GROK_LEADER_SOCKET", &socket)
.env("RUST_LOG", "xai_grok_shell=debug");
let log_path = sandbox.grok_home().join("leader.log");
match std::fs::File::create(&log_path) {
Ok(log) => {
cmd.stderr(log);
}
Err(_) => {
cmd.stderr(std::process::Stdio::null());
}
}
xai_tty_utils::detach_std_command(&mut cmd);
#[allow(clippy::disallowed_methods)]
let mut child = cmd.spawn()?;
let pid = child.id();
let tree = match TestProcessTree::try_attach(pid, "persistent grok test leader") {
Ok(tree) => tree,
Err(error) => {
let _ = child.kill();
let _ = wait_std_child_bounded(&mut child, Duration::from_secs(1));
return Err(error);
}
};
let fixture = Self {
inner: Arc::new(Mutex::new(LeaderFixtureState {
binary: binary.to_path_buf(),
socket,
lock,
active_clients: 0,
leader: Some(PersistentLeader { child, tree, pid }),
})),
};
fixture.finish_start(readiness_timeout).await
}
let (mut child, stderr) = spawn_piped_with_stderr_capture(cmd);
async fn finish_start(self, timeout: Duration) -> io::Result<Self> {
if let Err(error) = self.wait_ready(timeout).await {
let cleanup = self.close().await;
return Err(match cleanup {
Ok(()) => error,
Err(cleanup) => io::Error::new(
error.kind(),
format!("{error}; readiness cleanup also failed: {cleanup}"),
),
});
}
Ok(self)
}
let outgoing = child.stdin.take().unwrap().compat_write();
let incoming = child.stdout.take().unwrap().compat();
pub async fn spawn_client(
&self,
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
) -> io::Result<LeaderStdioClient> {
let binary = self
.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.binary
.clone();
self.spawn_client_with_binary(&binary, server, cwd, sandbox)
.await
}
pub async fn spawn_client_with_binary(
&self,
binary: &Path,
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
) -> io::Result<LeaderStdioClient> {
let socket = self
.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.socket
.clone();
let registration = FixtureClientRegistration::new(&self.inner);
LeaderStdioClient::spawn_with_binary_and_socket(
binary,
server,
cwd,
sandbox,
socket,
registration,
)
.await
}
/// PID of the concrete initial leader while the fixture still owns it.
pub fn leader_pid(&self) -> Option<u32> {
self.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.leader
.as_ref()
.map(|leader| leader.pid)
}
/// Observe a replacement PID from the lock file without adopting it.
pub async fn wait_for_new_leader(&self, old_pid: u32, timeout: Duration) -> io::Result<u32> {
let lock = self
.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.lock
.clone();
let deadline = tokio::time::Instant::now() + timeout;
loop {
if let Some(pid) = read_pid_path(&lock)
&& pid != old_pid
&& pid_alive(pid)
{
return Ok(pid);
}
if tokio::time::Instant::now() >= deadline {
return Err(io::Error::new(
ErrorKind::TimedOut,
format!("no replacement leader appeared after pid {old_pid}"),
));
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
/// Hard-kill only the concrete initial leader spawned by this fixture.
pub fn kill_current_concrete_leader(&self) -> io::Result<u32> {
let mut state = self.inner.lock().unwrap_or_else(|error| error.into_inner());
let leader = state
.leader
.as_mut()
.ok_or_else(|| io::Error::other("leader fixture no longer owns an initial leader"))?;
let tree_result = leader.tree.kill();
let child_result = leader.child.kill();
if let Err(error) = tree_result
&& !is_missing_process_error(&error)
{
return Err(error);
}
if let Err(error) = child_result
&& !is_missing_process_error(&error)
{
return Err(error);
}
Ok(leader.pid)
}
/// Reap the concrete initial leader after a crash test killed it.
pub async fn reap_exited_concrete_leaders(&self) -> io::Result<()> {
let state = self.inner.clone();
tokio::task::spawn_blocking(move || {
let mut state = state.lock().unwrap_or_else(|error| error.into_inner());
let Some(leader) = state.leader.as_mut() else {
return Ok(());
};
reap_exited_persistent_leader(leader, Duration::from_secs(2))?;
state.leader = None;
Ok(())
})
.await
.map_err(|error| io::Error::other(format!("leader reap task: {error}")))?
}
/// On failed test cleanup, hard-kill the concrete initial leader and leak
/// its already-signaled owner so unwind cannot run blocking Drop cleanup.
/// Lock-file and detached replacement PIDs are never consulted or signaled.
pub fn contain_failed_cleanup_for_unwind(&self) {
let leader = self
.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.leader
.take();
if let Some(mut leader) = leader {
let _ = leader.tree.kill();
let _ = leader.child.kill();
std::mem::forget(leader);
}
}
/// Close the directly-owned clients first, then shut down the concrete
/// initial leader. Detached replacements are intentionally untouched.
pub async fn close(&self) -> io::Result<()> {
let state = self.inner.clone();
tokio::task::spawn_blocking(move || {
let mut state = state.lock().unwrap_or_else(|error| error.into_inner());
if state.active_clients != 0 {
return Err(io::Error::other(format!(
"cannot close leader fixture while {} directly-owned client(s) remain; close/drop clients first",
state.active_clients
)));
}
let Some(leader) = state.leader.as_mut() else {
return Ok(());
};
shutdown_persistent_leader(leader)?;
state.leader = None;
Ok(())
})
.await
.map_err(|error| io::Error::other(format!("leader cleanup task: {error}")))?
}
async fn wait_ready(&self, timeout: Duration) -> io::Result<()> {
let (socket, pid) = {
let state = self.inner.lock().unwrap_or_else(|error| error.into_inner());
let leader = state
.leader
.as_ref()
.expect("leader fixture missing concrete owner");
(state.socket.clone(), leader.pid)
};
let deadline = tokio::time::Instant::now() + timeout;
loop {
if socket.exists() && pid_alive(pid) {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(io::Error::new(
ErrorKind::TimedOut,
format!(
"persistent leader pid {pid} did not become ready at {}",
socket.display()
),
));
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}
impl Drop for LeaderFixture {
fn drop(&mut self) {
let leader = self
.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.leader
.take();
if let Some(mut leader) = leader {
let _ = shutdown_persistent_leader(&mut leader);
}
}
}
fn shutdown_persistent_leader(leader: &mut PersistentLeader) -> io::Result<()> {
const GRACE: Duration = Duration::from_secs(2);
const HARD_WAIT: Duration = Duration::from_secs(2);
if crate::process::process_has_exited_without_reap(leader.pid, "persistent leader")? {
return reap_exited_persistent_leader(leader, HARD_WAIT);
}
if let Err(error) = leader.tree.terminate()
&& !is_missing_process_error(&error)
{
return Err(error);
}
if !wait_std_child_exit_without_reap(&leader.child, GRACE)? {
if let Err(error) = leader.tree.kill()
&& !is_missing_process_error(&error)
{
return Err(error);
}
if let Err(error) = leader.child.kill()
&& !is_missing_process_error(&error)
{
return Err(error);
}
if !wait_std_child_exit_without_reap(&leader.child, HARD_WAIT)? {
return Err(io::Error::new(
ErrorKind::TimedOut,
format!("persistent leader pid {} did not exit", leader.pid),
));
}
}
reap_exited_persistent_leader(leader, HARD_WAIT)
}
fn reap_exited_persistent_leader(
leader: &mut PersistentLeader,
timeout: Duration,
) -> io::Result<()> {
if !wait_std_child_exit_without_reap(&leader.child, timeout)? {
return Err(io::Error::new(
ErrorKind::TimedOut,
format!("persistent leader pid {} did not exit", leader.pid),
));
}
// macOS may report EPERM when the group contains only the unreaped zombie
// leader. The direct child is already known exited; attempt descendant
// cleanup while its PGID is reserved, then revoke before consuming status.
// Focused tests separately prove a live descendant is removed.
let _ = leader.tree.kill();
leader.tree.release();
leader.child.wait().map(|_| ())
}
fn wait_std_child_exit_without_reap(
child: &std::process::Child,
timeout: Duration,
) -> io::Result<bool> {
let deadline = std::time::Instant::now() + timeout;
loop {
if crate::process::process_has_exited_without_reap(child.id(), "persistent leader")? {
return Ok(true);
}
if std::time::Instant::now() >= deadline {
return Ok(false);
}
std::thread::sleep(Duration::from_millis(10));
}
}
fn is_missing_process_error(error: &io::Error) -> bool {
matches!(error.raw_os_error(), Some(code) if code == libc::ESRCH || code == libc::ECHILD)
}
fn read_pid_path(path: &Path) -> Option<u32> {
std::fs::read_to_string(path).ok()?.trim().parse().ok()
}
fn wait_std_child_bounded(
child: &mut std::process::Child,
timeout: Duration,
) -> io::Result<Option<std::process::ExitStatus>> {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait()? {
return Ok(Some(status));
}
if std::time::Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(Duration::from_millis(10));
}
}
impl LeaderStdioClient {
async fn spawn_with_binary_and_socket(
binary: &Path,
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
leader_socket: PathBuf,
registration: FixtureClientRegistration,
) -> io::Result<Self> {
let mut cmd = tokio::process::Command::new(binary);
cmd.args(["agent", "--leader", "stdio"]).current_dir(cwd);
let mut process = TestProcess::spawn(
cmd,
sandbox,
TestProcessConfig::new()
.label("grok leader stdio client")
.stdin(TestStdin::Piped)
.stdout(TestOutput::Piped)
.env("GROK_CLI_CHAT_PROXY_BASE_URL", server.url())
.env("GROK_XAI_API_BASE_URL", server.url())
.env("GROK_MODELS_BASE_URL", server.url())
.env("GROK_FEEDBACK_BASE_URL", server.url())
.env("GROK_TRACE_UPLOAD_URL", server.url())
.env("XAI_API_KEY", "test-key-for-ci")
.env("GROK_LEADER_SOCKET", leader_socket)
.env("RUST_LOG", "xai_grok_shell=debug"),
)
.map_err(|error| {
io::Error::new(
error.kind(),
format!(
"failed to spawn leader stdio client at {}: {error}\n{}",
binary.display(),
sandbox.diagnostic_summary(),
),
)
})?;
let outgoing = process
.take_stdin()
.ok_or_else(|| io::Error::other("leader stdio client stdin pipe missing"))?
.compat_write();
let incoming = process
.take_stdout()
.ok_or_else(|| io::Error::other("leader stdio client stdout pipe missing"))?
.compat();
let capture = Arc::new(Capture::default());
let client = LeaderAcpClient {
capture: capture.clone(),
};
let incoming = LineBufferedRead::spawn_local(incoming);
let (conn, handle_io) = acp::ClientSideConnection::new(client, outgoing, incoming, |fut| {
tokio::task::spawn_local(fut);
});
let (conn, handle_io) =
acp::ClientSideConnection::new(client, outgoing, incoming, |future| {
tokio::task::spawn_local(future);
});
tokio::task::spawn_local(handle_io);
Self {
Ok(Self {
conn,
child,
process,
capture,
stderr,
}
registration: Some(registration),
})
}
pub fn child_pid(&self) -> Option<u32> {
self.process.pid()
}
pub fn stderr_text(&self) -> String {
String::from_utf8_lossy(&self.stderr.lock().unwrap()).into_owned()
self.process.stderr_tail().text
}
pub fn process_diagnostics(&self) -> String {
self.process.diagnostic_summary()
}
pub fn start_terminate(&mut self) -> io::Result<()> {
self.process.start_terminate()
}
pub fn start_kill(&mut self) {
self.process.start_kill();
}
/// Request a nonblocking hard kill while retaining concrete process and
/// fixture-registration ownership for unwind containment.
pub fn contain_failed_cleanup_for_unwind(&mut self) {
self.process.start_kill();
}
pub async fn close(&mut self) -> io::Result<std::process::ExitStatus> {
let status = self.process.close().await?;
self.registration.take();
Ok(status)
}
pub async fn kill_and_close(&mut self) -> io::Result<std::process::ExitStatus> {
let status = self.process.kill().await?;
self.registration.take();
Ok(status)
}
pub fn captured_text(&self) -> String {
@ -215,7 +687,7 @@ impl LeaderStdioClient {
let api_key_method = init
.auth_methods
.iter()
.find(|m| &*m.id().0 == "xai.api_key")
.find(|method| &*method.id().0 == "xai.api_key")
.expect("xai.api_key auth method");
self.conn
.authenticate(
@ -296,10 +768,12 @@ pub fn read_leader_pid(home: &Path) -> Option<u32> {
}
pub fn pid_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as i32, 0) == 0 }
// 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)
}
/// Wait until the leader lock file contains a live PID, return it.
/// Wait until the leader lock file contains a live PID.
pub async fn wait_for_live_leader(home: &Path, timeout: Duration) -> Option<u32> {
let deadline = tokio::time::Instant::now() + timeout;
while tokio::time::Instant::now() < deadline {
@ -313,27 +787,7 @@ pub async fn wait_for_live_leader(home: &Path, timeout: Duration) -> Option<u32>
None
}
/// Wait until the leader lock file contains a live PID *different* from `old_pid`.
pub async fn wait_for_new_leader(home: &Path, old_pid: u32, timeout: Duration) -> Option<u32> {
let deadline = tokio::time::Instant::now() + timeout;
while tokio::time::Instant::now() < deadline {
if let Some(pid) = read_leader_pid(home)
&& pid != old_pid
&& pid_alive(pid)
{
return Some(pid);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
None
}
/// Wait for evidence that the bridge finished its reconnect replay.
///
/// The `x.ai/leader_reconnected` ext notification is dropped by the typed
/// `ClientSideConnection` (bare `x.ai/*` methods are rejected by the ACP
/// decoder), so we wait for the replayed `session/load` to emit session
/// notifications instead: the notification count rises above `baseline`.
pub async fn wait_for_replay_notifications(
client: &LeaderStdioClient,
baseline: u32,
@ -352,3 +806,115 @@ pub async fn wait_for_replay_notifications(
pub fn leader_log(home: &Path) -> String {
std::fs::read_to_string(home.join(".grok").join("leader.log")).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_leader(script: &str) -> PersistentLeader {
let mut cmd = std::process::Command::new("/bin/sh");
cmd.args(["-c", script])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.envs(xai_tty_utils::pager_env());
xai_tty_utils::detach_std_command(&mut cmd);
let child = cmd.spawn().expect("spawn fake persistent leader");
let pid = child.id();
let tree = TestProcessTree::try_attach(pid, "fake persistent leader")
.expect("attach fake persistent leader");
PersistentLeader { child, tree, pid }
}
fn fixture(root: &Path, leader: PersistentLeader) -> LeaderFixture {
LeaderFixture {
inner: Arc::new(Mutex::new(LeaderFixtureState {
binary: PathBuf::from("fixture"),
socket: root.join("leader.sock"),
lock: root.join("leader.lock"),
active_clients: 0,
leader: Some(leader),
})),
}
}
#[tokio::test]
async fn close_terminates_and_reaps_directly_owned_leader() {
let temp = tempfile::tempdir().expect("tempdir");
let leader = fake_leader("trap 'exit 0' TERM; while :; do sleep 1; done");
let pid = leader.pid;
let fixture = fixture(temp.path(), leader);
fixture.close().await.expect("close fixture");
assert!(!pid_alive(pid));
assert!(fixture.inner.lock().unwrap().leader.is_none());
}
#[tokio::test]
async fn active_direct_client_registration_blocks_fixture_close() {
let temp = tempfile::tempdir().expect("tempdir");
let fixture = fixture(
temp.path(),
fake_leader("trap 'exit 0' TERM; while :; do sleep 1; done"),
);
let registration = FixtureClientRegistration::new(&fixture.inner);
let error = fixture
.close()
.await
.expect_err("active client must block close");
assert!(error.to_string().contains("close/drop clients first"));
drop(registration);
fixture.close().await.expect("close after client drop");
}
#[test]
fn lock_file_replacement_pid_is_never_adopted_or_signaled() {
let temp = tempfile::tempdir().expect("tempdir");
let initial = fake_leader("trap 'exit 0' TERM; while :; do sleep 1; done");
let initial_pid = initial.pid;
let mut replacement = fake_leader("trap 'exit 0' TERM; while :; do sleep 1; done");
let replacement_pid = replacement.pid;
std::fs::write(temp.path().join("leader.lock"), replacement_pid.to_string())
.expect("replacement lock");
let fixture = fixture(temp.path(), initial);
drop(fixture);
assert!(!pid_alive(initial_pid));
assert!(
pid_alive(replacement_pid),
"observed replacement must remain untouched"
);
shutdown_persistent_leader(&mut replacement).expect("clean replacement test owner");
}
#[tokio::test]
async fn close_hard_kills_term_ignoring_descendant() {
let temp = tempfile::tempdir().expect("tempdir");
let pid_file = temp.path().join("descendant.pid");
let script = format!(
"trap 'exit 0' TERM; sh -c 'trap \"\" TERM; echo $$ > {}; while :; do sleep 1; done' & while :; do sleep 1; done",
pid_file.display()
);
let fixture = fixture(temp.path(), fake_leader(&script));
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while !pid_file.exists() && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(10)).await;
}
let descendant: u32 = std::fs::read_to_string(&pid_file)
.expect("descendant pid")
.trim()
.parse()
.expect("parse descendant pid");
fixture.close().await.expect("close fixture");
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while pid_alive(descendant) && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!pid_alive(descendant), "descendant {descendant} leaked");
}
}

View file

@ -6,7 +6,7 @@
dead_code
)]
//! Shared test utilities for grok-build crates: mock inference server, SSE
//! generators, ACP stdio client, headless runner, env sandbox.
//! generators, ACP stdio client, headless runner, env/process sandbox.
//!
//! Provides:
//! - [`MockInferenceServer`] — Mock /v1/chat/completions + /v1/responses with request logging
@ -14,8 +14,10 @@
//! - [`RawStdioClient`] — raw-wire ACP driver for bytes the typed client can't
//! produce (Foundation `\/` methods, string UUID ids)
//! - [`leader::LeaderStdioClient`] — ACP client that drives `grok agent --leader stdio` (unix)
//! - [`TestSandbox`] — Own isolated paths, hermetic child env, optional git setup, diagnostics
//! - [`TestProcess`] — Own detached child lifecycle, process-tree teardown, bounded output tails
//! - [`run_headless`] — Run `grok -p` against the mock server and capture output
//! - [`git_workdir`] — Create a temp directory with git repo (forces libgit2 init)
//! - [`git_workdir`] — Create a git-initialized [`TestSandbox`]
//! - [`grok_binary`] — Resolve the grok binary path (GROK_BINARY env or cargo_bin)
//! - [`spawn_counting_server`] — Connection-counting HTTP/1.1 server for wire/pooling tests
//! - [`uds_proxy::UdsProxy`] — Frame-aware fault-injection proxy for leader IPC sockets (unix)
@ -38,7 +40,8 @@ mod inference_override;
#[cfg(unix)]
pub mod leader;
pub mod mock_server;
mod process;
pub mod process;
pub mod sandbox;
pub mod scripted;
pub mod sse;
#[cfg(unix)]
@ -48,9 +51,20 @@ pub use counting_server::spawn_counting_server;
pub use env::{EnvGuard, git_workdir, grok_binary};
pub use headless::{
HeadlessResult, assert_headless_success, assert_no_crashes, run_headless,
run_headless_with_cmd, run_headless_with_env, stderr_tail,
run_headless_in_sandbox, run_headless_in_sandbox_borrowed,
run_headless_in_sandbox_borrowed_with_env, run_headless_in_sandbox_with_env,
run_headless_with_env, stderr_tail,
};
pub use inference_override::{InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher};
#[cfg(unix)]
pub use leader::LeaderFixture;
pub use mock_server::{
MockInferenceServer, MockModelEntry, ScriptedResponse, SseEvent, StorageUpload,
};
#[cfg(unix)]
pub use process::process_has_exited_without_reap;
pub use process::{
TestOutput, TestOutputSnapshot, TestProcess, TestProcessConfig, TestProcessState,
TestProcessStderr, TestProcessStdout, TestProcessTermination, TestProcessTree, TestStdin,
};
pub use sandbox::{TestSandbox, TestSandboxBuilder};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,919 @@
//! Hermetic filesystem and child-environment owner for grok integration tests.
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::TempDir;
const TEST_API_KEY: &str = "test-key-for-ci";
const REDACTED: &str = "<redacted>";
/// One test's isolated filesystem tree and canonical child environment.
///
/// Construction never mutates the process environment. Child commands start
/// from `env_clear()` and receive only platform essentials, sandbox paths,
/// grok network kill switches, and explicit overrides.
pub struct TestSandbox {
root: TempDir,
home: PathBuf,
grok_home: PathBuf,
workspace: PathBuf,
temp: PathBuf,
env: BTreeMap<OsString, OsString>,
}
impl TestSandbox {
/// Create an isolated non-git workspace with no mock endpoint configured.
pub fn new() -> Self {
Self::builder().build()
}
/// Configure construction-time sandbox options.
pub fn builder() -> TestSandboxBuilder {
TestSandboxBuilder::default()
}
/// Temp root owning every sandbox path.
pub fn root(&self) -> &Path {
self.root.path()
}
/// Isolated `HOME` / `USERPROFILE`.
pub fn home(&self) -> &Path {
&self.home
}
/// Explicit grok state root.
pub fn grok_home(&self) -> &Path {
&self.grok_home
}
/// Isolated working directory. When built with [`TestSandboxBuilder::git`],
/// this contains a repository with one committed `README.md`.
pub fn workspace(&self) -> &Path {
&self.workspace
}
/// Isolated `TMPDIR` / `TMP` / `TEMP`.
pub fn temp_dir(&self) -> &Path {
&self.temp
}
/// Override one child variable after the hermetic baseline. This is the
/// supported seam for feature flags and simulated terminal brands.
pub fn set_env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
self.env
.insert(key.as_ref().to_owned(), value.as_ref().to_owned());
self
}
/// Apply several explicit child overrides in order; later duplicate keys win.
pub fn extend_env<I, K, V>(&mut self, overrides: I) -> &mut Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.env.extend(
overrides
.into_iter()
.map(|(key, value)| (key.as_ref().to_owned(), value.as_ref().to_owned())),
);
self
}
/// Remove one child variable from the baseline or prior overrides.
pub fn remove_env(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
self.env.remove(key.as_ref());
self
}
/// Wire the mock endpoint onto an already-built sandbox.
pub fn set_mock_url(&mut self, url: impl Into<String>) -> &mut Self {
apply_mock_url(&mut self.env, url.into());
self
}
/// Return the effective child environment in stable key order.
pub fn env(&self) -> Vec<(OsString, OsString)> {
self.env
.iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect()
}
/// Apply the effective environment to a Tokio child command. Explicit
/// command-level `.env(...)` calls made afterward have final precedence.
pub fn apply_to_tokio_command(&self, cmd: &mut tokio::process::Command) {
cmd.env_clear().envs(self.env());
}
/// Merge the effective environment into a portable PTY command builder.
/// The caller is responsible for calling `env_clear()` first.
pub fn apply_to_command_builder(&self, cmd: &mut portable_pty::CommandBuilder) {
for (key, value) in &self.env {
cmd.env(key.as_os_str(), value.as_os_str());
}
}
/// Apply the effective environment to a standard child command. Explicit
/// command-level `.env(...)` calls made afterward have final precedence.
pub fn apply_to_std_command(&self, cmd: &mut Command) {
cmd.env_clear().envs(self.env());
}
/// Build a detached, non-interactive Git command using this sandbox's
/// selected binary and cleared child environment.
pub fn git_command(&self) -> Command {
let git = self
.env
.get(OsStr::new("GIT_BIN_PATH"))
.map_or_else(|| OsString::from("git"), OsString::to_owned);
let mut cmd = Command::new(git);
self.apply_to_std_command(&mut cmd);
xai_tty_utils::detach_std_command(&mut cmd);
cmd.stdin(Stdio::null()).envs(xai_tty_utils::pager_env());
for &(key, value) in &xai_tty_utils::GIT_AUTH_SUPPRESSION_ENVS {
cmd.env(key, value);
}
cmd.arg("--no-optional-locks");
cmd
}
/// Values that must be removed from captured child-output diagnostics.
///
/// This intentionally returns values only, never keys. Endpoint URLs,
/// credentials, and sandbox-owned private paths can be echoed by a failing
/// child even though process diagnostics never print its environment.
pub(crate) fn diagnostic_redactions(&self) -> Vec<String> {
self.env
.iter()
.filter(|(key, _)| diagnostic_value_is_sensitive(key))
.map(|(_, value)| value.to_string_lossy().into_owned())
.filter(|value| !value.is_empty())
.collect()
}
/// Sanitized, deterministic summary for assertion and spawn diagnostics.
/// Secret-bearing values are never included.
pub fn diagnostic_summary(&self) -> String {
let mut summary = format!(
"root={} home={} grok_home={} workspace={} temp={}",
self.root().display(),
self.home.display(),
self.grok_home.display(),
self.workspace.display(),
self.temp.display(),
);
for (key, value) in &self.env {
let key = key.to_string_lossy();
let display = if is_secret_key(&key) {
REDACTED.to_owned()
} else if is_endpoint_key(&key) {
sanitize_endpoint(value)
} else {
value.to_string_lossy().into_owned()
};
let _ = write!(summary, " {key}={display}");
}
summary
}
}
impl Default for TestSandbox {
fn default() -> Self {
Self::new()
}
}
/// Minimal construction-time choices for [`TestSandbox`]. Runtime feature
/// variables belong on [`TestSandbox::set_env`] instead of a growing config.
#[derive(Default)]
pub struct TestSandboxBuilder {
mock_url: Option<String>,
git: bool,
}
impl TestSandboxBuilder {
/// Wire grok API, models, feedback, trace, conversation, and web traffic to
/// a loopback mock endpoint and install the fake CI API key.
pub fn mock_url(mut self, url: impl Into<String>) -> Self {
self.mock_url = Some(url.into());
self
}
/// Initialize the workspace as a git repository with one committed file.
pub fn git(mut self) -> Self {
self.git = true;
self
}
/// Materialize the filesystem tree and canonical child environment.
pub fn build(self) -> TestSandbox {
let root = TempDir::new().expect("create test sandbox root");
let home = root.path().join("home");
let grok_home = home.join(".grok");
let workspace = root.path().join("workspace");
let temp = root.path().join("tmp");
for path in [&home, &grok_home, &workspace, &temp] {
std::fs::create_dir_all(path)
.unwrap_or_else(|e| panic!("create sandbox path {}: {e}", path.display()));
}
let parent_cwd = std::env::current_dir().expect("read parent cwd for test sandbox");
let mut env = baseline_env(&home, &grok_home, &temp, &parent_cwd);
if let Some(url) = self.mock_url {
apply_mock_url(&mut env, url);
}
let sandbox = TestSandbox {
root,
home,
grok_home,
workspace,
temp,
env,
};
if self.git {
sandbox.init_git_workspace();
}
sandbox
}
}
impl TestSandbox {
fn init_git_workspace(&self) {
run_git(self, &["init"]);
run_git(self, &["config", "user.email", "test@test.invalid"]);
run_git(self, &["config", "user.name", "Grok Test"]);
std::fs::write(self.workspace.join("README.md"), "test file\n")
.expect("write sandbox git fixture");
run_git(self, &["add", "-A"]);
run_git(self, &["commit", "-m", "init", "--no-gpg-sign"]);
}
}
fn run_git(sandbox: &TestSandbox, args: &[&str]) {
let mut cmd = sandbox.git_command();
let git = cmd.get_program().to_owned();
cmd.args(args).current_dir(sandbox.workspace());
let output = cmd.output().unwrap_or_else(|e| {
panic!(
"failed to spawn git at {} for `git {}`: {e}\n{}",
Path::new(&git).display(),
args.join(" "),
sandbox.diagnostic_summary(),
)
});
assert!(
output.status.success(),
"git {} failed (exit {:?}):\n{}\n{}",
args.join(" "),
output.status.code(),
String::from_utf8_lossy(&output.stderr),
sandbox.diagnostic_summary(),
);
}
fn apply_mock_url(env: &mut BTreeMap<OsString, OsString>, url: String) {
for key in [
"GROK_CLI_CHAT_PROXY_BASE_URL",
"GROK_XAI_API_BASE_URL",
"GROK_MODELS_BASE_URL",
"GROK_FEEDBACK_BASE_URL",
"GROK_TRACE_UPLOAD_URL",
"GROK_MANAGED_CONFIG_URL",
"GROK_CODE_WEB_URL",
"GROK_CONVERSATIONS_BASE_URL",
] {
env.insert(key.into(), url.clone().into());
}
env.insert("XAI_API_KEY".into(), TEST_API_KEY.into());
}
fn baseline_env(
home: &Path,
grok_home: &Path,
temp: &Path,
parent_cwd: &Path,
) -> BTreeMap<OsString, OsString> {
let parent_env = std::env::vars_os().collect();
baseline_env_from_parent(home, grok_home, temp, parent_cwd, &parent_env)
}
fn baseline_env_from_parent(
home: &Path,
grok_home: &Path,
temp: &Path,
parent_cwd: &Path,
parent_env: &BTreeMap<OsString, OsString>,
) -> BTreeMap<OsString, OsString> {
let mut env = BTreeMap::new();
for key in platform_allowlist() {
if let Some(value) = parent_env.get(OsStr::new(key)) {
env.insert((*key).into(), value.to_owned());
}
}
apply_hermetic_git_env(&mut env, parent_cwd, parent_env);
#[cfg(unix)]
env.entry("SHELL".into())
.or_insert_with(|| OsString::from("/bin/sh"));
for (key, value) in [
("HOME", home),
("USERPROFILE", home),
("GROK_HOME", grok_home),
("TMPDIR", temp),
("TMP", temp),
("TEMP", temp),
] {
env.insert(key.into(), value.as_os_str().to_owned());
}
for (key, value) in [
("GROK_TELEMETRY_ENABLED", "false"),
("GROK_TELEMETRY_TRACE_UPLOAD", "false"),
("GROK_FEEDBACK_ENABLED", "false"),
("GROK_TRACE_UPLOAD", "false"),
("GROK_INSTRUMENTATION", "disabled"),
("OTEL_SDK_DISABLED", "true"),
("DISABLE_TELEMETRY", "1"),
("DISABLE_FEEDBACK_COMMAND", "1"),
("GROK_DISABLE_AUTOUPDATER", "1"),
("GROK_PROMPT_SUGGESTIONS", "false"),
("NO_PROXY", "127.0.0.1,localhost,::1"),
("no_proxy", "127.0.0.1,localhost,::1"),
("GIT_CONFIG_NOSYSTEM", "1"),
("GIT_TERMINAL_PROMPT", "0"),
("GIT_ASKPASS", ""),
("GIT_LFS_SKIP_SMUDGE", "1"),
("PAGER", platform_pager()),
("GIT_PAGER", platform_pager()),
] {
env.insert(key.into(), value.into());
}
env.insert(
"GIT_CONFIG_GLOBAL".into(),
grok_home.join("gitconfig").into_os_string(),
);
env
}
fn apply_hermetic_git_env(
env: &mut BTreeMap<OsString, OsString>,
parent_cwd: &Path,
parent_env: &BTreeMap<OsString, OsString>,
) {
let Some(git_bin) = parent_env.get(OsStr::new("GIT_BIN_PATH")) else {
return;
};
let git_bin = PathBuf::from(git_bin);
let git_bin = if git_bin.is_absolute() {
git_bin
} else {
parent_cwd.join(git_bin)
};
let Some(parent) = git_bin.parent().map(Path::to_owned) else {
return;
};
let mut paths = vec![parent.to_owned()];
if let Some(path) = parent_env.get(OsStr::new("PATH")) {
paths.extend(std::env::split_paths(path));
}
let path = std::env::join_paths(paths).unwrap_or_else(|_| parent.as_os_str().to_owned());
env.insert("GIT_BIN_PATH".into(), git_bin.into_os_string());
env.insert("GIT_EXEC_PATH".into(), parent.into_os_string());
env.insert("PATH".into(), path);
}
fn platform_allowlist() -> &'static [&'static str] {
#[cfg(windows)]
{
&[
"PATH",
"PATHEXT",
"SystemRoot",
"WINDIR",
"ComSpec",
"NUMBER_OF_PROCESSORS",
"GIT_BIN_PATH",
]
}
#[cfg(not(windows))]
{
&[
"PATH",
"LANG",
"LC_ALL",
"DYLD_LIBRARY_PATH",
"LD_LIBRARY_PATH",
"GIT_BIN_PATH",
"SHELL",
]
}
}
fn platform_pager() -> &'static str {
#[cfg(unix)]
{
"cat"
}
#[cfg(not(unix))]
{
""
}
}
fn is_secret_key(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
let segments: Vec<_> = upper
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|segment| !segment.is_empty())
.collect();
segments.iter().any(|segment| {
matches!(
*segment,
"TOKEN"
| "SECRET"
| "PASSWORD"
| "PASSWD"
| "PASS"
| "KEY"
| "AUTH"
| "AUTHORIZATION"
| "CREDENTIAL"
| "CREDENTIALS"
| "COOKIE"
| "SESSION"
) || segment.ends_with("TOKEN")
|| segment.ends_with("SECRET")
|| segment.ends_with("PASSWORD")
|| segment.ends_with("CREDENTIAL")
|| segment.ends_with("CREDENTIALS")
|| segment.ends_with("APIKEY")
})
}
fn is_endpoint_key(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
upper.contains("URL") || upper.contains("ENDPOINT") || upper.contains("PROXY")
}
fn diagnostic_value_is_sensitive(key: &OsStr) -> bool {
let key = key.to_string_lossy();
is_secret_key(&key)
|| is_endpoint_key(&key)
|| matches!(
key.to_ascii_uppercase().as_str(),
"HOME" | "USERPROFILE" | "GROK_HOME" | "TMPDIR" | "TMP" | "TEMP" | "GIT_CONFIG_GLOBAL"
)
}
fn sanitize_endpoint(value: &OsStr) -> String {
let Ok(mut url) = url::Url::parse(value.to_string_lossy().as_ref()) else {
return REDACTED.to_owned();
};
let Some(host) = url.host() else {
return REDACTED.to_owned();
};
let loopback = match host {
url::Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"),
url::Host::Ipv4(address) => address.is_loopback(),
url::Host::Ipv6(address) => address.is_loopback(),
};
if !loopback || !matches!(url.scheme(), "http" | "https" | "ws" | "wss") {
return REDACTED.to_owned();
}
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
url.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn env_value(sandbox: &TestSandbox, key: &str) -> Option<OsString> {
sandbox
.env()
.into_iter()
.find(|(candidate, _)| candidate == key)
.map(|(_, value)| value)
}
#[test]
fn owns_distinct_isolated_paths() {
let sandbox = TestSandbox::new();
for path in [
sandbox.home(),
sandbox.grok_home(),
sandbox.workspace(),
sandbox.temp_dir(),
] {
assert!(path.starts_with(sandbox.root()), "{}", path.display());
assert!(path.is_dir(), "{}", path.display());
}
assert_ne!(sandbox.home(), sandbox.workspace());
assert_ne!(sandbox.home(), sandbox.temp_dir());
assert_eq!(sandbox.grok_home(), sandbox.home().join(".grok"));
}
#[test]
fn separate_instances_do_not_share_paths() {
let first = TestSandbox::new();
let second = TestSandbox::new();
assert_ne!(first.root(), second.root());
assert_ne!(first.home(), second.home());
assert_ne!(first.workspace(), second.workspace());
assert_ne!(first.temp_dir(), second.temp_dir());
}
#[test]
fn git_workspace_smoke_uses_committed_fixture() {
let sandbox = TestSandbox::builder().git().build();
assert!(sandbox.workspace().join(".git").is_dir());
assert_eq!(
std::fs::read_to_string(sandbox.workspace().join("README.md")).unwrap(),
"test file\n"
);
let mut cmd = sandbox.git_command();
cmd.args(["status", "--porcelain"])
.current_dir(sandbox.workspace());
let output = cmd.output().expect("run git status in sandbox");
assert!(
output.status.success(),
"git status failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.is_empty(), "workspace must start clean");
}
fn resolved_baseline_env(
parent_cwd: &Path,
parent_env: BTreeMap<OsString, OsString>,
) -> BTreeMap<OsString, OsString> {
let root = tempfile::tempdir().expect("create baseline fixture");
baseline_env_from_parent(
&root.path().join("home"),
&root.path().join("home/.grok"),
&root.path().join("tmp"),
parent_cwd,
&parent_env,
)
}
#[test]
fn relative_git_bin_path_resolves_against_parent_cwd() {
let parent = tempfile::tempdir().expect("create parent cwd fixture");
let parent_cwd = parent.path();
let relative_git = Path::new("external/git_hermetic/bin/git");
let env = resolved_baseline_env(
parent_cwd,
BTreeMap::from([
(OsString::from("GIT_BIN_PATH"), relative_git.into()),
(OsString::from("PATH"), OsString::from("/usr/bin")),
]),
);
let git_bin = parent_cwd.join(relative_git);
let parent = git_bin.parent().expect("git binary parent");
assert_eq!(
env.get(OsStr::new("GIT_BIN_PATH")).map(OsString::as_os_str),
Some(git_bin.as_os_str())
);
assert_eq!(
env.get(OsStr::new("GIT_EXEC_PATH"))
.map(OsString::as_os_str),
Some(parent.as_os_str())
);
assert_eq!(
std::env::split_paths(env.get(OsStr::new("PATH")).expect("git PATH"))
.next()
.as_deref(),
Some(parent)
);
}
#[test]
fn absent_git_bin_path_preserves_baseline_path_without_git_vars() {
let path = OsString::from("/ordinary/bin");
let env = resolved_baseline_env(
Path::new("/bazel/execroot/workspace"),
BTreeMap::from([(OsString::from("PATH"), path.to_owned())]),
);
assert_eq!(env.get(OsStr::new("PATH")), Some(&path));
assert!(!env.contains_key(OsStr::new("GIT_BIN_PATH")));
assert!(!env.contains_key(OsStr::new("GIT_EXEC_PATH")));
}
#[test]
fn git_command_uses_sandbox_state_without_process_global_mutation() {
let root = TempDir::new().expect("create git command fixture");
let git = root.path().join("git-dist/bin/git");
let git_parent = git.parent().expect("git binary parent");
let env = resolved_baseline_env(
root.path(),
BTreeMap::from([
(OsString::from("GIT_BIN_PATH"), git.as_os_str().to_owned()),
(OsString::from("PATH"), OsString::from("/ordinary/bin")),
]),
);
let sandbox = TestSandbox {
home: root.path().join("home"),
grok_home: root.path().join("home/.grok"),
workspace: root.path().join("workspace"),
temp: root.path().join("tmp"),
root,
env,
};
let process_git_env =
["GIT_BIN_PATH", "GIT_EXEC_PATH", "PATH"].map(|key| (key, std::env::var_os(key)));
let cmd = sandbox.git_command();
assert_eq!(
["GIT_BIN_PATH", "GIT_EXEC_PATH", "PATH"].map(|key| (key, std::env::var_os(key))),
process_git_env
);
let command_env: BTreeMap<_, _> = cmd
.get_envs()
.map(|(key, value)| (key.to_owned(), value.map(OsStr::to_owned)))
.collect();
assert_eq!(cmd.get_program(), git);
assert_eq!(
command_env
.get(OsStr::new("GIT_BIN_PATH"))
.and_then(Option::as_deref),
Some(git.as_os_str())
);
assert_eq!(
command_env
.get(OsStr::new("GIT_EXEC_PATH"))
.and_then(Option::as_deref),
Some(git_parent.as_os_str())
);
assert_eq!(
std::env::split_paths(
command_env
.get(OsStr::new("PATH"))
.and_then(Option::as_deref)
.expect("git PATH"),
)
.next()
.as_deref(),
Some(git_parent)
);
assert_eq!(
command_env
.get(OsStr::new("GIT_TERMINAL_PROMPT"))
.and_then(Option::as_deref),
Some(OsStr::new("0"))
);
assert_eq!(
command_env
.get(OsStr::new("GIT_SSH_COMMAND"))
.and_then(Option::as_deref),
Some(OsStr::new("ssh -o BatchMode=yes"))
);
assert_eq!(
cmd.get_args().next(),
Some(OsStr::new("--no-optional-locks"))
);
}
#[test]
fn baseline_is_hermetic_and_network_quiet() {
let sandbox = TestSandbox::builder()
.mock_url("http://127.0.0.1:43123/v1")
.build();
assert_eq!(env_value(&sandbox, "HOME"), Some(sandbox.home().into()));
assert_eq!(
env_value(&sandbox, "GROK_HOME"),
Some(sandbox.grok_home().into())
);
assert_eq!(
env_value(&sandbox, "TMPDIR"),
Some(sandbox.temp_dir().into())
);
assert_eq!(
env_value(&sandbox, "XAI_API_KEY").as_deref(),
Some(OsStr::new(TEST_API_KEY))
);
assert_eq!(
env_value(&sandbox, "GROK_DISABLE_AUTOUPDATER").as_deref(),
Some(OsStr::new("1"))
);
assert_eq!(
env_value(&sandbox, "GROK_TELEMETRY_TRACE_UPLOAD").as_deref(),
Some(OsStr::new("false"))
);
assert_eq!(
env_value(&sandbox, "NO_PROXY").as_deref(),
Some(OsStr::new("127.0.0.1,localhost,::1"))
);
for proxy in [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
] {
assert_eq!(env_value(&sandbox, proxy), None, "{proxy} must not leak");
}
assert_eq!(env_value(&sandbox, "GROK_LEADER_SOCKET"), None);
assert_eq!(env_value(&sandbox, "GROK_DISABLE_WEB_FETCH"), None);
assert_eq!(env_value(&sandbox, "GROK_WEB_FETCH"), None);
}
#[cfg(unix)]
#[test]
fn unix_shell_policy_preserves_host_or_falls_back_and_can_be_overridden() {
let mut sandbox = TestSandbox::new();
let expected = std::env::var_os("SHELL").unwrap_or_else(|| OsString::from("/bin/sh"));
assert_eq!(env_value(&sandbox, "SHELL"), Some(expected));
sandbox.set_env("SHELL", "/bin/bash");
assert_eq!(
env_value(&sandbox, "SHELL").as_deref(),
Some(OsStr::new("/bin/bash"))
);
let mut cmd = Command::new("unused");
sandbox.apply_to_std_command(&mut cmd);
assert_eq!(
cmd.get_envs()
.find(|(key, _)| *key == OsStr::new("SHELL"))
.and_then(|(_, value)| value),
Some(OsStr::new("/bin/bash"))
);
}
#[test]
fn command_application_clears_ambient_env_and_command_override_wins() {
let sandbox = TestSandbox::new();
let mut cmd = Command::new("unused");
cmd.env("AMBIENT_SECRET", "must-disappear")
.env("GROK_PROMPT_SUGGESTIONS", "ambient");
sandbox.apply_to_std_command(&mut cmd);
cmd.env("GROK_PROMPT_SUGGESTIONS", "command");
let env: BTreeMap<_, _> = cmd
.get_envs()
.filter_map(|(key, value)| value.map(|value| (key.to_owned(), value.to_owned())))
.collect();
assert!(!env.contains_key(OsStr::new("AMBIENT_SECRET")));
assert_eq!(
env.get(OsStr::new("GROK_PROMPT_SUGGESTIONS"))
.map(OsString::as_os_str),
Some(OsStr::new("command"))
);
}
#[test]
fn explicit_overrides_win_and_can_remove_baseline_entries() {
let mut sandbox = TestSandbox::new();
sandbox
.set_env("TERM_PROGRAM", "vscode")
.set_env("GROK_PROMPT_SUGGESTIONS", "true")
.set_env("NO_PROXY", "override.invalid")
.remove_env("GROK_DISABLE_AUTOUPDATER");
assert_eq!(
env_value(&sandbox, "TERM_PROGRAM").as_deref(),
Some(OsStr::new("vscode"))
);
assert_eq!(
env_value(&sandbox, "GROK_PROMPT_SUGGESTIONS").as_deref(),
Some(OsStr::new("true"))
);
assert_eq!(
env_value(&sandbox, "NO_PROXY").as_deref(),
Some(OsStr::new("override.invalid"))
);
assert_eq!(env_value(&sandbox, "GROK_DISABLE_AUTOUPDATER"), None);
}
#[test]
fn cross_platform_home_and_temp_names_are_present() {
let sandbox = TestSandbox::new();
assert_eq!(
env_value(&sandbox, "USERPROFILE"),
Some(sandbox.home().into())
);
assert_eq!(env_value(&sandbox, "TEMP"), Some(sandbox.temp_dir().into()));
assert_eq!(env_value(&sandbox, "TMP"), Some(sandbox.temp_dir().into()));
}
#[cfg(windows)]
#[test]
fn windows_platform_essentials_are_allowlisted() {
let sandbox = TestSandbox::new();
for essential in ["PATH", "PATHEXT", "SystemRoot", "ComSpec"] {
if std::env::var_os(essential).is_some() {
assert!(env_value(&sandbox, essential).is_some(), "{essential}");
}
}
}
#[test]
fn diagnostics_fail_closed_for_credential_keys() {
let mut sandbox = TestSandbox::new();
for (key, value) in [
("CUSTOM_TOKEN", "token-do-not-print"),
("SERVICE_API_KEY", "api-key-do-not-print"),
("clientSecret", "secret-do-not-print"),
("DB_PASSWORD_FILE", "/secret/password-file"),
("AWS_CREDENTIALS", "credentials-do-not-print"),
("SESSION_COOKIE", "cookie-do-not-print"),
("GROK_DEPLOYMENT_KEY", "deployment-key-do-not-print"),
("GROK_EXTRA_AUTH_KEY", "alpha-test-key-do-not-print"),
("AWS_ACCESS_KEY_ID", "aws-access-key-do-not-print"),
("PRIVATE_KEY", "private-key-do-not-print"),
] {
sandbox.set_env(key, value);
}
sandbox.set_env("SAFE_FEATURE", "enabled");
let summary = sandbox.diagnostic_summary();
for key in [
"CUSTOM_TOKEN",
"SERVICE_API_KEY",
"clientSecret",
"DB_PASSWORD_FILE",
"AWS_CREDENTIALS",
"SESSION_COOKIE",
"GROK_DEPLOYMENT_KEY",
"GROK_EXTRA_AUTH_KEY",
"AWS_ACCESS_KEY_ID",
"PRIVATE_KEY",
] {
assert!(summary.contains(&format!("{key}=<redacted>")), "{summary}");
}
assert!(summary.contains("SAFE_FEATURE=enabled"), "{summary}");
for secret in [
"token-do-not-print",
"api-key-do-not-print",
"secret-do-not-print",
"/secret/password-file",
"credentials-do-not-print",
"cookie-do-not-print",
"deployment-key-do-not-print",
"alpha-test-key-do-not-print",
"aws-access-key-do-not-print",
"private-key-do-not-print",
] {
assert!(!summary.contains(secret), "{summary}");
}
}
#[test]
fn diagnostics_show_only_sanitized_loopback_urls() {
let cases = [
(
"HTTP_URL",
"http://user:password@127.0.0.1:43123/v1?token=secret#fragment",
"http://127.0.0.1:43123/v1",
),
(
"HTTPS_URL",
"https://localhost:43124/path?api_key=secret",
"https://localhost:43124/path",
),
(
"IPV6_URL",
"http://user:password@[::1]:43125/v1#secret",
"http://[::1]:43125/v1",
),
(
"IPV4_OTHER_LOOPBACK_URL",
"http://127.0.0.2:43126/v1?secret=yes",
"http://127.0.0.2:43126/v1",
),
];
let mut sandbox = TestSandbox::new();
for (key, raw, _) in cases {
sandbox.set_env(key, raw);
}
sandbox
.set_env(
"REMOTE_URL",
"https://user:password@example.test/v1?token=secret",
)
.set_env("MALFORMED_URL", "not a url password=secret")
.set_env("HTTPS_PROXY", "https://user:pass@proxy.example.test");
let summary = sandbox.diagnostic_summary();
for (key, _, expected) in cases {
assert!(summary.contains(&format!("{key}={expected}")), "{summary}");
}
for key in ["REMOTE_URL", "MALFORMED_URL", "HTTPS_PROXY"] {
assert!(summary.contains(&format!("{key}=<redacted>")), "{summary}");
}
for secret in ["user", "password", "token=secret", "fragment", "pass@"] {
assert!(!summary.contains(secret), "{summary}");
}
assert!(!summary.contains(TEST_API_KEY), "{summary}");
}
}