Synced from monorepo

Synced from monorepo

Changes:
- Temporarily disable session share link creation in the TUI
- Do not approve plan on empty Enter from the revise prompt
- Expose chat product Skills via ACP available_commands_update
- Return immediately from a blocking wait on an already-completed ACP task
- Split headless pager module for clearer structure
- Stop git worktree prune from removing user registrations on resume
- Use compaction sampler tokenizer for item token counts
- Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE
- Cancel all session subagents when the user stops
- Let the session persistence actor exit when its session ends
- Make fullscreen terminal resize much cheaper on long sessions
- Report honestly from kill_task when an ACP task does not exist
- Hide /usage for external-auth deployments
- Forward the history-load trailer’s computer_reason to the client
- Remove ineffective no-op tool reminder
- Declare slash-command screen-mode support in one place
- Keep settings enum picker on the committed value until Enter
- Reap a PTY’s full process tree
- Stream tool calls from headless mode over ACP
- Bridge gateway task lifecycle to ACP for chat session background tasks
- Don’t warn about truncated history on a suppressed replay
- Fit full-replace summarizer input and recover on context-length errors
- Stop dropping agents over an unrecognized frontmatter color
- Add /undo as a slash alias for /rewind
- Harden sleep/wake token-refresh paths against forced re-login
- Add session/list ACP method
- Give each sampling backend its own conversion module
- Treat an unenrolled child process as a lint error
- Suppress the cancelled marker on send-now wake turns
- Stop tearing down Roslyn on every edit, and read C# diagnostics

Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
This commit is contained in:
grokkybara[bot] 2026-07-30 19:07:40 +00:00
commit dd04f397b1
367 changed files with 29489 additions and 10051 deletions

View file

@ -6,7 +6,7 @@ use std::sync::{Arc, OnceLock};
use std::time::Instant;
use dashmap::DashMap;
use xai_file_utils::events::{Event, EventWriter, ToolOutcome};
use xai_file_utils::events::{Event, EventWriter, ToolCompletedSource, ToolOutcome};
use xai_file_utils::queue::UploadQueueStats;
use xai_tool_protocol::{ToolServerLifecycleStatus, ToolServerStatusPayload};
@ -442,11 +442,13 @@ impl ActivityTracker {
// captured writer handle keeps the file open). `duration_ms` is always
// truthful because the start time is paired with the writer.
if let Some((_, (started_ms, writer))) = self.call_started_ms.remove(call_id) {
let duration_ms = now.saturating_sub(started_ms);
// Workspace = hub/proxy hop; join package durations on shell rows (no source).
writer.emit(Event::ToolCompleted {
tool_name,
duration_ms,
duration_ms: now.saturating_sub(started_ms),
outcome,
tool_call_id: call_id.to_owned(),
source: ToolCompletedSource::Workspace,
});
}
}
@ -1879,6 +1881,7 @@ mod tests {
assert_eq!(events[1]["type"], "tool_completed");
assert_eq!(events[1]["tool_name"], "read_file");
assert_eq!(events[1]["outcome"], "success");
assert_eq!(events[1]["source"], "workspace");
assert!(
events[1]["duration_ms"].as_u64().is_some(),
"ToolCompleted must carry a duration_ms"

View file

@ -789,6 +789,7 @@ mod tests {
/// Spawn a long-sleeping child to stand in for a predecessor process.
#[cfg(target_os = "linux")]
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
fn spawn_predecessor() -> Child {
Command::new("sleep")
.arg("300")
@ -884,6 +885,7 @@ mod tests {
// escalation can end it. It touches a marker once the trap is
// installed so the test cannot signal it during bash startup.
let trap_ready = dir.path().join("trap-ready");
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
let mut child = Command::new("bash")
.arg("-c")
.arg(format!(

View file

@ -144,6 +144,7 @@ where
let max_matches = params.max_matches.unwrap_or(DEFAULT_MAX_MATCHES);
let mut cmd = build_ripgrep_command(root, params);
#[allow(clippy::disallowed_methods)] // waited on below; killed when cancelled
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn ripgrep: {}", e))?;

View file

@ -72,7 +72,7 @@ struct AuthEntry {
expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
fn default_auth_path() -> anyhow::Result<PathBuf> {
pub fn default_auth_path() -> anyhow::Result<PathBuf> {
let grok = xai_grok_config::user_grok_home()
.ok_or_else(|| anyhow::anyhow!("no user grok home (set $GROK_HOME or $HOME)"))?;
Ok(grok.join("auth.json"))
@ -80,6 +80,11 @@ fn default_auth_path() -> anyhow::Result<PathBuf> {
/// Read the active OIDC entry and its scope key. The key is threaded to the
/// refresh write so rotation updates exactly the entry that was read.
///
/// When several OIDC entries qualify, pick the **latest `expires_at`** — the
/// entry the shell is actively refreshing. The previous first-key selection
/// was alphabetical and could rotate a *different principal's* RT chain than
/// the one the user's sessions use.
fn read_auth_entry(path: &Path) -> anyhow::Result<(String, AuthEntry)> {
if !path.exists() {
anyhow::bail!(
@ -95,7 +100,14 @@ fn read_auth_entry(path: &Path) -> anyhow::Result<(String, AuthEntry)> {
entries
.into_iter()
.find(|(_, e)| e.refresh_token.is_some() && e.oidc_issuer.is_some())
.filter(|(_, e)| e.refresh_token.is_some() && e.oidc_issuer.is_some())
// Strictly-greater comparison: ties (including all-`None`) keep the
// first candidate in BTreeMap (alphabetical) order, so single-entry
// and legacy no-`expires_at` files behave exactly as before.
.fold(None::<(String, AuthEntry)>, |best, cand| match best {
Some(b) if cand.1.expires_at <= b.1.expires_at => Some(b),
_ => Some(cand),
})
.ok_or_else(|| {
anyhow::anyhow!(
"no OIDC auth entry found in {}. Run `grok login` first.",
@ -134,22 +146,131 @@ fn build_oidc_provider(
builder = builder.expires_at(exp);
}
// The SDK invokes `on_refresh` inside its async refresh path, and the
// persist below blocks on a cross-process file lock — run it on its own
// thread so a contended lock never stalls the runtime.
builder = builder.on_refresh(Arc::new(move |event: &RefreshEvent| {
if let Err(e) = write_refreshed_token(&auth_path, &scope_key, event) {
tracing::warn!(error = %e, "failed to persist refreshed token to auth.json");
}
let auth_path = auth_path.clone();
let scope_key = scope_key.clone();
let event = event.clone();
std::thread::spawn(move || {
if let Err(e) = write_refreshed_token(&auth_path, &scope_key, &event) {
tracing::warn!(error = %e, "failed to persist refreshed token to auth.json");
}
});
}));
Ok(Arc::new(builder.build()))
}
/// How long [`lock_auth_file`] polls for the shared `auth.json.lock` before
/// skipping the persist. Covers the shell's normal refresh hold (~1 s); its
/// worst-case 45 s budget is deliberately not waited out — losing one persist
/// is recoverable (see [`write_refreshed_token`]), stalling the persist thread
/// for a minute is not worth it.
const AUTH_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// RAII flock on the sibling `auth.json.lock` — the same advisory lock every
/// grok-shell `auth.json` writer takes. Polling `try_lock` rather than a
/// blocking `flock` to bound the wait; never breaks a held lock (a stale
/// holder here would be the shell mid-refresh, exactly the writer we must not
/// race).
struct AuthFileLockGuard {
_file: std::fs::File,
}
fn lock_auth_file(auth_json_path: &Path) -> Option<AuthFileLockGuard> {
use fs2::FileExt;
use std::io::Write;
let lock_path = auth_json_path.with_file_name("auth.json.lock");
let deadline = std::time::Instant::now() + AUTH_LOCK_TIMEOUT;
loop {
// The shell's stale-lock recovery breaks locks by unlink+recreate, so
// only a flock on the live inode counts (mirrors the shell's own
// acquire path). A dead inode falls through to the same deadline and
// sleep as a busy lock — retrying without them spins this thread for
// as long as the check keeps failing.
if let Ok(mut file) = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
&& file.try_lock_exclusive().is_ok()
&& lock_inode_is_live(&file, &lock_path)
{
// Holder info (`PID:TS`) through the locked fd, so the shell can
// identify (and, if this process dies, break) our hold.
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let _ = file.set_len(0);
let _ = write!(file, "{}:{ts}", std::process::id());
let _ = file.sync_all();
return Some(AuthFileLockGuard { _file: file });
}
if std::time::Instant::now() >= deadline {
return None;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
/// `fstat(fd)` vs `stat(path)`: `false` when the locked file was concurrently
/// unlinked and recreated (our flock would be on the dead inode).
#[cfg(unix)]
fn lock_inode_is_live(file: &std::fs::File, path: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
match (file.metadata(), std::fs::metadata(path)) {
(Ok(fd), Ok(p)) => fd.ino() == p.ino() && fd.dev() == p.dev(),
_ => false,
}
}
#[cfg(not(unix))]
fn lock_inode_is_live(_file: &std::fs::File, _path: &Path) -> bool {
true
}
fn write_refreshed_token(path: &Path, scope_key: &str, event: &RefreshEvent) -> anyhow::Result<()> {
// Read-modify-write under the shared advisory lock. Writing unlocked here
// raced the shell's own refresh writer: whichever wrote second silently
// rolled back the other's freshly rotated refresh token on disk — a
// guaranteed future `invalid_grant` for every session sharing the file.
let Some(_lock) = lock_auth_file(path) else {
// The rotated token still serves this process from memory, so warn
// rather than fail — but disk now trails the IdP by one rotation, and
// a fresh process that picks it up will present a spent token.
tracing::warn!(
timeout = ?AUTH_LOCK_TIMEOUT,
"auth.json.lock busy; skipping refreshed-token persist (disk left one rotation behind)"
);
return Ok(());
};
let content = std::fs::read_to_string(path)?;
let mut raw: serde_json::Value = serde_json::from_str(&content)?;
let Some(obj) = raw.get_mut(scope_key).and_then(|e| e.as_object_mut()) else {
anyhow::bail!("auth entry '{scope_key}' not found while persisting refreshed token");
};
// Never roll disk back to an older token. Each refresh persists on its own
// thread and a sibling shell writes the same file, so writes can arrive out
// of order; the loser would replace a live refresh token with a spent one
// and guarantee a future `invalid_grant`.
if let Some(new_expiry) = event.expires_at
&& let Some(disk_expiry) = obj
.get("expires_at")
.and_then(serde_json::Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
&& disk_expiry.with_timezone(&chrono::Utc) >= new_expiry
{
tracing::debug!("auth.json already holds a same-or-newer token; skipping persist");
return Ok(());
}
obj.insert(
"key".to_owned(),
serde_json::Value::String(event.access_token.clone()),
@ -416,6 +537,38 @@ mod tests {
assert_eq!(updated["legacy"]["key"], "xai-old"); // untouched
}
/// With several OIDC entries (personal + enterprise login), the one with
/// the latest `expires_at` wins — that's the entry the user's grok
/// sessions actively refresh. Alphabetical-order selection could adopt a
/// different principal's refresh token and rotate it out from under the
/// shell.
#[test]
fn read_auth_entry_prefers_latest_expiry() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{
"aaa-stale": { "key": "eyJ.a", "refresh_token": "rt-a", "oidc_issuer": "https://auth.x.ai", "expires_at": "2026-01-01T00:00:00Z" },
"zzz-active": { "key": "eyJ.z", "refresh_token": "rt-z", "oidc_issuer": "https://auth.x.ai", "expires_at": "2026-06-01T00:00:00Z" }
}"#,
);
let (key, entry) = read_auth_entry(&path).unwrap();
assert_eq!(key, "zzz-active", "latest expires_at must win");
assert_eq!(entry.refresh_token.as_deref(), Some("rt-z"));
// An entry with no expires_at never beats one with a timestamp.
let path = write_auth_json(
dir.path(),
r#"{
"aaa-with-expiry": { "key": "eyJ.a", "refresh_token": "rt-a", "oidc_issuer": "https://auth.x.ai", "expires_at": "2026-01-01T00:00:00Z" },
"zzz-no-expiry": { "key": "eyJ.z", "refresh_token": "rt-z", "oidc_issuer": "https://auth.x.ai" }
}"#,
);
let (key, _) = read_auth_entry(&path).unwrap();
assert_eq!(key, "aaa-with-expiry");
}
#[test]
fn write_refreshed_token_targets_exact_scope_key() {
// Non-sorted order: refresh must update the read-selected key ("aaa"),
@ -447,6 +600,32 @@ mod tests {
assert_eq!(updated["zzz"]["refresh_token"], "rt-z");
}
/// Persists run on detached threads and race a sibling shell writing the
/// same file, so a late write must not replace a live refresh token with
/// the one it already rotated away.
#[test]
fn write_refreshed_token_does_not_roll_back_a_newer_token() {
let dir = tempfile::tempdir().unwrap();
let path = write_auth_json(
dir.path(),
r#"{
"oidc": { "key": "eyJ.newer", "refresh_token": "rt-newer", "oidc_issuer": "https://auth.x.ai", "expires_at": "2026-06-01T00:00:00Z" }
}"#,
);
let stale = RefreshEvent {
access_token: "eyJ.older".into(),
new_refresh_token: Some("rt-older".into()),
expires_at: Some("2026-05-01T00:00:00Z".parse().unwrap()),
};
write_refreshed_token(&path, "oidc", &stale).unwrap();
let updated: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(updated["oidc"]["refresh_token"], "rt-newer");
assert_eq!(updated["oidc"]["key"], "eyJ.newer");
}
#[test]
fn write_refreshed_token_preserves_existing_rt_when_not_rotated() {
let dir = tempfile::tempdir().unwrap();

View file

@ -314,6 +314,7 @@ async fn supervise_loop<F>(
let mut step = 0u32;
while !*shutdown.borrow() {
let started = Instant::now();
#[allow(clippy::disallowed_methods)] // supervised preview child, killed on drop
let spawned = make_command().and_then(|mut cmd| cmd.kill_on_drop(true).spawn());
let mut child = match spawned {
Ok(child) => child,