Synced from monorepo

Synced from monorepo

Changes:
- Cache growing transcripts on the messages backend
- Tell the model when a wait was clamped instead of re-inviting it
- Stop the stationarity nudge from claiming results are identical
- Deliver the stationarity nudge after the tool result
- Run auth provider commands through the platform shell (fixes Windows)
- Keep monitor tool stdout short and prescriptive
- Use UUIDs for analytics event insert IDs
- Stop crashing at startup when the host runs out of threads
- Delete the current session from within the session
- Add project forking-settings toggle (backend and deploy-time control)
- Reap a session’s bash and background commands when it closes
- Reap a session’s hook child processes when it closes
- Track coding-data consent decisions
- Fail open the access gate to stop false CLI paywalls
- Ship Agent Dashboard user guide
- Enable doom-loop recovery by default
- Kill agent children and the idle inhibitor when the parent process dies
- Fix multi-process credential wipe and orphaned session log writers

Source-Revision: 6372e41d828b8a6ee82c29e01a69e27ec895cca9
This commit is contained in:
grokkybara[bot] 2026-07-29 17:17:54 +00:00
commit 500129c714
89 changed files with 3841 additions and 771 deletions

View file

@ -1,5 +1,51 @@
# Changelog
# 0.2.114 — 2026-07-29
## Features
- **New `/delete` slash command** removes the current session's history after confirmation.
## Bug Fixes
- **Grok** no longer crashes on startup when the host machine has no free threads.
# 0.2.113 — 2026-07-28
## Features
- **MCP servers** can now be enabled or disabled directly from the CLI with `grok mcp enable <name>` and `grok mcp disable <name>`.
- **Full plan markdown** can now be copied to the clipboard with `y` during plan approval or preview.
- **Added support for the new SuperGrok Plus subscription tier** in authentication and feature gating.
- **Enabled automatic recovery** from repetitive loops in model output by default.
## Bug Fixes
- **Terminal command output** is no longer lost or duplicated when the gateway is unreachable.
- **Invalid MCP server entries** in config.toml no longer prevent Grok from starting; problems are shown in `grok inspect`.
- **SessionEnd hooks** now run on exit in non-leader TUI and headless sessions.
- **Paste chips** now display with the correct background in inline prompts and question inputs.
- **Pasted content chips** now behave consistently when editing answers in the question view.
- **Background task status** now shows only elapsed duration instead of absolute timestamps.
- **Session lists** no longer drop real sessions when the remote registry reports an outdated turn count of zero.
- **/loop** now stores prompts that include stop conditions so recurring tasks can terminate themselves when done.
- **Reduced spurious warning messages** for common auth and config scenarios.
- **Fixed conda activation** (and other sourced scripts that read $@) when using persistent or login-capture shells.
- **Fixed stuck background-task tray rows** after long foreground shell commands complete.
- **Agent subprocesses and idle inhibitors** are now cleaned up when the parent CLI process dies unexpectedly.
- **Fixed truncated plans** in minimal mode and improved visual separation between reasoning and output (including NO_COLOR).
- **Fixed credential loss** across multiple grok processes sharing the same auth file.
- **Fixed doubled Enter** and other keys on older Alacritty terminals.
- **Fixed false paywall** messages for free-tier and unmatched users.
## Performance
- **Cold start** shows the UI instantly while models and settings load in the background.
- **Large session forks and resumes** now use far less memory and avoid spikes.
- **Prevented thread exhaustion** on high-core shared machines by limiting the workspace daemon's worker threads.
# 0.2.112 — 2026-07-24
## Breaking Changes

View file

@ -1,7 +1,7 @@
[package]
license = "Apache-2.0"
name = "xai-grok-shell"
version = "0.2.112"
version = "0.2.114"
edition.workspace = true
[features]
@ -222,7 +222,10 @@ name = "fork_copy"
harness = false
required-features = ["test-support"]
# Consume `session::testkit`, so they need the gate (on by default under Bazel).
[[test]]
name = "test_leader_soak"
required-features = ["test-support"]
[[test]]
name = "test_session_load_memory"
required-features = ["test-support"]

View file

@ -232,7 +232,9 @@ export GROK_AUTH_TOKEN_TTL=3600 # optional
If your binary outputs a bare token string (not JSON with `expires_in`), set `auth_token_ttl` to the token's expected lifetime in seconds. Without it, Grok cannot detect expiry proactively and will only refresh after a 401.
The command is run via `sh -c`, so it can be a binary path, a shell script, or a pipeline.
The command runs through the platform shell — `sh -c` on macOS/Linux, `cmd /C` on Windows — so it can be a binary path, a script, or a pipeline.
> **Windows:** write the path as a TOML *literal* string (single quotes) so backslashes survive: `auth_provider_command = 'C:\corp\grok-auth.exe'`. Inside a double-quoted TOML string `\t`, `\n`, `\r`, `\b` and `\f` are escape sequences, so `"C:\temp\auth.exe"` parses into a path containing a tab character and the provider fails to start — after which Grok falls back to browser login as if the setting were ignored.
When `auth_provider_label` is set, the TUI welcome screen shows **"Login with Acme Corp"** instead of "Login with grok.com". In headless mode (`grok -p`), the label has no effect — stderr from your binary is printed directly to the terminal.

View file

@ -0,0 +1,117 @@
[
{
"category": "performance",
"description": "**Cold start** shows the UI instantly while models and settings load in the background.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Terminal command output** is no longer lost or duplicated when the gateway is unreachable.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Invalid MCP server entries** in config.toml no longer prevent Grok from starting; problems are shown in `grok inspect`.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**SessionEnd hooks** now run on exit in non-leader TUI and headless sessions.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Paste chips** now display with the correct background in inline prompts and question inputs.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Pasted content chips** now behave consistently when editing answers in the question view.",
"breaking_change": false
},
{
"category": "performance",
"description": "**Large session forks and resumes** now use far less memory and avoid spikes.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Background task status** now shows only elapsed duration instead of absolute timestamps.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Session lists** no longer drop real sessions when the remote registry reports an outdated turn count of zero.",
"breaking_change": false
},
{
"category": "features",
"description": "**MCP servers** can now be enabled or disabled directly from the CLI with `grok mcp enable <name>` and `grok mcp disable <name>`.",
"breaking_change": false
},
{
"category": "features",
"description": "**Full plan markdown** can now be copied to the clipboard with `y` during plan approval or preview.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**/loop** now stores prompts that include stop conditions so recurring tasks can terminate themselves when done.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Reduced spurious warning messages** for common auth and config scenarios.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed conda activation** (and other sourced scripts that read $@) when using persistent or login-capture shells.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed stuck background-task tray rows** after long foreground shell commands complete.",
"breaking_change": false
},
{
"category": "performance",
"description": "**Prevented thread exhaustion** on high-core shared machines by limiting the workspace daemon's worker threads.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Agent subprocesses and idle inhibitors** are now cleaned up when the parent CLI process dies unexpectedly.",
"breaking_change": false
},
{
"category": "features",
"description": "**Added support for the new SuperGrok Plus subscription tier** in authentication and feature gating.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed truncated plans** in minimal mode and improved visual separation between reasoning and output (including NO_COLOR).",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed credential loss** across multiple grok processes sharing the same auth file.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed doubled Enter** and other keys on older Alacritty terminals.",
"breaking_change": false
},
{
"category": "features",
"description": "**Enabled automatic recovery** from repetitive loops in model output by default.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed false paywall** messages for free-tier and unmatched users.",
"breaking_change": false
}
]

View file

@ -0,0 +1,34 @@
# 0.2.113 — 2026-07-28
## Features
- **MCP servers** can now be enabled or disabled directly from the CLI with `grok mcp enable <name>` and `grok mcp disable <name>`.
- **Full plan markdown** can now be copied to the clipboard with `y` during plan approval or preview.
- **Added support for the new SuperGrok Plus subscription tier** in authentication and feature gating.
- **Enabled automatic recovery** from repetitive loops in model output by default.
## Bug Fixes
- **Terminal command output** is no longer lost or duplicated when the gateway is unreachable.
- **Invalid MCP server entries** in config.toml no longer prevent Grok from starting; problems are shown in `grok inspect`.
- **SessionEnd hooks** now run on exit in non-leader TUI and headless sessions.
- **Paste chips** now display with the correct background in inline prompts and question inputs.
- **Pasted content chips** now behave consistently when editing answers in the question view.
- **Background task status** now shows only elapsed duration instead of absolute timestamps.
- **Session lists** no longer drop real sessions when the remote registry reports an outdated turn count of zero.
- **/loop** now stores prompts that include stop conditions so recurring tasks can terminate themselves when done.
- **Reduced spurious warning messages** for common auth and config scenarios.
- **Fixed conda activation** (and other sourced scripts that read $@) when using persistent or login-capture shells.
- **Fixed stuck background-task tray rows** after long foreground shell commands complete.
- **Agent subprocesses and idle inhibitors** are now cleaned up when the parent CLI process dies unexpectedly.
- **Fixed truncated plans** in minimal mode and improved visual separation between reasoning and output (including NO_COLOR).
- **Fixed credential loss** across multiple grok processes sharing the same auth file.
- **Fixed doubled Enter** and other keys on older Alacritty terminals.
- **Fixed false paywall** messages for free-tier and unmatched users.
## Performance
- **Cold start** shows the UI instantly while models and settings load in the background.
- **Large session forks and resumes** now use far less memory and avoid spikes.
- **Prevented thread exhaustion** on high-core shared machines by limiting the workspace daemon's worker threads.

View file

@ -0,0 +1,12 @@
[
{
"category": "fixes",
"description": "**Grok** no longer crashes on startup when the host machine has no free threads.",
"breaking_change": false
},
{
"category": "features",
"description": "**New `/delete` slash command** removes the current session's history after confirmation.",
"breaking_change": false
}
]

View file

@ -0,0 +1,10 @@
# 0.2.114 — 2026-07-29
## Features
- **New `/delete` slash command** removes the current session's history after confirmation.
## Bug Fixes
- **Grok** no longer crashes on startup when the host machine has no free threads.

View file

@ -294,6 +294,22 @@ pub async fn run_stdio_agent(
memory_config: Option<crate::config::MemoryConfig>,
) -> anyhow::Result<()> {
register_fs_watch_runtime();
// A stdio agent is a protocol child speaking over pipes inherited from
// whoever spawned it (grok-desktop, IDE clients, the agent SDKs, a parent
// agent's subagent harness) — it is useless without that parent. stdin
// EOF already triggers shutdown below, but an agent wedged mid-turn (or
// under thread exhaustion) may never read stdin again; bind to parent
// death (Linux `PR_SET_PDEATHSIG(SIGTERM)`, no-op elsewhere) so the
// kernel reaps it instead of leaving an orphan accumulating pid slots on
// shared hosts. The leader entrypoint intentionally does NOT do this —
// it is designed to outlive its clients.
if let Err(error) = xai_tty_utils::kill_current_process_on_parent_death() {
tracing::warn!(
%error,
"failed to bind to parent death; agent will not die with its \
parent stdin EOF remains the only cleanup"
);
}
// Stamp binary version into unified log entries so zombie processes
// are identifiable by version in diagnostic logs.
xai_grok_telemetry::unified_log::set_version(xai_grok_version::VERSION);

View file

@ -2464,10 +2464,11 @@ impl Config {
/// remote settings `doom_loop_recovery` object (a partial remote object only
/// overrides the fields it sets). Gate precedence: env
/// `GROK_DOOM_LOOP_RECOVERY` > TOML `enabled` > remote `enabled` >
/// default off — `None` IS the off state, so disabled has exactly one
/// spelling. Tunables have no env layer (TOML > remote > default) and
/// are clamped to their documented ranges. Returns the composite runtime
/// policy rather than `Resolved` because each knob resolves from its own
/// default ON — each layer's `false` is an independent kill switch, and
/// `None` IS the off state, so disabled has exactly one spelling.
/// Tunables have no env layer (TOML > remote > default) and are clamped
/// to their documented ranges. Returns the composite runtime policy
/// rather than `Resolved` because each knob resolves from its own
/// source (the `resolve_reminder_policy` pattern).
pub(crate) fn resolve_doom_loop_recovery(
&self,
@ -2480,7 +2481,7 @@ impl Config {
let enabled = BoolFlag::env("GROK_DOOM_LOOP_RECOVERY")
.config(self.doom_loop_recovery.enabled)
.feature_flag(remote.and_then(|s| s.enabled))
.default(false)
.default(true)
.resolve()
.value;
enabled.then(|| Policy {
@ -9052,8 +9053,9 @@ reasoning_effort = "low"
unsafe { std::env::remove_var("GROK_TWO_PASS_COMPACTION") };
}
/// Gate precedence: env > `[doom_loop_recovery]` > remote settings >
/// default(off), with the remote layer merged PER-FIELD from the nested
/// `doom_loop_recovery` object. One test covers the full ladder (the
/// default(ON), with the remote layer merged PER-FIELD from the nested
/// `doom_loop_recovery` object and each layer's `false` an independent
/// kill switch. One test covers the full ladder (the
/// `resolve_two_pass_compaction_precedence` pattern).
#[test]
#[serial]
@ -9061,10 +9063,42 @@ reasoning_effort = "low"
use crate::util::config::DoomLoopRecoverySettings;
unsafe { std::env::remove_var("GROK_DOOM_LOOP_RECOVERY") };
let default_cfg = Config::default();
let p = default_cfg
.resolve_doom_loop_recovery()
.expect("default is ON");
assert_eq!(p.max_threshold, 8, "default tunables unchanged");
assert_eq!(p.max_retries, 2, "default tunables unchanged");
let toml_off = Config {
doom_loop_recovery: DoomLoopRecoverySettings {
enabled: Some(false),
..Default::default()
},
..Default::default()
};
assert!(
toml_off.resolve_doom_loop_recovery().is_none(),
"TOML kill switch"
);
let remote_off = Config {
remote_settings: Some(crate::util::config::RemoteSettings {
doom_loop_recovery: Some(DoomLoopRecoverySettings {
enabled: Some(false),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
assert!(
remote_off.resolve_doom_loop_recovery().is_none(),
"remote settings kill switch"
);
unsafe { std::env::set_var("GROK_DOOM_LOOP_RECOVERY", "0") };
assert!(
default_cfg.resolve_doom_loop_recovery().is_none(),
"default is opt-in off"
"env kill switch"
);
unsafe { std::env::remove_var("GROK_DOOM_LOOP_RECOVERY") };
let remote_on = Config {
remote_settings: Some(crate::util::config::RemoteSettings {
doom_loop_recovery: Some(DoomLoopRecoverySettings {
@ -9080,10 +9114,6 @@ reasoning_effort = "low"
assert_eq!(p.max_threshold, 16);
assert_eq!(p.max_retries, 1);
let partial_remote = Config {
doom_loop_recovery: DoomLoopRecoverySettings {
enabled: Some(true),
..Default::default()
},
remote_settings: Some(crate::util::config::RemoteSettings {
doom_loop_recovery: Some(DoomLoopRecoverySettings {
max_threshold: Some(16),
@ -9095,7 +9125,7 @@ reasoning_effort = "low"
};
let p = partial_remote
.resolve_doom_loop_recovery()
.expect("gate from TOML despite remote object omitting enabled");
.expect("default-on gate despite remote object omitting enabled");
assert_eq!(p.max_threshold, 16, "remote tunable applies");
assert_eq!(p.max_retries, 2, "unset field falls to the default");
let config_over_remote = Config {

View file

@ -134,6 +134,7 @@ pub(crate) fn jwt_tier_claim(jwt: &str) -> Option<String> {
4 => "x_premium_plus",
5 => "supergrok_heavy",
6 => "supergrok_lite",
7 => "supergrok_plus",
0 => "free",
_ => return Some(tier.to_string()),
}
@ -178,6 +179,7 @@ pub(crate) fn jwt_claim_matches_user_subscription_tier(
"XPremiumPlus" => jwt_claim == "x_premium_plus",
"SuperGrokPro" => jwt_claim == "supergrok_heavy",
"SuperGrokLite" => jwt_claim == "supergrok_lite",
"SuperGrokPlus" => jwt_claim == "supergrok_plus",
_ => jwt_claim.parse::<u64>().is_ok_and(|n| n != 0),
}
}
@ -1777,10 +1779,9 @@ impl MvpAgent {
/// Check whether the user has access via remote settings `allow_access`.
///
/// Non-xAI auth (API keys, enterprise) always passes. For xAI OAuth2
/// users, reads `allow_access` from remote settings. When settings exist
/// but the field is absent/false, defaults to `false` (blocked); when
/// settings have not arrived yet (background fetch pending) the gate is
/// provisionally open and re-resolved on arrival.
/// users, reads `allow_access` from remote settings (explicit `false`
/// blocks; absent field fails open). When settings have not arrived yet
/// the gate is provisionally open and re-resolved on arrival.
pub(super) async fn enforce_grok_code_access(&self, auth: &crate::auth::GrokAuth) {
if !auth.is_xai_auth() {
self.tier_allowed.set(true);
@ -2005,18 +2006,7 @@ impl MvpAgent {
.auth_manager
.current()
.map(|auth| {
let gate = if !self.tier_allowed.get() && gate.is_none() {
let message = "A subscription is required.".to_string();
Some(crate::auth::GateInfo {
message,
url: Some(
"https://grok.com/supergrok?referrer=grok-build".to_string(),
),
label: Some("Subscribe".to_string()),
})
} else {
gate
};
let gate = if self.tier_allowed.get() { None } else { gate };
let auth_meta = crate::auth::AuthMeta {
email: auth.email.clone(),
auth_mode: Some(format!("{:?}", auth.auth_mode)),
@ -2636,19 +2626,11 @@ fn spawn_post_unblock_jwt_and_catalog_retry(
}
});
}
/// Resolve `allow_access` from remote settings.
///
/// Returns `true` only when remote settings explicitly set `allow_access: true`.
/// Defaults to `false` (blocked) when settings are `None` or the field is
/// absent — matching the `grok_build_access_gate` flag's server-side default.
///
/// Used by both `enforce_grok_code_access` (initial login gate) and
/// `retry_subscription_check` (poller gate lift) to keep the decision in
/// one place.
/// `allow_access` from remote settings. Fail-open unless explicitly `false`.
pub(crate) fn settings_allow_access(
rs: Option<&crate::util::config::RemoteSettings>,
) -> bool {
rs.and_then(|s| s.allow_access).unwrap_or(false)
!matches!(rs.and_then(|s| s.allow_access), Some(false))
}
#[cfg(test)]
mod tests;

View file

@ -9,6 +9,30 @@ impl MvpAgent {
let _ = handle.cmd_tx.send(SessionCommand::Shutdown);
}
}
/// Hard-stop a live session before wiping its history.
///
/// Cancels the turn (subagents + background tasks), shuts the actor down,
/// reaps process scope, then waits briefly for flush so delete can remove
/// the session directory without the actor rewriting it.
pub(crate) async fn teardown_live_session_before_delete(&self, id: &acp::SessionId) {
let Some(handle) = self.sessions.borrow().get(id).cloned() else {
return;
};
let _ = handle.cmd_tx.send(SessionCommand::Cancel {
cancel_subagents: true,
kill_background_tasks: true,
rewind_if_pristine: false,
trigger: Some("session_delete".into()),
});
let _ = handle.cmd_tx.send(SessionCommand::Shutdown);
drop(handle);
let thread = self.session_threads.borrow_mut().remove(id);
self.remove_session_terminal(id, SessionLiveState::Completed);
if let Some(thread) = thread {
self.session_threads.borrow_mut().insert(id.clone(), thread);
self.drain_old_session_thread(id).await;
}
}
/// Finalize the cloud session replica (fire-and-forget, "Hook 4").
///
/// Marks the session **done** upstream, so this MUST only run on a genuine
@ -438,9 +462,10 @@ impl MvpAgent {
)
.registry_counts()
.await;
let (session_index_claims, require_gateway_sessions) = {
let (resident_resources, session_index_claims, require_gateway_sessions) = {
let resident = self.resident_resources.borrow();
(
resident.len(),
resident
.values()
.filter(|r| r.codebase_index.is_some())
@ -449,6 +474,7 @@ impl MvpAgent {
)
};
let retained = self.retained_resources.borrow();
let retained_resources = retained.len();
let dispatch_locks = retained
.values()
.filter(|d| d.dispatch_lock.is_some())
@ -465,6 +491,8 @@ impl MvpAgent {
RegistrySnapshot {
sessions: self.sessions.borrow().len(),
session_threads: self.session_threads.borrow().len(),
resident_resources,
retained_resources,
dispatch_locks,
session_turn_numbers,
permission_event_receivers,
@ -489,6 +517,8 @@ impl MvpAgent {
pub struct RegistrySnapshot {
pub sessions: usize,
pub session_threads: usize,
pub resident_resources: usize,
pub retained_resources: usize,
pub dispatch_locks: usize,
pub session_turn_numbers: usize,
pub permission_event_receivers: usize,

View file

@ -34,6 +34,10 @@ fn jwt_tier_claim_maps_free_and_paid() {
jwt_tier_claim(&jwt_with_tier(6)).as_deref(),
Some("supergrok_lite")
);
assert_eq!(
jwt_tier_claim(&jwt_with_tier(7)).as_deref(),
Some("supergrok_plus")
);
assert_eq!(jwt_tier_claim(&jwt_with_tier(9)).as_deref(), Some("9"));
assert_eq!(jwt_tier_claim(&jwt_with_tier(99)).as_deref(), Some("99"));
}
@ -102,6 +106,7 @@ fn jwt_claim_matches_user_subscription_tier_known_pairs() {
("supergrok_heavy", "SuperGrokPro"),
("9", "EnterpriseMystery"),
("supergrok_lite", "SuperGrokLite"),
("supergrok_plus", "SuperGrokPlus"),
];
for (claim, user_tier) in cases {
assert!(
@ -120,6 +125,14 @@ fn jwt_claim_matches_user_subscription_tier_rejects_stale_and_unknown() {
"supergrok",
"SuperGrokPro"
));
assert!(!jwt_claim_matches_user_subscription_tier(
"supergrok",
"SuperGrokPlus"
));
assert!(!jwt_claim_matches_user_subscription_tier(
"supergrok_heavy",
"SuperGrokPlus"
));
assert!(!jwt_claim_matches_user_subscription_tier("free", "GrokPro"));
assert!(!jwt_claim_matches_user_subscription_tier("", "XPremium"));
assert!(!jwt_claim_matches_user_subscription_tier(
@ -360,12 +373,10 @@ fn trace_turn_to_i32_saturates_at_max() {
let result = i32::try_from(boundary).unwrap_or(i32::MAX);
assert_eq!(result, i32::MAX);
}
/// When remote settings are absent (`None`), default to blocked.
#[test]
fn settings_allow_access_none_settings_is_blocked() {
assert!(!settings_allow_access(None));
fn settings_allow_access_none_settings_is_allowed() {
assert!(settings_allow_access(None));
}
/// When `allow_access` is `Some(true)`, user is allowed.
#[test]
fn settings_allow_access_true_is_allowed() {
let rs = crate::util::config::RemoteSettings {
@ -374,10 +385,6 @@ fn settings_allow_access_true_is_allowed() {
};
assert!(settings_allow_access(Some(&rs)));
}
/// When `allow_access` is `Some(false)` (remote settings default / rule
/// disabled), user stays blocked — even if they hold a qualifying
/// subscription. This is the regression guard for the bug where
/// `retry_subscription_check` unconditionally lifted the gate.
#[test]
fn settings_allow_access_false_is_blocked() {
let rs = crate::util::config::RemoteSettings {
@ -386,15 +393,13 @@ fn settings_allow_access_false_is_blocked() {
};
assert!(!settings_allow_access(Some(&rs)));
}
/// When `/settings` returned successfully but the field is absent
/// (`None`), default to blocked (conservative).
#[test]
fn settings_allow_access_field_absent_is_blocked() {
fn settings_allow_access_field_absent_is_allowed() {
let rs = crate::util::config::RemoteSettings {
allow_access: None,
..Default::default()
};
assert!(!settings_allow_access(Some(&rs)));
assert!(settings_allow_access(Some(&rs)));
}
/// After allocating a turn number, the retained (in-memory) turn counter holds
/// the next value (current + 1). This is the value that must be persisted via

View file

@ -147,6 +147,7 @@ mod tests {
fn all_paid_tiers_qualify() {
for tier in &[
"SuperGrokPro",
"SuperGrokPlus",
"GrokPro",
"SuperGrokLite",
"XPremiumPlus",

View file

@ -383,15 +383,7 @@ async fn mint_provider_token(
cmd.args(args);
cmd
}
None => {
#[cfg(windows)]
let (shell, flag) = ("cmd", "/C");
#[cfg(not(windows))]
let (shell, flag) = ("sh", "-c");
let mut cmd = tokio::process::Command::new(shell);
cmd.args([flag, config.command.as_str()]);
cmd
}
None => crate::util::subprocess::shell_c(config.command.as_str()),
};
if let Some(ref dir) = cwd {
cmd.current_dir(dir);

View file

@ -5,7 +5,7 @@ use crate::util::subprocess::CommandLog;
use crate::util::subprocess::RunError;
use crate::util::subprocess::RunOptions;
use crate::util::subprocess::run_detached_with_timeout;
use crate::util::subprocess::sh_c;
use crate::util::subprocess::shell_c;
use std::time::Duration;
/// Parse stdout into a session-credential `GrokAuth`.
@ -48,7 +48,7 @@ const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) async fn run_external_refresh(command: &str) -> Option<GrokAuth> {
tracing::info!(cmd = %command, timeout_secs = EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs(), "auth: running external auth provider (headless refresh)");
let mut cmd = sh_c(command);
let mut cmd = shell_c(command);
cmd.env("GROK_AUTH_EXPIRED", "1");
// Route through the group-killing runner so a provider that spawns helpers
// is torn down as a unit on timeout.

View file

@ -215,12 +215,14 @@ async fn run_external_auth_provider(
"auth: running external auth provider"
);
let mut cmd = tokio::process::Command::new("sh");
cmd.args(["-c", command])
.stdin(std::process::Stdio::null())
// `sh -c` on unix, `cmd /C` on Windows — a hardcoded `sh` cannot spawn on a
// default Windows install, and the spawn failure fell through to the
// built-in browser login instead of honoring `auth_provider_command`.
let mut cmd = crate::util::subprocess::shell_c(command);
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.kill_on_drop(true);
// TODO: `kill_on_drop` SIGKILLs only the direct `sh` child; a provider that
// TODO: `kill_on_drop` SIGKILLs only the direct shell child; a provider that
// backgrounds work (setsid / `&`) leaks the grandchild on shutdown-cancel.
// Proper fix: pgid-kill via xai-tty-utils.

View file

@ -1072,27 +1072,49 @@ impl AuthManager {
Some(auth)
}
/// `true` when a sibling process has rotated the refresh token on
/// disk (disk RT differs from in-memory RT). Used by `refresh_chain`
/// to demote a `PermanentFailure` to transient so the sibling's
/// fresher token can be tried on the next attempt.
/// `true` when the refresh token on disk is present and differs from the
/// one we actually spent — i.e. a sibling process rotated the RT while our
/// exchange was in flight, so the rejection we just got is a lost race
/// rather than a revoked session.
///
/// The single definition of "disk moved past the token we spent". Two
/// hand-rolled copies of this comparison is how the wrong one survived
/// long enough to log a dozen processes out at once.
///
/// Takes an already-observed `disk_rt` rather than reading `auth.json`
/// itself, so one observation drives the decision, the unattributed
/// fallback, and the telemetry that explains them. A second read can catch
/// a *later* sibling write and produce a record that contradicts the
/// branch it documents — in the log whose whole purpose is post-incident
/// truth. Callers read under the auth file lock, so the observation
/// includes the sibling's committed write.
///
/// Disk holding no RT is *not* divergence: there is no successor to fall
/// back to, so the rejection must be honored.
fn refresh_token_superseded(disk_rt: Option<&str>, spent_rt: &str) -> bool {
disk_rt.is_some_and(|disk_rt| disk_rt != spent_rt)
}
/// `true` when a sibling process has rotated the refresh token on disk
/// past the one in memory. Used by `refresh_chain` to demote a
/// `PermanentFailure` to transient so the sibling's fresher token can be
/// tried on the next attempt.
///
/// Requires an in-memory RT: empty `inner` means the disk credential is
/// the only candidate (not a multi-process rotation). Does **not**
/// require a non-expired disk AT — a sibling may still hold a usable RT
/// while its AT is buffer/hard-expired.
fn sibling_has_different_refresh_token(&self) -> bool {
let disk_auth = self.read_disk_auth();
let Some(ref disk) = disk_auth else {
return false;
};
let Some(disk_rt) = disk.refresh_token.as_deref() else {
return false;
};
let Some(mem_rt) = self.current_or_expired().and_then(|a| a.refresh_token) else {
return false;
};
mem_rt.as_str() != disk_rt
///
/// Only a fallback for authorities that cannot report which RT they spent.
/// Attributed refreshers pass the token they actually sent to
/// [`Self::refresh_token_superseded`] directly; because
/// `resolve_refresh_credential` is disk-first, the RT actually sent is
/// usually the disk one, and comparing disk against *memory* then answers
/// `false` in precisely the case that needs the demotion.
fn sibling_has_different_refresh_token(&self, disk_rt: Option<&str>) -> bool {
self.current_or_expired()
.and_then(|a| a.refresh_token)
.is_some_and(|mem_rt| Self::refresh_token_superseded(disk_rt, &mem_rt))
}
/// Re-read `auth.json` from disk without updating in-memory state.
@ -1815,7 +1837,11 @@ impl AuthManager {
Err(AuthError::transient_source(e))
}
},
RefreshOutcome::PermanentFailure { error, tried_key } => {
RefreshOutcome::PermanentFailure {
error,
tried_key,
tried_refresh_token,
} => {
tracing::warn!(reason = ?error.reason, "auth.refresh.permanent_failure");
xai_grok_telemetry::unified_log::warn(
"auth.refresh.permanent_failure",
@ -1843,10 +1869,42 @@ impl AuthManager {
if is_rtr {
let mem = self.current_or_expired();
let disk = self.read_disk_auth();
// Unattributed + diverging RTs: demote without recording so
// the next attempt can try the other side (no sticky lockout).
if tried_key.is_none() && self.sibling_has_different_refresh_token() {
// Diverging RTs mean a sibling rotated while we were in
// flight: our RT was superseded, not revoked. Demote
// without recording so the next attempt picks up the
// sibling's token (no sticky lockout, no credential loss).
//
// When the refresher told us which RT it spent (every
// in-tree OIDC path), compare disk against *that*. The
// legacy disk-vs-memory heuristic is only a fallback for
// unattributed authorities: it asks the wrong question,
// because `resolve_refresh_credential` is disk-first, so
// the RT actually spent is usually the disk one and the
// comparison collapses to "false" exactly when it matters.
//
// Both arms and the log below read one `disk` observation.
// Re-reading per use lets the decision and the line that
// explains it disagree about what disk held.
let disk_rt = disk.as_ref().and_then(|d| d.refresh_token.as_deref());
let sibling_rotated = match tried_refresh_token.as_deref() {
Some(tried_rt) => Self::refresh_token_superseded(disk_rt, tried_rt),
None => {
tried_key.is_none() && self.sibling_has_different_refresh_token(disk_rt)
}
};
if sibling_rotated {
tracing::info!("auth: sibling-rotation detected; demoting to transient");
xai_grok_telemetry::unified_log::info(
"auth.refresh.sibling_rotation_demoted",
None,
Some(serde_json::json!({
"reason": format!("{failed_reason:?}"),
"tried_rt_prefix": tried_refresh_token
.as_deref()
.map(token_suffix),
"disk_rt_prefix": disk_rt.map(token_suffix),
})),
);
return Err(AuthError::transient(format!(
"sibling-rotation: {failed_reason:?}"
)));
@ -1912,7 +1970,13 @@ impl AuthManager {
/// Re-read auth.json from disk and update the in-memory cache (used by the
/// refresh chains). Non-destructive: only updates in-memory if disk has a
/// different valid token (a sibling process wrote a fresher one).
pub(crate) fn pick_up_sibling_token(&self) {
///
/// Returns `true` only when in-memory state was actually replaced, so
/// callers can log adoption truthfully instead of inferring it from
/// "we have a token now" — which is also true when our own token was fine
/// all along, and made the proactive-refresh log actively misleading when
/// reconstructing a rotation chain after an incident.
pub(crate) fn pick_up_sibling_token(&self) -> bool {
let auth = match read_auth_json(&self.path) {
Ok(map) => lookup_auth(&map, &self.scope),
_ => None,
@ -1932,7 +1996,9 @@ impl AuthManager {
})),
);
self.with_inner_write(|inner| *inner = Some(a.clone()));
return true;
}
false
}
/// Check if a candidate auth has a different token than what's in memory.
@ -2227,7 +2293,7 @@ impl AuthManager {
// already refreshed and wrote a valid token to disk.
// Combined with jitter, the first process to wake
// refreshes; later processes adopt the result here.
this.pick_up_sibling_token();
let adopted_from_sibling = this.pick_up_sibling_token();
if this.current().is_some() {
let adopted = this.current().map(|a| token_suffix(&a.key).to_owned());
let expires_at = this
@ -2235,14 +2301,25 @@ impl AuthManager {
.read()
.as_ref()
.and_then(|a| a.expires_at.map(|e| e.to_rfc3339()));
tracing::info!(
"auth: proactive refresh skipped, adopted sibling token from disk"
);
// Distinguish "a sibling's token replaced ours" from "our
// own token is still valid". Both skip the refresh, but
// conflating them makes the log actively misleading when
// reconstructing a rotation chain after an incident.
if adopted_from_sibling {
tracing::info!(
"auth: proactive refresh skipped, adopted sibling token from disk"
);
} else {
tracing::info!(
"auth: proactive refresh skipped, in-memory token still valid"
);
}
xai_grok_telemetry::unified_log::info(
"auth: proactive refresh adopted sibling token",
"auth: proactive refresh skipped",
None,
Some(serde_json::json!({
"adopted_key_prefix": adopted,
"adopted_from_sibling": adopted_from_sibling,
"key_prefix": adopted,
"expires_at": expires_at,
})),
);

View file

@ -1396,6 +1396,145 @@ async fn refresh_chain_demotes_when_disk_rt_differs_even_if_at_expired() {
);
}
/// Regression test for the multi-process logout incident.
///
/// This is the shape `OidcRefresher` actually emits in production: the tried
/// credential is **fully attributed** (`tried_key` *and* `tried_refresh_token`
/// are `Some`). The pre-existing demotion tests all built the outcome with
/// `tried_key = None` — the external-binary shape — so they passed while the
/// OIDC path was gated behind `tried_key.is_none()` and could never demote.
///
/// Scenario: a sibling rotated the RT while our token exchange was in flight,
/// so the IdP rejected the RT we spent. That is a lost race, not a revoked
/// session: it must demote to transient and leave the sibling's credential on
/// disk untouched.
#[tokio::test]
async fn refresh_chain_demotes_when_attributed_tried_rt_differs_from_disk() {
let dir = tempfile::tempdir().unwrap();
let cfg = GrokComConfig::default();
let scope = cfg.auth_scope();
let mgr = Arc::new(AuthManager::new(dir.path(), cfg));
// We hold, and spend, the predecessor RT.
let tried = GrokAuth {
key: "tried-key".into(),
auth_mode: AuthMode::Oidc,
refresh_token: Some("rt-spent".into()),
expires_at: Some(Utc::now() - Duration::hours(1)),
oidc_issuer: Some("https://issuer.example".into()),
oidc_client_id: Some("client-1".into()),
..GrokAuth::test_default()
};
mgr.hot_swap(tried.clone());
// A sibling already rotated: disk carries the successor RT. Its AT is
// expired too, so disk adoption cannot short-circuit the failure path —
// the demotion is the only thing standing between us and a wipe.
let sibling = GrokAuth {
key: "sibling-key".into(),
auth_mode: AuthMode::Oidc,
refresh_token: Some("rt-successor".into()),
expires_at: Some(Utc::now() - Duration::minutes(30)),
oidc_issuer: Some("https://issuer.example".into()),
oidc_client_id: Some("client-1".into()),
..GrokAuth::test_default()
};
let mut store = AuthStore::new();
store.insert(scope, sibling);
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
struct AttributedRejection(GrokAuth);
#[async_trait::async_trait]
impl TokenRefresher for AttributedRejection {
async fn refresh(
&self,
_reason: crate::auth::manager::RefreshReason,
) -> crate::auth::refresh::RefreshOutcome {
// Exactly what OidcRefresher builds on a 400 invalid_grant.
crate::auth::refresh::RefreshOutcome::permanent_for(
crate::auth::error::RefreshTokenFailedReason::RefreshTokenRejected,
&self.0,
)
}
}
mgr.set_refresher(Arc::new(AttributedRejection(tried)));
let err = mgr
.refresh_chain(TokenType::OidcSession, RefreshReason::PreRequest)
.await
.unwrap_err();
assert!(
matches!(err, AuthError::Refresh(RefreshTokenError::Transient(_))),
"a rejected RT that disk has already rotated past is a lost race, \
not a revoked session; must demote to transient, got: {err:?}",
);
assert_eq!(
mgr.read_disk_auth().and_then(|a| a.refresh_token),
Some("rt-successor".into()),
"the sibling's successor RT must survive our rejection",
);
assert!(
mgr.permanent_failure().is_none(),
"demotion must not record a sticky verdict that locks out every \
sibling process until the user re-runs `grok login`",
);
}
/// The demotion must *not* fire when disk still holds the very RT that was
/// just rejected: nobody rotated, the session really is dead, and holding on
/// to a known-revoked credential would loop forever.
#[tokio::test]
async fn refresh_chain_still_discards_when_attributed_tried_rt_matches_disk() {
let dir = tempfile::tempdir().unwrap();
let cfg = GrokComConfig::default();
let scope = cfg.auth_scope();
let mgr = Arc::new(AuthManager::new(dir.path(), cfg));
let tried = GrokAuth {
key: "only-key".into(),
auth_mode: AuthMode::Oidc,
refresh_token: Some("rt-revoked".into()),
expires_at: Some(Utc::now() - Duration::hours(1)),
oidc_issuer: Some("https://issuer.example".into()),
oidc_client_id: Some("client-1".into()),
..GrokAuth::test_default()
};
mgr.hot_swap(tried.clone());
let mut store = AuthStore::new();
store.insert(scope, tried.clone());
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
struct AttributedRejection(GrokAuth);
#[async_trait::async_trait]
impl TokenRefresher for AttributedRejection {
async fn refresh(
&self,
_reason: crate::auth::manager::RefreshReason,
) -> crate::auth::refresh::RefreshOutcome {
crate::auth::refresh::RefreshOutcome::permanent_for(
crate::auth::error::RefreshTokenFailedReason::RefreshTokenRejected,
&self.0,
)
}
}
mgr.set_refresher(Arc::new(AttributedRejection(tried)));
let err = mgr
.refresh_chain(TokenType::OidcSession, RefreshReason::PreRequest)
.await
.unwrap_err();
assert!(
matches!(err, AuthError::Refresh(RefreshTokenError::Permanent(_))),
"an un-rotated rejected RT is a genuinely dead session, got: {err:?}",
);
assert!(
mgr.permanent_failure().is_some(),
"a genuine revocation must still record a verdict",
);
}
/// Disk-first invalid_grant must not wipe an untried in-memory successor RT
/// (mem-ahead-of-disk after a failed persist of a successful rotation).
#[tokio::test]
@ -2865,6 +3004,28 @@ async fn update_recovers_from_whitespace_only_auth_json() {
assert!(on_disk.contains("ws-token"), "credential must be persisted");
}
// -- sibling-rotation comparison ------------------------------------------
/// The demotion — and therefore whether a dozen processes keep their
/// credentials — rests entirely on this comparison, so pin its three cases
/// directly rather than only through the refresh chain.
#[test]
fn refresh_token_superseded_needs_a_successor_on_disk() {
assert!(
AuthManager::refresh_token_superseded(Some("rt-successor"), "rt-spent"),
"a different RT on disk is a sibling's successor: demote"
);
assert!(
!AuthManager::refresh_token_superseded(Some("rt-spent"), "rt-spent"),
"disk still holding the RT the IdP just rejected is a real revocation"
);
assert!(
!AuthManager::refresh_token_superseded(None, "rt-spent"),
"no RT on disk means there is no successor to fall back to, so the \
rejection must be honored rather than demoted into a retry loop"
);
}
// -- sibling_has_different_refresh_token ----------------------------------
/// Expired disk AT with different RT is still treated as a sibling RT
@ -2897,8 +3058,9 @@ async fn sibling_different_rt_with_expired_at_is_still_sibling() {
store.insert(cfg.auth_scope(), successor);
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
let disk_rt = mgr.read_disk_auth().and_then(|a| a.refresh_token);
assert!(
mgr.sibling_has_different_refresh_token(),
mgr.sibling_has_different_refresh_token(disk_rt.as_deref()),
"different disk RT must demote even when the sibling AT is expired"
);
}
@ -2931,8 +3093,9 @@ async fn sibling_different_rt_with_valid_at_is_treated_as_live() {
store.insert(cfg.auth_scope(), sibling);
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
let disk_rt = mgr.read_disk_auth().and_then(|a| a.refresh_token);
assert!(
mgr.sibling_has_different_refresh_token(),
mgr.sibling_has_different_refresh_token(disk_rt.as_deref()),
"valid disk token with different RT must be treated as live sibling"
);
}

View file

@ -104,6 +104,16 @@ pub(crate) enum RefreshOutcome {
/// has no token key (external binary flow); the caller falls back to
/// its own resolution.
tried_key: Option<String>,
/// The **refresh token** actually spent at the IdP. `refresh_chain`
/// compares it against disk to tell "this session is revoked" apart
/// from "a sibling process rotated the RT out from under us" — the
/// latter must never discard credentials.
///
/// `tried_key` cannot answer that question: it is the *access* token,
/// and a sibling's rotation changes the RT while the AT the loser
/// holds may be untouched. `None` when the authority does not expose
/// which RT it sent (external binary flow).
tried_refresh_token: Option<String>,
},
/// Transient / unknown failure. Caller may retry later. Message-only: the
/// underlying cause is logged structurally at the refresher, then flattened
@ -119,6 +129,12 @@ impl RefreshOutcome {
/// Terminal failure for an already-classified reason against the credential
/// `tried_key` (the one actually sent to the IdP).
///
/// Leaves the tried **refresh token** unattributed, which disables the
/// sibling-rotation check in `refresh_chain`. Only correct for authorities
/// that genuinely cannot report which RT they spent (the external-binary
/// flow). Any refresher holding the [`GrokAuth`] it sent must use
/// [`Self::permanent_for`] instead.
pub(crate) fn permanent(
reason: crate::auth::error::RefreshTokenFailedReason,
tried_key: Option<String>,
@ -126,6 +142,23 @@ impl RefreshOutcome {
Self::PermanentFailure {
error: reason.into(),
tried_key,
tried_refresh_token: None,
}
}
/// Terminal failure attributed to the exact credential sent to the IdP.
///
/// Prefer this wherever the attempted [`GrokAuth`] is in hand: it captures
/// both the AT key (verdict scope) and the RT (sibling-rotation check), so
/// a lost rotation race cannot be mistaken for a revoked session.
pub(crate) fn permanent_for(
reason: crate::auth::error::RefreshTokenFailedReason,
tried: &GrokAuth,
) -> Self {
Self::PermanentFailure {
error: reason.into(),
tried_key: Some(tried.key.clone()),
tried_refresh_token: tried.refresh_token.clone(),
}
}

View file

@ -147,10 +147,7 @@ impl OidcRefresher {
None,
Some(serde_json::json!({ "reason": format!("{reason:?}") })),
);
Some(RefreshOutcome::permanent(
reason,
Some(disk_now.key.clone()),
))
Some(RefreshOutcome::permanent_for(reason, &disk_now))
}
OidcRefreshResult::Failed => {
Some(RefreshOutcome::transient("OIDC disk-retry refresh failed"))
@ -245,7 +242,7 @@ impl TokenRefresher for OidcRefresher {
&self.upload_in_flight,
);
}
RefreshOutcome::permanent(reason, Some(auth.key.clone()))
RefreshOutcome::permanent_for(reason, &auth)
}
OidcRefreshResult::Failed => {
tracing::warn!(

View file

@ -251,6 +251,83 @@ async fn oidc_refresher_e2e_near_expiry_within_buffer_refreshes() {
server.abort();
}
/// Contract: on `invalid_grant`, `OidcRefresher` must report **which refresh
/// token it spent**, not just the access-token key.
///
/// `refresh_chain` uses `tried_refresh_token` to tell a lost rotation race
/// apart from a revoked session; an unattributed outcome silently disables
/// that check and turns any concurrent-refresh race into a machine-wide
/// logout. This is the shape assertion that the previous demotion tests
/// missed — they hand-built outcomes with `tried_key: None`, a shape this
/// refresher never emits, so they passed while production was unprotected.
#[tokio::test]
async fn oidc_refresher_attributes_the_refresh_token_it_spent_on_invalid_grant() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
let base_for_discovery = base_url.clone();
let app = axum::Router::new()
.route(
"/.well-known/openid-configuration",
axum::routing::get(move || {
let b = base_for_discovery.clone();
async move {
axum::Json(serde_json::json!({
"authorization_endpoint": format!("{b}/authorize"),
"token_endpoint": format!("{b}/token"),
}))
}
}),
)
.route(
"/token",
axum::routing::post(|| async {
(
axum::http::StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid_grant"})),
)
}),
);
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let dir = tempfile::tempdir().unwrap();
let mgr = Arc::new(
AuthManager::new(dir.path(), GrokComConfig::default()).with_proxy_base_url(&base_url),
);
mgr.hot_swap(GrokAuth {
key: "spent-access-token".into(),
user_id: "user-42".into(),
refresh_token: Some("rt-spent".into()),
expires_at: Some(Utc::now() - Duration::minutes(1)),
oidc_issuer: Some(base_url.clone()),
oidc_client_id: Some("test-client".into()),
..GrokAuth::test_default()
});
let outcome = OidcRefresher::new(mgr.clone())
.refresh(crate::auth::manager::RefreshReason::PreRequest)
.await;
match outcome {
RefreshOutcome::PermanentFailure {
tried_key,
tried_refresh_token,
..
} => {
assert_eq!(
tried_refresh_token.as_deref(),
Some("rt-spent"),
"the RT actually sent to the IdP must be reported so \
refresh_chain can detect a sibling rotation",
);
assert_eq!(tried_key.as_deref(), Some("spent-access-token"));
}
other => panic!("expected PermanentFailure, got: {other:?}"),
}
server.abort();
}
/// When the near-expiry token has a refresh_token but the IdP rejects
/// the refresh (e.g. refresh_token revoked), silent refresh must fail.
#[tokio::test]

View file

@ -253,6 +253,13 @@ async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
let needs_remote =
agent.is_writeback_storage() && agent.current_auth().is_some_and(|a| !a.is_zdr_team());
// Tear down any live actor first (cancel turn/subagents/bg tasks,
// process-scope kill, flush). Then wipe history so shutdown cannot
// rewrite the session directory after delete.
if agent.sessions.borrow().contains_key(&session_id) {
agent.teardown_live_session_before_delete(&session_id).await;
}
// Shared delete: remote-first, then local disk + FTS eviction.
// Mirrored by the `grok sessions delete <id>` CLI path.
crate::session::persistence::delete_session_history(
@ -269,15 +276,6 @@ async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
acp::Error::internal_error().data(e.to_string())
})?;
// If an in-memory live session exists for this id (e.g. the user
// deleted history for a session that is still open in another agent
// or the current one), shut it down and drop the MvpAgent bookkeeping
// so we don't leave a live actor whose on-disk/FTS state is gone.
if agent.sessions.borrow().contains_key(&session_id) {
agent.request_session_shutdown(&session_id);
agent.remove_session(&session_id);
}
tracing::info!(session_id = %req.session_id, "Session deleted");
to_raw_response(&serde_json::json!({ "success": true }))

View file

@ -0,0 +1,77 @@
//! A real agent behind a leader server, in this process rather than a child.
use std::sync::Arc;
use agent_client_protocol as acp;
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader, simplex};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use xai_acp_lib::{
AcpAgentGatewayReceiver as GatewayReceiver, AcpAgentGatewaySender as GatewaySender,
LineBufferedRead,
};
use crate::agent::config::Config as AgentConfig;
use crate::agent::mvp_agent::MvpAgent;
const SIMPLEX_BUF: usize = 8 * 1024 * 1024;
/// Spawns an agent on the current `LocalSet`, reading requests from `to_agent`
/// and writing responses to `from_agent`. Returns the task handles so a caller
/// can end the agent. Panics if the ambient configuration cannot build one.
pub fn spawn_agent(
mut to_agent: UnboundedReceiver<String>,
from_agent: UnboundedSender<String>,
) -> Vec<JoinHandle<()>> {
let (agent_in_read, mut agent_in_write) = simplex(SIMPLEX_BUF);
let (agent_out_read, agent_out_write) = simplex(SIMPLEX_BUF);
let agent = tokio::task::spawn_local(async move {
let config = AgentConfig::default();
let auth_manager = Arc::new(config.create_auth_manager());
let (gateway_tx, gateway_rx) = tokio::sync::mpsc::unbounded_channel();
let agent = MvpAgent::new(GatewaySender::new(gateway_tx), &config, auth_manager, None)
.expect("valid agent config");
let incoming = LineBufferedRead::spawn_local(agent_in_read.compat());
let (conn, handle_io) =
acp::AgentSideConnection::new(agent, agent_out_write.compat_write(), incoming, |fut| {
tokio::task::spawn_local(fut);
});
tokio::task::spawn_local(
GatewayReceiver::new(gateway_rx, conn)
.with_on_meta(xai_file_utils::trace_context::span_from_meta_traceparent)
.run(),
);
let _ = handle_io.await;
});
let requests = tokio::task::spawn_local(async move {
while let Some(msg) = to_agent.recv().await {
if agent_in_write.write_all(msg.as_bytes()).await.is_err()
|| agent_in_write.write_all(b"\n").await.is_err()
{
break;
}
}
});
let responses = tokio::task::spawn_local(async move {
let mut reader = BufReader::new(agent_out_read);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {
let msg = line.trim_end_matches(['\r', '\n']).to_string();
if !msg.is_empty() {
let _ = from_agent.send(msg);
}
}
}
}
});
vec![agent, requests, responses]
}

View file

@ -51,6 +51,8 @@
//! }
//! ```
mod client;
#[cfg(feature = "test-support")]
pub mod in_process;
mod lock;
pub mod protocol;
mod server;

View file

@ -1822,6 +1822,9 @@ mod build_tool_parse_error_message_tests;
#[path = "acp_session_tests/cancel_running_task_tests.rs"]
mod cancel_running_task_tests;
#[cfg(test)]
#[path = "acp_session_tests/turn/chat_history_integrity_tests.rs"]
mod chat_history_integrity_tests;
#[cfg(test)]
#[path = "acp_session_tests/feedback_turn_lookup_tests.rs"]
mod feedback_turn_lookup_tests;
#[cfg(test)]

View file

@ -98,6 +98,7 @@ impl SessionActor {
xai_grok_hooks::runner::RunContext {
session_id: &self.session_info.id.0,
workspace_root: &self.hook_resolved_workspace_root,
process_scope: self.tool_context.process_scope.clone(),
}
}

View file

@ -698,6 +698,7 @@ pub(crate) async fn spawn_session_actor(
std::sync::Arc::new(LocalTerminalBackend::new_local_with_persistent_shell(
resolve_search_shadows(),
resolve_policy(),
tool_context.process_scope.clone(),
))
}
TerminalBackendKind::LocalNonPersistent => {
@ -708,6 +709,7 @@ pub(crate) async fn spawn_session_actor(
resolve_search_shadows(),
login_shell_capture,
resolve_policy(),
tool_context.process_scope.clone(),
))
}
};

View file

@ -2035,6 +2035,37 @@ impl SessionActor {
snapshot: Box::new(snapshot),
});
}
if identical_tool_calls.take_nudge() {
let run_len = identical_tool_calls.run_len;
let tool_name = identical_tool_calls.tool_name.clone();
tracing::warn!(
session_id = %self.session_info.id,
tool_name = %tool_name,
run_len,
"action stationarity: nudging model to break repeated identical tool calls"
);
xai_grok_telemetry::unified_log::warn(
"shell.turn.action_stationarity_nudge",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!({
"loop_index": loop_index,
"tool_name": tool_name,
"run_len": run_len,
})),
);
let reminder = self
.tool_bridge_handle()
.render_prompt(
ACTION_STATIONARITY_NUDGE_TEMPLATE,
&serde_json::json!({
"tool_name": tool_name,
"run_len": run_len,
}),
)
.await
.unwrap_or_else(|| ACTION_STATIONARITY_NUDGE_TEMPLATE.to_string());
self.push_system_reminder(&reminder);
}
self.drain_pending_interjections().await;
self.flush_pending_skill_reminders().await;
self.inject_pending_monitor_events().await;
@ -2506,8 +2537,7 @@ impl SessionActor {
.map(|tc| tc.name.clone())
.unwrap_or_default();
let is_true_noop = self.is_run_true_step(&tool_calls).await;
let identical_run_len =
identical_tool_calls.observe(&step_signature, &step_tool_name, is_true_noop);
identical_tool_calls.observe(&step_signature, &step_tool_name, is_true_noop);
if is_true_noop {
xai_grok_telemetry::session_ctx::log_event(
xai_grok_telemetry::events::ShellTrueNoop {
@ -2515,35 +2545,6 @@ impl SessionActor {
},
);
}
if identical_run_len == NUDGE_AFTER_IDENTICAL_TOOL_CALLS {
tracing::warn!(
session_id = %self.session_info.id,
tool_name = %step_tool_name,
run_len = identical_run_len,
"action stationarity: nudging model to break repeated identical tool calls"
);
xai_grok_telemetry::unified_log::warn(
"shell.turn.action_stationarity_nudge",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!({
"loop_index": loop_index,
"tool_name": step_tool_name,
"run_len": identical_run_len,
})),
);
let reminder = self
.tool_bridge_handle()
.render_prompt(
ACTION_STATIONARITY_NUDGE_TEMPLATE,
&serde_json::json!({
"tool_name": step_tool_name,
"run_len": identical_run_len,
}),
)
.await
.unwrap_or_else(|| ACTION_STATIONARITY_NUDGE_TEMPLATE.to_string());
self.push_system_reminder(&reminder);
}
let tool_call_responses: Vec<ToolCallResponse> = tool_calls
.into_iter()
.map(|tc| ToolCallResponse {
@ -2627,13 +2628,13 @@ const MAX_CONSECUTIVE_TRUE_NOOPS: u32 = 4;
const _: () = assert!(NUDGE_AFTER_IDENTICAL_TOOL_CALLS < MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS);
const _: () = assert!(MAX_CONSECUTIVE_TRUE_NOOPS < NUDGE_AFTER_IDENTICAL_TOOL_CALLS);
const ACTION_STATIONARITY_NUDGE_TEMPLATE: &str = "You have called the same tool \
(`${{ tool_name }}`) with the exact same arguments ${{ run_len }} times in a row, \
getting the same result each time you appear to be stuck in a polling loop. Stop \
repeating this call. If you are waiting on a long-running job or command, use a \
background task${%- if tools.by_kind.monitor %} or the `${{ tools.by_kind.monitor }}` \
tool${%- endif %}, or run a single `sleep` and then check once do not poll in a tight \
loop. If you cannot make progress, stop and tell the user what you are waiting for. This \
turn will be halted automatically if the identical call keeps repeating.";
(`${{ tool_name }}`) with the exact same arguments ${{ run_len }} times in a row \
you appear to be stuck in a polling loop. Stop repeating this call. If you are \
waiting on a long-running job or command, use a background task${%- if tools.by_kind.monitor %} \
or the `${{ tools.by_kind.monitor }}` tool${%- endif %}, or run a single `sleep` and \
then check once do not poll in a tight loop. If you cannot make progress, stop and \
tell the user what you are waiting for. This turn will be halted automatically if the \
identical call keeps repeating.";
fn hash_step_signature(signature: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
@ -2649,6 +2650,7 @@ struct IdenticalToolCallRun {
tool_name: String,
run_len: u32,
is_true_noop_run: bool,
nudged: bool,
}
impl IdenticalToolCallRun {
fn observe(&mut self, signature: &str, tool_name: &str, is_true_noop: bool) -> u32 {
@ -2663,10 +2665,17 @@ impl IdenticalToolCallRun {
self.run_len = 1;
self.last_signature_hash = Some(hash);
self.is_true_noop_run = is_true_noop;
self.nudged = false;
}
self.tool_name = tool_name.to_string();
self.run_len
}
/// Once per identical run at/after the nudge threshold. Call only after results are committed.
fn take_nudge(&mut self) -> bool {
let fire = self.run_len >= NUDGE_AFTER_IDENTICAL_TOOL_CALLS && !self.nudged;
self.nudged |= fire;
fire
}
fn hard_stop_threshold(&self) -> u32 {
if self.is_true_noop_run {
MAX_CONSECUTIVE_TRUE_NOOPS
@ -2679,7 +2688,7 @@ impl IdenticalToolCallRun {
mod identical_tool_call_run_tests {
use super::{
IdenticalToolCallRun, MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS, MAX_CONSECUTIVE_TRUE_NOOPS,
command_is_true,
NUDGE_AFTER_IDENTICAL_TOOL_CALLS, command_is_true,
};
#[test]
fn identical_non_true_resets_and_caps_at_16() {
@ -2715,6 +2724,31 @@ mod identical_tool_call_run_tests {
assert!(!command_is_true("true && echo hi"));
assert!(!command_is_true("lisa status"));
}
#[test]
fn nudge_latch_fires_once_per_run_after_threshold() {
let mut run = IdenticalToolCallRun::default();
for i in 1..NUDGE_AFTER_IDENTICAL_TOOL_CALLS {
assert_eq!(run.observe("poll", "get_task_output", false), i);
assert!(
!run.take_nudge(),
"must not nudge before threshold; run_len={i}"
);
}
assert_eq!(
run.observe("poll", "get_task_output", false),
NUDGE_AFTER_IDENTICAL_TOOL_CALLS
);
assert!(run.take_nudge());
assert!(!run.take_nudge());
assert_eq!(
run.observe("poll", "get_task_output", false),
NUDGE_AFTER_IDENTICAL_TOOL_CALLS + 1
);
assert!(!run.take_nudge());
assert_eq!(run.observe("other", "bash", false), 1);
assert!(!run.nudged);
assert!(!run.take_nudge());
}
}
/// Backoff schedule for resubmits after a *successful* 401 auth recovery
/// (fresh token minted, request to be re-sent).

View file

@ -0,0 +1,252 @@
//! Chat-history integrity across mid-turn user-message injection.
//!
//! A `push_user_message` (system reminder, interjection, etc.) while an
//! assistant `tool_use` is committed but unanswered makes integrity repair
//! fabricate a `"cancelled by the user"` `tool_result`. If the tool then
//! runs and appends its real result, the conversation has **two**
//! `tool_result`s for one `tool_use_id`. Providers reject that shape with
//! HTTP 400 on every subsequent request — a permanently bricked session
//! with no in-band recovery.
//!
//! The concrete injector that first hit this was the action-stationarity
//! nudge (8 consecutive identical tool calls). The invariant is broader:
//! **no mid-turn user injection may leave duplicate results for one id.**
//! The nudge is only the driver that reaches the vulnerable window.
//!
//! This is intentionally *not* a unit test of `IdenticalToolCallRun`'s latch
//! (see `identical_tool_call_run_tests` in `turn.rs`). It drives a real
//! turn loop against a scripted model so a reordering that pushes the
//! reminder between `record_assistant_response` and `execute_tool_calls`
//! fails here.
use super::support::*;
use super::*;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use xai_grok_test_support::sse::{
responses_api_reasoning_then_tool_call_events, responses_api_script_exact,
};
use xai_grok_test_support::{MockInferenceServer, ScriptedResponse};
/// Product threshold at which the stationarity nudge fires. Kept as a local
/// literal so this suite does not couple to the private latch constants;
/// changing the threshold still trips the same history invariant as long as
/// a nudge is delivered mid-turn after identical calls.
const IDENTICAL_CALLS_TO_TRIP_NUDGE: usize = 8;
const TODO_ARGS: &str = r#"{"todos":[{"id":"t1","content":"poll","status":"completed"}]}"#;
const CANCEL_MARKER: &str = "cancelled by the user";
/// Distinctive fragment of the action-stationarity reminder. Present in both
/// the rendered template and the unrendered fallback string.
const STATIONARITY_NUDGE_MARKER: &str = "stuck in a polling loop";
fn tool_call_sse(call_id: &str) -> ScriptedResponse {
ScriptedResponse::sse(responses_api_reasoning_then_tool_call_events(
"poll",
call_id,
"todo_write",
TODO_ARGS,
"test",
))
}
fn drain_gateway(mut rx: tokio::sync::mpsc::UnboundedReceiver<xai_acp_lib::AcpClientMessage>) {
tokio::task::spawn_local(async move {
while let Some(msg) = rx.recv().await {
if let xai_acp_lib::AcpClientMessage::SessionNotification(args) = msg {
let _ = args.response_tx.send(Ok(()));
}
}
});
}
fn drain_persistence(mut rx: tokio::sync::mpsc::UnboundedReceiver<PersistenceMsg>) {
tokio::task::spawn_local(async move {
while let Some(msg) = rx.recv().await {
if let PersistenceMsg::FlushAndAck { respond_to } = msg {
let _ = respond_to.send(());
}
}
});
}
/// Group `ToolResult` bodies by `tool_call_id` across the whole conversation
/// (not just the contiguous run after an assistant message). Provider validation
/// is global; a user row between two results for the same id still 400s.
fn tool_results_by_call_id(conv: &[ConversationItem]) -> HashMap<String, Vec<String>> {
let mut by_id: HashMap<String, Vec<String>> = HashMap::new();
for item in conv {
if let ConversationItem::ToolResult(tr) = item {
by_id
.entry(tr.tool_call_id.clone())
.or_default()
.push(item.text_content());
}
}
by_id
}
/// Mid-turn system reminder (stationarity nudge after 8 identical tool calls)
/// must not fabricate a phantom cancel that duplicates a live tool's result.
///
/// Pre-fix this failed: the nudge was pushed after the assistant `tool_use`
/// was committed and before `execute_tool_calls`, so integrity repair wrote
/// a cancel result and the real result landed beside it under the same id.
#[tokio::test(flavor = "current_thread")]
async fn mid_turn_user_injection_must_not_duplicate_tool_results_for_one_tool_use_id() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let server = MockInferenceServer::start().await.expect("mock inference server");
for i in 1..=IDENTICAL_CALLS_TO_TRIP_NUDGE {
server.enqueue_response(
"/v1/responses",
tool_call_sse(&format!("stat-call-{i}")),
);
}
server.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_script_exact("done", "test")),
);
let sampling_cfg = xai_grok_sampler::SamplerConfig {
api_key: Some("test-key".to_string()),
base_url: server.url(),
model: "test".to_string(),
api_backend: xai_grok_sampler::ApiBackend::Responses,
context_window: 256_000,
max_retries: Some(0),
idle_timeout_secs: Some(30),
..Default::default()
};
let (sampler_event_tx, sampler_event_rx) =
tokio::sync::mpsc::unbounded_channel::<xai_grok_sampler::SamplingEvent>();
let sampler_handle = xai_grok_sampler::SamplerActor::spawn(
sampling_cfg,
xai_grok_sampler::RetryPolicy {
max_retries: 0,
rate_limit_retry_threshold: 0,
..Default::default()
},
sampler_event_tx,
);
let (gateway_tx, gateway_rx) =
tokio::sync::mpsc::unbounded_channel::<xai_acp_lib::AcpClientMessage>();
drain_gateway(gateway_rx);
let (persistence_tx, persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
drain_persistence(persistence_rx);
let mut actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await;
actor.sampler_handle = sampler_handle;
*actor.agent.borrow_mut() = test_grok_build_agent_with_todo().await;
let mut cfg = actor
.chat_state_handle
.get_sampling_config()
.await
.expect("test actor has sampling config");
cfg.base_url = server.url();
cfg.api_backend = xai_grok_sampling_types::ApiBackend::Responses;
cfg.model = "test".to_string();
actor.chat_state_handle.update_sampling_config(cfg);
let mut creds = actor.chat_state_handle.get_credentials().await;
creds.api_key = Some("test-key".to_string());
actor.chat_state_handle.update_credentials(creds);
actor
.workspace_ops
.bind_local_session(
&actor.session_id_string(),
actor.tool_context.cwd.as_path().to_path_buf(),
actor.tool_context.hunk_tracker_handle.clone(),
actor.agent.borrow().tool_bridge().toolset(),
None,
)
.expect("bind_local_session");
let actor = Arc::new(actor);
{
let drainer = actor.clone();
let mut sampler_event_rx = sampler_event_rx;
tokio::task::spawn_local(async move {
while let Some(event) = sampler_event_rx.recv().await {
drainer.handle_sampling_event(event).await;
}
});
}
let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
"keep polling the same todo".to_string(),
))];
let outcome = tokio::time::timeout(
Duration::from_secs(60),
actor.handle_prompt(
"chat-history-integrity",
prompt_blocks,
PromptMode::Agent,
None,
None,
None,
None,
true,
None,
None,
None,
),
)
.await
.expect("turn must finish within timeout");
assert!(
outcome.is_ok(),
"turn must not error: {outcome:?}"
);
let conv = actor.chat_state_handle.get_conversation().await;
let by_id = tool_results_by_call_id(&conv);
assert!(
by_id.len() >= IDENTICAL_CALLS_TO_TRIP_NUDGE,
"expected at least {IDENTICAL_CALLS_TO_TRIP_NUDGE} executed tool calls to trip the nudge; got {} distinct tool_call_ids. conversation={conv:#?}",
by_id.len()
);
for (tool_call_id, results) in &by_id {
let has_cancel = results.iter().any(|r| r.contains(CANCEL_MARKER));
let has_real = results.iter().any(|r| !r.contains(CANCEL_MARKER));
assert!(
!(has_cancel && has_real),
"tool_use_id `{tool_call_id}` has both a fabricated `{CANCEL_MARKER}` \
tool_result and a real execution result. The tool ran; integrity repair \
must not claim it was cancelled. A mid-turn user message was pushed while \
the tool_use was unanswered. results={results:?}"
);
assert_eq!(
results.len(),
1,
"tool_use_id `{tool_call_id}` has {} tool_results. \
Duplicate tool_results for one tool_use_id brick the session: the \
Anthropic Messages API (and siblings) reject the history with HTTP 400 \
on every subsequent request, and there is no in-band recovery. \
Mid-turn user messages (nudges, reminders, interjections) must not be \
pushed while a tool_use is unanswered integrity repair fabricates a \
cancel result, then the live tool appends a second result. results={results:?}",
results.len()
);
}
assert!(
conv.iter()
.any(|item| item.text_content().contains(STATIONARITY_NUDGE_MARKER)),
"action-stationarity nudge must still be delivered after the identical-call \
run; deleting the nudge is not a valid fix for chat-history corruption. \
conversation={conv:#?}"
);
})
.await;
}

View file

@ -51,11 +51,24 @@ pub(crate) fn git_bin() -> OsString {
}
}
/// A `sh -c <script>` command: the portable shell escape hatch shared by the
/// identity and auth providers.
pub(crate) fn sh_c(script: &str) -> Command {
let mut cmd = Command::new("sh");
cmd.args(["-c", script]);
/// Run a config-provided command string through the platform shell: `sh -c`
/// on unix, `cmd /C` on Windows. The escape hatch shared by the auth
/// providers and the identity command.
///
/// Windows has no `sh` on `PATH` in a default install, so hardcoding it made
/// every one of those call sites fail to spawn — and where Git Bash *is*
/// installed, `sh` eats the backslashes in a native path such as
/// `C:\corp\auth.exe`. `cmd /C` runs `.exe` / `.cmd` / `.bat` directly and
/// propagates the child's exit code, which the auth providers' "exit 0 =
/// success" contract depends on (PowerShell's `-Command` does not).
pub(crate) fn shell_c(script: &str) -> Command {
let (shell, flag) = if cfg!(windows) {
("cmd", "/C")
} else {
("sh", "-c")
};
let mut cmd = Command::new(shell);
cmd.args([flag, script]);
cmd
}
@ -246,7 +259,7 @@ mod tests {
use super::*;
fn sh(script: &str) -> Command {
sh_c(script)
shell_c(script)
}
fn opts(label: &str) -> RunOptions<'_> {
@ -258,6 +271,19 @@ mod tests {
const TIMEOUT: Duration = Duration::from_secs(10);
/// The command-string escape hatch must spawn on the host platform. A
/// hardcoded `sh` fails here on Windows, which silently downgraded
/// `auth_provider_command` to the built-in login. `echo hi` is valid in
/// both `sh -c` and `cmd /C`.
#[tokio::test]
async fn shell_c_spawns_on_this_platform() {
let out = run_detached_with_timeout(shell_c("echo hi"), TIMEOUT, opts("test shell_c"))
.await
.expect("the platform shell must be spawnable");
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hi");
}
#[tokio::test]
async fn large_stderr_is_streamed_and_capped() {
let out = run_detached_with_timeout(

View file

@ -6,7 +6,7 @@ use crate::util::subprocess::CommandLog;
use crate::util::subprocess::RunOptions;
use crate::util::subprocess::git_bin;
use crate::util::subprocess::run_detached_with_timeout;
use crate::util::subprocess::sh_c;
use crate::util::subprocess::shell_c;
use std::env;
use std::time::Duration;
use std::time::Instant;
@ -151,7 +151,7 @@ async fn git_global_email() -> Option<String> {
/// `None` on any failure; callers fall back to the declarative sources.
async fn run_identity_command(command: &str) -> Option<ResolvedUserIdentity> {
let cmd = sh_c(command);
let cmd = shell_c(command);
let output = run_detached_with_timeout(
cmd,
COMMAND_TIMEOUT,

View file

@ -0,0 +1,98 @@
//! End-to-end guard for `auth_provider_command`: a configured external auth
//! provider must actually mint the session credential on the host platform.
//!
//! Regression cover. The provider used to be spawned through a hardcoded
//! `sh -c`. On Windows that either fails to spawn (no `sh` in a default
//! install) or, where Git Bash is present, silently eats the backslashes in a
//! native path — `C:\Windows\System32\whoami.exe` reaches the shell as
//! `C:WindowsSystem32whoami.exe` and exits 127. Either way the auth flow fell
//! through to the built-in browser login, so a configured provider looked like
//! it had been ignored.
//!
//! The test drives the public entry point (`try_ensure_fresh_auth` →
//! `AuthManager::auth` → external refresher → platform shell) and is hermetic:
//! a throwaway `GROK_HOME`, no network, and a provider command that needs no
//! binary beyond what the platform shell already provides.
use std::collections::BTreeMap;
use std::path::Path;
use chrono::Utc;
use xai_grok_shell::auth::{AuthMode, GrokAuth, GrokComConfig, try_ensure_fresh_auth};
const SEED_TOKEN: &str = "stale-token-that-must-be-replaced";
/// Point the process at a throwaway grok home. `grok_home()` memoizes into a
/// `OnceLock`, so every phase below shares this one directory — which is why
/// they live in a single test rather than racing each other as separate ones.
fn use_temp_grok_home(dir: &Path) {
// SAFETY: single-threaded test entry, before any thread that reads the
// environment is spawned.
unsafe {
std::env::set_var("GROK_HOME", dir);
}
}
/// Seed an expired credential so `auth()` takes the refresh path; a cold home
/// returns `NotLoggedIn` without ever consulting the provider.
fn seed_expired_credential(home: &Path, scope: &str) {
let expired = GrokAuth {
key: SEED_TOKEN.to_owned(),
auth_mode: AuthMode::External,
expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
..GrokAuth::default()
};
let store: BTreeMap<String, GrokAuth> = [(scope.to_owned(), expired)].into_iter().collect();
std::fs::write(
home.join("auth.json"),
serde_json::to_string(&store).expect("serialize auth store"),
)
.expect("write auth.json");
}
/// Run one provider command through the real auth path and return the token.
async fn mint_with_provider(home: &Path, command: &str) -> String {
let config = GrokComConfig {
auth_provider_command: Some(command.to_owned()),
..GrokComConfig::default()
};
seed_expired_credential(home, &config.auth_scope());
let auth = try_ensure_fresh_auth(&config).await.unwrap_or_else(|| {
panic!("auth_provider_command `{command}` was configured but no credential was minted")
});
assert_eq!(
auth.auth_mode,
AuthMode::External,
"credential must come from the provider, not a cached or built-in path"
);
assert_ne!(
auth.key, SEED_TOKEN,
"the expired seed must have been replaced by the provider's output"
);
auth.key
}
#[tokio::test]
async fn auth_provider_command_mints_the_session_credential() {
let home = tempfile::tempdir().expect("tempdir");
use_temp_grok_home(home.path());
// `echo <token>` is valid in both `sh -c` and `cmd /C`, so this phase needs
// no external binary and runs identically on every platform.
let token = mint_with_provider(home.path(), "echo grok-ext-token").await;
assert_eq!(token, "grok-ext-token");
// Windows only: an absolute native path, the form an operator actually
// writes in config.toml, and the exact shape a POSIX shell mangles. Run
// after the portable phase so a failure here is unambiguously about
// backslash handling rather than the provider path in general.
#[cfg(windows)]
{
let token = mint_with_provider(home.path(), r"C:\Windows\System32\whoami.exe").await;
assert!(
!token.trim().is_empty(),
"a native Windows path must reach the provider intact"
);
}
}

View file

@ -1237,6 +1237,93 @@ async fn test_stdio_xcode_escaped_slash_methods_get_responses() {
);
}
/// `grok agent stdio` must initiate shutdown and exit when its client closes
/// stdin (EOF) — a dead parent means closed pipes, so this is the primary
/// orphan guard on every platform (the Linux `PR_SET_PDEATHSIG` binding in
/// `run_stdio_agent` additionally covers an agent wedged mid-turn that never
/// reads stdin again). Guards the `spawn_stdin_line_reader` → stdin_closed →
/// simplex-shutdown → `handle_io` completion chain end to end.
#[tokio::test]
#[ignore] // requires pre-built binary; run with --ignored
async fn test_stdio_agent_exits_on_stdin_eof() {
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _};
let server = MockInferenceServer::start()
.await
.expect("start mock server");
let mut sandbox = TestSandbox::builder().git().build();
sandbox.set_mock_url(server.url());
let mut cmd = tokio::process::Command::new(grok_binary());
cmd.args(["agent", "stdio"])
.current_dir(sandbox.workspace());
let mut process = TestProcess::spawn(
cmd,
&sandbox,
TestProcessConfig::new()
.label("grok agent stdio (eof)")
.stdin(TestStdin::Piped)
.stdout(TestOutput::Piped),
)
.expect("spawn grok agent stdio");
// Prove the agent is up and serving before the EOF (an exit during
// startup would trivially pass the wait below).
let mut stdin = process.take_stdin().expect("child stdin missing");
let stdout = process.take_stdout().expect("child stdout missing");
let mut reader = tokio::io::BufReader::new(stdout);
stdin
.write_all(
concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"#,
r#""clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"#,
r#""_meta":{"startupHints":{"nonInteractive":true,"skipGitStatus":true,"skipProjectLayout":true},"#,
r#""clientType":"eof-test","clientVersion":"0.0.0"}}}"#,
"\n"
)
.as_bytes(),
)
.await
.expect("write initialize");
stdin.flush().await.expect("flush initialize");
let mut line = String::new();
tokio::time::timeout(scaled(Duration::from_secs(20)), reader.read_line(&mut line))
.await
.unwrap_or_else(|_| {
panic!(
"no initialize response before EOF\nstderr:\n{}",
stderr_tail(&process.stderr_tail().text, 1200)
)
})
.expect("read initialize response");
assert!(
line.contains("\"result\""),
"initialize must respond with a result, got: {line}"
);
// Close the write end: the agent sees stdin EOF, exactly as when its
// parent dies and the inherited pipe closes.
drop(stdin);
// Exit path includes a bounded teardown (100ms simplex flush + 2s upload
// queue grace), so allow comfortably more than that.
let status = process
.wait_with_deadline(scaled(Duration::from_secs(30)))
.await
.expect("wait for agent exit")
.unwrap_or_else(|| {
panic!(
"grok agent stdio did not exit after stdin EOF\n{}",
process.diagnostic_summary()
)
});
assert!(
status.success(),
"agent should exit cleanly on stdin EOF, got {status:?}\nstderr:\n{}",
stderr_tail(&process.stderr_tail().text, 1200)
);
}
// ── Config test harness ─────────────────────────────────────────────────────
/// Isolated headless run with a custom `~/.grok/`. Clean env (no leaked

View file

@ -1,15 +1,10 @@
//! Leader soak: an in-process leader server fronting a REAL `MvpAgent`, hammered
//! by churning `LeaderClient`s until a time budget expires. Asserts the leader
//! neither leaks memory nor accumulates zombie clients, and that no response is
//! ever dropped on a live-client send (`leader.response.send_failed`).
//!
//! Duration is bounded by `LEADER_SOAK_SECS` (default 10s so an ad-hoc
//! `--ignored` run stays quick). RSS growth is bounded by
//! `LEADER_SOAK_MAX_RSS_GROWTH_MB` (default 1024). On-demand today — no CI
//! lane runs it; a real soak is the long form:
//! Leader soak: a real `MvpAgent` behind an in-process leader, churned by
//! clients until `LEADER_SOAK_SECS` expires. Each cycle closes its sessions,
//! so the bounds measure what teardown reclaims.
//!
//! ```bash
//! LEADER_SOAK_SECS=1200 cargo test -p xai-grok-shell --test test_leader_soak -- --ignored --nocapture
//! LEADER_SOAK_SECS=1200 cargo test -p xai-grok-shell --features test-support \
//! --test test_leader_soak -- --ignored --nocapture
//! ```
#![cfg(unix)]
@ -17,25 +12,14 @@
use std::sync::Arc;
use std::time::Duration;
use agent_client_protocol as acp;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use tokio_util::sync::CancellationToken;
use xai_acp_lib::{
AcpAgentGatewayReceiver as GatewayReceiver, AcpAgentGatewaySender as GatewaySender,
LineBufferedRead,
};
use xai_grok_shell::agent::config::Config as AgentConfig;
use xai_grok_shell::agent::mvp_agent::MvpAgent;
use xai_grok_shell::leader::{
ClientCapabilities, ClientMode, LeaderClient, LeaderServerControlState, LeaderServerMetadata,
run_leader_server,
};
use xai_grok_test_support::resources::ResourceSnapshot;
const SIMPLEX_BUF: usize = 8 * 1024 * 1024;
fn env_u64(key: &str, default: u64) -> u64 {
std::env::var(key)
.ok()
@ -113,13 +97,13 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
let sock_path = grok_home.path().join("leader-soak.sock");
let soak_secs = env_u64("LEADER_SOAK_SECS", 10);
let max_growth_mb = env_u64("LEADER_SOAK_MAX_RSS_GROWTH_MB", 1024);
let max_thread_growth = env_u64("LEADER_SOAK_MAX_THREAD_GROWTH", 64) as usize;
let send_failed_before = send_failed_count();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
// ── Leader server (survives client churn) ────────────────────
let (acp_tx, mut acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (acp_tx, acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let cancel = CancellationToken::new();
let client_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
@ -152,70 +136,9 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
.await;
});
// ── Real agent behind it ──────────────────────────────────────
// Copied from `run_leader`'s agent-spawn + IPC/stdout bridge
// blocks in src/agent/app.rs (inside its LocalSet body); kept as
// a deliberate copy so production stays untouched. Second copy of
// the same wiring: xai-grok-pager/src/app/leader_cluster/mod.rs
// (`spawn_leader_generation`) — keep the two copies behaviorally
// identical.
let (agent_in_read, agent_in_write) = tokio::io::simplex(SIMPLEX_BUF);
let (agent_out_read, agent_out_write) = tokio::io::simplex(SIMPLEX_BUF);
tokio::task::spawn_local(async move {
let agent_config = AgentConfig::default();
let auth_manager = Arc::new(agent_config.create_auth_manager());
let (gw_tx, gw_rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(gw_tx);
let agent = MvpAgent::new(gateway, &agent_config, auth_manager, None)
.expect("valid agent config");
let incoming = LineBufferedRead::spawn_local(agent_in_read.compat());
let (conn, handle_io) = acp::AgentSideConnection::new(
agent,
agent_out_write.compat_write(),
incoming,
|fut| {
tokio::task::spawn_local(fut);
},
);
tokio::task::spawn_local(
GatewayReceiver::new(gw_rx, conn)
.with_on_meta(xai_file_utils::trace_context::span_from_meta_traceparent)
.run(),
);
let _ = handle_io.await;
});
// Leader → agent stdin.
tokio::task::spawn_local(async move {
let mut agent_in_write = agent_in_write;
while let Some(msg) = acp_rx.recv().await {
if agent_in_write.write_all(msg.as_bytes()).await.is_err()
|| agent_in_write.write_all(b"\n").await.is_err()
{
break;
}
}
});
// Agent stdout → leader responses.
let response_tx_for_agent = response_tx.clone();
tokio::task::spawn_local(async move {
let mut reader = BufReader::new(agent_out_read);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let msg = line.trim_end_matches(['\r', '\n']).to_string();
if !msg.is_empty() {
let _ = response_tx_for_agent.send(msg);
}
}
Err(_) => break,
}
}
});
// Hold a sender for the whole soak: the leader's response channel
// must not close when the agent's output ends.
xai_grok_shell::leader::in_process::spawn_agent(acp_rx, response_tx.clone());
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while !sock_path.exists() && tokio::time::Instant::now() < deadline {
@ -223,7 +146,6 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
}
assert!(sock_path.exists(), "leader socket never bound");
// ── One-time initialize + authenticate through the leader ────
let mut bootstrap = LeaderClient::connect(
sock_path.clone(),
"soak-bootstrap",
@ -247,14 +169,17 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
)
.await;
eprintln!(
"[soak] budgets: {soak_secs}s, rss {max_growth_mb} MB, threads {max_thread_growth}"
);
let rss_before = ResourceSnapshot::capture();
let soak_deadline = tokio::time::Instant::now() + Duration::from_secs(soak_secs);
let workdir_str = workdir.path().to_string_lossy().to_string();
let mut cycles: u64 = 0;
let mut turns: u64 = 0;
// ── Churn: 10 fresh clients per cycle, 2 sessions each, one
// scripted turn per session, then all disconnect ───────────────
// Each cycle: 10 fresh clients, 2 sessions each, one scripted
// turn per session, then all disconnect.
while tokio::time::Instant::now() < soak_deadline {
cycles += 1;
let mut clients = Vec::new();
@ -298,10 +223,22 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
)
.await;
turns += 1;
// Disconnecting leaves sessions resident for a
// reconnect; `_` is the wire form for a custom method.
let close_id = 300 + s;
rpc(
client,
format!(
r#"{{"jsonrpc":"2.0","id":{close_id},"method":"_x.ai/session/close","params":{{"sessionId":"{sid}"}}}}"#
),
close_id,
"x.ai/session/close",
)
.await;
}
}
// Churn: everyone disconnects; the roster must drain fully.
for client in clients {
client.cancel();
}
@ -314,13 +251,39 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
// Linear in cycles is a leak; flattening is the allocator.
if let Some(rss) = ResourceSnapshot::capture().rss {
eprintln!(
"[soak] cycle {cycles}: rss {:.1} MB",
rss as f64 / (1024.0 * 1024.0)
);
}
if cycles == 1 {
let snap = rpc(
&mut bootstrap,
r#"{"jsonrpc":"2.0","id":901,"method":"_x.ai/debug/agent","params":{}}"#
.to_string(),
901,
"x.ai/debug/agent",
)
.await;
eprintln!("[soak] registries after cycle 1: {}", snap["result"]["registries"]);
}
}
let snap = rpc(
&mut bootstrap,
r#"{"jsonrpc":"2.0","id":902,"method":"_x.ai/debug/agent","params":{}}"#
.to_string(),
902,
"x.ai/debug/agent",
)
.await;
eprintln!("[soak] registries at end: {}", snap["result"]["registries"]);
eprintln!("[soak] {cycles} cycles, {turns} turns in {soak_secs}s budget");
assert!(cycles > 0, "soak budget too small to complete one cycle");
// ── Convergence: only the bootstrap client remains, and the
// leader still serves a healthy round-trip ────────────────────
assert_eq!(
client_count.load(std::sync::atomic::Ordering::Relaxed),
1,
@ -337,14 +300,12 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
.await;
assert!(resp["result"]["sessionId"].is_string());
// ── No response was ever dropped on a live-client send ────────
assert_eq!(
send_failed_count(),
send_failed_before,
"leader.response.send_failed must not occur during the soak"
);
// ── RSS bound ─────────────────────────────────────────────────
let rss_after = ResourceSnapshot::capture();
let growth = rss_after.growth_from(&rss_before);
if let (Some(before), Some(after), Some(growth_bytes)) =
@ -357,11 +318,28 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
after as f64 / (1024.0 * 1024.0),
);
assert!(
growth_mb < max_growth_mb as f64,
growth_mb <= max_growth_mb as f64,
"leader RSS grew {growth_mb:.1} MB over the soak (bound {max_growth_mb} MB)"
);
} else {
eprintln!("[soak] rss measurement unavailable on this platform; bound skipped");
panic!("memory sample unavailable; the soak cannot bound it");
}
// A missing sample means the probe failed, which would silently
// retire the nightly budget. Threads are Linux-only.
match growth.threads {
Some(thread_growth) => {
eprintln!("[soak] threads: growth {thread_growth}");
assert!(
thread_growth <= max_thread_growth,
"leader threads grew by {thread_growth} over the soak \
(bound {max_thread_growth})"
);
}
None if cfg!(target_os = "linux") => {
panic!("thread growth sample unavailable; the soak cannot bound it")
}
None => {}
}
bootstrap.cancel();

View file

@ -32,6 +32,8 @@ const RPC_TIMEOUT: Duration = Duration::from_secs(60);
struct Counts {
sessions: usize,
session_threads: usize,
resident_resources: usize,
retained_resources: usize,
dispatch_locks: usize,
session_turn_numbers: usize,
permission_event_receivers: usize,
@ -73,11 +75,11 @@ async fn ext_method(
method: &str,
params: serde_json::Value,
) -> serde_json::Value {
let raw =
let params_json =
serde_json::value::RawValue::from_string(params.to_string()).expect("serialize ext params");
let resp = tokio::time::timeout(
RPC_TIMEOUT,
conn.ext_method(acp::ExtRequest::new(method, Arc::from(raw))),
conn.ext_method(acp::ExtRequest::new(method, Arc::from(params_json))),
)
.await
.unwrap_or_else(|_| panic!("{method} timed out"))
@ -255,6 +257,12 @@ fn session_churn_returns_registry_snapshot_to_baseline() {
baseline.sessions, 0,
"warmup session must be fully removed before baseline"
);
assert_eq!(
(baseline.resident_resources, baseline.retained_resources),
(0, 0),
"warmup must leave no per-session resource entries, including \
entries holding no resources"
);
assert_eq!(
baseline.workspace_bindings,
Some(0),

View file

@ -3,7 +3,7 @@
//! Resuming a large session once OOM-killed the process under a cgroup cap.
//!
//! Run:
//! cargo test -p xai-grok-shell --features dhat-heap --test test_session_load_memory \
//! cargo test -p xai-grok-shell --features dhat-heap,test-support --test test_session_load_memory \
//! session_load_dhat_bounded_and_freed -- --ignored --nocapture
#![cfg(unix)]