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:
parent
5da6962e4a
commit
500129c714
89 changed files with 3841 additions and 771 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
117
crates/codegen/xai-grok-shell/changelogs/0.2.113.json
Normal file
117
crates/codegen/xai-grok-shell/changelogs/0.2.113.json
Normal 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
|
||||
}
|
||||
]
|
||||
34
crates/codegen/xai-grok-shell/changelogs/0.2.113.md
Normal file
34
crates/codegen/xai-grok-shell/changelogs/0.2.113.md
Normal 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.
|
||||
|
||||
12
crates/codegen/xai-grok-shell/changelogs/0.2.114.json
Normal file
12
crates/codegen/xai-grok-shell/changelogs/0.2.114.json
Normal 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
|
||||
}
|
||||
]
|
||||
10
crates/codegen/xai-grok-shell/changelogs/0.2.114.md
Normal file
10
crates/codegen/xai-grok-shell/changelogs/0.2.114.md
Normal 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.
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ mod tests {
|
|||
fn all_paid_tiers_qualify() {
|
||||
for tier in &[
|
||||
"SuperGrokPro",
|
||||
"SuperGrokPlus",
|
||||
"GrokPro",
|
||||
"SuperGrokLite",
|
||||
"XPremiumPlus",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
77
crates/codegen/xai-grok-shell/src/leader/in_process.rs
Normal file
77
crates/codegen/xai-grok-shell/src/leader/in_process.rs
Normal 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]
|
||||
}
|
||||
|
|
@ -51,6 +51,8 @@
|
|||
//! }
|
||||
//! ```
|
||||
mod client;
|
||||
#[cfg(feature = "test-support")]
|
||||
pub mod in_process;
|
||||
mod lock;
|
||||
pub mod protocol;
|
||||
mod server;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue