Synced from monorepo Changes: - Workspace server: surface preview-proxy metrics through the hub metric pump - Shell: reclaim a session’s retained state in one entry - Shell: reclaim a session’s resident state in one entry - Pager: withhold key event types from Alacritty builds that double keys - Tools: cancel a session’s subagents when it closes - Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode - Pager: probe terminal version over DA2 and include it with feedback - SuperGrok Plus: identity, CLI, and analytics tier surfaces - Shell: inherit the session process scope into subagents - Pager: build @-file-search matcher lazily on first use - Tools: fix description and output contradictions in tool definitions - Workspace: degrade @-file-search instead of aborting on thread exhaustion - Tools: reap a session’s LSP servers when it closes - Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools - MCP: reap stdio MCP children on session close - Shell: reuse spawn-time skill discovery for session telemetry - Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell) - Shell: self-heal corrupt session-search SQLite cache - Workspace: cap workspace-server tokio workers on many-core hosts - Shell: reap a session’s child processes when it closes - Crash handler: capture SIGABRT so panic-aborts leave crash reports - CLI chat proxy: team-scoped Grok Code managed-config admin routes - MCP: add CLI enable/disable for MCP servers - Shell: cap tokio worker threads for startup thread demand - Workspace: harden git_commit and add git_sync_base operation - Circuit breaker: add feature-gated gRPC retry policy Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
246 lines
9.2 KiB
Rust
246 lines
9.2 KiB
Rust
//! E2E: the coding-data privacy upsell banner — shown on the welcome screen
|
|
//! for an opted-out OAuth user under the `privacy_notice_rollout` flag,
|
|
//! persisting into the agent view, and acked (never re-shown) via both
|
|
//! buttons: `[Opt out]` dismisses on the spot, stamping
|
|
//! `[privacy].privacy_banner_acked` without waiting on the server; `[Opt in]`
|
|
//! opts the user in through the shell's `PUT /privacy/coding-data-retention`
|
|
//! round trip and acks only once that succeeds.
|
|
//!
|
|
//! Drives the real pager binary through a PTY against the shared mock
|
|
//! inference server (isolated `$HOME`), with a seeded opted-out OAuth entry
|
|
//! as the active auth (`XAI_API_KEY` removed) and the rollout forced on via
|
|
//! `GROK_PRIVACY_NOTICE_ROLLOUT=1`.
|
|
//!
|
|
//! ```bash
|
|
//! cargo test -p xai-grok-pager-pty-harness --test privacy_banner_e2e \
|
|
//! -- --ignored --nocapture
|
|
//! ```
|
|
|
|
use std::path::Path;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use xai_grok_pager_pty_harness::{
|
|
ContentController, EnvOp, PtyExitPoll, PtyHarness, keys, pager_binary,
|
|
seed_fake_oauth_coding_data_opted_out,
|
|
};
|
|
|
|
const ROWS: u16 = 50;
|
|
const COLS: u16 = 120;
|
|
const BANNER_TITLE: &str = "Help improve Grok";
|
|
const OPT_OUT: &str = "[Opt out]";
|
|
const OPT_IN: &str = "[Opt in]";
|
|
const ACK: &str = "BANNERACK";
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored)
|
|
async fn privacy_banner_welcome_opt_out_ack_persists() {
|
|
run_opt_out().await.expect("privacy banner opt-out e2e");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored)
|
|
async fn privacy_banner_persists_into_agent_view_and_opt_in_shares() {
|
|
run_opt_in().await.expect("privacy banner opt-in e2e");
|
|
}
|
|
|
|
/// Rollout flag forced on (env override beats remote settings) and the
|
|
/// sandbox's fake `XAI_API_KEY` removed so the seeded opted-out OAuth entry
|
|
/// is the active auth — the banner's two preconditions.
|
|
fn banner_env_ops() -> [EnvOp<'static>; 2] {
|
|
[
|
|
EnvOp::set("GROK_PRIVACY_NOTICE_ROLLOUT", "1"),
|
|
EnvOp::remove("XAI_API_KEY"),
|
|
]
|
|
}
|
|
|
|
async fn run_opt_out() -> Result<()> {
|
|
let content = ContentController::start()
|
|
.await
|
|
.context("start mock server")?;
|
|
seed_fake_oauth_coding_data_opted_out(&content, "pty-privacy-user");
|
|
|
|
let project = tempfile::tempdir().context("project dir")?;
|
|
std::fs::create_dir_all(project.path().join(".git")).context("create .git")?;
|
|
let binary = pager_binary().context("resolve pager binary")?;
|
|
|
|
let mut pager = spawn_pager(&binary, &content, project.path()).context("spawn pager")?;
|
|
wait_for_banner(&mut pager)?;
|
|
assert!(
|
|
pager.contains_text(OPT_IN),
|
|
"welcome banner is missing {OPT_IN}:\n{}",
|
|
pager.screen_contents()
|
|
);
|
|
|
|
click_text(&mut pager, OPT_OUT).context("click Opt out")?;
|
|
|
|
// Dismissal is local and immediate — it must not wait on the server, and
|
|
// must not detour into settings.
|
|
pager
|
|
.wait_for_text_absent(BANNER_TITLE, Duration::from_secs(10))
|
|
.context("banner dismissed by [Opt out]")?;
|
|
assert!(
|
|
!pager.contains_text("Coding data,"),
|
|
"[Opt out] must answer the question, not open settings:\n{}",
|
|
pager.screen_contents()
|
|
);
|
|
|
|
// The config write is async — poll for it.
|
|
wait_for_ack_on_disk(&mut pager, content.home(), Duration::from_secs(10))?;
|
|
|
|
quit_via_double_ctrl_c(&mut pager)?;
|
|
drop(pager);
|
|
|
|
// Relaunch with the same sandbox: the acked banner must not re-show.
|
|
// Sync on "New worktree" — rendered only on the authenticated welcome
|
|
// menu ("Quit" also appears while auth is still pending, where the
|
|
// banner is gated off regardless of the ack).
|
|
let mut relaunched =
|
|
spawn_pager(&binary, &content, project.path()).context("relaunch pager")?;
|
|
relaunched
|
|
.wait_for_text("New worktree", Duration::from_secs(20))
|
|
.context("relaunched authenticated welcome screen")?;
|
|
relaunched.update(Duration::from_secs(2));
|
|
assert!(
|
|
!relaunched.contains_text(BANNER_TITLE),
|
|
"acked banner re-showed after relaunch:\n{}",
|
|
relaunched.screen_contents()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_opt_in() -> Result<()> {
|
|
let content = ContentController::start()
|
|
.await
|
|
.context("start mock server")?;
|
|
content.set_response(format!("{ACK} done."));
|
|
seed_fake_oauth_coding_data_opted_out(&content, "pty-privacy-user");
|
|
|
|
let project = tempfile::tempdir().context("project dir")?;
|
|
std::fs::create_dir_all(project.path().join(".git")).context("create .git")?;
|
|
let binary = pager_binary().context("resolve pager binary")?;
|
|
|
|
let mut pager = spawn_pager(&binary, &content, project.path()).context("spawn pager")?;
|
|
wait_for_banner(&mut pager)?;
|
|
|
|
pager.inject_keys(b"hello").context("type prompt")?;
|
|
pager.inject_keys(keys::ENTER).context("submit prompt")?;
|
|
pager
|
|
.wait_for_text(ACK, Duration::from_secs(30))
|
|
.context("turn response rendered")?;
|
|
pager.update(Duration::from_millis(1000));
|
|
assert!(
|
|
pager.contains_text(BANNER_TITLE),
|
|
"banner did not persist into the agent view:\n{}",
|
|
pager.screen_contents()
|
|
);
|
|
|
|
click_text(&mut pager, OPT_IN).context("click Opt in")?;
|
|
|
|
// Ack only lands after the shell's PUT round trip confirms 2xx.
|
|
pager
|
|
.wait_for_text_absent(BANNER_TITLE, Duration::from_secs(20))
|
|
.context("banner disappeared after [Opt in]")?;
|
|
wait_for_ack_on_disk(&mut pager, content.home(), Duration::from_secs(10))?;
|
|
|
|
let put_bodies: Vec<_> = content
|
|
.requests()
|
|
.iter()
|
|
.filter(|e| e.method == "PUT" && e.path == "/v1/privacy/coding-data-retention")
|
|
.filter_map(|e| e.body.clone())
|
|
.collect();
|
|
assert!(
|
|
put_bodies
|
|
.iter()
|
|
.any(|b| b["codingDataRetentionOptOut"] == serde_json::json!(false)),
|
|
"mock server did not see the opt-in PUT; got: {put_bodies:?}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn spawn_pager(binary: &Path, content: &ContentController, project: &Path) -> Result<PtyHarness> {
|
|
PtyHarness::spawn_with_content_env_ops_in_dir(
|
|
binary,
|
|
ROWS,
|
|
COLS,
|
|
content,
|
|
&[],
|
|
&banner_env_ops(),
|
|
Some(project),
|
|
)
|
|
}
|
|
|
|
/// Wait for the welcome menu first (auth resolved) so a missing banner is a
|
|
/// real failure rather than an early frame, then for the banner itself.
|
|
fn wait_for_banner(pager: &mut PtyHarness) -> Result<()> {
|
|
pager
|
|
.wait_for_text("Quit", Duration::from_secs(20))
|
|
.context("welcome screen")?;
|
|
pager
|
|
.wait_for_text(BANNER_TITLE, Duration::from_secs(20))
|
|
.context("privacy banner on screen")
|
|
}
|
|
|
|
/// Click `needle` by injecting an SGR (DECSET 1006) press + release at its
|
|
/// first character. The wire encoding is 1-based `col;row`
|
|
/// (`screen_contents` line 0 = row 1); the banner region is ASCII-only, so
|
|
/// the byte offset within the line is the column.
|
|
fn click_text(pager: &mut PtyHarness, needle: &str) -> Result<()> {
|
|
let screen = pager.screen_contents();
|
|
let (row0, col0) = screen
|
|
.lines()
|
|
.enumerate()
|
|
.find_map(|(row, line)| line.find(needle).map(|col| (row, col)))
|
|
.with_context(|| format!("{needle:?} not on screen:\n{screen}"))?;
|
|
let (row, col) = (row0 + 1, col0 + 1);
|
|
pager
|
|
.inject_keys(format!("\x1b[<0;{col};{row}M\x1b[<0;{col};{row}m").as_bytes())
|
|
.context("inject SGR click")?;
|
|
pager.update(Duration::from_millis(250));
|
|
Ok(())
|
|
}
|
|
|
|
/// Poll `<home>/.grok/config.toml` for the async `privacy_banner_acked`
|
|
/// write, pumping PTY output between polls so the pager never blocks on a
|
|
/// full output buffer.
|
|
fn wait_for_ack_on_disk(pager: &mut PtyHarness, home: &Path, timeout: Duration) -> Result<()> {
|
|
let path = home.join(".grok").join("config.toml");
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
let body = std::fs::read_to_string(&path).unwrap_or_default();
|
|
if body.contains("privacy_banner_acked") {
|
|
return Ok(());
|
|
}
|
|
if Instant::now() >= deadline {
|
|
bail!(
|
|
"timed out after {timeout:?} waiting for privacy_banner_acked in {}\n\
|
|
config contents:\n{body}\nscreen:\n{}",
|
|
path.display(),
|
|
pager.screen_contents()
|
|
);
|
|
}
|
|
pager.update(Duration::from_millis(100));
|
|
}
|
|
}
|
|
|
|
/// First Ctrl+C arms the quit confirmation on the empty prompt, the second
|
|
/// confirms; retry the pair in case an overlay swallowed the first one.
|
|
fn quit_via_double_ctrl_c(pager: &mut PtyHarness) -> Result<()> {
|
|
for _ in 0..3 {
|
|
pager.inject_keys(keys::CTRL_C).context("ctrl-c arm")?;
|
|
pager.update(Duration::from_millis(250));
|
|
pager.inject_keys(keys::CTRL_C).context("ctrl-c confirm")?;
|
|
pager.update(Duration::from_millis(250));
|
|
match pager.wait_exit_code(Duration::from_secs(5))? {
|
|
PtyExitPoll::Exited(code) => {
|
|
assert_eq!(code, 0, "graceful quit should exit 0, got {code}");
|
|
return Ok(());
|
|
}
|
|
_ => continue,
|
|
}
|
|
}
|
|
bail!(
|
|
"pager did not exit after repeated double Ctrl+C\nscreen:\n{}",
|
|
pager.screen_contents()
|
|
)
|
|
}
|