Synced from monorepo

Synced from monorepo

Changes:
- Report invalid MCP server config instead of failing startup
- Keep completed terminal output when the gateway connection is lost
- Show a duration-only detail view for single-task task output
- Don't let a stale registry turn counter hide local sessions
- Raise the file-descriptor soft limit on Linux and log effective limits at startup
- Stop aborting when HTTP client construction fails
- Make session thread and runtime spawn failures recoverable
- Fix main-prompt paste parity in the question freeform input
- Fire SessionEnd hooks on /exit and headless quit
- Embed the deployment-config signing public key
- Repaint paste-chip background on inline panel inputs
- Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root
- Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker
- Parse hooks from config files
- Add a remote kill-switch for managed-config signature verification
- Security: fix workspace file-reference resolution bypassing workspace filesystem confinement

Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
grokkybara[bot] 2026-07-25 18:44:42 +00:00
commit 47348d13ec
138 changed files with 7283 additions and 5796 deletions

View file

@ -1,5 +1,55 @@
# Changelog
# 0.2.112 — 2026-07-24
## Breaking Changes
- **CLI version policy** now has separate soft update floors/ceilings and hard startup requirements.
## Features
- **New /tutorial slash command** opens an opt-in nine-topic onboarding tour of Grok.
- **New tool_overrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools.
- **New toolOverrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools.
- **New config options** let you add query parameters or environment-backed headers to custom model providers and control which variables reach shell tools.
- **Terminal and environment fixes** are now consolidated under the `/doctor` command with clearer guidance.
- **Marketplace add** now rejects non-git URLs at add time instead of failing later.
- **Slash commands** can now show optional bracket tags (e.g. [new]) via config or remote settings.
- **Queued prompts** now offer an [edit] mouse button alongside Send now and cancel.
- **Voice shortcut** toggle in settings can disable the Ctrl+Space/F8 keybind without disabling voice entirely.
- **Image edit** can now use a remotely configured model slug instead of the hardcoded default.
- **`grok doctor fix`** can now repair common tmux clipboard and passthrough problems.
- **Per-provider auth helpers** now work on Windows and can run from a configurable working directory.
- **/resume** now shows only native Grok sessions by default and shows a hint when external sessions are hidden.
- **`grok --resume`** can now resume a session by its title as well as by ID.
- **Workflows overlay** now shows live per-agent progress and automatically follows the active phase.
- **Workflow runs** that failed can now be resumed; scratch file limits were also increased.
- **Hooks** can now be defined in config.toml in addition to JSON files.
- **Clicking** the "still running" status now opens the tasks pane.
## Bug Fixes
- **File attachments** now appear correctly when resuming or replaying conversations.
- **Terminal output** from remote clients is now recorded so read-file hints and monitors function correctly.
- **Background shell commands** now correctly report their real exit codes instead of always showing -1.
- **Marketplace source refreshes** no longer hang the TUI or trap you in the extensions modal.
- **Background task tray** now correctly clears killed tasks and keeps task descriptions after reconnect.
- **Dashboard overlay** now correctly returns after forking a dashboard-attached session.
- **Linux voice dictation** now works on PipeWire versions before 1.6.
- **Fork** from a rewound session now copies the correct live-branch history.
- **Account pane** now shows name and email even after the access token expires.
- **Voice mode** now lets you edit already-dictated text without closing the microphone.
- **Fixed startup hangs** on Linux after concurrent launches or rapid restarts.
- **MCP tools** now appear without restart after enrolling or updating a managed service.
- **Plugin subagents** now see the same MCP tools as the parent session.
- **Copy confirmations** now show shorter messages when the clipboard succeeds.
- **Repeated identical tool calls** now end the turn silently instead of showing a stop banner.
- **Web search** now defaults to grok-4.5.
- **Voice dictation** text is no longer dropped when pressing Enter to send.
- **Bash mode** (`!`) now shows yellow prefix and action label in minimal mode.
- **Parked turns** no longer spam duplicate "Worked for" markers in the transcript.
# 0.2.111 — 2026-07-22
## Features
@ -10,6 +60,7 @@
## Bug Fixes
- **Plugin subagents** now inherit the parent sessions connected MCP servers (default `mcpInheritance: all`), so `search_tool` / `use_tool` work the same as for local agents. Plugin agents still cannot declare their own MCP servers, hooks, or elevated permission modes.
- **`!cmd` commands** now allow up to one hour before timing out.
- **npm package** now installs the native binary under `$GROK_HOME/bin` (honoring the same override as the Rust CLI).
- **Startup warnings** now point to `/doctor` for details and fixes.

View file

@ -1,7 +1,7 @@
[package]
license = "Apache-2.0"
name = "xai-grok-shell"
version = "0.2.111"
version = "0.2.112"
edition.workspace = true
[features]
@ -212,6 +212,10 @@ workspace = true
name = "session_list"
harness = false
[[bench]]
name = "fork_copy"
harness = false
[lints]
workspace = true
[build-dependencies]

View file

@ -1727,6 +1727,32 @@ Grok discovers hooks from `.grok/hooks/` in the project directory. Manage them w
/hooks-add <path> # add a custom hook file or directory
```
### Hooks in config files
Hooks can also be defined directly in the config layers, so they can be
distributed with your other configuration instead of as separate JSON files. Add
a `[[hooks.<Event>]]` table to `config.toml` (your own), `managed_config.toml`, or
`requirements.toml`:
```toml
[[hooks.PreToolUse]]
matcher = "Bash|Write|Edit"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/opt/guard/pretooluse.sh" # use an absolute path
timeout = 10
```
The schema matches the JSON `hooks` object used in hook files. Hooks are read from
every layer and combined additively: a lower-priority layer can add hooks but
never removes or replaces another layer's block. Each hook's `/hooks-list` name is
prefixed with the layer it came from (for example `managed:` or
`requirements/user:`).
Config-layer hooks are convenience distribution, not an enforcement boundary: on
an unmanaged device a user can still edit these files. Tamper-resistant,
admin-enforced hooks are tracked separately.
---
## Custom Models

View file

@ -0,0 +1,139 @@
//! Fork-path benchmark and profiling workbench.
//!
//! Synthesizes a session whose `updates.jsonl` matches a configurable target
//! size (realistic mixed update shapes: user/agent chunks, tool calls with
//! large results), then measures `StorageAdapter::copy_session_data` — the
//! path that materializes the whole file and produced multi-GB RSS spikes on
//! large production sessions. Also the substrate for allocation/CPU profiling
//! (`cargo flamegraph --bench fork_copy`, dhat) and future peak-RSS bounds.
//!
//! Run: `cargo bench -p xai-grok-shell --bench fork_copy`
//! Size override: `FORK_BENCH_MB=64 cargo bench ...` (default 16 MB).
use std::hint::black_box;
use std::time::Duration;
use acp::{ContentBlock, ContentChunk, TextContent};
use agent_client_protocol as acp;
use criterion::{
BenchmarkId, Criterion, SamplingMode, Throughput, criterion_group, criterion_main,
};
use tempfile::TempDir;
use xai_grok_shell::session::info::Info;
use xai_grok_shell::session::storage::{
CopySessionOptions, JsonlStorageAdapter, SessionUpdate, StorageAdapter,
};
/// One synthetic "turn": a user chunk, agent chunks, and a bulky tool result,
/// so line-size distribution and parse cost resemble production sessions.
fn turn_updates(info: &Info, turn: usize) -> Vec<SessionUpdate> {
let text = |s: String| ContentChunk::new(ContentBlock::Text(TextContent::new(s)));
let notify =
|u| SessionUpdate::Acp(Box::new(acp::SessionNotification::new(info.id.clone(), u)));
let mut updates = vec![notify(acp::SessionUpdate::UserMessageChunk(text(format!(
"prompt {turn}: check the build and summarize failures"
))))];
for i in 0..8 {
updates.push(notify(acp::SessionUpdate::AgentMessageChunk(text(format!(
"agent chunk {turn}/{i}: analyzing module {i} for regressions and drafting a fix plan"
)))));
}
// ~4 KB tool-result payload: the dominant byte source in real sessions.
updates.push(notify(acp::SessionUpdate::AgentMessageChunk(text(
format!("tool result {turn}: {}", "x".repeat(4096)),
))));
updates
}
/// Build a session dir whose `updates.jsonl` is at least `target_bytes`.
fn synthesize_session(root: &TempDir, target_bytes: u64) -> Info {
let adapter = JsonlStorageAdapter::with_root(root.path().to_path_buf());
let info = Info {
id: acp::SessionId::new("fork-bench-src"),
cwd: "/bench/workspace".to_string(),
};
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("bench runtime");
rt.block_on(async {
adapter
.init_session(&info, acp::ModelId::new("bench-model"))
.await
.expect("init session");
let updates_path = adapter.updates_file_path(&info).expect("updates path");
let mut turn = 0usize;
loop {
for update in turn_updates(&info, turn) {
adapter.append_update(&info, &update).await.expect("append");
}
turn += 1;
// Stat every 32 turns; sizes only grow.
if turn % 32 == 0
&& std::fs::metadata(&updates_path)
.map(|m| m.len())
.unwrap_or(0)
>= target_bytes
{
break;
}
}
});
info
}
fn bench_fork_copy(c: &mut Criterion) {
let target_mb: u64 = std::env::var("FORK_BENCH_MB")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(16);
let root = TempDir::new().expect("tempdir");
let source = synthesize_session(&root, target_mb * 1024 * 1024);
let adapter = JsonlStorageAdapter::with_root(root.path().to_path_buf());
let updates_len = std::fs::metadata(adapter.updates_file_path(&source).expect("updates path"))
.expect("updates.jsonl")
.len();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("bench runtime");
let mut group = c.benchmark_group("fork_copy");
group
.sampling_mode(SamplingMode::Flat)
.sample_size(10)
.measurement_time(Duration::from_secs(30))
.throughput(Throughput::Bytes(updates_len));
group.bench_function(
BenchmarkId::new("copy_session_data", format!("{target_mb}MB")),
|b| {
let mut n = 0usize;
b.iter(|| {
n += 1;
let target = Info {
id: acp::SessionId::new(format!("fork-bench-dst-{n}")),
cwd: "/bench/workspace-fork".to_string(),
};
let result = rt
.block_on(adapter.copy_session_data(
&source,
&target,
CopySessionOptions::default(),
))
.expect("fork copy");
// Keep each iteration's output dir from accumulating.
if let Some(dir) = adapter
.updates_file_path(&target)
.and_then(|p| p.parent().map(std::path::Path::to_path_buf))
{
std::fs::remove_dir_all(&dir).ok();
}
black_box(result)
});
},
);
group.finish();
}
criterion_group!(benches, bench_fork_copy);
criterion_main!(benches);

View file

@ -0,0 +1,192 @@
[
{
"category": "features",
"description": "**New /tutorial slash command** opens an opt-in nine-topic onboarding tour of Grok.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**File attachments** now appear correctly when resuming or replaying conversations.",
"breaking_change": false
},
{
"category": "features",
"description": "**New tool_overrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools.",
"breaking_change": false
},
{
"category": "features",
"description": "**New toolOverrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Terminal output** from remote clients is now recorded so read-file hints and monitors function correctly.",
"breaking_change": false
},
{
"category": "features",
"description": "**New config options** let you add query parameters or environment-backed headers to custom model providers and control which variables reach shell tools.",
"breaking_change": false
},
{
"category": "breaking",
"description": "**CLI version policy** now has separate soft update floors/ceilings and hard startup requirements.",
"breaking_change": true
},
{
"category": "fixes",
"description": "**Background shell commands** now correctly report their real exit codes instead of always showing -1.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Marketplace source refreshes** no longer hang the TUI or trap you in the extensions modal.",
"breaking_change": false
},
{
"category": "features",
"description": "**Terminal and environment fixes** are now consolidated under the `/doctor` command with clearer guidance.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Background task tray** now correctly clears killed tasks and keeps task descriptions after reconnect.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Dashboard overlay** now correctly returns after forking a dashboard-attached session.",
"breaking_change": false
},
{
"category": "features",
"description": "**Marketplace add** now rejects non-git URLs at add time instead of failing later.",
"breaking_change": false
},
{
"category": "features",
"description": "**Slash commands** can now show optional bracket tags (e.g. [new]) via config or remote settings.",
"breaking_change": false
},
{
"category": "features",
"description": "**Queued prompts** now offer an [edit] mouse button alongside Send now and cancel.",
"breaking_change": false
},
{
"category": "features",
"description": "**Voice shortcut** toggle in settings can disable the Ctrl+Space/F8 keybind without disabling voice entirely.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Linux voice dictation** now works on PipeWire versions before 1.6.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fork** from a rewound session now copies the correct live-branch history.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Account pane** now shows name and email even after the access token expires.",
"breaking_change": false
},
{
"category": "features",
"description": "**Image edit** can now use a remotely configured model slug instead of the hardcoded default.",
"breaking_change": false
},
{
"category": "features",
"description": "**`grok doctor fix`** can now repair common tmux clipboard and passthrough problems.",
"breaking_change": false
},
{
"category": "features",
"description": "**Per-provider auth helpers** now work on Windows and can run from a configurable working directory.",
"breaking_change": false
},
{
"category": "features",
"description": "**/resume** now shows only native Grok sessions by default and shows a hint when external sessions are hidden.",
"breaking_change": false
},
{
"category": "features",
"description": "**`grok --resume`** can now resume a session by its title as well as by ID.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Voice mode** now lets you edit already-dictated text without closing the microphone.",
"breaking_change": false
},
{
"category": "features",
"description": "**Workflows overlay** now shows live per-agent progress and automatically follows the active phase.",
"breaking_change": false
},
{
"category": "features",
"description": "**Workflow runs** that failed can now be resumed; scratch file limits were also increased.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed startup hangs** on Linux after concurrent launches or rapid restarts.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**MCP tools** now appear without restart after enrolling or updating a managed service.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Plugin subagents** now see the same MCP tools as the parent session.",
"breaking_change": false
},
{
"category": "features",
"description": "**Hooks** can now be defined in config.toml in addition to JSON files.",
"breaking_change": false
},
{
"category": "features",
"description": "**Clicking** the \"still running\" status now opens the tasks pane.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Copy confirmations** now show shorter messages when the clipboard succeeds.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Repeated identical tool calls** now end the turn silently instead of showing a stop banner.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Web search** now defaults to grok-4.5.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Voice dictation** text is no longer dropped when pressing Enter to send.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Bash mode** (`!`) now shows yellow prefix and action label in minimal mode.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Parked turns** no longer spam duplicate \"Worked for\" markers in the transcript.",
"breaking_change": false
}
]

View file

@ -0,0 +1,49 @@
# 0.2.112 — 2026-07-24
## Breaking Changes
- **CLI version policy** now has separate soft update floors/ceilings and hard startup requirements.
## Features
- **New /tutorial slash command** opens an opt-in nine-topic onboarding tour of Grok.
- **New tool_overrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools.
- **New toolOverrides option** lets you set date cutoffs and domain allowlists for the agent's built-in search tools.
- **New config options** let you add query parameters or environment-backed headers to custom model providers and control which variables reach shell tools.
- **Terminal and environment fixes** are now consolidated under the `/doctor` command with clearer guidance.
- **Marketplace add** now rejects non-git URLs at add time instead of failing later.
- **Slash commands** can now show optional bracket tags (e.g. [new]) via config or remote settings.
- **Queued prompts** now offer an [edit] mouse button alongside Send now and cancel.
- **Voice shortcut** toggle in settings can disable the Ctrl+Space/F8 keybind without disabling voice entirely.
- **Image edit** can now use a remotely configured model slug instead of the hardcoded default.
- **`grok doctor fix`** can now repair common tmux clipboard and passthrough problems.
- **Per-provider auth helpers** now work on Windows and can run from a configurable working directory.
- **/resume** now shows only native Grok sessions by default and shows a hint when external sessions are hidden.
- **`grok --resume`** can now resume a session by its title as well as by ID.
- **Workflows overlay** now shows live per-agent progress and automatically follows the active phase.
- **Workflow runs** that failed can now be resumed; scratch file limits were also increased.
- **Hooks** can now be defined in config.toml in addition to JSON files.
- **Clicking** the "still running" status now opens the tasks pane.
## Bug Fixes
- **File attachments** now appear correctly when resuming or replaying conversations.
- **Terminal output** from remote clients is now recorded so read-file hints and monitors function correctly.
- **Background shell commands** now correctly report their real exit codes instead of always showing -1.
- **Marketplace source refreshes** no longer hang the TUI or trap you in the extensions modal.
- **Background task tray** now correctly clears killed tasks and keeps task descriptions after reconnect.
- **Dashboard overlay** now correctly returns after forking a dashboard-attached session.
- **Linux voice dictation** now works on PipeWire versions before 1.6.
- **Fork** from a rewound session now copies the correct live-branch history.
- **Account pane** now shows name and email even after the access token expires.
- **Voice mode** now lets you edit already-dictated text without closing the microphone.
- **Fixed startup hangs** on Linux after concurrent launches or rapid restarts.
- **MCP tools** now appear without restart after enrolling or updating a managed service.
- **Plugin subagents** now see the same MCP tools as the parent session.
- **Copy confirmations** now show shorter messages when the clipboard succeeds.
- **Repeated identical tool calls** now end the turn silently instead of showing a stop banner.
- **Web search** now defaults to grok-4.5.
- **Voice dictation** text is no longer dropped when pressing Enter to send.
- **Bash mode** (`!`) now shows yellow prefix and action label in minimal mode.
- **Parked turns** no longer spam duplicate "Worked for" markers in the transcript.

View file

@ -35,6 +35,17 @@ use crate::session::{SessionCommand, SessionHandle};
/// not yet exited.
const FLUSH_POLL: Duration = Duration::from_millis(50);
/// Default bound on a process-exit session flush ([`AgentActivity::flush_all_sessions`]):
/// leader auto-update shutdown and the in-process agent's `/exit` / headless-quit
/// path both use it, so one wedged actor delays exit by the same amount everywhere.
/// Sessions are normally idle by then and the flush completes in milliseconds.
///
/// Known gap: a `SessionEnd` hook configured with a longer `timeout` than this
/// is still cut off at the grace. Aligning the two needs the hook registry's
/// configured timeouts at flush time, which this layer does not see — tracked as
/// a follow-up rather than hardcoding a larger bound for every exit.
pub const SESSION_FLUSH_GRACE: Duration = Duration::from_secs(10);
/// Per-session slice of state shared with the session actor (the same `Arc`s
/// the actor mutates — see the matching `SessionHandle` fields).
struct SessionActivityEntry {
@ -131,9 +142,13 @@ impl AgentActivity {
/// with a fresh actor gets its own signal), all against one deadline —
/// `grace` bounds the **total** shutdown delay.
///
/// Call **before** cancelling the leader's root token so session state
/// is durable before the `LocalSet` drop aborts remaining tasks. Actors
/// that miss the grace are logged and abandoned.
/// Callers: the leader's auto-update / `RelaunchForUpdate` shutdown, and
/// the in-process agent worker on `/exit` / headless quit. In the leader
/// case, call **before** cancelling the root token; in the in-process case,
/// **after** the cancel that ends the worker's run loop but before its
/// `LocalSet` drops — either way, session state must be durable before the
/// drop aborts remaining tasks. Actors that miss the grace are logged and
/// abandoned.
pub async fn flush_all_sessions(&self, grace: Duration) {
let deadline = tokio::time::Instant::now() + grace;
// Every distinct channel signaled so far (id kept for logging).
@ -148,7 +163,7 @@ impl AgentActivity {
.collect();
for (id, tx) in snapshot {
if !signaled.iter().any(|(_, s)| s.same_channel(&tx)) {
tracing::info!(session_id = %id, "leader shutdown: flushing session");
tracing::info!(session_id = %id, "shutdown: flushing session");
let _ = tx.send(SessionCommand::Shutdown);
signaled.push((id, tx));
}
@ -162,7 +177,7 @@ impl AgentActivity {
if !tx.is_closed() {
tracing::warn!(
session_id = %id,
"leader shutdown: session actor did not exit within grace; proceeding"
"shutdown: session actor did not exit within grace; proceeding"
);
}
}

View file

@ -60,9 +60,10 @@ pub struct LeaderAutoUpdateConfig {
const AUTO_UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
/// How long the auto-update shutdown waits for session actors to flush
/// before the leader exits. Sessions are idle at this point, so the flush
/// normally completes in milliseconds; the cap only bounds a wedged actor.
const AUTO_UPDATE_FLUSH_GRACE: Duration = Duration::from_secs(10);
/// before the leader exits. Aliases the shared
/// [`crate::agent::activity::SESSION_FLUSH_GRACE`] so this path and the
/// in-process agent's `/exit` / headless-quit flush cannot drift apart.
const AUTO_UPDATE_FLUSH_GRACE: Duration = crate::agent::activity::SESSION_FLUSH_GRACE;
/// Consecutive busy deferrals after which an installed update proceeds
/// anyway (with the graceful flush). Bounds how long a permanently-"busy"
@ -829,8 +830,8 @@ fn relay_config_for_session(
}
/// Start the leader's grok.com relay connection according to the start policy,
/// returning the slot where the [`RelayHandle`](crate::agent::relay::RelayHandle)
/// is parked once the connection task is running.
/// parking the [`RelayHandle`](crate::agent::relay::RelayHandle) in `slot`
/// once the connection task is running.
///
/// * `relay_on_demand == false` (default — explicit `grok agent leader`
/// invocation: devbox / systemd / nohup): connect **eagerly**, right now.
@ -854,29 +855,29 @@ fn relay_config_for_session(
/// via `session/load`).
///
/// Must be called within a `LocalSet` (uses `spawn_local`). The handle is
/// parked in a slot rather than returned from the deferred task because
/// `RelayHandle` cancels its loop on Drop; the leader shutdown path takes it
/// out of the slot to stop the relay explicitly (the `cancel` token would stop
/// it anyway).
/// parked in the caller-owned `slot` rather than returned from the deferred
/// task because `RelayHandle` cancels its loop on Drop; the leader shutdown
/// path takes it out of the slot to stop the relay explicitly (the `cancel`
/// token would stop it anyway). The slot is passed in (not created here) so
/// a deferred arm ([`DeferredRelayArm`]) parks the handle in the same slot
/// the shutdown path drains.
fn spawn_leader_relay(
slot: Rc<std::cell::RefCell<Option<crate::agent::relay::RelayHandle>>>,
relay_config: crate::agent::relay::RelayConfig,
relay_on_demand: bool,
mut relay_demand_rx: tokio::sync::watch::Receiver<bool>,
ws_to_agent_tx: mpsc::UnboundedSender<String>,
agent_to_ws_tx: Rc<Mutex<Option<mpsc::UnboundedSender<String>>>>,
cancel: tokio_util::sync::CancellationToken,
) -> Rc<std::cell::RefCell<Option<crate::agent::relay::RelayHandle>>> {
) {
use crate::agent::relay::spawn_relay_connection;
let slot: Rc<std::cell::RefCell<Option<crate::agent::relay::RelayHandle>>> =
Rc::new(std::cell::RefCell::new(None));
if !relay_on_demand {
info!("Starting relay connection (eager)");
let (tx, handle) = spawn_relay_connection(relay_config, ws_to_agent_tx, cancel);
*agent_to_ws_tx.lock() = Some(tx);
*slot.borrow_mut() = Some(handle);
return slot;
return;
}
let slot_for_task = slot.clone();
@ -903,14 +904,75 @@ fn spawn_leader_relay(
*agent_to_ws_tx.lock() = Some(tx);
*slot_for_task.borrow_mut() = Some(handle);
});
slot
}
/// Everything needed to arm the leader's grok.com relay *after* startup.
///
/// A leader that boots without auth used to disable the relay forever — the
/// decision was made once in [`run_leader`] and never revisited. On devboxes
/// that turned a transient mint-provider outage at provision time into a
/// permanently invisible box: the external auth provider succeeded minutes
/// later and the config watcher hot-reloaded the token into the leader, but
/// the relay never connected, the agent never registered, and tooling
/// reported the (healthy) box as "not found online" for its whole lifetime.
///
/// These parts are captured in the no-auth startup path and consumed by the
/// config-update loop on the first relay-eligible
/// [`ConfigUpdate::Auth`](crate::config::reloader::ConfigUpdate::Auth).
struct DeferredRelayArm {
relay_on_demand: bool,
relay_demand_rx: tokio::sync::watch::Receiver<bool>,
ws_to_agent_tx: mpsc::UnboundedSender<String>,
agent_to_ws_tx: Rc<Mutex<Option<mpsc::UnboundedSender<String>>>>,
cancel: tokio_util::sync::CancellationToken,
/// Shared with [`run_leader`]'s shutdown path, which drains it to stop
/// the relay explicitly.
slot: Rc<std::cell::RefCell<Option<crate::agent::relay::RelayHandle>>>,
grok_com_config: crate::auth::GrokComConfig,
alpha_test_key: Option<String>,
}
impl DeferredRelayArm {
/// Arm the relay for a hot-reloaded session if it is relay-eligible.
///
/// Consumes the parts and returns `None` when the relay was armed.
/// Returns `Some(self)` when the session is not relay-eligible (BYOK /
/// non-x.ai issuer — see
/// [`RelayConfig::for_session`](crate::agent::relay::RelayConfig::for_session))
/// so a later eligible token can still arm.
///
/// Must be called within a `LocalSet` (delegates to
/// [`spawn_leader_relay`]).
fn arm_if_eligible(self, session: &GrokAuth, auth_manager: &Arc<AuthManager>) -> Option<Self> {
let Some(relay_config) = crate::agent::relay::RelayConfig::for_session(
session,
&self.grok_com_config,
self.alpha_test_key.clone(),
Some(auth_manager.clone()),
) else {
return Some(self);
};
info!("Relay-eligible auth token appeared after startup — arming grok.com relay");
spawn_leader_relay(
self.slot,
relay_config,
self.relay_on_demand,
self.relay_demand_rx,
self.ws_to_agent_tx,
self.agent_to_ws_tx,
self.cancel,
);
None
}
}
/// Run the agent in leader mode, accepting IPC connections from multiple clients.
/// When a grok.com session is present, the leader connects to the websocket relay
/// after startup (post-auth, post-prefetch); BYOK / no-session leaders skip it and
/// serve clients over IPC only. See [`spawn_leader_relay`] for when the relay
/// connection is opened (eager by default, demand-gated with `relay_on_demand`).
/// after startup (post-auth, post-prefetch); BYOK / no-session leaders start
/// serving clients over IPC only, then arm the relay if a relay-eligible token
/// is hot-reloaded later (see [`DeferredRelayArm`]). See [`spawn_leader_relay`]
/// for when the relay connection is opened (eager by default, demand-gated with
/// `relay_on_demand`).
///
/// Startup sequence (lock-then-socket):
/// 1. Acquire the leader flock FIRST — bail if another process holds it.
@ -1198,7 +1260,10 @@ pub async fn run_leader(
// process so a refresh can't straddle a suspend.
shared_auth_manager.start_system_power_listener();
// Decided once here; not (re)started if a client authenticates mid-session.
// Resolved from startup auth here; when this is `None` (leader booted
// without auth) the relay is NOT permanently off — the config-update loop
// arms it later via `DeferredRelayArm` when the watcher hot-reloads a
// relay-eligible token.
// The refresher lands on `shared_auth_manager` during `MvpAgent`
// construction below; a relay 401 in the window before that surfaces as
// a transient recovery failure and is retried, not a dead end.
@ -1363,19 +1428,42 @@ pub async fn run_leader(
// connect unconditionally. Leaders auto-spawned by interactive
// clients pass `relay_on_demand` and defer the WebSocket until the
// first headless registration. See `spawn_leader_relay`.
let relay_handle_slot = if let Some(relay_config) = relay_config {
let relay_handle_slot: Rc<
std::cell::RefCell<Option<crate::agent::relay::RelayHandle>>,
> = Rc::new(std::cell::RefCell::new(None));
let mut deferred_relay_arm: Option<DeferredRelayArm> = None;
if let Some(relay_config) = relay_config {
spawn_leader_relay(
relay_handle_slot.clone(),
relay_config,
relay_on_demand,
relay_demand_rx,
ws_to_agent_tx.clone(),
agent_to_ws_tx.clone(),
cancel_clone.clone(),
)
);
} else {
info!("Relay disabled: no grok.com session token (BYOK / local-only leader)");
Rc::new(std::cell::RefCell::new(None))
};
// No relay-eligible auth at startup (BYOK / local-only — or a
// devbox whose initial mint failed transiently). Don't decide
// "relay off" forever: park the parts so the config-update
// loop below arms the relay when the watcher hot-reloads a
// relay-eligible token. See `DeferredRelayArm`.
info!(
"Relay not started: no grok.com session token \
(BYOK / local-only leader); will arm if an eligible \
token is hot-reloaded"
);
deferred_relay_arm = Some(DeferredRelayArm {
relay_on_demand,
relay_demand_rx,
ws_to_agent_tx: ws_to_agent_tx.clone(),
agent_to_ws_tx: agent_to_ws_tx.clone(),
cancel: cancel_clone.clone(),
slot: relay_handle_slot.clone(),
grok_com_config: agent_config.grok_com_config.clone(),
alpha_test_key: agent_config.endpoints.alpha_test_key.clone(),
});
}
// Spawn auto-update checker if configured.
let update_cancel = cancel_clone.clone();
@ -1502,7 +1590,24 @@ pub async fn run_leader(
"expires_at": auth.expires_at.map(|e| e.to_rfc3339()),
})),
);
// Cloned only while a deferred relay arm is
// pending (leader booted without auth) — `None`
// for the lifetime of a normally-authed leader.
let session_for_relay = deferred_relay_arm
.is_some()
.then(|| (*auth).clone());
auth_manager_for_config.hot_swap(*auth);
// Deferred relay arm for a leader that booted
// without auth (post-hot-swap, so the shared
// manager already holds the token when the relay
// connects). A non-eligible token (BYOK) hands
// the parts back for a later attempt.
if let (Some(arm), Some(session)) =
(deferred_relay_arm.take(), session_for_relay)
{
deferred_relay_arm = arm
.arm_if_eligible(&session, &auth_manager_for_config);
}
models_manager_for_config.on_auth_changed().await;
let line = internal_reload_request_line(
"config-auth-reloaded",
@ -1846,7 +1951,9 @@ mod tests {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let slot = spawn_leader_relay(
let slot = Rc::new(std::cell::RefCell::new(None));
spawn_leader_relay(
slot.clone(),
config,
false, // eager: explicit `grok agent leader` invocation
demand_rx,
@ -1885,7 +1992,13 @@ mod tests {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let _slot = spawn_leader_relay(
// Keep an Rc on the slot for the whole test: the demand task
// drops its clone after parking the handle, and `RelayHandle`
// cancels the relay loop on Drop (mirrors `run_leader`, which
// owns the slot until shutdown).
let slot = Rc::new(std::cell::RefCell::new(None));
spawn_leader_relay(
slot.clone(),
config,
true, // on-demand: spawned via spawn_leader_subprocess
demand_rx,
@ -1910,6 +2023,81 @@ mod tests {
cancel.cancel();
}
/// Regression test for the "leader booted without auth is invisible
/// forever" bug: a leader that starts with no session (e.g. a devbox
/// whose initial mint hit a transient provider outage) must arm the
/// relay when a relay-eligible token is later hot-reloaded — and must
/// hand the parts back (not consume them) for a non-eligible token, so
/// a later eligible one can still arm.
#[tokio::test]
async fn deferred_arm_connects_relay_when_auth_appears() {
let (addr, count) = spawn_mock_relay_server().await;
let cancel = CancellationToken::new();
let (ws_to_agent_tx, _ws_to_agent_rx) = mpsc::unbounded_channel();
let agent_to_ws_tx: Rc<Mutex<Option<mpsc::UnboundedSender<String>>>> =
Rc::new(Mutex::new(None));
let (_demand_tx, demand_rx) = watch::channel(false);
let slot = Rc::new(std::cell::RefCell::new(None));
let grok_com_config = crate::auth::GrokComConfig {
grok_ws_url: format!("ws://{addr}"),
grok_ws_origin: format!("http://{addr}"),
..Default::default()
};
let tmp = tempfile::tempdir().unwrap();
let auth_manager = Arc::new(AuthManager::new(tmp.path(), grok_com_config.clone()));
let arm = DeferredRelayArm {
relay_on_demand: false, // bare leader: eager once armed
relay_demand_rx: demand_rx,
ws_to_agent_tx,
agent_to_ws_tx: agent_to_ws_tx.clone(),
cancel: cancel.clone(),
slot: slot.clone(),
grok_com_config,
alpha_test_key: None,
};
let local = tokio::task::LocalSet::new();
local
.run_until(async {
// A non-relay-eligible token (no x.ai issuer) must not arm
// and must hand the parts back.
let ineligible = GrokAuth::test_default();
let arm = arm
.arm_if_eligible(&ineligible, &auth_manager)
.expect("non-eligible token must hand the parts back");
assert!(slot.borrow().is_none(), "no handle parked yet");
assert_eq!(
count.load(Ordering::SeqCst),
0,
"non-eligible token must not connect the relay"
);
// A relay-eligible x.ai OIDC token arms the relay eagerly.
let eligible = GrokAuth {
auth_mode: AuthMode::Oidc,
oidc_issuer: Some(crate::auth::XAI_OAUTH2_ISSUER.to_string()),
..GrokAuth::test_default()
};
assert!(
arm.arm_if_eligible(&eligible, &auth_manager).is_none(),
"eligible token must consume the arm parts"
);
assert!(
slot.borrow().is_some(),
"handle must be parked in the shared shutdown slot"
);
assert!(
agent_to_ws_tx.lock().is_some(),
"outbound relay sender must be installed"
);
wait_for_connection(&count, "deferred arm after auth hot-reload").await;
})
.await;
cancel.cancel();
}
/// The watcher-injected internal reload requests must carry the ACP
/// wire-level `_` extension prefix. `agent-client-protocol`'s inbound
/// decoder routes non-built-in methods to `ext_method` only when

View file

@ -2043,9 +2043,18 @@ impl Config {
t.remove("auth_provider");
t.remove("model_providers");
}
let parsed_mcp_servers =
crate::util::config::parse_mcp_servers_from_toml(&raw_without_model_sections);
if let toml::Value::Table(ref mut t) = raw_without_model_sections {
t.remove("mcp_servers");
}
crate::config::deep_merge_toml(&mut base, &raw_without_model_sections);
if let toml::Value::Table(ref mut t) = base {
t.remove("mcp_servers");
}
let (mut config, user_unused) =
Self::deserialize_collecting_unrecognized(base, &raw_without_model_sections)?;
config.mcp_servers = parsed_mcp_servers.into_iter().collect();
if !user_unused.is_empty() {
let keys = user_unused.join(", ");
tracing::warn!(
@ -3445,6 +3454,15 @@ pub fn apply_external_otel_remote_policy(settings: Option<&crate::util::config::
}
/// Seed free-function remote caches after writing `Config.remote_settings`.
pub fn apply_remote_settings_side_effects(settings: Option<&crate::util::config::RemoteSettings>) {
if let Some(s) = settings {
let origin_trusted = crate::util::is_prod_cli_chat_proxy_url(
&EndpointsConfig::from_effective_config().proxy_url(),
);
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
s.managed_config_signature_verification,
origin_trusted,
);
}
crate::util::config::cache_remote_mcp_startup_timeout_secs(
settings.and_then(|s| s.mcp_startup_timeout_secs),
);
@ -5954,6 +5972,45 @@ reasoning_effort = "low"
.expect("warm cache resolves");
assert_eq!(resolved.api_key.as_deref(), Some("ws-token"));
}
/// GBT-4128: bad `[mcp_servers.*]` entries are dropped, not fatal.
#[test]
fn invalid_mcp_server_stub_does_not_fail_config_load() {
let raw_config: toml::Value = toml::from_str(
r#"
[mcp_servers.github]
enabled = false
mcp_servers.broken = "not-a-table"
[mcp_servers.also_broken]
enabled = "yes"
[mcp_servers.linear]
command = "npx"
args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"]
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config)
.expect("bad mcp stubs must be dropped, not fail whole config");
assert!(
!cfg.mcp_servers.contains_key("broken"),
"non-table entry is dropped"
);
assert!(
!cfg.mcp_servers.contains_key("also_broken"),
"wrong-type enabled is dropped"
);
assert!(
!cfg.mcp_servers.contains_key("github"),
"transport-less stub is dropped (disable via disabled_mcp_servers)"
);
assert!(
cfg.mcp_servers.contains_key("linear"),
"valid MCP neighbor must still load"
);
assert!(cfg.mcp_servers["linear"].enabled);
}
/// The lenient parser warns per problem and never fails the whole
/// config.
#[test]
@ -12522,4 +12579,81 @@ default = "grok-4.5"
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Remote);
}
#[test]
#[serial_test::serial(remote_sig_disarm)]
fn remote_settings_disarm_managed_config_signatures() {
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(true),
true,
);
assert!(xai_grok_config::signed_policy::verification_active());
let settings = crate::util::config::RemoteSettings {
managed_config_signature_verification: Some(false),
..Default::default()
};
apply_remote_settings_side_effects(Some(&settings));
assert!(!xai_grok_config::signed_policy::verification_active());
let settings = crate::util::config::RemoteSettings {
managed_config_signature_verification: Some(true),
..Default::default()
};
apply_remote_settings_side_effects(Some(&settings));
assert!(xai_grok_config::signed_policy::verification_active());
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(false),
true,
);
apply_remote_settings_side_effects(None);
assert!(!xai_grok_config::signed_policy::verification_active());
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(true),
true,
);
assert!(xai_grok_config::signed_policy::verification_active());
}
/// Keyed path: prod proxy origin can disarm; env override cannot.
#[test]
#[serial_test::serial(remote_sig_disarm)]
fn remote_settings_disarm_requires_prod_proxy_when_keys_embedded() {
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(true),
true,
);
assert!(xai_grok_config::signed_policy::verification_active());
let settings = crate::util::config::RemoteSettings {
managed_config_signature_verification: Some(false),
..Default::default()
};
unsafe {
std::env::remove_var("GROK_CLI_CHAT_PROXY_BASE_URL");
}
apply_remote_settings_side_effects(Some(&settings));
assert!(
!xai_grok_config::signed_policy::verification_active(),
"prod proxy origin must allow disarm when keys are embedded"
);
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(true),
true,
);
assert!(xai_grok_config::signed_policy::verification_active());
unsafe {
std::env::set_var(
"GROK_CLI_CHAT_PROXY_BASE_URL",
"https://attacker.example/v1",
);
}
apply_remote_settings_side_effects(Some(&settings));
assert!(
xai_grok_config::signed_policy::verification_active(),
"env-overridden proxy must not be able to disarm keyed verification"
);
unsafe {
std::env::remove_var("GROK_CLI_CHAT_PROXY_BASE_URL");
}
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(true),
true,
);
}
}

View file

@ -23,9 +23,12 @@ pub fn bootstrap(
auth_manager: &Arc<AuthManager>,
prefetched: Option<IndexMap<String, ModelEntry>>,
) -> Result<(AgentConfig, ModelsManager), String> {
// Fail closed before any policy is read: a tampered managed policy must not run unmanaged.
// Remote kill-switch before the gate (settings-only prefetch — no managed-config
// sync, so a live server cannot heal a tampered policy before fail-closed).
let mut cfg = cfg.clone();
ensure_remote_settings_side_effects(&mut cfg, false);
crate::managed_config::managed_policy_gate()?;
let cfg = resolve_config(cfg, auth_manager);
let cfg = resolve_config(&cfg, auth_manager);
cfg.validate_model_filters()?;
init_process(&cfg, auth_manager);
let models_manager = ModelsManager::from_config(&cfg, prefetched, auth_manager.clone())?;
@ -48,6 +51,42 @@ pub(crate) fn exit_on_config_error<T>(e: String) -> T {
std::process::exit(1);
}
/// Fill `remote_settings` if absent and apply process-global remote side effects
/// (signature kill-switch and caches). Safe to call more than once.
///
/// `sync_managed`: when true, missing-settings fallback may also refresh
/// managed-config. Must be false before the managed-policy gate.
fn ensure_remote_settings_side_effects(cfg: &mut AgentConfig, sync_managed: bool) {
// Fallback: if the client didn't pre-supply remote settings, fetch them
// now so remote-settings-gated features work regardless of which client
// spawned us. Clients that already call `start_early_prefetch()` and
// thread the result into `cfg.remote_settings` skip this entirely.
if cfg.remote_settings.is_none() {
let handle = if sync_managed {
crate::agent::models::start_early_prefetch(Some(cfg.grok_com_config.clone()))
} else {
crate::agent::models::start_early_prefetch_settings_only(Some(
cfg.grok_com_config.clone(),
))
};
if let Some(handle) = handle {
match handle.join() {
Ok(result) => {
cfg.remote_settings = result.settings;
crate::util::config::set_remote_campaigns_from_settings(
cfg.remote_settings.as_ref(),
);
tracing::info!("remote_settings fetched as shell-level fallback");
}
Err(_) => {
tracing::warn!("remote_settings fallback prefetch thread panicked");
}
}
}
}
crate::agent::config::apply_remote_settings_side_effects(cfg.remote_settings.as_ref());
}
/// Config transform: apply managed settings, fetch remote settings,
/// resolve storage mode.
fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig {
@ -74,29 +113,10 @@ fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig
tracing::info!(field = %e.path, value = %e.value, source = %e.source, "policy override");
}
// Fallback: if the client didn't pre-supply remote settings, fetch them
// now so remote-settings-gated features work regardless of which client
// spawned us. Clients that already call `start_early_prefetch()` and
// thread the result into `cfg.remote_settings` skip this entirely.
if cfg.remote_settings.is_none()
&& let Some(handle) =
crate::agent::models::start_early_prefetch(Some(cfg.grok_com_config.clone()))
{
match handle.join() {
Ok(result) => {
cfg.remote_settings = result.settings;
crate::util::config::set_remote_campaigns_from_settings(
cfg.remote_settings.as_ref(),
);
tracing::info!("remote_settings fetched as shell-level fallback");
}
Err(_) => {
tracing::warn!("remote_settings fallback prefetch thread panicked");
}
}
}
// Idempotent: bootstrap may already have fetched + applied side effects for the gate.
// Full prefetch (with managed-config sync when stale) is allowed after the gate.
ensure_remote_settings_side_effects(&mut cfg, true);
crate::util::config::sync_campaign_fields(&mut cfg);
crate::agent::config::apply_remote_settings_side_effects(cfg.remote_settings.as_ref());
// env var > remote settings > Local. Skip remote settings for Generic (grok -p, subagents).
if cfg.storage_mode == StorageMode::Local
@ -128,6 +148,12 @@ fn init_process(cfg: &AgentConfig, auth_manager: &AuthManager) {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
// Every agent mode (stdio/headless/leader and the in-process TUI
// agent) passes through here, so diagnostic uploads always carry
// the version stamp and the resource ceilings in effect.
xai_grok_telemetry::unified_log::set_version(xai_grok_version::VERSION);
crate::util::limits::log_effective_limits();
if !cfg!(test) {
// Clear a logged-out team's files before the background sync runs.
crate::managed_config::clear_orphan();

View file

@ -1570,19 +1570,31 @@ fn resolve_prefetch_env(grok_com_config: Option<GrokComConfig>) -> Option<Prefet
/// credentials from disk.
pub fn start_early_prefetch_with_auth(auth: Option<GrokAuth>) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env_with_auth(auth)?;
Some(spawn_prefetch_thread(env))
Some(spawn_prefetch_thread(env, true))
}
/// Start model + settings prefetch on a background thread.
///
/// Convenience wrapper that reads cached auth from disk. Prefer
/// `start_early_prefetch_with_auth` when you have pre-resolved credentials.
/// Also runs a best-effort managed-config sync when the cache is stale.
pub fn start_early_prefetch(grok_com_config: Option<GrokComConfig>) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env(grok_com_config)?;
Some(spawn_prefetch_thread(env))
Some(spawn_prefetch_thread(env, true))
}
fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
/// Prefetch models + remote settings only — **no** managed-config sync.
///
/// Used before the managed-policy gate so a kill-switch can apply on cold start
/// without healing a tampered on-disk policy before the fail-closed gate runs.
pub fn start_early_prefetch_settings_only(
grok_com_config: Option<GrokComConfig>,
) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env(grok_com_config)?;
Some(spawn_prefetch_thread(env, false))
}
fn spawn_prefetch_thread(env: PrefetchEnv, sync_managed: bool) -> EarlyPrefetchHandle {
std::thread::spawn(move || {
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
let proxy_endpoint = env.endpoints.proxy_url();
@ -1592,7 +1604,9 @@ fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
env.auth.as_ref(),
env.model_fetch_auth,
);
if (env.endpoints.deployment_key.is_some() || crate::managed_config::has_active_team_auth())
if sync_managed
&& (env.endpoints.deployment_key.is_some()
|| crate::managed_config::has_active_team_auth())
&& crate::config::is_managed_config_stale_for(
&crate::managed_config::current_serving_identity(),
)

View file

@ -489,6 +489,16 @@ impl MvpAgent {
pub fn set_activity(&mut self, activity: crate::agent::activity::AgentActivity) {
self.activity = activity;
}
/// Send [`SessionCommand::Shutdown`] to every live session actor and wait
/// up to `grace` for them to exit (SessionEnd hooks, memory save, etc.).
///
/// Call on non-leader process quit **after** the cancel token fires but
/// **before** dropping the agent / exiting the process, so session actors
/// are not killed mid-hook. Mirrors the leader auto-update / relaunch
/// flush path ([`crate::agent::activity::AgentActivity::flush_all_sessions`]).
pub async fn flush_all_sessions(&self, grace: std::time::Duration) {
self.activity.flush_all_sessions(grace).await;
}
/// Install the channel that fans new session cwds into the leader's
/// `ConfigFileWatcher::watch_path`. Called once after
/// the watcher is constructed in `agent/app.rs`. In simple /
@ -3610,7 +3620,11 @@ impl MvpAgent {
let hooks_val = hooks_config.as_value();
let (specs, errors) = xai_grok_hooks::config::parse_hooks_from_value_with_dir(
&hooks_val,
&format!("agent:{}", agent_definition.name),
&format!(
"{}{}",
xai_grok_hooks::config::AGENT_HOOK_PREFIX,
agent_definition.name
),
std::path::Path::new(&session_info.cwd),
);
for e in &errors {

View file

@ -835,7 +835,11 @@ pub(crate) async fn run_shell_child(
let hooks_val = hooks_config.as_value();
let (specs, errors) = xai_grok_hooks::config::parse_hooks_from_value_with_dir(
&hooks_val,
&format!("agent:{}", definition.name),
&format!(
"{}{}",
xai_grok_hooks::config::AGENT_HOOK_PREFIX,
definition.name
),
&ctx.parent_cwd,
);
for e in &errors {

View file

@ -303,6 +303,7 @@ mod tests {
timeout_ms: 5000,
source_dir: PathBuf::from("/tmp"),
extra_env: HashMap::new(),
layer: xai_grok_hooks::config::HookProvenance::File,
}
}

View file

@ -75,6 +75,9 @@ pub struct InspectReport {
pub external_compat: ExternalCompatReport,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub config_warnings: Vec<crate::agent::config_model_override_parse::ConfigWarning>,
/// Invalid or ignored `[mcp_servers.*]` entries.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub mcp_config_problems: Vec<crate::util::config::McpServerConfigProblem>,
}
#[derive(Debug, Serialize)]
@ -382,6 +385,7 @@ async fn build_report(cwd: &Path) -> InspectReport {
.as_ref()
.map(|c| c.config_warnings.clone())
.unwrap_or_default();
let mcp_config_problems = crate::util::config::load_mcp_server_problems_with_project(cwd);
InspectReport {
grok_version: xai_grok_version::VERSION.to_string(),
@ -404,6 +408,7 @@ async fn build_report(cwd: &Path) -> InspectReport {
config_sources: configs,
external_compat,
config_warnings,
mcp_config_problems,
}
}
@ -664,36 +669,42 @@ fn list_hooks(
discovered_plugins: &[xai_grok_agent::plugins::DiscoveredPlugin],
) -> Vec<HookEntry> {
let all_on = xai_grok_tools::types::compat::CompatConfig::default();
let source_paths = crate::util::hooks::discover_hook_source_paths(git_root, &all_on);
let (global_sources, project_sources) = source_paths.as_sources(project_trusted);
// Route through the same assembly as session startup so config-layer hooks
// (config.toml / managed_config.toml / requirements.toml) appear in `/hooks`
// status alongside file hooks, each carrying its provenance name prefix.
let config_layers = xai_grok_config::hook_config_layers();
let (registry, _errors) =
xai_grok_hooks::discovery::load_hooks_from_sources(&global_sources, &project_sources);
let home_dir = dirs::home_dir();
let grok_home = xai_grok_config::grok_home();
crate::util::hooks::assemble_hooks(&config_layers, git_root, &all_on, project_trusted);
let mut entries: Vec<HookEntry> = registry
.all_hooks()
.into_iter()
.map(|h| {
let is_user_scope = h.source_dir.starts_with(&grok_home)
|| home_dir.as_deref().is_some_and(|home| {
h.source_dir.starts_with(home.join(".cursor"))
|| h.source_dir.starts_with(home.join(".claude"))
});
let source = if is_user_scope {
ConfigSource::User {
path: h.source_dir.clone(),
}
} else {
ConfigSource::Project {
path: h.source_dir.clone(),
}
// Classify via the shared `hook_origin` (typed provenance + file-tier
// name prefix), the same classifier telemetry uses, so admin/system
// hooks aren't mislabeled and the two surfaces can't diverge.
use xai_grok_hooks::config::HookOrigin as O;
// Config-layer hooks store the layer's directory in `source_dir`;
// rejoin the tier's filename so inspect shows the actual config file.
let config_file = |name: &str| h.source_dir.join(name);
let path = h.source_dir.clone();
let source = match xai_grok_hooks::config::hook_origin(h) {
O::SystemManaged | O::Managed => ConfigSource::Managed {
path: Some(config_file(xai_grok_config::MANAGED_CONFIG_FILENAME)),
},
O::Requirements => ConfigSource::Managed {
path: Some(config_file(xai_grok_config::REQUIREMENTS_FILENAME)),
},
O::UserConfig => ConfigSource::ConfigToml {
path: config_file(xai_grok_config::USER_CONFIG_FILENAME),
},
O::ProjectFile => ConfigSource::Project { path },
// File/plugin/agent/unknown hooks are user-scoped for display.
O::UserFile | O::Plugin | O::Agent | O::Unknown => ConfigSource::User { path },
};
let vendor = derive_vendor(&h.source_dir.display().to_string()).map(String::from);
HookEntry {
event: format!("{:?}", h.event),
event: h.event.to_string(),
hook_type: h.handler_type.as_str().to_string(),
target: h
.command
@ -1243,6 +1254,25 @@ fn render_config_warnings(
out
}
fn render_mcp_config_problems(problems: &[crate::util::config::McpServerConfigProblem]) -> String {
use crate::util::config::McpServerProblemSeverity;
use std::fmt::Write as _;
if problems.is_empty() {
return String::new();
}
let mut out = String::from("\n MCP Config Problems\n");
let _ = writeln!(out, " {TREE} {} problem(s)", problems.len());
for p in problems {
let severity = match p.severity {
McpServerProblemSeverity::Error => "error",
McpServerProblemSeverity::Warning => "warning",
};
let _ = writeln!(out, " {TREE} [{severity}] {}", p.message);
}
out
}
fn render_harness_compatibility(report: &ExternalCompatReport) -> String {
use std::fmt::Write as _;
@ -1514,6 +1544,7 @@ fn print_human(r: &InspectReport) {
}
print!("{}", render_config_warnings(&r.config_warnings));
print!("{}", render_mcp_config_problems(&r.mcp_config_problems));
print!("{}", render_harness_compatibility(&r.external_compat));
}

View file

@ -294,7 +294,10 @@ impl BackendClient {
.connect_timeout(Duration::from_secs(10))
.timeout(DEFAULT_TIMEOUT)
.build()
.expect("failed to build HTTP client")
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to build backend HTTP client; falling back to shared client");
crate::http::shared_client()
})
}
pub fn new() -> Self {
let reqwest_client = Self::build_default_client();
@ -435,7 +438,7 @@ impl BackendClient {
) -> Result<reqwest::Response, BackendError> {
let headers = self.auth_header_map().await?;
let builder = xai_file_utils::trace_context::inject_trace_context_into_request(
builder.headers(headers),
builder.timeout(DEFAULT_TIMEOUT).headers(headers),
);
let request = builder.build()?;
self.client.execute(request).await.map_err(|e| match e {

View file

@ -887,8 +887,18 @@ impl SessionActor {
hook_reg.remove_by_prefix("plugin/");
hook_reg.append_specs(new_specs);
} else if !new_specs.is_empty() {
let (mut new_reg, _) =
xai_grok_hooks::discovery::load_hooks_from_sources(&[], &[]);
// No registry yet: bootstrap config-layer and file hooks (as
// reload_hooks_impl does), not empty sources, so a plugin-first
// snapshot doesn't drop config hooks.
let git_root =
xai_grok_workspace::session::git::find_git_root_from_path(session_cwd).ok();
let is_trusted =
crate::agent::folder_trust::resolve_and_record(session_cwd, None, false);
let (mut new_reg, _errs) = crate::util::hooks::discover_hooks(
git_root.as_deref(),
&self.rebuild_spec.compat,
is_trusted,
);
new_reg.append_specs(new_specs);
*reg = Some(Arc::new(new_reg));
}

View file

@ -30,6 +30,111 @@ fn drop_cli_catchall_allows(
}
(kept, dropped)
}
/// Build the per-session current-thread tokio runtime.
///
/// Construction acquires fds (epoll/kqueue, waker) and fails with
/// `EMFILE`/`EAGAIN` under resource pressure. Extracted so the containment
/// contract — exhaustion returns `Err`, never aborts — is testable
/// (`runtime_containment_tests`).
pub(crate) fn build_session_runtime() -> std::io::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
}
/// Building the session runtime under fd exhaustion must return `Err`, never
/// panic (under `panic=abort` a panic kills every live session).
///
/// The rlimit is lowered only in a re-exec'd child (the `xai-gix-status`
/// pattern), so parallel tests are unaffected; stdout markers distinguish
/// skip (unenforceable environment) from pass/fail.
#[cfg(all(test, unix))]
mod runtime_containment_tests {
use super::build_session_runtime;
/// Env marker dispatching the re-exec'd test binary into child logic.
const CHILD_ENV: &str = "XAI_GROK_SHELL_RUNTIME_CONTAINMENT_CHILD";
const PASS_MARK: &str = "runtime-build-contained:";
const SKIP_MARK: &str = "skip-child:";
/// Child: lower RLIMIT_NOFILE, fill the fd table, assert `Err`.
fn run_child() -> ! {
let mut lim = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) } != 0 {
println!("{SKIP_MARK} getrlimit failed");
std::process::exit(0);
}
lim.rlim_cur = 64.min(lim.rlim_max);
if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &lim) } != 0 {
println!("{SKIP_MARK} setrlimit failed");
std::process::exit(0);
}
let mut held = Vec::new();
loop {
let fd = unsafe { libc::dup(0) };
if fd < 0 {
break;
}
held.push(fd);
if held.len() > 4096 {
println!("{SKIP_MARK} fd limit not enforced");
std::process::exit(0);
}
}
match build_session_runtime() {
Err(e) => {
println!("{PASS_MARK} {e}");
std::process::exit(0);
}
Ok(_) => {
println!("{SKIP_MARK} runtime built despite full fd table");
std::process::exit(0);
}
}
}
/// Doubles as the child entry point when `CHILD_ENV` is set.
#[test]
fn child_entry_runtime_build_under_fd_exhaustion() {
if std::env::var_os(CHILD_ENV).is_some() {
run_child();
}
}
#[test]
fn runtime_build_failure_is_contained() {
let filter = module_path!()
.split_once("::")
.map(|(_, rest)| rest)
.unwrap_or_default();
let exe = std::env::current_exe().expect("current_exe");
let mut cmd = std::process::Command::new(exe);
cmd.arg("--exact")
.arg(format!(
"{filter}::child_entry_runtime_build_under_fd_exhaustion"
))
.arg("--nocapture")
.arg("--test-threads=1")
.env(CHILD_ENV, "1")
.stdin(std::process::Stdio::null());
xai_tty_utils::detach_std_command(&mut cmd);
let out = cmd.output().expect("spawn child test process");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success() && !stderr.contains("panicked at"),
"child aborted/panicked instead of containing the failure \
(status: {:?})\nstdout:\n{stdout}\nstderr:\n{stderr}",
out.status
);
if stdout.contains(SKIP_MARK) {
eprintln!("skipped: {stdout}");
return;
}
assert!(
stdout.contains(PASS_MARK),
"no pass/skip marker (filter matched nothing?)\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}
}
#[cfg(test)]
mod cli_catchall_drop_tests {
use super::drop_cli_catchall_allows;
@ -2184,10 +2289,17 @@ pub(crate) async fn spawn_session_on_thread(
};
(initial_last_compaction, initial_prompt_texts)
};
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("session runtime");
let rt = match build_session_runtime() {
Ok(rt) => rt,
Err(e) => {
tracing::error!(
error = %e,
"failed to build session runtime (resource exhaustion?)"
);
let _ = init_tx.send(Err(xai_grok_agent::AgentBuildError::RuntimeBuild(e)));
return;
}
};
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
let _trace_span = parent_traceparent.as_ref().map(|tp| {
@ -2320,15 +2432,28 @@ pub(crate) async fn spawn_session_on_thread(
}));
let _ = session_done_rx.await;
});
})
.expect("spawn session thread");
});
let join_handle = match join_handle {
Ok(h) => h,
Err(e) => {
tracing::error!(
error = %e,
"failed to spawn session thread (thread/PID limit or memory pressure?)"
);
return Err(
acp::Error::internal_error().data(format!("failed to spawn session thread: {e}"))
);
}
};
let init = init_rx
.await
.map_err(|_| {
tracing::error!("Session thread panicked during initialization");
acp::Error::internal_error().data("session thread panicked during initialization")
})?
.map_err(|e| acp::Error::internal_error().data(format!("agent building failed: {e}")))?;
.map_err(|e| {
acp::Error::internal_error().data(format!("session initialization failed: {e}"))
})?;
Ok((
init.handle,
init.permission_events_rx,

View file

@ -711,6 +711,7 @@ fn file_registry_with_stop_spec(
timeout_ms: 5000,
source_dir: std::path::PathBuf::from("/tmp"),
extra_env: std::collections::HashMap::new(),
layer: xai_grok_hooks::config::HookProvenance::File,
}]);
registry
}

View file

@ -275,7 +275,7 @@ pub fn merge(
hostname: r.hostname,
source: source.to_string(),
model_id: r.model_id,
num_messages: r.last_turn_number.max(0) as usize,
num_messages: local.num_messages.max(r.last_turn_number.max(0) as usize),
last_active_at: merged_last_active,
branch: local.branch,
repo_name: local.repo_name,
@ -446,6 +446,37 @@ mod tests {
assert_eq!(merged[0].source, "both");
}
#[test]
fn stale_remote_turn_counter_does_not_demote_local_sessions_to_empty() {
// The registry's last_turn_number is updated fire-and-forget and can
// stay at 0 for sessions with real local turns. The merged row must
// keep the local num_messages, or dedup_empty_sessions collapses every
// such same-cwd session into a single "empty draft" row — hiding real
// sessions (and their unread indicators) from every list surface.
let local = vec![
make_summary("s1", "first real session", "2026-03-01T00:00:00Z"),
make_summary("s2", "second real session", "2026-03-01T01:00:00Z"),
];
let remote = vec![
SessionRecord {
last_turn_number: 0,
..make_remote("s1", "first real session", "2026-03-01T00:00:00Z")
},
SessionRecord {
last_turn_number: 0,
..make_remote("s2", "second real session", "2026-03-01T01:00:00Z")
},
];
let merged = merge(remote, local, None, &[], 20);
assert_eq!(merged.len(), 2, "both real sessions must survive the merge");
for row in &merged {
assert_eq!(
row.num_messages, 10,
"local num_messages wins over a stale 0"
);
}
}
#[test]
fn remote_overwrite_preserves_local_metadata() {
let local = vec![Summary {

View file

@ -88,22 +88,19 @@ pub(crate) fn format_hook_name(spec: &xai_grok_hooks::config::HookSpec) -> Strin
}
}
/// Provenance from the namespace prefix each loader stamps on the spec name:
/// `global/` → user, `project/` → project, `plugin/` → plugin, `agent:` →
/// agent, else unknown. (Source-dir classification was wrong — both global and
/// project dirs contain `/.grok/`.)
/// Provenance for telemetry, mapped from the shared [`hook_origin`] classifier so
/// this and `/hooks` inspect can't diverge.
fn format_hook_source(spec: &xai_grok_hooks::config::HookSpec) -> &'static str {
let name = spec.name.as_str();
if name.starts_with("global/") {
"userSettings"
} else if name.starts_with("project/") {
"projectSettings"
} else if name.starts_with("plugin/") {
"pluginHook"
} else if name.starts_with("agent:") {
"agentHook"
} else {
"unknown"
use xai_grok_hooks::config::HookOrigin as O;
match xai_grok_hooks::config::hook_origin(spec) {
O::SystemManaged | O::Managed => "managedConfig",
O::Requirements => "requirementsConfig",
O::UserConfig => "userConfig",
O::UserFile => "userSettings",
O::ProjectFile => "projectSettings",
O::Plugin => "pluginHook",
O::Agent => "agentHook",
O::Unknown => "unknown",
}
}

View file

@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::exit_watcher::{poll_for_terminal_exit, release_terminal, watch_for_exit};
use super::output_recorder::OutputRecorder;
use super::output_recorder::{OutputRecorder, read_log_tail};
use agent_client_protocol as acp;
use xai_acp_lib::AcpAgentGatewaySender as GatewaySender;
use xai_grok_tools::computer::types::{
@ -42,6 +42,7 @@ pub(super) struct TrackedTask {
kind: TaskKind,
owner_session_id: Option<String>,
description: Option<String>,
output_byte_limit: usize,
}
/// Hand-written (`SystemTime` has no `Default`); call sites spread from it.
@ -64,6 +65,7 @@ impl Default for TrackedTask {
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
output_byte_limit: crate::terminal::DEFAULT_OUTPUT_BYTE_LIMIT,
}
}
}
@ -79,7 +81,7 @@ impl TrackedTask {
}
pub(super) fn to_snapshot(&self, task_id: &str, out: SnapshotOutput) -> TaskSnapshot {
let completed = self.completed || out.exit_code.is_some();
let completed = self.completed || out.exit_code.is_some() || out.signal.is_some();
TaskSnapshot {
task_id: task_id.to_string(),
command: self.command.clone(),
@ -272,6 +274,7 @@ impl TerminalBackend for AcpTerminalAdapter {
kind: request.kind,
owner_session_id: request.owner_session_id.clone(),
description,
output_byte_limit: request.output_byte_limit,
..Default::default()
},
);
@ -309,7 +312,11 @@ impl TerminalBackend for AcpTerminalAdapter {
// under the lock and read the log file after releasing it.
enum Resolved {
Ready(TaskSnapshot),
FromLog(TaskSnapshot, PathBuf),
FromLog {
snapshot: TaskSnapshot,
output_file: PathBuf,
limit: usize,
},
Missing,
}
let resolved = {
@ -339,8 +346,10 @@ impl TerminalBackend for AcpTerminalAdapter {
},
))
}
(None, Some(tracked)) => Resolved::FromLog(
tracked.to_snapshot(
// Live poll failed: a completed task keeps its authoritative
// last_output; only a still-running task falls back to the log.
(None, Some(tracked)) => {
let snapshot = tracked.to_snapshot(
task_id,
SnapshotOutput {
output: tracked.last_output.clone(),
@ -348,9 +357,17 @@ impl TerminalBackend for AcpTerminalAdapter {
exit_code: tracked.exit_code,
signal: tracked.signal.clone(),
},
),
tracked.output_file.clone(),
),
);
if snapshot.completed {
Resolved::Ready(snapshot)
} else {
Resolved::FromLog {
snapshot,
output_file: tracked.output_file.clone(),
limit: tracked.output_byte_limit,
}
}
}
(None, None) => Resolved::Missing,
}
};
@ -358,13 +375,14 @@ impl TerminalBackend for AcpTerminalAdapter {
match resolved {
Resolved::Ready(snapshot) => Some(snapshot),
Resolved::Missing => None,
// Live poll failed: fill output from the mirrored log so a running
// task does not report empty while the file already holds data.
Resolved::FromLog(mut snapshot, output_file) => {
if let Ok(logged) = tokio::fs::read_to_string(&output_file).await
&& !logged.is_empty()
{
snapshot.output = logged;
Resolved::FromLog {
mut snapshot,
output_file,
limit,
} => {
if let Some(tail) = read_log_tail(&output_file, limit).await {
snapshot.output = tail.text;
snapshot.truncated = tail.truncated;
}
Some(snapshot)
}
@ -497,20 +515,6 @@ mod tests {
}
}
#[test]
fn to_snapshot_preserves_description() {
let mut task = make_tracked_task("sleep 1");
task.description = Some("build frontend".to_string());
let snap = task.to_snapshot("t-1", out("ok", Some(0), None));
assert_eq!(snap.description.as_deref(), Some("build frontend"));
assert_eq!(snap.task_id, "t-1");
assert_eq!(snap.exit_code, Some(0));
let bare = make_tracked_task("sleep 1");
let snap = bare.to_snapshot("t-2", out("", None, None));
assert!(snap.description.is_none());
}
#[test]
fn wrap_command_quotes_shell_metacharacters() {
let cmd = wrap_command("echo 'hello world' && ls").unwrap();
@ -537,37 +541,21 @@ mod tests {
}
#[test]
fn tracked_task_to_snapshot_running() {
let task = make_tracked_task("ls -la");
let snap = task.to_snapshot("t-1", out("file1\nfile2", None, None));
fn to_snapshot_derives_completed_and_end_time() {
let running = make_tracked_task("ls -la").to_snapshot("t-1", out("partial", None, None));
assert!(!running.completed);
assert!(running.end_time.is_none());
assert_eq!(snap.task_id, "t-1");
assert_eq!(snap.command, "ls -la");
assert_eq!(snap.cwd, "/tmp");
assert_eq!(snap.output, "file1\nfile2");
assert!(!snap.completed);
assert!(snap.end_time.is_none());
assert_eq!(snap.exit_code, None);
}
// An exit code or a signal marks the snapshot complete and stamps end_time.
let exited = make_tracked_task("fast").to_snapshot("t-2", out("", Some(1), None));
assert!(exited.completed);
assert!(exited.end_time.is_some());
assert_eq!(exited.exit_code, Some(1));
#[test]
fn tracked_task_to_snapshot_completed() {
let mut task = make_tracked_task("echo done");
task.mark_completed(out("done\n", Some(0), None));
let snap = task.to_snapshot("t-2", out("done\n", Some(0), None));
assert!(snap.completed);
assert!(snap.end_time.is_some());
assert_eq!(snap.exit_code, Some(0));
assert_eq!(snap.signal, None);
}
#[test]
fn tracked_task_to_snapshot_completed_by_exit_code_alone() {
let task = make_tracked_task("fast cmd");
let snap = task.to_snapshot("t-3", out("", Some(1), None));
assert!(snap.completed);
assert!(snap.end_time.is_some());
let signaled =
make_tracked_task("killed").to_snapshot("t-3", out("", None, Some("SIGTERM".into())));
assert!(signaled.completed);
assert!(signaled.end_time.is_some());
}
/// Scripted client side of the terminal protocol: each `terminal/output`
@ -689,4 +677,75 @@ mod tests {
"line1\nline2\nline3\n"
);
}
/// A gateway whose `terminal/output` never replies, so live polls fail and
/// `get_task` exercises its offline fallback.
fn output_unavailable_gateway() -> GatewaySender {
use xai_acp_lib::AcpClientMessage;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if let AcpClientMessage::ReleaseTerminal(args) = msg {
let _ = args
.response_tx
.send(Ok(acp::ReleaseTerminalResponse::new()));
}
}
});
GatewaySender::new(tx)
}
fn insert_task(adapter: &AcpTerminalAdapter, task_id: &str, task: TrackedTask) {
adapter
.tasks
.lock()
.unwrap()
.insert(task_id.to_string(), task);
}
#[tokio::test]
async fn get_task_completed_keeps_completion_buffer_over_log() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("done.log");
tokio::fs::write(&log, "stale mirrored bytes")
.await
.unwrap();
let adapter =
AcpTerminalAdapter::new(output_unavailable_gateway(), acp::SessionId::new("s"));
let mut task = TrackedTask {
output_file: log,
..Default::default()
};
task.mark_completed(out("authoritative output", Some(0), None));
insert_task(&adapter, "t-done", task);
let snap = adapter.get_task("t-done").await.unwrap();
assert_eq!(snap.output, "authoritative output");
assert!(snap.completed);
}
#[tokio::test]
async fn get_task_running_fills_output_from_log() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("run.log");
tokio::fs::write(&log, "live streamed bytes").await.unwrap();
let adapter =
AcpTerminalAdapter::new(output_unavailable_gateway(), acp::SessionId::new("s"));
insert_task(
&adapter,
"t-run",
TrackedTask {
output_file: log,
output_byte_limit: 1024,
..Default::default()
},
);
let snap = adapter.get_task("t-run").await.unwrap();
assert_eq!(snap.output, "live streamed bytes");
assert!(!snap.completed);
assert!(!snap.truncated);
}
}

View file

@ -146,6 +146,40 @@ fn largest_overlap(
overlap
}
pub(crate) struct LogTail {
pub(crate) text: String,
pub(crate) truncated: bool,
}
pub(crate) async fn read_log_tail(path: &std::path::Path, limit: usize) -> Option<LogTail> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = tokio::fs::File::open(path).await.ok()?;
let len = file.seek(std::io::SeekFrom::End(0)).await.ok()?;
let back = len.min(limit as u64);
let truncated = back < len;
file.seek(std::io::SeekFrom::End(-i64::try_from(back).ok()?))
.await
.ok()?;
let mut buf = Vec::with_capacity(back as usize);
file.take(back).read_to_end(&mut buf).await.ok()?;
let head = buf
.iter()
.position(|&b| b & 0xC0 != 0x80)
.unwrap_or(buf.len());
let text = match std::str::from_utf8(&buf[head..]) {
Ok(s) => s,
Err(e) => std::str::from_utf8(&buf[head..head + e.valid_up_to()])
.expect("valid_up_to() yields a valid UTF-8 prefix"),
};
if text.is_empty() {
return None;
}
Some(LogTail {
text: text.to_owned(),
truncated,
})
}
#[cfg(test)]
mod tests {
use super::*;
@ -233,4 +267,28 @@ mod tests {
"line1\nline2\nline3\n"
);
}
#[tokio::test]
async fn read_log_tail_drops_leading_partial_char() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("lead.log");
// "€ab" is [E2 82 AC 61 62]; a 3-byte limit cuts inside the euro sign.
tokio::fs::write(&path, "€ab").await.unwrap();
let tail = read_log_tail(&path, 3).await.unwrap();
assert_eq!(tail.text, "ab");
assert!(tail.truncated);
}
#[tokio::test]
async fn read_log_tail_drops_trailing_partial_char() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trail.log");
// File ends mid-character: "ab" then the first two bytes of the euro sign.
tokio::fs::write(&path, [b'a', b'b', 0xE2, 0x82])
.await
.unwrap();
let tail = read_log_tail(&path, 1024).await.unwrap();
assert_eq!(tail.text, "ab");
assert!(!tail.truncated);
}
}

View file

@ -1,7 +1,6 @@
use agent_client_protocol as acp;
use anyhow::Result;
use indexmap::IndexMap;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use toml::Value as TomlValue;
@ -13,9 +12,10 @@ pub use xai_grok_mcp::oauth_config::{McpOAuthConfig, McpOAuthConfigMap};
// MCP server config value types extracted to `xai-grok-config-types` (config
// dependency inversion); re-exported so `crate::util::config::*` paths keep working.
pub use xai_grok_config_types::{
McpJsonOAuthBlock, McpPreferenceSource, McpPreferencesFile, McpServerConfig,
McpServerPreferences, McpServerTransportConfig, McpSetupConfig, McpSetupDerivedValue,
McpSetupField, McpSetupFieldType, McpSetupOption, McpSetupResolution,
KNOWN_MCP_SERVER_FIELDS, McpJsonOAuthBlock, McpPreferenceSource, McpPreferencesFile,
McpServerConfig, McpServerConfigProblem, McpServerPreferences, McpServerProblemSeverity,
McpServerTransportConfig, McpSetupConfig, McpSetupDerivedValue, McpSetupField,
McpSetupFieldType, McpSetupOption, McpSetupResolution,
};
// Permission-policy value types likewise extracted; re-exported to keep paths stable.
pub use xai_grok_config_types::{
@ -557,10 +557,8 @@ pub fn collect_mcp_setup_configs(
if let Some(ref inline_value) = plugin.inline_mcp_servers {
let normalized =
xai_grok_agent::plugins::manifest::normalize_inline_mcp_servers(inline_value);
if let Ok(config) = serde_json::from_value::<McpConfig>(normalized) {
for (name, server) in config.mcp_servers {
plugin_configs.entry(name).or_insert(server);
}
for (name, server) in mcp_config_from_json_value(&normalized).mcp_servers {
plugin_configs.entry(name).or_insert(server);
}
}
for (name, config) in plugin_configs {
@ -862,21 +860,112 @@ pub fn load_mcp_server_configs() -> IndexMap<String, McpServerConfig> {
parse_mcp_servers_from_toml(&root)
}
fn parse_mcp_servers_from_toml(root: &TomlValue) -> IndexMap<String, McpServerConfig> {
let TomlValue::Table(table) = root else {
return IndexMap::new();
/// Deserialize one `[mcp_servers.<name>]` table, also returning any unrecognized keys.
fn deserialize_mcp_server_config(
value: &TomlValue,
) -> Result<(McpServerConfig, Vec<String>), String> {
let unknown_fields = value.as_table().map_or_else(Vec::new, |table| {
table
.keys()
.filter(|field| !KNOWN_MCP_SERVER_FIELDS.contains(&field.as_str()))
.cloned()
.collect()
});
let config = toml::Value::try_into::<McpServerConfig>(value.clone())
.map_err(|error| error.to_string())?;
Ok((config, unknown_fields))
}
/// Turn a failed `[mcp_servers.<name>]` entry into an actionable problem. The
/// transport-less case is steered to `disabled_mcp_servers`, Grok's real
/// disable mechanism.
fn diagnose_invalid_entry(name: &str, value: &TomlValue, error: &str) -> McpServerConfigProblem {
let has_command = value.get("command").is_some();
let has_url = value.get("url").is_some();
let message = if !has_command && !has_url {
format!(
"`mcp_servers.{name}` has no transport. To run it, set `command = \"...\"` or \
`url = \"...\"`. To turn it off, add \"{name}\" to `disabled_mcp_servers` instead of \
leaving an entry with no transport. \
See ~/.grok/docs/user-guide/07-mcp-servers.md"
)
} else {
format!(
"`mcp_servers.{name}` has an invalid transport: {error}. \
See ~/.grok/docs/user-guide/07-mcp-servers.md"
)
};
let Some(TomlValue::Table(mcp_servers)) = table.get("mcp_servers") else {
return IndexMap::new();
McpServerConfigProblem {
server: name.to_string(),
field: None,
severity: McpServerProblemSeverity::Error,
message,
}
}
pub(crate) struct ParsedMcpServers {
pub servers: IndexMap<String, McpServerConfig>,
pub problems: Vec<McpServerConfigProblem>,
}
/// Parse `[mcp_servers.*]` without ever failing the whole config: valid servers
/// load, invalid entries are reported (GBT-4128).
pub(crate) fn parse_mcp_servers_with_problems(root: &TomlValue) -> ParsedMcpServers {
let mut servers = IndexMap::new();
let mut problems = Vec::new();
let entries = match root {
TomlValue::Table(table) => match table.get("mcp_servers") {
Some(TomlValue::Table(mcp_servers)) => mcp_servers,
_ => return ParsedMcpServers { servers, problems },
},
_ => return ParsedMcpServers { servers, problems },
};
let mut result = IndexMap::new();
for (name, value) in mcp_servers {
if let Ok(config) = toml::Value::try_into::<McpServerConfig>(value.clone()) {
result.insert(name.clone(), config);
for (name, value) in entries {
match deserialize_mcp_server_config(value) {
Ok((config, unknown_fields)) => {
for field in unknown_fields {
problems.push(McpServerConfigProblem {
server: name.clone(),
field: Some(field.clone()),
severity: McpServerProblemSeverity::Warning,
message: format!(
"`mcp_servers.{name}` has an unrecognized field `{field}`; it is \
ignored. See ~/.grok/docs/user-guide/07-mcp-servers.md"
),
});
}
if config.enabled
&& let Some(field) = config.blank_transport_field()
{
problems.push(McpServerConfigProblem {
server: name.clone(),
field: Some(field.to_string()),
severity: McpServerProblemSeverity::Error,
message: format!(
"`mcp_servers.{name}` is enabled but its `{field}` is blank. \
Set a value, or add \"{name}\" to `disabled_mcp_servers` to turn it \
off. See ~/.grok/docs/user-guide/07-mcp-servers.md"
),
});
continue;
}
servers.insert(name.clone(), config);
}
Err(error) => problems.push(diagnose_invalid_entry(name, value, &error)),
}
}
result
ParsedMcpServers { servers, problems }
}
/// Wrapper that logs problems and returns only the valid servers.
pub(crate) fn parse_mcp_servers_from_toml(root: &TomlValue) -> IndexMap<String, McpServerConfig> {
let ParsedMcpServers { servers, problems } = parse_mcp_servers_with_problems(root);
for problem in &problems {
tracing::warn!(server = %problem.server, "{}", problem.message);
}
servers
}
// ── .mcp.json support ────────────────────────────────────────────────
@ -1061,7 +1150,7 @@ fn load_claude_json_mcp_servers_from_as_configs(
return IndexMap::new();
}
};
let config: ClaudeJsonConfig = match serde_json::from_str(&content) {
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
tracing::debug!(
@ -1072,6 +1161,7 @@ fn load_claude_json_mcp_servers_from_as_configs(
return IndexMap::new();
}
};
let config = claude_json_mcp_from_value(&value);
let mut result = IndexMap::new();
@ -1190,20 +1280,6 @@ pub(crate) fn load_cursor_mcp_servers_as_configs(
result
}
/// Subset of `~/.claude.json` we care about for MCP server discovery.
///
/// Reuses `McpConfig` for both the top-level user MCP servers and per-project
/// entries — the JSON shape (`{ "mcpServers": { ... } }`) is identical at both levels.
#[derive(Default, Deserialize)]
struct ClaudeJsonConfig {
/// User-level MCP servers (top-level `mcpServers` key).
#[serde(flatten)]
user_mcp: McpConfig,
/// Per-project entries, keyed by absolute project path.
#[serde(default)]
projects: HashMap<String, McpConfig>,
}
/// Inner implementation that accepts the file path, making it testable.
fn load_claude_json_mcp_servers_from(
claude_json_path: &std::path::Path,
@ -1213,7 +1289,7 @@ fn load_claude_json_mcp_servers_from(
Ok(c) => c,
Err(_) => return vec![],
};
let config: ClaudeJsonConfig = match serde_json::from_str(&content) {
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
tracing::debug!(
@ -1224,6 +1300,7 @@ fn load_claude_json_mcp_servers_from(
return vec![];
}
};
let config = claude_json_mcp_from_value(&value);
let sub = &crate::config::expand_env_vars_in_string;
let mut servers = Vec::new();
@ -1243,18 +1320,62 @@ fn load_claude_json_mcp_servers_from(
servers
}
/// Read and parse a JSON file. Returns `None` on I/O or parse errors (logged).
/// Build an `McpConfig` from a JSON value, skipping any `mcpServers` entry that
/// fails to deserialize instead of dropping the whole file. Mirrors the
/// per-entry tolerance of [`parse_mcp_servers_with_problems`] for TOML, so one
/// bad entry in a `.mcp.json` or `~/.claude.json` cannot take out its siblings.
fn mcp_config_from_json_value(value: &serde_json::Value) -> McpConfig {
let mut mcp_servers = IndexMap::new();
if let Some(entries) = value.get("mcpServers").and_then(|v| v.as_object()) {
for (name, entry) in entries {
match serde_json::from_value::<McpServerConfig>(entry.clone()) {
Ok(config) => {
mcp_servers.insert(name.clone(), config);
}
Err(error) => tracing::warn!(
server = %name,
error = %error,
"skipping invalid MCP server entry in JSON config"
),
}
}
}
McpConfig { mcp_servers }
}
/// Parsed `~/.claude.json` MCP view: top-level user servers plus per-project maps.
struct ClaudeJsonMcp {
user_mcp: McpConfig,
projects: HashMap<String, McpConfig>,
}
/// Build the `~/.claude.json` MCP view from a JSON value, tolerating bad entries
/// per server (see [`mcp_config_from_json_value`]).
fn claude_json_mcp_from_value(value: &serde_json::Value) -> ClaudeJsonMcp {
let user_mcp = mcp_config_from_json_value(value);
let mut projects = HashMap::new();
if let Some(entries) = value.get("projects").and_then(|v| v.as_object()) {
for (path, project) in entries {
projects.insert(path.clone(), mcp_config_from_json_value(project));
}
}
ClaudeJsonMcp { user_mcp, projects }
}
/// Read and parse a JSON file. Returns `None` on I/O or top-level parse errors
/// (logged); individual bad `mcpServers` entries are skipped, not fatal.
pub(crate) fn read_mcp_json(path: &std::path::Path) -> Option<McpConfig> {
let content = std::fs::read_to_string(path)
.map_err(|e| {
tracing::warn!(error = %e, "failed to read MCP JSON");
})
.ok()?;
serde_json::from_str(&content)
let value: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| {
tracing::warn!(error = %e, "failed to parse MCP JSON");
})
.ok()
.ok()?;
Some(mcp_config_from_json_value(&value))
}
/// Like `load_mcp_servers_with_project` but returns raw configs without filtering by `enabled`.
@ -1295,6 +1416,21 @@ pub fn load_mcp_server_configs_with_project(
servers
}
/// MCP config problems across the same layers as
/// [`load_mcp_server_configs_with_project`], for `grok inspect`.
pub fn load_mcp_server_problems_with_project(cwd: &std::path::Path) -> Vec<McpServerConfigProblem> {
let mut problems = Vec::new();
if let Ok(global_config) = crate::config::load_effective_config() {
problems.extend(parse_mcp_servers_with_problems(&global_config).problems);
}
for config_path in crate::config::find_project_configs(cwd) {
if let Ok(root) = crate::config::load_config_file(&config_path) {
problems.extend(parse_mcp_servers_with_problems(&root).problems);
}
}
problems
}
/// MCP server names with `enabled = false` in config.toml (including project overrides).
pub fn disabled_mcp_server_names(cwd: &std::path::Path) -> std::collections::HashSet<String> {
let mut disabled: std::collections::HashSet<String> = load_all_mcp_configs(cwd)
@ -1518,6 +1654,133 @@ mod tests {
}
/// Covers all canonical wire values plus the unknown/corrupt fallback.
#[test]
fn parse_mcp_servers_skips_unparseable_entries() {
let root = toml::from_str::<TomlValue>(
r#"
mcp_servers.broken = "not-a-table"
[mcp_servers.also_broken]
enabled = "yes"
[mcp_servers.ok]
command = "echo"
args = ["hi"]
"#,
)
.unwrap();
let servers = parse_mcp_servers_from_toml(&root);
assert!(!servers.contains_key("broken"));
assert!(!servers.contains_key("also_broken"));
assert!(servers.contains_key("ok"));
}
#[test]
fn parse_mcp_server_config_reports_unknown_fields() {
let value = toml::from_str::<TomlValue>(
r#"
command = "echo"
enabeld = false
"#,
)
.unwrap();
let (config, unknown_fields) = deserialize_mcp_server_config(&value).unwrap();
assert!(
config.enabled,
"the misspelled field must not silently disable the server"
);
assert_eq!(unknown_fields, vec!["enabeld"]);
}
#[test]
fn parse_mcp_servers_drops_transport_less_entry() {
let root = toml::from_str::<TomlValue>(
r#"
[mcp_servers.github]
enabled = false
[mcp_servers.linear]
command = "npx"
args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"]
"#,
)
.unwrap();
let ParsedMcpServers { servers, problems } = parse_mcp_servers_with_problems(&root);
assert!(
!servers.contains_key("github"),
"transport-less entry is dropped, not kept"
);
assert!(servers.contains_key("linear"));
assert!(servers["linear"].enabled);
let problem = problems
.iter()
.find(|p| p.server == "github")
.expect("github problem reported");
assert_eq!(problem.severity, McpServerProblemSeverity::Error);
assert!(
problem.message.contains("disabled_mcp_servers"),
"{problem:?}"
);
}
#[test]
fn parse_mcp_servers_rejects_enabled_without_transport() {
let root = toml::from_str::<TomlValue>(
r#"
[mcp_servers.half]
enabled = true
"#,
)
.unwrap();
let ParsedMcpServers { servers, problems } = parse_mcp_servers_with_problems(&root);
assert!(
!servers.contains_key("half"),
"enabled without command/url must be dropped"
);
assert!(problems.iter().any(|p| p.server == "half"));
}
#[test]
fn parse_mcp_servers_rejects_blank_transport() {
let root = toml::from_str::<TomlValue>(
r#"
[mcp_servers.blank_url]
url = " "
[mcp_servers.blank_cmd]
command = ""
"#,
)
.unwrap();
let ParsedMcpServers { servers, problems } = parse_mcp_servers_with_problems(&root);
assert!(!servers.contains_key("blank_url"), "blank url dropped");
assert!(!servers.contains_key("blank_cmd"), "blank command dropped");
assert_eq!(
problems
.iter()
.filter(|p| p.severity == McpServerProblemSeverity::Error)
.count(),
2,
"both blank transports reported: {problems:?}"
);
}
#[test]
fn json_map_skips_bad_entry_and_keeps_the_rest() {
// One transport-less entry must not drop its siblings in the same JSON
// file (.mcp.json / ~/.claude.json).
let value = serde_json::json!({
"mcpServers": {
"bad": { "enabled": false },
"good": { "command": "npx", "args": ["-y", "pkg"] }
}
});
let config = mcp_config_from_json_value(&value);
assert!(!config.mcp_servers.contains_key("bad"));
assert!(config.mcp_servers.contains_key("good"));
assert!(config.mcp_servers["good"].enabled);
}
#[test]
fn test_parse_mcp_servers_empty() {
let root = toml::from_str::<TomlValue>("").unwrap();

View file

@ -43,7 +43,7 @@ fn include_cursor_hooks(compat: &xai_grok_tools::types::compat::CompatConfig) ->
}
/// Global + project hook source paths. Registry file is never a discovery
/// source; Claude/Cursor globals are appended when gates are on.
/// source; compatible vendor globals are appended when their gates are on.
pub fn discover_hook_source_paths(
git_root: Option<&Path>,
compat: &xai_grok_tools::types::compat::CompatConfig,
@ -110,7 +110,37 @@ pub fn discover_hooks(
compat: &xai_grok_tools::types::compat::CompatConfig,
trusted: bool,
) -> (xai_grok_hooks::discovery::HookRegistry, Vec<HookError>) {
// Read fresh each call (not cached): a mid-session `/hooks` reload must see an
// updated `config.toml` / `managed_config.toml`. This is lighter than
// `ConfigLayers::load` (only the small per-layer files, no campaigns, version
// overrides, or MDM).
let config_layers = xai_grok_config::hook_config_layers();
assemble_hooks(&config_layers, git_root, compat, trusted)
}
/// Pure, injectable core: combine config-layer hooks with file-source hooks and
/// dedup once. Config-layer specs are placed first so that, under the first-wins
/// dedup in [`xai_grok_hooks::discovery::registry_from_specs_deduped`], a config
/// hook wins over a byte-identical file hook. `config_layers` is a parameter (not
/// read here) so tests can drive it with hand-built layers.
pub fn assemble_hooks(
config_layers: &[xai_grok_config::HookConfigLayer],
git_root: Option<&Path>,
compat: &xai_grok_tools::types::compat::CompatConfig,
trusted: bool,
) -> (xai_grok_hooks::discovery::HookRegistry, Vec<HookError>) {
let (mut specs, mut errors) =
xai_grok_hooks::config::parse_hooks_from_config_layers(config_layers);
let source_paths = discover_hook_source_paths(git_root, compat);
let (global_sources, project_sources) = source_paths.as_sources(trusted);
xai_grok_hooks::discovery::load_hooks_from_sources(&global_sources, &project_sources)
let (file_specs, file_errors) =
xai_grok_hooks::discovery::collect_specs_from_sources(&global_sources, &project_sources);
specs.extend(file_specs);
errors.extend(file_errors);
(
xai_grok_hooks::discovery::registry_from_specs_deduped(specs),
errors,
)
}

View file

@ -0,0 +1,85 @@
//! Startup logging of effective OS resource limits, so EMFILE/EAGAIN/OOM
//! crash reports carry the ceilings that were in effect.
/// Emit one `startup.effective_limits` entry to the unified log.
pub fn log_effective_limits() {
xai_grok_telemetry::unified_log::info("startup.effective_limits", None, Some(gather()));
}
fn gather() -> serde_json::Value {
serde_json::json!({
"nofile": rlimit_pair(RlimitKind::Nofile),
"nproc": rlimit_pair(RlimitKind::Nproc),
"available_parallelism": std::thread::available_parallelism().map(usize::from).ok(),
"cgroup": cgroup_v2_limits(),
})
}
enum RlimitKind {
Nofile,
Nproc,
}
/// `[soft, hard]` for the given rlimit; `RLIM_INFINITY` maps to JSON null.
#[cfg(unix)]
fn rlimit_pair(kind: RlimitKind) -> Option<serde_json::Value> {
let resource = match kind {
RlimitKind::Nofile => libc::RLIMIT_NOFILE,
RlimitKind::Nproc => libc::RLIMIT_NPROC,
};
let mut lim = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
// SAFETY: getrlimit writes only into local `lim`.
if unsafe { libc::getrlimit(resource, &mut lim) } != 0 {
return None;
}
let val = |v: libc::rlim_t| (v != libc::RLIM_INFINITY).then_some(v);
Some(serde_json::json!([val(lim.rlim_cur), val(lim.rlim_max)]))
}
#[cfg(not(unix))]
fn rlimit_pair(_kind: RlimitKind) -> Option<serde_json::Value> {
None
}
/// Best-effort cgroup v2 pids/memory ceilings — the limits behind EAGAIN
/// thread-spawn failures and memcg OOM kills on shared hosts. `None` on any
/// read error or non-cgroup-v2 environment.
#[cfg(target_os = "linux")]
fn cgroup_v2_limits() -> Option<serde_json::Value> {
let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
// cgroup v2 unified hierarchy line: "0::<path>".
let path = cgroup.lines().find_map(|l| l.strip_prefix("0::"))?.trim();
let read = |f: &str| {
std::fs::read_to_string(format!("/sys/fs/cgroup{path}/{f}"))
.ok()
.map(|s| s.trim().to_owned())
};
Some(serde_json::json!({
"pids_current": read("pids.current"),
"pids_max": read("pids.max"),
"memory_current": read("memory.current"),
"memory_max": read("memory.max"),
}))
}
#[cfg(not(target_os = "linux"))]
fn cgroup_v2_limits() -> Option<serde_json::Value> {
None
}
#[cfg(test)]
mod tests {
use super::gather;
#[test]
#[cfg(unix)]
fn gather_reports_rlimits_and_parallelism() {
let v = gather();
assert!(v["nofile"].is_array(), "nofile missing: {v}");
assert!(v["nproc"].is_array(), "nproc missing: {v}");
assert!(v["available_parallelism"].is_u64(), "parallelism: {v}");
}
}

View file

@ -1,6 +1,7 @@
pub mod config;
pub mod grok_auth_credentials;
pub mod hooks;
pub mod limits;
pub(crate) mod subprocess;
pub(crate) mod user_identity;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,122 @@
//! Built-binary e2e: SessionEnd hooks fire on headless process exit.
//!
//! Regression for the non-leader quit path that used to cancel the agent
//! without flushing session actors, so SessionEnd never ran on `/exit` /
//! `grok -p` exit.
//!
//! `#[ignore]`d by default — needs the grok binary (`GROK_BINARY` or a local
//! debug build):
//! ```bash
//! cargo test -p xai-grok-shell --test test_session_end_hook_e2e -- --ignored
//! ```
//!
//! CI coverage of the same machinery without a built binary lives in
//! `xai_grok_shell::agent::activity` tests (the flush quiesce loop and its
//! grace expiry) and `xai_grok_pager::acp::spawn` tests (the worker join:
//! clean exit, worker error, panic rendering, and the abandon-at-budget
//! branch this e2e cannot reach).
use xai_grok_test_support::*;
/// Runs headless with a SessionEnd hook that writes stdin + a marker file.
async fn run_with_session_end_hook() -> (HeadlessResult, MockInferenceServer, tempfile::TempDir) {
let state_dir = tempfile::TempDir::new().expect("create state dir");
let server = MockInferenceServer::start()
.await
.expect("start mock server");
let sandbox = TestSandbox::builder().mock_url(server.url()).git().build();
let state = state_dir.path().display();
let script_path = sandbox.home().join("session_end_hook.sh");
std::fs::write(
&script_path,
format!(
"#!/bin/sh\n\
cat > {state}/stdin.json\n\
touch {state}/marker\n\
exit 0\n"
),
)
.expect("write hook script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
.expect("chmod hook script");
}
let hooks_dir = sandbox.grok_home().join("hooks");
std::fs::create_dir_all(&hooks_dir).expect("create hooks dir");
std::fs::write(
hooks_dir.join("session_end.json"),
serde_json::json!({
"hooks": {
"SessionEnd": [{
"hooks": [{
"type": "command",
"command": format!("sh {}", script_path.display()),
"timeout": 30
}]
}]
}
})
.to_string(),
)
.expect("write hook config");
let mut cmd = tokio::process::Command::new(grok_binary());
cmd.args(["-p", "say hello", "--yolo"])
.current_dir(sandbox.workspace())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let result = run_headless_in_sandbox(cmd, sandbox).await;
(result, server, state_dir)
}
#[tokio::test]
#[ignore]
async fn session_end_hook_fires_on_headless_exit() {
let (result, server, state_dir) = run_with_session_end_hook().await;
assert_headless_success(&result, "session_end hook e2e", Some(&server));
let marker = state_dir.path().join("marker");
assert!(
marker.is_file(),
"SessionEnd hook must write a marker on process exit (non-leader flush path); \
missing {marker:?}. stderr:\n{}",
result.stderr
);
let stdin_path = state_dir.path().join("stdin.json");
let text = std::fs::read_to_string(&stdin_path)
.unwrap_or_else(|e| panic!("read {}: {e}", stdin_path.display()));
let envelope: serde_json::Value =
serde_json::from_str(&text).unwrap_or_else(|e| panic!("hook stdin not JSON: {e}\n{text}"));
let event = envelope["hookEventName"]
.as_str()
.unwrap_or_else(|| panic!("hookEventName missing: {envelope}"));
assert!(
event == "session_end" || event == "SessionEnd",
"expected SessionEnd event name, got {event:?}"
);
// `reason` is an already-shipped part of the hook payload that user scripts
// match on: `shutdown` is emitted by the `SessionCommand::Shutdown` arm
// (leader auto-update / relaunch today), `channel_closed` by the actor's
// channel-closed arm. This change adds no new value — it routes non-leader
// exits through the existing Shutdown command — so renaming `shutdown` to
// something narrower here would break those scripts. Future distinct causes
// (e.g. a signal-driven or idle-eviction end) should be added as new values
// alongside it.
let reason = envelope["reason"]
.as_str()
.unwrap_or_else(|| panic!("reason missing: {envelope}"));
assert_eq!(
reason, "shutdown",
"flush path should send SessionCommand::Shutdown (reason=shutdown), got {reason:?}"
);
}