Synced from monorepo

Changes:
- Persist submitter identity for /feedback
- Let custom models use rotating tokens from named auth providers
- Template stale tool/param name literals in server-native descriptions
- Minimal mode commits thinking in full, lookups as one-liners
- Tighten durable append internals
- Nudge model to end turn on no-op bash commands
- Per-fetch signing nonce in the managed-config envelope, with a server-side replay probe
- Include working tree in startup status
This commit is contained in:
grokkybara[bot] 2026-07-20 18:06:59 +01:00
commit a881e6703f
140 changed files with 6746 additions and 2377 deletions

View file

@ -598,12 +598,13 @@ mod tests {
fn has_placeholder(item: &ConversationItem) -> bool {
matches!(
item,
ConversationItem::User(u) if u.content.iter().any(|p| matches!(
p,
ContentPart::Text { text } if text.as_ref() == IMAGE_COMPACT_PLACEHOLDER
))
)
item,
ConversationItem::User(u) if u.content.iter().any(|p| matches!(
p,
ContentPart::Text { text }
if text.as_ref() == IMAGE_COMPACT_PLACEHOLDER
))
)
}
// Images are sized ~100 KB so the ~235 B placeholder that replaces an

View file

@ -2020,10 +2020,12 @@ description: Minimal agent
assert_eq!(v, McpServerRef::Named("slack".to_string()));
let v: McpServerRef =
serde_json::from_value(serde_json::json!({ "s" : { "type" : "stdio" } })).unwrap();
assert!(matches!(v, McpServerRef::Inline { ref name, .. } if name == "s"));
assert!(matches!(v, McpServerRef::Inline { ref name, .. }
if name == "s"));
let v: McpServerRef =
serde_json::from_value(serde_json::json!({ "name" : "s", "type" : "stdio" })).unwrap();
assert!(matches!(v, McpServerRef::Inline { ref name, .. } if name == "s"));
assert!(matches!(v, McpServerRef::Inline { ref name, .. }
if name == "s"));
assert!(
serde_json::from_value::<McpServerRef>(serde_json::json!({ "type" :
"stdio" }))

View file

@ -35,7 +35,7 @@ pub const GIT_STATUS_CHARACTER_LIMIT: usize = 10_000;
/// and no empty code fence is emitted), otherwise the status capped at
/// [`GIT_STATUS_CHARACTER_LIMIT`] -- snapped back to the last newline -- with
/// the `... (git status truncated)` marker appended.
fn normalize_git_status(status: &str) -> Option<String> {
pub fn normalize_git_status(status: &str) -> Option<String> {
let status = status.trim();
if status.is_empty() {
return None;

View file

@ -75,14 +75,12 @@ pub fn patch_touches_any(patch: &toml::Table, paths: &[PatchPath]) -> bool {
paths.iter().any(|p| patch_touches_path(patch, p))
}
/// Keys stripped from every applied patch so an override can't re-introduce a
/// nested `version_overrides`/`campaigns` array (recursive re-injection). This
/// const owns the recursive-injection keys for every override kind; [`apply_patches`]
/// takes the strip list as a parameter so the strip step itself stays key-agnostic.
pub const PATCH_STRIP_KEYS: &[&str] = &["version_overrides", "campaigns"];
/// Keys stripped from every applied patch: an override cannot re-inject nested
/// `version_overrides`/`campaigns` or define `[auth_provider.*]` command tables.
pub const PATCH_STRIP_KEYS: &[&str] = &["version_overrides", "campaigns", "auth_provider"];
/// Deep-merge each patch in iteration order (later wins on a leaf), stripping
/// `strip_keys` from every patch first.
/// `strip_keys` (top level) first.
pub fn apply_patches(
config: &mut toml::Value,
patches: impl IntoIterator<Item = toml::Table>,
@ -133,10 +131,28 @@ mod tests {
let mut p = toml::Table::new();
p.insert("version_overrides".into(), toml::Value::Array(vec![]));
p.insert("campaigns".into(), toml::Value::Array(vec![]));
p.insert(
"auth_provider".into(),
toml::Value::Table(toml::Table::new()),
);
p.insert("keep".into(), toml::Value::Boolean(true));
apply_patches(&mut cfg2, std::iter::once(p), PATCH_STRIP_KEYS);
assert!(cfg2.get("version_overrides").is_none());
assert!(cfg2.get("campaigns").is_none());
assert!(cfg2.get("auth_provider").is_none());
assert_eq!(cfg2["keep"].as_bool(), Some(true));
// Top-level strip only: a model may still reference a local provider by name.
let mut cfg3 = toml::Value::Table(toml::Table::new());
let p = table(
"[auth_provider.injected]\ncommand = \"evil\"\n\
[model.x]\nauth_provider = \"local-name\"\n",
);
apply_patches(&mut cfg3, std::iter::once(p), PATCH_STRIP_KEYS);
assert!(cfg3.get("auth_provider").is_none());
assert_eq!(
cfg3["model"]["x"]["auth_provider"].as_str(),
Some("local-name")
);
}
}

View file

@ -9,8 +9,8 @@
//! marker stays the (best-effort) authority.
use base64::Engine;
pub use prod_mc_cli_chat_proxy_types::{
MANAGED_IDENTITY_TYP, MANAGED_POLICY_TYP, ManagedIdentityClaim, SignatureEnvelope,
SignedPayload, now_unix,
MANAGED_CONFIG_NONCE_ECHO_HEADER, MANAGED_IDENTITY_TYP, MANAGED_POLICY_TYP,
ManagedIdentityClaim, SignatureEnvelope, SignedPayload, is_server_nonce_shape, now_unix,
};
/// Compiled-in trusted Ed25519 public keys, `(key_id, raw 32 bytes)`; more than one
/// entry only during a rotation. Empty ships dark (see [`verification_active`]).
@ -327,6 +327,27 @@ fn write_envelope_at(path: &std::path::Path, sidecar: &SignatureEnvelope) -> std
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
crate::fs_atomic::write_atomically(path, &json, Some(0o600))
}
/// Persisted envelope nonce for [`MANAGED_CONFIG_NONCE_ECHO_HEADER`] (unverified;
/// telemetry only, never a trust input). Both guards fail open by skipping the
/// echo: only the server mint shape (header-safe, so a corrupt sidecar can't brick
/// the fetch), and only a payload issued to `fetch_principal`. A leftover sidecar
/// from a prior identity must not read as a cross-tenant replay upstream.
pub fn stored_envelope_nonce(
home: &std::path::Path,
fetch_principal: Option<&str>,
) -> Option<String> {
let fetch_principal = fetch_principal?;
let SidecarRead::Present(sidecar) = read_sidecar(home) else {
return None;
};
let payload: SignedPayload = serde_json::from_str(&sidecar.signed_payload).ok()?;
let issued_to = payload
.deployment_id
.as_deref()
.or(payload.team_id.as_deref());
(issued_to == Some(fetch_principal) && is_server_nonce_shape(&payload.nonce))
.then_some(payload.nonce)
}
/// Whether an authentic claim IMPOSES fail-closed enforcement: verified, bound to
/// the KNOWN `expected_principal`, in-date vs the caller-clamped `now_unix`, and
/// `fail_closed`. Anything else imposes nothing: permissive (must not override a

View file

@ -33,6 +33,7 @@ fn payload() -> SignedPayload {
requirements: Some("[features]\nweb_fetch = false\n".into()),
fail_closed: false,
expires_at: 4_000_000_000,
nonce: String::new(),
key_id: "v1".into(),
}
}
@ -888,6 +889,7 @@ fn unknown_signed_key_id_is_rejected() {
let home = dir.path();
let (kp, pubkey) = test_keypair();
let p = SignedPayload {
nonce: String::new(),
key_id: "v9".into(),
fail_closed: true,
..payload()
@ -930,6 +932,7 @@ fn rotation_selects_the_trusted_key_by_signed_key_id() {
let v1 = sign(&kp1, &payload());
let v2_payload = SignedPayload {
nonce: String::new(),
key_id: "v2".into(),
..payload()
};

View file

@ -738,7 +738,8 @@ mod tests {
);
assert_eq!(result.results.len(), 1);
assert!(
matches!(&result.results[0], HookRunResult::Failed { hook_name, .. } if hook_name == "crasher"),
matches!(&result.results[0], HookRunResult::Failed { hook_name, .. }
if hook_name == "crasher"),
"the failure must still appear in run_results for UI scrollback, got {:?}",
result.results
);

View file

@ -3807,11 +3807,14 @@ mod tests {
)
.unwrap();
assert!(s.items.iter().any(|it| matches!(it,
SeqItem::Message { text: Some(t), .. } if t.contains("call <svc>") && !t.contains("&lt;"))));
SeqItem::Message { text: Some(t), .. }
if t.contains("call <svc>") && !t.contains("&lt;"))));
assert!(s.items.iter().any(|it| matches!(it,
SeqItem::Note { text, .. } if text.contains("memo <o>") && !text.contains("&lt;"))));
SeqItem::Note { text, .. }
if text.contains("memo <o>") && !text.contains("&lt;"))));
assert!(s.items.iter().any(|it| matches!(it,
SeqItem::Divider { text } if text.contains("c <x>") && !text.contains("&lt;"))));
SeqItem::Divider { text }
if text.contains("c <x>") && !text.contains("&lt;"))));
// Class members and ER attributes have no clean quoted form (splitter
// fragments unquoted `;`; ER drops quoted text as a comment), so exercise

View file

@ -170,7 +170,8 @@ impl InitProgress {
/// True iff every per-server handshake has settled and `finish_init`
/// has fired. Pairs with [`Self::is_in_progress`].
pub fn is_complete(&self) -> bool {
matches!(self, Self::Finished { handshaking } if handshaking.is_empty())
matches!(self, Self::Finished { handshaking }
if handshaking.is_empty())
}
/// True iff any init work is outstanding — either we are pre-

View file

@ -867,7 +867,8 @@ mod tests {
let result = execute_dream(&lock, &storage, response, 5, 300, &sdir, &[]);
assert!(
matches!(result.status, DreamStatus::Completed { chars_written } if chars_written == response.chars().count())
matches!(result.status, DreamStatus::Completed { chars_written }
if chars_written == response.chars().count())
);
assert_eq!(result.sessions_eligible, 5);
assert_eq!(result.cleaned_stems.len(), 0);

View file

@ -115,21 +115,18 @@ pub fn is_committable(entry: &ScrollbackEntry, turn_running: bool, is_last: bool
}
/// The display mode a block should be committed in (minimal mode, print-once).
///
/// Independent of the interactive `default_display_mode` / `finished_display_mode`
/// because committed scrollback can't be re-folded later (it is static terminal
/// text). The per-type fidelity policy (design decision K9) lives here in one
/// place: messages full, reasoning collapsed-but-expandable, tool output
/// truncated, diffs always full.
pub fn minimal_commit_display_mode(block: &RenderBlock) -> DisplayMode {
match block {
// Diffs are the key artifact of an edit — always full.
RenderBlock::ToolCall(ToolCallBlock::Edit(_)) => DisplayMode::Expanded,
// Other tool calls: truncated (first/last N + hidden-line count).
RenderBlock::ToolCall(
tc @ (ToolCallBlock::Search(_)
| ToolCallBlock::Read(_)
| ToolCallBlock::ListDir(_)
| ToolCallBlock::MemorySearch(_)
| ToolCallBlock::IntegrationSearch(_)),
) if tc.is_success() => DisplayMode::Collapsed,
RenderBlock::ToolCall(_) => DisplayMode::Truncated,
// Reasoning: collapsed marker ("Thought for Xs"); expandable via Ctrl+E.
RenderBlock::Thinking(_) => DisplayMode::Collapsed,
// Messages, system/session events, etc.: full.
RenderBlock::Thinking(_) => DisplayMode::Expanded,
_ => DisplayMode::Expanded,
}
}
@ -1342,7 +1339,7 @@ mod tests {
fn commit_display_mode_policy() {
assert_eq!(
minimal_commit_display_mode(&RenderBlock::thinking("reasoning")),
DisplayMode::Collapsed
DisplayMode::Expanded
);
assert_eq!(
minimal_commit_display_mode(&RenderBlock::edit("file.rs", None)),
@ -1357,4 +1354,42 @@ mod tests {
DisplayMode::Expanded
);
}
#[test]
fn commit_display_mode_lookups_collapse_on_success_only() {
use xai_grok_pager::scrollback::blocks::{
ListDirToolCallBlock, ReadToolCallBlock, SearchToolCallBlock,
};
assert_eq!(
minimal_commit_display_mode(&RenderBlock::search("pat", 3, vec![])),
DisplayMode::Collapsed
);
assert_eq!(
minimal_commit_display_mode(&RenderBlock::read("src/lib.rs", None)),
DisplayMode::Collapsed
);
assert_eq!(
minimal_commit_display_mode(&RenderBlock::list_dir_with_output("src", "a.rs\nb.rs")),
DisplayMode::Collapsed
);
for failed in [
RenderBlock::ToolCall(ToolCallBlock::Search(
SearchToolCallBlock::new("pat").with_error("regex parse error"),
)),
RenderBlock::ToolCall(ToolCallBlock::Read(
ReadToolCallBlock::new("gone.rs").with_error("file not found"),
)),
RenderBlock::ToolCall(ToolCallBlock::ListDir(
ListDirToolCallBlock::new("gone/").with_error("no such directory"),
)),
] {
assert_eq!(
minimal_commit_display_mode(&failed),
DisplayMode::Truncated,
"failed lookup must stay truncated: {failed:?}"
);
}
}
}

View file

@ -397,21 +397,18 @@ mod tests {
assert!(check_quiet(0).is_pass());
assert!(check_quiet(QUIET_MAX_FRAMES).is_pass());
let result = check_quiet(QUIET_MAX_FRAMES + 1);
assert!(
matches!(result, InvariantResult::Violated { ref detail } if detail.contains("churn"))
);
assert!(matches!(result, InvariantResult::Violated { ref detail }
if detail.contains("churn")));
}
#[test]
fn screen_rejects_streaming_sessions_and_marker_loss() {
let streaming = check_screen(SessionKind::Streaming, 100, Some(100), &[]);
assert!(
matches!(streaming, InvariantResult::Violated { ref detail } if detail.contains("streaming"))
);
assert!(matches!(streaming, InvariantResult::Violated { ref detail }
if detail.contains("streaming")));
let lost = check_screen(SessionKind::BottomPinned, 100, None, &[]);
assert!(
matches!(lost, InvariantResult::Violated { ref detail } if detail.contains("no marker"))
);
assert!(matches!(lost, InvariantResult::Violated { ref detail }
if detail.contains("no marker")));
// Empty capture ⇒ no movement expected; a matching marker passes.
assert!(check_screen(SessionKind::BottomPinned, 100, Some(100), &[]).is_pass());
let moved = check_screen(SessionKind::BottomPinned, 100, Some(97), &[]);

View file

@ -157,9 +157,10 @@ pub(super) fn handle_mcp_tools_changed(notif: &acp::ExtNotification, app: &mut A
pub(super) fn agent_has_pending_mcps_fetch(app: &AppView, agent_id: AgentId) -> bool {
app.pending_effects.iter().any(|e| {
matches!(
e,
Effect::FetchMcpsList { agent_id: a, .. } if *a == agent_id
)
e,
Effect::FetchMcpsList { agent_id: a, .. }
if *a == agent_id
)
})
}

View file

@ -94,7 +94,8 @@
assert!(
app.pending_effects.iter().any(|e| matches!(
e,
Effect::PersistAnnouncementsHidden { hidden_ids } if hidden_ids == &expected
Effect::PersistAnnouncementsHidden { hidden_ids }
if hidden_ids == &expected
)),
"prune must persist the shrunken set, got {:?}",
app.pending_effects

View file

@ -25,9 +25,11 @@ impl AgentView {
// BEFORE the removal so a potential auto-hide pane switch can't hit
// the editing lock (see queue_edit.rs ordering invariant).
if matches!(
self.prompt_mode,
PromptMode::EditingQueued { id: editing_id, server_id: None, .. } if editing_id == id
) {
self.prompt_mode,
PromptMode::EditingQueued { id: editing_id, server_id: None, .. }
if editing_id == id
)
{
self.exit_editing_mode();
}
self.queue.select_after_delete(id);

View file

@ -343,8 +343,9 @@ impl VoiceState {
/// it). `/voice` and toggle-style starts leave this false.
pub(crate) fn hold(&self) -> bool {
matches!(
self, Self::ColdStart { hold, .. } | Self::Recording { hold, .. } if * hold
)
self, Self::ColdStart { hold, .. } | Self::Recording { hold, .. }
if * hold
)
}
}
/// Entry in the session picker list on the welcome screen.

View file

@ -965,7 +965,8 @@ mod tests {
// Turn ends → should drain "second" (front, not being edited) + FetchBilling.
let effects = dispatch(end_turn(), &mut app);
assert_eq!(effects.len(), 2);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "second"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "second"));
assert!(matches!(
&effects[1],
Effect::FetchBilling { silent: true, .. }
@ -991,7 +992,8 @@ mod tests {
// DrainQueue should pop and send.
let effects = dispatch(Action::DrainQueue, &mut app);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "queued"));
assert_eq!(app.agents[&id].session.queue_len(), 0);
}
@ -1960,7 +1962,8 @@ mod tests {
let effects = dispatch(Action::DrainQueue, &mut app);
assert_eq!(effects.len(), 1);
assert!(
matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "p3-edited"),
matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "p3-edited"),
"should send the edited prompt, got: {:?}",
effects[0]
);

View file

@ -790,10 +790,6 @@ pub(in crate::app::dispatch) fn skip_picker_and_create_session(
model_id: None,
preferred_session_id,
chat_kind,
}]
}
pub(in crate::app::dispatch) fn handle_session_created(

View file

@ -492,7 +492,8 @@ fn show_usage_returns_fetch_billing_effect() {
// together and renders a single summary.
assert_eq!(effects.len(), 1, "got: {effects:?}");
assert!(
matches!(&effects[0], Effect::FetchBilling { agent_id, silent } if *agent_id == AgentId(0) && !*silent),
matches!(&effects[0], Effect::FetchBilling { agent_id, silent }
if *agent_id == AgentId(0) && !*silent),
"effect should be a non-silent FetchBilling, got: {effects:?}"
);
}
@ -859,7 +860,8 @@ fn free_usage_failure_opens_paywall_modal() {
// 1. Real send.
let effects = dispatch(Action::SendPrompt("draw me a cat".into()), &mut app);
assert!(
matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "draw me a cat"),
matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "draw me a cat"),
"send must dispatch: {effects:?}"
);
let prompt_id = app.agents[&id].session.current_prompt_id.clone();
@ -1051,7 +1053,8 @@ fn unknown_non_restricted_command_still_passes_through() {
assert_eq!(effects.len(), 1);
assert!(
matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "/frobnicate arg"),
matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "/frobnicate arg"),
"unknown command must still pass through: {effects:?}"
);
assert!(

View file

@ -218,9 +218,10 @@ fn plugin_cta_catalog_load_recomputes_match_for_typed_draft() {
&mut app,
);
assert!(matches!(
&app.agents[&id].plugin_cta.phase,
CtaPhase::Matched { name, .. } if name == "zzctaplugin"
));
&app.agents[&id].plugin_cta.phase,
CtaPhase::Matched { name, .. }
if name == "zzctaplugin"
));
}
#[test]

View file

@ -568,7 +568,8 @@ async fn dashboard_change_location_valid_updates_cwd_and_closes_modal() {
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SetWorkingDir { path } if path == &target))
.any(|e| matches!(e, Effect::SetWorkingDir { path }
if path == &target))
);
assert!(
app.dashboard.as_ref().unwrap().location_picker.is_none(),
@ -2187,9 +2188,10 @@ fn dashboard_dispatch_applies_pending_model_and_plan() {
let new_id = *app.agents.keys().next().unwrap();
// CreateSession carries the staged model id.
assert!(effects.iter().any(|e| matches!(
e,
Effect::CreateSession { model_id: Some(m), .. } if *m == model_id
)));
e,
Effect::CreateSession { model_id: Some(m), .. }
if *m == model_id
)));
let agent = &app.agents[&new_id];
assert_eq!(
agent.session.deferred_model_switch,
@ -2230,9 +2232,10 @@ fn dashboard_new_agent_button_applies_pending_model_and_plan() {
let new_id = *app.agents.keys().next().unwrap();
// CreateSession carries the staged model id.
assert!(effects.iter().any(|e| matches!(
e,
Effect::CreateSession { model_id: Some(m), .. } if *m == model_id
)));
e,
Effect::CreateSession { model_id: Some(m), .. }
if *m == model_id
)));
let agent = &app.agents[&new_id];
assert_eq!(
agent.session.deferred_model_switch,
@ -2277,7 +2280,8 @@ fn dashboard_deferred_plan_mode_applied_on_session_created() {
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SetSessionMode { session_id: s, .. } if *s == session_id)),
.any(|e| matches!(e, Effect::SetSessionMode { session_id: s, .. }
if *s == session_id)),
"SessionCreated must emit SetSessionMode for the deferred plan mode"
);
}
@ -5077,7 +5081,8 @@ fn dashboard_peek_reply_to_idle_agent_sends() {
/* attach */ false,
);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "please continue"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "please continue"));
assert!(app.agents[&AgentId(0)].session.state.is_turn_running());
assert_eq!(app.agents[&AgentId(0)].session.queue_len(), 0);
// Reply draft cleared after sending.

View file

@ -161,7 +161,8 @@ fn slash_plan_no_args_not_in_plan_enters_plan_mode() {
// Should emit SetSessionMode to enter plan mode.
assert_eq!(effects.len(), 1);
assert!(
matches!(&effects[0], Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "plan"),
matches!(&effects[0], Effect::SetSessionMode { mode_id, .. }
if &*mode_id.0 == "plan"),
"expected SetSessionMode(plan), got: {effects:?}"
);
// Optimistic pending state should be set.
@ -1932,9 +1933,10 @@ fn cycle_always_approve_with_nudge_jumps_to_plan() {
);
assert!(
effects.iter().any(|e| matches!(
e,
Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "plan"
)),
e,
Effect::SetSessionMode { mode_id, .. }
if &*mode_id.0 == "plan"
)),
"expected SetSessionMode(plan), got {effects:?}"
);
assert!(
@ -1988,9 +1990,10 @@ fn cycle_auto_with_nudge_jumps_to_plan() {
);
assert!(
effects.iter().any(|e| matches!(
e,
Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "plan"
)),
e,
Effect::SetSessionMode { mode_id, .. }
if &*mode_id.0 == "plan"
)),
"expected SetSessionMode(plan), got {effects:?}"
);
assert!(
@ -2312,7 +2315,8 @@ fn set_plan_mode_idempotency_uses_pending_over_active() {
"OFF from EFFECTIVE-ON must emit Effect::SetSessionMode (not idempotent)"
);
assert!(
matches!(&effects[0], Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "default"),
matches!(&effects[0], Effect::SetSessionMode { mode_id, .. }
if &*mode_id.0 == "default"),
"OFF transition must emit SetSessionMode(default): {effects:?}"
);
let agent = app.agents.get(&AgentId(0)).unwrap();

View file

@ -534,7 +534,8 @@ fn send_prompt_produces_effect_and_clears_input() {
// Prompt is enqueued and immediately drained (agent was idle).
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "hello"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "hello"));
assert!(app.agents[&id].prompt.text().is_empty());
assert!(app.agents[&id].session.state.is_turn_running());
assert_eq!(app.agents[&id].scrollback.len(), 1);
@ -754,7 +755,8 @@ fn chip_submit_while_enqueued_clears_follow_up_chips() {
assert!(
!effects
.iter()
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text == "Summarize")),
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
if text == "Summarize")),
"chip must be enqueued, not immediate-sent, got {effects:?}"
);
// The chips are cleared on the enqueue path too (the bug fix).
@ -979,7 +981,8 @@ fn multiple_queued_prompts_drain_one_per_turn() {
// Turn end → drain "b" + FetchBilling.
let effects = dispatch(end_turn(), &mut app);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "b"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "b"));
assert!(matches!(
&effects[1],
Effect::FetchBilling { silent: true, .. }
@ -988,7 +991,8 @@ fn multiple_queued_prompts_drain_one_per_turn() {
// Turn end → drain "c" + FetchBilling.
let effects = dispatch(end_turn(), &mut app);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "c"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "c"));
assert!(matches!(
&effects[1],
Effect::FetchBilling { silent: true, .. }
@ -1825,7 +1829,8 @@ fn cancel_with_queued_prompt_drains_on_completion() {
);
assert_eq!(effects.len(), 2);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "queued"));
assert!(matches!(
&effects[1],
Effect::FetchBilling { silent: true, .. }
@ -1907,7 +1912,8 @@ fn cancel_with_multiple_queued_prompts_drains_only_front_prompt() {
);
assert_eq!(effects.len(), 2);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "queued-1"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "queued-1"));
assert!(matches!(
&effects[1],
Effect::FetchBilling { silent: true, .. }
@ -2172,7 +2178,8 @@ fn slash_unknown_command_passthrough_enqueues_prompt() {
let effects = dispatch(Action::SendPrompt("/unknown-cmd arg1".into()), &mut app);
// Unknown slash command → PassThrough → enqueue as prompt.
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "/unknown-cmd arg1"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "/unknown-cmd arg1"));
assert!(app.agents[&id].prompt.text().is_empty());
}
@ -2184,7 +2191,8 @@ fn non_slash_prompt_still_works() {
let effects = dispatch(Action::SendPrompt("hello world".into()), &mut app);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "hello world"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "hello world"));
assert!(app.agents[&id].prompt.text().is_empty());
}

View file

@ -67,7 +67,8 @@ fn rewind_then_resubmit_drains_immediately_and_discards_orphan() {
// User edits and re-submits without waiting.
let effects = dispatch(Action::SendPrompt("second".into()), &mut app);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "second"));
assert!(matches!(&effects[0], Effect::SendPrompt { text, .. }
if text == "second"));
assert!(app.agents[&id].session.state.is_turn_running());
let second_pid = app.agents[&id].session.current_prompt_id.clone();
assert!(second_pid.is_some());
@ -379,9 +380,10 @@ fn inline_edit_conversation_only_success_resubmits_and_closes_editor() {
);
assert!(
effects.iter().any(
|e| matches!(e, Effect::SendPrompt { text, .. } if text == "fix the bug properly")
),
effects
.iter()
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
if text == "fix the bug properly")),
"edited prompt must be sent, got {effects:?}"
);
let agent = &app.agents[&id];
@ -467,9 +469,10 @@ fn inline_edit_all_mode_previews_confirms_and_resubmits() {
&mut app,
);
assert!(
effects.iter().any(
|e| matches!(e, Effect::SendPrompt { text, .. } if text == "fix the bug properly")
),
effects
.iter()
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
if text == "fix the bug properly")),
"got {effects:?}"
);
assert!(
@ -722,9 +725,10 @@ fn inline_edit_resubmit_sends_slash_text_literally() {
);
assert!(
effects.iter().any(
|e| matches!(e, Effect::SendPrompt { text, .. } if text == "/etc/hosts is wrong, fix it")
),
effects
.iter()
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
if text == "/etc/hosts is wrong, fix it")),
"slash-lookalike edit must be sent as a prompt, got {effects:?}"
);
}

View file

@ -505,7 +505,8 @@ fn dispatch_send_prompt_announcements_via_registry() {
effects
.iter()
.any(|e| matches!(e, Effect::PersistAnnouncementsHidden {
hidden_ids } if hidden_ids.contains("crit-a"))),
hidden_ids }
if hidden_ids.contains("crit-a"))),
"expected persist effect carrying the hidden id, got {effects:?}"
);
assert!(app.hidden_announcement_ids.contains("crit-a"));
@ -569,7 +570,8 @@ fn announcements_show_clears_visible_critical_ids_only() {
effects
.iter()
.any(|e| matches!(e, Effect::PersistAnnouncementsHidden {
hidden_ids } if ! hidden_ids.contains("outage-a"))),
hidden_ids }
if ! hidden_ids.contains("outage-a"))),
"expected persist effect without the un-hidden id, got {effects:?}"
);
assert_eq!(shown_banner_id(&app).as_deref(), Some("outage-a"));
@ -661,7 +663,8 @@ fn announcements_show_clears_hidden_promo_ids() {
effects
.iter()
.any(|e| matches!(e, Effect::PersistAnnouncementsHidden {
hidden_ids } if ! hidden_ids.contains("promo-a"))),
hidden_ids }
if ! hidden_ids.contains("promo-a"))),
"expected persist effect without the un-hidden promo id, got {effects:?}"
);
assert_eq!(shown_banner_id(&app).as_deref(), Some("promo-a"));
@ -685,7 +688,8 @@ fn switch_model_dispatch_produces_effect_and_sets_pending() {
);
assert_eq!(effects.len(), 1);
assert!(
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. } if mid == &
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. }
if mid == &
model_id)
);
assert!(app.agents[&id].session.model_switch_pending);
@ -706,7 +710,8 @@ fn switch_model_allowed_when_agent_chat_kind() {
);
assert_eq!(effects.len(), 1);
assert!(
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. } if mid == &
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. }
if mid == &
model_id)
);
assert!(app.agents[&id].session.model_switch_pending);
@ -726,7 +731,8 @@ fn switch_model_allowed_when_app_chat_mode() {
);
assert_eq!(effects.len(), 1);
assert!(
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. } if mid == &
matches!(& effects[0], Effect::SwitchModel { model_id : mid, .. }
if mid == &
model_id)
);
assert!(app.agents[&id].session.model_switch_pending);
@ -946,7 +952,8 @@ fn acp_bootstrap_command_executes_as_passthrough() {
let effects = dispatch(Action::SendPrompt("/flush".into()), &mut app);
assert_eq!(effects.len(), 1);
assert!(
matches!(& effects[0], Effect::SendPrompt { text, .. } if text == "/flush"),
matches!(& effects[0], Effect::SendPrompt { text, .. }
if text == "/flush"),
"ACP command should passthrough, got: {effects:?}"
);
}
@ -1081,7 +1088,8 @@ fn acp_command_with_args_passthrough_includes_args() {
let effects = dispatch(Action::SendPrompt("/search find bugs".into()), &mut app);
assert_eq!(effects.len(), 1);
assert!(
matches!(& effects[0], Effect::SendPrompt { text, .. } if text ==
matches!(& effects[0], Effect::SendPrompt { text, .. }
if text ==
"/search find bugs"),
"ACP passthrough should preserve args, got: {effects:?}"
);
@ -1324,7 +1332,8 @@ fn view_catalog_entry_emits_fetch_effect() {
);
assert_eq!(effects.len(), 1);
assert!(
matches!(& effects[0], Effect::FetchCatalogEntry { kind, name } if kind ==
matches!(& effects[0], Effect::FetchCatalogEntry { kind, name }
if kind ==
"persona" && name == "researcher")
);
}

View file

@ -96,9 +96,10 @@ fn worktree_forked_with_restore_shows_summary_in_scrollback() {
// Should emit LoadSession.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::LoadSession { session_id, .. } if session_id == "forked-sess-2"
));
&effects[0],
Effect::LoadSession { session_id, .. }
if session_id == "forked-sess-2"
));
// Scrollback should contain the restore summary.
let has_restore_msg = app.agents[&id]
.scrollback

View file

@ -137,7 +137,8 @@ fn session_created_sets_session_id() {
);
assert_eq!(effects.len(), 7);
assert!(
matches!(& effects[0], Effect::FetchPromptHistory { session_id, .. } if
matches!(& effects[0], Effect::FetchPromptHistory { session_id, .. }
if
session_id == "new-session-123")
);
assert!(matches!(&effects[1], Effect::FetchSessionAgentName { .. }));
@ -491,7 +492,8 @@ fn worktree_session_created_drains_queued_prompts() {
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text ==
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
if text ==
"hello"))
);
assert!(
@ -525,7 +527,8 @@ fn session_created_drains_queued_prompts() {
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text ==
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
if text ==
"queued msg"))
);
assert!(
@ -825,7 +828,8 @@ fn deferred_model_switch_applied_on_session_created() {
effects
.iter()
.any(|e| matches!(e, Effect::SwitchModel { agent_id : a_id,
session_id : s_id, model_id : m_id, .. } if * a_id == id && * s_id == session_id
session_id : s_id, model_id : m_id, .. }
if * a_id == id && * s_id == session_id
&& * m_id == model_id))
);
}
@ -864,7 +868,8 @@ fn deferred_model_switch_applied_on_worktree_session_created() {
effects
.iter()
.any(|e| matches!(e, Effect::SwitchModel { agent_id : a_id,
session_id : s_id, model_id : m_id, .. } if * a_id == id && * s_id == session_id
session_id : s_id, model_id : m_id, .. }
if * a_id == id && * s_id == session_id
&& * m_id == model_id))
);
}
@ -1164,7 +1169,8 @@ fn deferred_worktree_ref_replays_through_gate() {
effects
.iter()
.any(|e| matches!(e, Effect::CreateWorktreeSession { git_ref :
Some(r), .. } if r == "feature-branch")),
Some(r), .. }
if r == "feature-branch")),
"the deferred --worktree <ref> replays with its git ref",
);
assert!(
@ -1225,7 +1231,8 @@ fn gated_worktree_without_load_id_preserves_stashed_resume() {
effects
.iter()
.any(|e| matches!(e, Effect::CreateWorktreeSession {
load_session_id : Some(id), .. } if id == "resume-me")),
load_session_id : Some(id), .. }
if id == "resume-me")),
"the deferred worktree replays with the preserved resume id",
);
assert!(app.deferred_startup.session.is_none());
@ -1297,7 +1304,8 @@ fn gated_worktree_with_none_companions_preserves_stashed_label_and_ref() {
effects
.iter()
.any(|e| matches!(e, Effect::CreateWorktreeSession {
load_session_id : Some(id), label : Some(l), git_ref : Some(r), .. } if id ==
load_session_id : Some(id), label : Some(l), git_ref : Some(r), .. }
if id ==
"mysess" && l == "mylabel" && r == "featbranch")),
"the deferred worktree replays with the preserved id, label, and ref",
);
@ -1411,7 +1419,8 @@ fn auth_complete_retries_stashed_prompt_after_mid_session_login() {
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text ==
.any(|e| matches!(e, Effect::SendPrompt { text, .. }
if text ==
"retry me")),
"the stashed prompt must be auto-resubmitted, got: {effects:?}"
);
@ -1604,13 +1613,15 @@ async fn project_selected_creates_session_and_sends_prompt() {
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SetWorkingDir { path } if path == &
.any(|e| matches!(e, Effect::SetWorkingDir { path }
if path == &
selected))
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CreateSession { cwd, .. } if cwd ==
.any(|e| matches!(e, Effect::CreateSession { cwd, .. }
if cwd ==
& selected))
);
assert_eq!(app.agents[&id].session.queue_len(), 1);
@ -1837,7 +1848,8 @@ fn set_plan_mode_on_from_off_emits_set_session_mode() {
);
assert_eq!(effects.len(), 1);
assert!(
matches!(& effects[0], Effect::SetSessionMode { mode_id, .. } if &* mode_id.0 ==
matches!(& effects[0], Effect::SetSessionMode { mode_id, .. }
if &* mode_id.0 ==
"plan"),
"expected SetSessionMode(plan), got: {effects:?}"
);

View file

@ -629,7 +629,8 @@ fn resume_known_session_id_loads_not_creates() {
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::LoadSession { session_id, .. } if
.any(|e| matches!(e, Effect::LoadSession { session_id, .. }
if
session_id == "resume-known-id")),
"expected LoadSession, got {effects:?}"
);
@ -964,7 +965,8 @@ fn resume_unknown_session_still_creates_new_agent() {
effects
.iter()
.any(|e| matches!(e, Effect::LoadSession { agent_id, session_id,
.. } if * agent_id == new_id && session_id == "sess-never-open"))
.. }
if * agent_id == new_id && session_id == "sess-never-open"))
);
}
/// Stale `attached_agent` (not equal to visible agent) must not re-arm overlay.
@ -1027,7 +1029,8 @@ fn resume_conversation_does_not_focus_build_id_collision() {
effects
.iter()
.any(|e| matches!(e, Effect::LoadSession { session_id, chat_kind
: true, .. } if session_id == "shared-id"))
: true, .. }
if session_id == "shared-id"))
);
assert!(!app.agents[&agent_0].chat_kind);
}
@ -1165,7 +1168,8 @@ fn resume_after_load_failed_reissues_load() {
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::LoadSession { agent_id, .. } if *
.any(|e| matches!(e, Effect::LoadSession { agent_id, .. }
if *
agent_id == agent_0))
);
assert!(app.agents[&agent_0].loading_placeholder_id.is_some());
@ -1188,7 +1192,8 @@ fn resume_after_load_failed_reissues_load() {
effects
.iter()
.any(|e| matches!(e, Effect::LoadSession { agent_id, session_id,
.. } if * agent_id != agent_0 && session_id == "fail-then-retry")),
.. }
if * agent_id != agent_0 && session_id == "fail-then-retry")),
"retry after failure must emit LoadSession for a new agent, got {effects:?}"
);
assert_eq!(app.agents.len(), count_before + 1);

View file

@ -295,7 +295,8 @@ fn slash_model_valid_dispatches_set_default_model_with_switch_and_persist() {
effects[0],
);
assert!(
matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. } if mid == &
matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. }
if mid == &
model_id),
"second effect must be SwitchModel(<resolved id>), got {:?}",
effects[1],
@ -993,7 +994,8 @@ fn clear_default_model_persists_but_keeps_live_current() {
);
assert!(
matches!(& effects[0], Effect::PersistSetting { key : "default_model", value :
crate ::settings::SettingValue::String(s), .. } if s.is_empty()),
crate ::settings::SettingValue::String(s), .. }
if s.is_empty()),
"expected PersistSetting(default_model, ''), got {:?}",
effects[0],
);
@ -1028,9 +1030,13 @@ fn set_default_model_resolves_known_name() {
assert_eq!(effects.len(), 2);
assert!(
matches!(& effects[0], Effect::PersistSetting { key : "default_model", value :
crate ::settings::SettingValue::String(s), .. } if s == "grok-4.5")
crate ::settings::SettingValue::String(s), .. }
if s == "grok-4.5")
);
assert!(
matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. }
if mid == & id)
);
assert!(matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. } if mid == & id));
assert_eq!(app.agents[&agent_id].session.models.current, Some(id));
}
/// Re-dispatching the same model

View file

@ -660,9 +660,10 @@ fn switch_model_complete_success_updates_model_and_pushes_message() {
// PersistPreferredModel effect emitted.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::PersistPreferredModel { model_id: mid, .. } if *mid == model_id.clone()
));
&effects[0],
Effect::PersistPreferredModel { model_id: mid, .. }
if *mid == model_id.clone()
));
}
#[test]

View file

@ -249,7 +249,8 @@ fn parse_subagent_kill_outcome_reads_typed_outcome() {
);
assert!(
matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":false,"outcome":{"kind":"already_finished","status":"completed"}}}"#),
SubagentKillOutcome::NothingLive { status : Some(s) } if s == "completed")
SubagentKillOutcome::NothingLive { status : Some(s) }
if s == "completed")
);
assert!(
matches!(parse_subagent_kill_outcome(r#"{"result":{"subagentId":"sa-1","cancelled":false,"outcome":{"kind":"not_found"}}}"#),
@ -302,7 +303,8 @@ fn parse_subagent_kill_outcome_round_trips_agent_serialization() {
.unwrap();
assert!(
matches!(parse_subagent_kill_outcome(& wire), SubagentKillOutcome::NothingLive {
status : Some(s) } if s == "failed")
status : Some(s) }
if s == "failed")
);
}
/// A top-level payload (no `result` envelope), error envelopes, and
@ -1200,7 +1202,8 @@ async fn check_marketplace_updates_dispatches_update_and_skips_failed_notificati
MarketplaceAction::Update {
source_url_or_path,
plugin_relative_path,
} if source_url_or_path == "https://example.com/plugins.git"
}
if source_url_or_path == "https://example.com/plugins.git"
&& plugin_relative_path == "plugins/test-plugin" => {
saw_update_for_task.store(true, Ordering::SeqCst);
}

View file

@ -258,9 +258,11 @@ fn leader_kill_reconnect_reloads_without_duplicating_history() {
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
loop {
if matches!(
*status_rx.borrow_and_update(),
ConnectionStatus::Connected { generation } if generation >= 1
) {
*status_rx.borrow_and_update(),
ConnectionStatus::Connected { generation }
if generation >= 1
)
{
break;
}
assert!(

View file

@ -2086,9 +2086,10 @@ mod tests {
}),
);
assert!(matches!(
handle_ext_notification(&notif, OutputFormat::Plain),
ExtEvent::TaskBackgrounded { task_id, is_monitor: false } if task_id == "task-abc"
));
handle_ext_notification(&notif, OutputFormat::Plain),
ExtEvent::TaskBackgrounded { task_id, is_monitor: false }
if task_id == "task-abc"
));
}
#[test]
@ -2102,9 +2103,10 @@ mod tests {
}),
);
assert!(matches!(
handle_ext_notification(&notif, OutputFormat::Plain),
ExtEvent::TaskBackgrounded { task_id, is_monitor: true } if task_id == "mon-1"
));
handle_ext_notification(&notif, OutputFormat::Plain),
ExtEvent::TaskBackgrounded { task_id, is_monitor: true }
if task_id == "mon-1"
));
}
#[test]
@ -2122,9 +2124,10 @@ mod tests {
}),
);
assert!(matches!(
handle_ext_notification(&notif, OutputFormat::Plain),
ExtEvent::TaskCompleted { task_id } if task_id == "task-abc"
));
handle_ext_notification(&notif, OutputFormat::Plain),
ExtEvent::TaskCompleted { task_id }
if task_id == "task-abc"
));
}
#[test]
@ -2141,9 +2144,10 @@ mod tests {
}),
);
assert!(matches!(
handle_ext_notification(&spawned, OutputFormat::Plain),
ExtEvent::SubagentSpawned { subagent_id } if subagent_id == "sub-1"
));
handle_ext_notification(&spawned, OutputFormat::Plain),
ExtEvent::SubagentSpawned { subagent_id }
if subagent_id == "sub-1"
));
let finished = make_ext_notif(
"x.ai/session_notification",
serde_json::json!({
@ -2157,9 +2161,10 @@ mod tests {
}),
);
assert!(matches!(
handle_ext_notification(&finished, OutputFormat::Plain),
ExtEvent::SubagentFinished { subagent_id } if subagent_id == "sub-1"
));
handle_ext_notification(&finished, OutputFormat::Plain),
ExtEvent::SubagentFinished { subagent_id }
if subagent_id == "sub-1"
));
}
#[test]

View file

@ -888,9 +888,10 @@ fn marketplace_add(
if u.trim_end_matches(".git") == normalized)
})
}
MarketplaceAddInput::LocalPath(path) => sources
.iter()
.any(|s| matches!(&s.kind, SourceKind::Local { path: p } if p == path)),
MarketplaceAddInput::LocalPath(path) => sources.iter().any(|s| {
matches!(&s.kind, SourceKind::Local { path: p }
if p == path)
}),
};
if already_configured {
bail!("Marketplace source already configured: {identity}");

View file

@ -579,10 +579,13 @@ impl ExecuteToolCallBlock {
.with_joiner(joiner.clone()),
);
}
// Ellipsis (non-selectable, breaks range continuity)
let hidden = total - threshold;
lines.push(
BlockLine::separator(Line::from(Span::styled("\u{2026}", theme.muted())))
.with_panel_background(theme.bg_dark),
BlockLine::separator(Line::from(Span::styled(
format!("\u{2026} +{hidden} lines"),
theme.muted(),
)))
.with_panel_background(theme.bg_dark),
);
// Last M lines: range base + 1 (distinct from first chunk)
for (wrapped_line, joiner) in

View file

@ -97,9 +97,6 @@ impl ListDirToolCallBlock {
self.output = output.into();
}
/// Render collapsed line: `List path`.
///
/// When `width` is provided, the path is fish-shortened to fit.
fn collapsed_line(&self, theme: &Theme, muted: bool, width: Option<usize>) -> Line<'static> {
let text_style = if muted {
theme.muted()
@ -114,15 +111,32 @@ impl ListDirToolCallBlock {
};
let prefix = "List ";
let entry_count = self.output.lines().filter(|l| !l.trim().is_empty()).count();
let suffix = if self.error.is_none() && entry_count > 0 {
let s = if entry_count == 1 { "y" } else { "ies" };
format!(" ({entry_count} entr{s})")
} else {
String::new()
};
let suffix_fits = width.is_none_or(|w| prefix.len() + suffix.len() < w);
let effective_suffix = if suffix_fits { suffix.as_str() } else { "" };
let path_budget = width
.map(|w| w.saturating_sub(prefix.len()))
.map(|w| {
w.saturating_sub(prefix.len())
.saturating_sub(effective_suffix.len())
})
.unwrap_or(usize::MAX);
let path = crate::render::tool_paths::shorten_path(&self.path, path_budget);
Line::from(vec![
let mut spans = vec![
Span::styled(prefix, bold_style),
Span::styled(path, path_style),
])
];
if !effective_suffix.is_empty() {
spans.push(Span::styled(effective_suffix.to_string(), theme.muted()));
}
Line::from(spans)
}
/// Header line with only the path span selectable (exclude "List " prefix).
@ -224,3 +238,49 @@ impl BlockContent for ListDirToolCallBlock {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scrollback::types::BlockContext;
fn ctx() -> BlockContext {
BlockContext {
width: 80,
mode: DisplayMode::Collapsed,
is_running: false,
raw: false,
max_lines: None,
appearance: Default::default(),
is_selected: false,
cwd: None,
}
}
fn header_text(block: &ListDirToolCallBlock) -> String {
block.output(&ctx()).lines[0]
.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
#[test]
fn collapsed_header_shows_entry_count() {
let block = ListDirToolCallBlock::new("src").with_output("a.rs\nb.rs\nsub/\n");
assert_eq!(header_text(&block), "List src (3 entries)");
let single = ListDirToolCallBlock::new("src").with_output("lonely.rs\n");
assert_eq!(header_text(&single), "List src (1 entry)");
}
#[test]
fn collapsed_header_omits_count_when_empty_or_failed() {
let empty = ListDirToolCallBlock::new("src");
assert_eq!(header_text(&empty), "List src");
let failed = ListDirToolCallBlock::new("gone").with_error("no such directory");
assert_eq!(header_text(&failed), "List gone");
}
}

View file

@ -342,6 +342,25 @@ impl ToolCallBlock {
}
}
/// Whether the tool call finished without an error.
pub fn is_success(&self) -> bool {
match self {
ToolCallBlock::Execute(b) => b.is_success(),
ToolCallBlock::Read(b) => b.is_success(),
ToolCallBlock::Edit(b) => b.is_success(),
ToolCallBlock::Search(b) => b.is_success(),
ToolCallBlock::ListDir(b) => b.is_success(),
ToolCallBlock::WebFetch(b) => b.is_success(),
ToolCallBlock::WebSearch(b) => b.is_success(),
ToolCallBlock::IntegrationSearch(b) => b.is_success(),
ToolCallBlock::UseTool(b) => b.is_success(),
ToolCallBlock::MemorySearch(b) => b.is_success(),
ToolCallBlock::Skill(b) => b.is_success(),
ToolCallBlock::Other(b) => b.is_success(),
ToolCallBlock::Lifecycle(_) => true,
}
}
/// Set `started_at` on the inner variant block.
///
/// Unlike `transfer_timing_from`, this works across variant boundaries

View file

@ -11,8 +11,8 @@ use crate::scrollback::types::{
};
use crate::theme::Theme;
/// Max lines of output shown inline before truncation.
const MAX_INLINE_LINES: usize = 10;
const TRUNCATED_INLINE_LINES: usize = 3;
/// Use tool call — dispatching to an MCP integration tool.
#[derive(Debug, Clone)]
@ -175,7 +175,11 @@ impl BlockContent for UseToolCallBlock {
}
}
// Output preview
let max_inline = if ctx.mode == DisplayMode::Truncated {
TRUNCATED_INLINE_LINES
} else {
MAX_INLINE_LINES
};
if let Some(ref output) = self.output {
lines.push(Line::from("").into());
lines
@ -185,8 +189,8 @@ impl BlockContent for UseToolCallBlock {
let content_lines: Vec<&str> = output.lines().collect();
for (i, line) in content_lines.iter().enumerate() {
if i >= MAX_INLINE_LINES {
let remaining = content_lines.len() - MAX_INLINE_LINES;
if i >= max_inline {
let remaining = content_lines.len() - max_inline;
lines.push(
BlockLine::from(Line::from(Span::styled(
format!(
@ -284,3 +288,58 @@ impl BlockContent for UseToolCallBlock {
Some(Text::from(vec![self.header_line(&theme, false, None)]))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scrollback::types::BlockContext;
fn ctx(mode: DisplayMode) -> BlockContext {
BlockContext {
width: 80,
mode,
is_running: false,
raw: false,
max_lines: None,
appearance: Default::default(),
is_selected: false,
cwd: None,
}
}
fn rendered_text(block: &UseToolCallBlock, mode: DisplayMode) -> String {
block
.output(&ctx(mode))
.lines
.iter()
.map(|l| {
l.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn truncated_caps_inline_output_tighter_than_expanded() {
let mut block = UseToolCallBlock::new("linear__list_issues");
let content: Vec<String> = (1..=12).map(|i| format!("l{i:02} row")).collect();
block.output = Some(content.join("\n"));
let truncated = rendered_text(&block, DisplayMode::Truncated);
assert!(truncated.contains("l03"), "truncated:\n{truncated}");
assert!(!truncated.contains("l04"), "truncated:\n{truncated}");
assert!(
truncated.contains("(9 more lines"),
"truncated:\n{truncated}"
);
let expanded = rendered_text(&block, DisplayMode::Expanded);
assert!(expanded.contains("l10"), "expanded:\n{expanded}");
assert!(!expanded.contains("l11"), "expanded:\n{expanded}");
assert!(expanded.contains("(2 more lines"), "expanded:\n{expanded}");
}
}

View file

@ -11,8 +11,8 @@ use crate::scrollback::types::{
};
use crate::theme::Theme;
/// Max lines of content shown inline before truncation.
const MAX_INLINE_LINES: usize = 10;
const TRUNCATED_INLINE_LINES: usize = 3;
/// Web fetch tool call — fetching a URL and returning markdown content.
#[derive(Debug, Clone)]
@ -238,9 +238,11 @@ impl BlockContent for WebFetchToolCallBlock {
lines.push(BlockLine::separator(meta));
}
// Content preview with bg_dark background, capped at
// MAX_INLINE_LINES. Full content is available via the
// fullscreen viewer (Enter/o).
let max_inline = if ctx.mode == DisplayMode::Truncated {
TRUNCATED_INLINE_LINES
} else {
MAX_INLINE_LINES
};
if let Some(ref output) = self.output {
lines.push(Line::from("").into());
@ -252,12 +254,12 @@ impl BlockContent for WebFetchToolCallBlock {
let total_lines = output.lines().count();
for (i, line) in output.lines().enumerate() {
if i >= MAX_INLINE_LINES {
if i >= max_inline {
lines.push(
BlockLine::from(Line::from(Span::styled(
format!(
"{indent}... ({} more lines, press Enter to view)",
total_lines - MAX_INLINE_LINES
total_lines - max_inline
),
theme.dim(),
)))
@ -347,3 +349,58 @@ impl BlockContent for WebFetchToolCallBlock {
Some(Text::from(vec![self.header_line(&theme, false, None)]))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scrollback::types::BlockContext;
fn ctx(mode: DisplayMode) -> BlockContext {
BlockContext {
width: 80,
mode,
is_running: false,
raw: false,
max_lines: None,
appearance: Default::default(),
is_selected: false,
cwd: None,
}
}
fn rendered_text(block: &WebFetchToolCallBlock, mode: DisplayMode) -> String {
block
.output(&ctx(mode))
.lines
.iter()
.map(|l| {
l.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn truncated_caps_inline_content_tighter_than_expanded() {
let content: Vec<String> = (1..=12).map(|i| format!("l{i:02} body")).collect();
let block =
WebFetchToolCallBlock::new("https://example.com").with_output(content.join("\n"));
let truncated = rendered_text(&block, DisplayMode::Truncated);
assert!(truncated.contains("l03"), "truncated:\n{truncated}");
assert!(!truncated.contains("l04"), "truncated:\n{truncated}");
assert!(
truncated.contains("(9 more lines"),
"truncated:\n{truncated}"
);
let expanded = rendered_text(&block, DisplayMode::Expanded);
assert!(expanded.contains("l10"), "expanded:\n{expanded}");
assert!(!expanded.contains("l11"), "expanded:\n{expanded}");
assert!(expanded.contains("(2 more lines"), "expanded:\n{expanded}");
}
}

View file

@ -13,8 +13,8 @@ use crate::scrollback::types::{
};
use crate::theme::Theme;
/// Max lines of content shown inline before truncation.
const MAX_INLINE_LINES: usize = 10;
const TRUNCATED_INLINE_LINES: usize = 3;
/// Max number of domain names shown in the sources summary line.
const MAX_INLINE_SOURCES: usize = 3;
@ -258,9 +258,11 @@ impl BlockContent for WebSearchToolCallBlock {
})
.collect();
// Content preview with bg_dark background, capped at
// MAX_INLINE_LINES. Full content is available via the
// fullscreen viewer (Enter/o).
let max_inline = if ctx.mode == DisplayMode::Truncated {
TRUNCATED_INLINE_LINES
} else {
MAX_INLINE_LINES
};
if let Some(ref content) = self.content {
lines.push(BlockLine::separator(Line::from("")));
@ -272,8 +274,8 @@ impl BlockContent for WebSearchToolCallBlock {
let content_lines: Vec<&str> = content.lines().collect();
for (i, line) in content_lines.iter().enumerate() {
if i >= MAX_INLINE_LINES {
let remaining = content_lines.len() - MAX_INLINE_LINES;
if i >= max_inline {
let remaining = content_lines.len() - max_inline;
lines.push(
BlockLine::from(Line::from(Span::styled(
format!(
@ -379,3 +381,58 @@ impl BlockContent for WebSearchToolCallBlock {
Some(Text::from(vec![self.header_line(&theme, false, None)]))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scrollback::types::BlockContext;
fn ctx(mode: DisplayMode) -> BlockContext {
BlockContext {
width: 80,
mode,
is_running: false,
raw: false,
max_lines: None,
appearance: Default::default(),
is_selected: false,
cwd: None,
}
}
fn rendered_text(block: &WebSearchToolCallBlock, mode: DisplayMode) -> String {
block
.output(&ctx(mode))
.lines
.iter()
.map(|l| {
l.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn truncated_caps_inline_content_tighter_than_expanded() {
let mut block = WebSearchToolCallBlock::new("rust async traits");
let content: Vec<String> = (1..=12).map(|i| format!("l{i:02} result")).collect();
block.content = Some(content.join("\n"));
let truncated = rendered_text(&block, DisplayMode::Truncated);
assert!(truncated.contains("l03"), "truncated:\n{truncated}");
assert!(!truncated.contains("l04"), "truncated:\n{truncated}");
assert!(
truncated.contains("(9 more lines"),
"truncated:\n{truncated}"
);
let expanded = rendered_text(&block, DisplayMode::Expanded);
assert!(expanded.contains("l10"), "expanded:\n{expanded}");
assert!(!expanded.contains("l11"), "expanded:\n{expanded}");
assert!(expanded.contains("(2 more lines"), "expanded:\n{expanded}");
}
}

View file

@ -79,7 +79,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
Arc::new(docs::DocsCommand),
Arc::new(home::HomeCommand),
Arc::new(new::NewCommand),
Arc::new(fork::ForkCommand),
Arc::new(compact::CompactCommand),
Arc::new(copy::CopyCommand),
@ -117,7 +116,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
Arc::new(mcps::McpsCommand),
Arc::new(btw::BtwCommand),
Arc::new(recap::RecapCommand),
Arc::new(terminal_setup::TerminalSetupCommand),
Arc::new(voice::VoiceCommand),
Arc::new(loop_cmd::LoopCommand),

View file

@ -6040,9 +6040,10 @@ mod tests {
);
assert!(
lines.iter().any(|l| matches!(
l,
DashboardLine::Header { state, count } if *state == RowState::Working && *count == 2
)),
l,
DashboardLine::Header { state, count }
if *state == RowState::Working && *count == 2
)),
"collapsed Working header must still render with its true count",
);
let working_rows = lines
@ -6080,7 +6081,8 @@ mod tests {
assert!(
lines
.iter()
.any(|l| matches!(l, DashboardLine::PinnedHeader { count } if *count == 1)),
.any(|l| matches!(l, DashboardLine::PinnedHeader { count }
if *count == 1)),
"collapsed Pinned header must still render",
);
// The pinned row is hidden; the (non-pinned) Working row remains.
@ -6142,9 +6144,10 @@ mod tests {
// Header still shows the TRUE total, not the visible count.
assert!(
lines.iter().any(|l| matches!(
l,
DashboardLine::Header { state, count } if *state == RowState::Idle && *count == total as usize
)),
l,
DashboardLine::Header { state, count }
if *state == RowState::Idle && *count == total as usize
)),
"Idle header keeps the true total count",
);
}

View file

@ -595,7 +595,8 @@ fn render_file_list(buf: &mut Buffer, area: Rect, state: &mut MemoryModalState,
);
if is_selected
&& matches!(state.mode, MemoryModalMode::ConfirmingDelete { idx } if idx == filt_idx)
&& matches!(state.mode, MemoryModalMode::ConfirmingDelete { idx }
if idx == filt_idx)
{
let hint = " [x to confirm]";
let hint_w = hint.len() as u16;

View file

@ -275,21 +275,20 @@ impl SettingsModalState {
self.invalidate_filter();
if let Some(key) = subpane_key {
let still_visible = self
.rows
.iter()
.any(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key));
let still_visible = self.rows.iter().any(|r| {
matches!(r, RowEntry::Setting { key: k, .. }
if *k == key)
});
if !still_visible {
self.transition_to_browse();
}
}
if let Some(key) = prev_key {
if let Some(idx) = self
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key))
{
if let Some(idx) = self.rows.iter().position(|r| {
matches!(r, RowEntry::Setting { key: k, .. }
if *k == key)
}) {
self.selected = idx;
} else {
self.selected = self

View file

@ -36,13 +36,17 @@ fn contextual_hints_group_sub_sheet_flow() {
let group_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "contextual_hints"))
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "contextual_hints")
})
.expect("group row present");
assert!(
!s.rows.iter().any(|r| matches!(
r,
RowEntry::Setting { key, .. } if key.starts_with("contextual_hints.")
)),
r,
RowEntry::Setting { key, .. }
if key.starts_with("contextual_hints.")
)),
"child rows must be hidden from the top-level list",
);
@ -4118,7 +4122,10 @@ fn advance_next_recovers_when_selection_is_hidden() {
let compact_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "compact_mode"))
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "compact_mode")
})
.unwrap();
s.selected = compact_idx;
// Advance: lands on the first visible setting (show_timestamps).
@ -4127,7 +4134,10 @@ fn advance_next_recovers_when_selection_is_hidden() {
let show_ts_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "show_timestamps"))
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "show_timestamps")
})
.unwrap();
assert_eq!(s.selected, show_ts_idx);
}
@ -4150,7 +4160,10 @@ fn advance_prev_recovers_when_selection_is_hidden() {
let compact_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "compact_mode"))
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "compact_mode")
})
.unwrap();
s.selected = compact_idx;
let moved = s.advance_prev();
@ -4158,7 +4171,10 @@ fn advance_prev_recovers_when_selection_is_hidden() {
let simple_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "simple_mode"))
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "simple_mode")
})
.unwrap();
assert_eq!(s.selected, simple_idx);
}
@ -4258,10 +4274,10 @@ fn section_headers_have_blank_line_above_except_first() {
for cat in SettingCategory::ALL {
// Skip categories the default registry doesn't populate
// (e.g. Session — no settings registered).
let has_setting = s
.rows
.iter()
.any(|r| matches!(r, RowEntry::Header { category } if category == cat));
let has_setting = s.rows.iter().any(|r| {
matches!(r, RowEntry::Header { category }
if category == cat)
});
if !has_setting {
continue;
}
@ -4607,7 +4623,10 @@ fn two_line_row_hit_rect_spans_both_lines() {
let row_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"))
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "coding_data_sharing")
})
.expect("coding_data_sharing must be registered");
// Render at a narrow width so coding_data_sharing forces a
// two-line layout.
@ -4672,7 +4691,10 @@ fn two_line_row_with_expansion_renders_three_segments() {
let row_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"))
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "coding_data_sharing")
})
.expect("coding_data_sharing must be registered");
s.selected = row_idx;
s.expanded_keys.insert("coding_data_sharing");
@ -4730,7 +4752,10 @@ fn group_row_renders_expanded_description() {
let row_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "contextual_hints"))
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == "contextual_hints")
})
.expect("contextual_hints group must be registered");
s.selected = row_idx;
s.expanded_keys.insert("contextual_hints");
@ -5769,7 +5794,10 @@ fn enter_picker_for(key: &'static str) -> SettingsModalState {
let row_idx = s
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key: k, .. } if *k == key))
.position(|r| {
matches!(r, RowEntry::Setting { key: k, .. }
if *k == key)
})
.unwrap_or_else(|| panic!("no row for key `{key}` in default registry"));
assert!(s.select_at(row_idx), "select_at({row_idx})");
assert!(

View file

@ -1681,9 +1681,10 @@ mod tests {
let entries = build_entries(&all_contexts(), &registry, true);
let has_row = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. } if item.label == "mouse reporting"
)
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "mouse reporting"
)
});
assert!(
!has_row,
@ -1776,21 +1777,24 @@ mod tests {
let has_todos = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. } if item.label == "todos"
)
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "todos"
)
});
let has_sessions = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. } if item.label == "sessions"
)
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "sessions"
)
});
let has_queue = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. } if item.label == "queue"
)
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == "queue"
)
});
assert!(has_todos, "should include toggle todos");
assert!(has_sessions, "should include open sessions");
@ -1835,13 +1839,14 @@ mod tests {
.iter()
.find(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
..
} if item.label == "paste"
)
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
..
}
if item.label == "paste"
)
})
.expect("cheatsheet should list paste");
let ShortcutsHelpEntry::Hint {
@ -1922,9 +1927,10 @@ mod tests {
let nav_dimmed = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "nav"
)
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "nav"
)
});
assert!(
nav_dimmed,
@ -1933,17 +1939,19 @@ mod tests {
let quit_bright = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: false, .. } if item.label == "quit"
)
e,
ShortcutsHelpEntry::Hint { item, dimmed: false, .. }
if item.label == "quit"
)
});
assert!(quit_bright, "quit should not be dimmed (When::Always)");
let cancel_bright = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: false, .. } if item.label == "cancel"
)
e,
ShortcutsHelpEntry::Hint { item, dimmed: false, .. }
if item.label == "cancel"
)
});
assert!(
cancel_bright,
@ -1959,9 +1967,10 @@ mod tests {
let send_dimmed = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "send"
)
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "send"
)
});
assert!(
send_dimmed,
@ -1970,9 +1979,10 @@ mod tests {
let nav_dimmed = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. } if item.label == "nav"
)
e,
ShortcutsHelpEntry::Hint { item, dimmed: true, .. }
if item.label == "nav"
)
});
assert!(
nav_dimmed,
@ -2638,14 +2648,15 @@ mod tests {
.iter()
.position(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
long_help: Some(_),
..
} if item.label == "paste"
)
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
long_help: Some(_),
..
}
if item.label == "paste"
)
})
.expect("paste pseudo-row with long_help");
assert_eq!(
@ -3024,9 +3035,10 @@ mod tests {
for label in ["top", "btm", "copy", "copy cmd"] {
let present = entries.iter().any(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint { item, .. } if item.label == label
)
e,
ShortcutsHelpEntry::Hint { item, .. }
if item.label == label
)
});
assert!(
!present,
@ -3315,14 +3327,15 @@ mod tests {
.iter()
.position(|e| {
matches!(
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
long_help: Some(_),
..
} if item.label == "paste"
)
e,
ShortcutsHelpEntry::Hint {
item,
action_id: None,
long_help: Some(_),
..
}
if item.label == "paste"
)
})
.expect("paste pseudo-row with long_help");
let key_id = ExpandKey::Pseudo("paste");

View file

@ -992,11 +992,10 @@ impl TasksPane {
};
if changed {
self.rebuild_entries();
if let Some(header) = self
.entries
.iter()
.find(|e| matches!(e, TaskEntry::Header { group: g, .. } if *g == group))
{
if let Some(header) = self.entries.iter().find(|e| {
matches!(e, TaskEntry::Header { group: g, .. }
if *g == group)
}) {
let id = header.stable_id();
self.list_state.select_by_id(id);
}

View file

@ -2949,10 +2949,12 @@ mod tests {
// Verify headers
assert!(
matches!(&result[0], crate::views::picker::PickerEntry::Header { label } if label == &"fw-1")
matches!(&result[0], crate::views::picker::PickerEntry::Header { label }
if label == &"fw-1")
);
assert!(
matches!(&result[2], crate::views::picker::PickerEntry::Header { label } if label == &"xai")
matches!(&result[2], crate::views::picker::PickerEntry::Header { label }
if label == &"xai")
);
}
@ -2981,11 +2983,13 @@ mod tests {
Some("zzz"),
);
assert!(
matches!(&result[0], crate::views::picker::PickerEntry::Header { label } if label == &"zzz"),
matches!(&result[0], crate::views::picker::PickerEntry::Header { label }
if label == &"zzz"),
"current repo group pinned first"
);
assert!(
matches!(&result[2], crate::views::picker::PickerEntry::Header { label } if label == &"aaa"),
matches!(&result[2], crate::views::picker::PickerEntry::Header { label }
if label == &"aaa"),
"remaining group follows alphabetically"
);
}

View file

@ -42,15 +42,17 @@ async fn bash_full_output_double_click_fold_pty() {
harness
.wait_for_text("L12", Duration::from_secs(30))
.expect("bash output tail");
// Live tail can show L06L12 while L01 is still clipped; wait for
// expand-on-finish before asserting the head is present.
harness
.wait_for_text("L06", Duration::from_secs(10))
.wait_for_text("L01", Duration::from_secs(15))
.unwrap_or_else(|_| {
panic!(
"finished ! command must show its full output (middle lines); got:\n{}",
"finished ! command must not truncate output (L01 missing)\nscreen:\n{}",
harness.screen_contents()
)
});
for line in ["L01", "L03", "L09"] {
for line in ["L03", "L06", "L09"] {
assert!(
harness.contains_text(line),
"finished ! command must not truncate output ({line} missing)\nscreen:\n{}",

View file

@ -6,20 +6,11 @@ use crate::common::*;
/// so screen assertions can tell the two apart.
const REASONING_SENTINEL: &str = "REASONINGSENTINEL";
/// Dogfood bug: "I don't see thoughts in the transcript". With thinking
/// enabled (`[ui] show_thinking_blocks` — the default, set
/// explicitly here so the test doesn't depend on the rollout default),
/// minimal commits reasoning as a **collapsed** `Thought for Xs` header
/// (print-once display policy) — the body is intentionally not in the live
/// scrollback. The advertised full-fidelity `/transcript` view must therefore
/// render the thinking body **expanded**, or the reasoning is unreachable.
///
/// Flow: stream a reasoning+text turn → the answer commits, the reasoning
/// collapses to its header (body nowhere on screen) → `/transcript` with
/// `PAGER=cat` dumps the full view → the reasoning body appears.
/// `[ui] show_thinking_blocks` is set explicitly (not left to the default)
/// so the test doesn't depend on the rollout default.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_transcript_expands_collapsed_thinking() {
async fn minimal_commits_thinking_body_to_scrollback() {
// The model must run on the Responses backend — reasoning summary deltas
// are a Responses-API stream shape (the scripted events below).
let content = ContentController::start_with_models(vec![
@ -54,10 +45,7 @@ async fn minimal_transcript_expands_collapsed_thinking() {
)
.expect("write config");
// Minimal env + PAGER=cat (non-interactive dump, same as
// `minimal_transcript_opens_in_pager`).
let mut env = content.env_for_pager();
env.push(("PAGER".to_string(), "cat".to_string()));
let env = content.env_for_pager();
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::new(&binary, DEFAULT_ROWS, DEFAULT_COLS, MINIMAL_ARGS, &env_refs)
@ -73,34 +61,17 @@ async fn minimal_transcript_expands_collapsed_thinking() {
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn committed");
// The reasoning committed as its collapsed header: the body is NOT in the
// live view (that's the print-once display policy, not a bug)…
harness
.wait_for_full_text("Thought for", Duration::from_secs(10))
.expect("collapsed thinking header committed");
assert!(
!harness.full_text().contains(REASONING_SENTINEL),
"reasoning body must be collapsed in the live view\nfull:\n{}",
harness.full_text()
);
// …so the transcript is the only way to read it. cat dumps the full view.
inject_keys_paced(&mut harness, b"/transcript");
harness.inject_keys(b"\r").expect("submit /transcript");
.expect("thinking header committed");
harness
.wait_for_full_text(REASONING_SENTINEL, Duration::from_secs(15))
.wait_for_full_text(REASONING_SENTINEL, Duration::from_secs(10))
.unwrap_or_else(|e| {
panic!(
"transcript must expand the collapsed thinking body: {e}\nfull:\n{}",
"reasoning body must be committed to scrollback: {e}\nfull:\n{}",
harness.full_text()
)
});
// And the inline TUI survives the suspend/restore round trip.
harness
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(10))
.expect("inline TUI restored after the pager exited");
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",

View file

@ -0,0 +1,74 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
const BODY_SENTINEL: &str = "READBODYONLYSENTINEL";
const DONE_SENTINEL: &str = "LOOKUP_TURN_DONE";
/// Uses `read_file` rather than `grep`: the grep tool shells out to `rg`,
/// which is absent from the Bazel remote-exec sandbox (only xai-grok-tools'
/// own test targets ship `@ripgrep_hermetic//:rg`), and a failed spawn
/// degrades to a zero-match result that vacuously passes the absence assert.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_lookup_commits_one_line_summary() {
let content = ContentController::start().await.expect("start content");
let fixture = content.home().join("haystack.txt");
std::fs::write(&fixture, format!("{BODY_SENTINEL} body line\n")).expect("write fixture");
enqueue_tool_turn(
&content,
"call_read",
"read_file",
json!({ "target_file": fixture.to_string_lossy() }).to_string(),
);
content.set_response(DONE_SENTINEL);
let mut harness = spawn_minimal_in_dir(
&content,
DEFAULT_ROWS,
DEFAULT_COLS,
&["--yolo", "--trust"],
content.home(),
);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_full_text(DONE_SENTINEL, Duration::from_secs(60))
.expect("tool turn settles");
harness
.wait_for_text(MINIMAL_IDLE_SENTINEL, Duration::from_secs(20))
.expect("return to idle");
harness
.wait_for_full_text("haystack.txt", Duration::from_secs(10))
.expect("read header committed");
assert!(
!harness.full_text().contains(BODY_SENTINEL),
"successful read must commit as a one-line header, without file \
content\nfull:\n{}",
harness.full_text()
);
harness.inject_keys(b"\x05").expect("ctrl+e expand");
harness
.wait_for_full_text(BODY_SENTINEL, Duration::from_secs(10))
.unwrap_or_else(|e| {
panic!(
"Ctrl+E must re-print the read with its file content: {e}\nfull:\n{}",
harness.full_text()
)
});
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -9,6 +9,7 @@
mod minimal_cli_screen_mode_does_not_persist;
mod minimal_commits_response_to_scrollback;
mod minimal_commits_thinking_body_to_scrollback;
mod minimal_committed_content_survives_overlay_grow;
mod minimal_continue_reprints_transcript;
mod minimal_ctrl_c_arms_and_quits;
@ -16,6 +17,7 @@ mod minimal_double_esc_committed_queued_prompt_single_render;
mod minimal_esc_mid_turn_is_swallowed;
mod minimal_flush_left_no_hpad;
mod minimal_help_opens_command_palette;
mod minimal_lookup_commits_one_line_summary;
mod minimal_new_session_keeps_history_and_resets;
mod minimal_queue_indicator_shows_while_running;
mod minimal_resize_preserves_committed_scrollback;
@ -25,6 +27,5 @@ mod minimal_short_response_stays_on_screen;
mod minimal_slash_dropdown_dismisses_with_esc;
mod minimal_slash_switches_from_fullscreen;
mod minimal_slash_switches_to_fullscreen;
mod minimal_transcript_expands_collapsed_thinking;
mod minimal_transcript_opens_in_pager;
mod minimal_transcript_pager_restore_no_artifacts;

View file

@ -148,7 +148,10 @@ fn row_idx_for(state: &SettingsModalState, target: &str) -> usize {
state
.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == target))
.position(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == target)
})
.unwrap_or_else(|| panic!("setting `{target}` not present in modal rows"))
}
@ -2146,10 +2149,10 @@ fn d_key_emits_open_reset_confirm_for_every_setting() {
// without key-release reporting, which tests run without). Skip settings
// with no visible row; their reset path is covered by the dispatch
// round-trip tests.
let has_row = s
.rows
.iter()
.any(|r| matches!(r, RowEntry::Setting { key, .. } if *key == meta.key));
let has_row = s.rows.iter().any(|r| {
matches!(r, RowEntry::Setting { key, .. }
if *key == meta.key)
});
if !has_row {
continue;
}
@ -3031,7 +3034,8 @@ fn pr6_permission_mode_picker_enter_dispatches_set_permission_mode_commit() {
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
assert!(
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"),
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. }
if key == "permission_mode"),
"Enter on permission_mode row must open the picker, got {:?}",
s.mode(),
);
@ -3312,7 +3316,8 @@ fn pr11_picker_commit_for_default_dispatches_set_permission_mode_default() {
navigate_to(&mut s, "permission_mode");
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
assert!(
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"),
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. }
if key == "permission_mode"),
"Enter on permission_mode row must open the picker, got {:?}",
s.mode(),
);
@ -3357,7 +3362,8 @@ fn pr11_picker_commit_for_ask_dispatches_set_permission_mode_ask() {
navigate_to(&mut s, "permission_mode");
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
assert!(
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"),
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. }
if key == "permission_mode"),
"Enter on permission_mode row must open the picker, got {:?}",
s.mode(),
);
@ -4221,7 +4227,8 @@ fn pr14_default_model_picker_commits_resolved_model_id() {
"Enter on DynamicEnum row must transition to PickingEnum, got {outcome:?}"
);
assert!(
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "default_model"),
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. }
if key == "default_model"),
"Enter must transition to PickingEnum for default_model"
);
@ -4336,7 +4343,8 @@ fn pr14_mouse_click_on_dynamic_enum_row_opens_picker() {
"second click on DynamicEnum row must open picker, got {outcome:?}",
);
assert!(
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "default_model"),
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. }
if key == "default_model"),
"second click on DynamicEnum row must transition to PickingEnum, got {:?}",
s.mode(),
);
@ -4380,7 +4388,8 @@ fn pr8_mouse_click_on_int_row_opens_editor() {
"second click on Int row must be Changed, got {outcome:?}",
);
assert!(
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "max_thoughts_width"),
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. }
if key == "max_thoughts_width"),
"second click on Int row must transition to EditingValue, got {:?}",
s.mode(),
);
@ -6880,7 +6889,8 @@ fn scroll_speed_mouse_click_opens_editor() {
"second click on focused Int row must enter the editor, got {outcome:?}"
);
assert!(
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "scroll_speed"),
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. }
if key == "scroll_speed"),
"mode must be EditingValue(scroll_speed) after Enter-equivalent click, got {:?}",
s.mode(),
);
@ -7070,7 +7080,8 @@ fn scroll_lines_mouse_click_opens_editor() {
"second click on focused Int row must enter the editor, got {outcome:?}"
);
assert!(
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "scroll_lines"),
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. }
if key == "scroll_lines"),
"mode must be EditingValue(scroll_lines), got {:?}",
s.mode(),
);

View file

@ -281,9 +281,8 @@ mod tests {
let sources = load_sources(&config);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].name, "Local Dev");
assert!(
matches!(&sources[0].kind, SourceKind::Local { path } if path == &PathBuf::from("/home/user/plugins"))
);
assert!(matches!(&sources[0].kind, SourceKind::Local { path }
if path == &PathBuf::from("/home/user/plugins")));
}
#[test]
@ -300,9 +299,8 @@ mod tests {
let sources = load_sources(&config);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].name, "xAI Official");
assert!(
matches!(&sources[0].kind, SourceKind::Git { url, branch } if url.contains("xai-org") && branch.as_deref() == Some("main"))
);
assert!(matches!(&sources[0].kind, SourceKind::Git { url, branch }
if url.contains("xai-org") && branch.as_deref() == Some("main")));
}
#[test]
@ -397,9 +395,8 @@ mod tests {
extract_marketplace_entries(marketplaces, &mut seen, &mut sources);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].name, "my-marketplace");
assert!(
matches!(&sources[0].kind, SourceKind::Git { url, .. } if url == "https://github.com/anthropics/claude-plugins-official.git")
);
assert!(matches!(&sources[0].kind, SourceKind::Git { url, .. }
if url == "https://github.com/anthropics/claude-plugins-official.git"));
}
#[test]
@ -420,9 +417,8 @@ mod tests {
let mut sources = Vec::new();
extract_marketplace_entries(marketplaces, &mut seen, &mut sources);
assert_eq!(sources.len(), 1);
assert!(
matches!(&sources[0].kind, SourceKind::Git { url, .. } if url == "git@github.com:org/repo.git")
);
assert!(matches!(&sources[0].kind, SourceKind::Git { url, .. }
if url == "git@github.com:org/repo.git"));
}
#[test]

View file

@ -3742,18 +3742,20 @@ mod tests {
};
assert_eq!(u.content.len(), 2);
assert_matches!(
&u.content[1],
ContentPart::Image { url } if url.as_ref() == "https://example.com/image.png"
);
&u.content[1],
ContentPart::Image { url }
if url.as_ref() == "https://example.com/image.png"
);
// Convert to chat request and verify
let chat_msg = conversation_item_to_chat_message(user);
let blocks = chat_msg.content.blocks();
assert_eq!(blocks.len(), 2);
assert_matches!(
&blocks[1],
ChatContentBlock::ImageUrl { image_url } if image_url.url == "https://example.com/image.png"
);
&blocks[1],
ChatContentBlock::ImageUrl { image_url }
if image_url.url == "https://example.com/image.png"
);
}
#[test]
@ -4936,7 +4938,8 @@ mod tests {
let chat_msg = conversation_item_to_chat_message(user);
let blocks = chat_msg.content.blocks();
assert_eq!(blocks.len(), 4);
assert_matches!(&blocks[0], ChatContentBlock::Text { text } if text == "Compare these images:");
assert_matches!(&blocks[0], ChatContentBlock::Text { text }
if text == "Compare these images:");
assert_matches!(&blocks[1], ChatContentBlock::ImageUrl { .. });
assert_matches!(&blocks[2], ChatContentBlock::ImageUrl { .. });
assert_matches!(&blocks[3], ChatContentBlock::ImageUrl { .. });
@ -7647,11 +7650,11 @@ mod tests {
);
};
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], ChatContentBlock::Text { text }
if text == "Read image file: photo.png"));
assert!(
matches!(&blocks[0], ChatContentBlock::Text { text } if text == "Read image file: photo.png")
);
assert!(
matches!(&blocks[1], ChatContentBlock::ImageUrl { image_url } if image_url.url == "data:image/png;base64,iVBOR")
matches!(&blocks[1], ChatContentBlock::ImageUrl { image_url }
if image_url.url == "data:image/png;base64,iVBOR")
);
}
@ -7717,10 +7720,12 @@ mod tests {
};
assert_eq!(inner.len(), 2);
assert!(
matches!(&inner[0], crate::messages::ContentBlock::Text { text, .. } if text == "Read image file: photo.png")
matches!(&inner[0], crate::messages::ContentBlock::Text { text, .. }
if text == "Read image file: photo.png")
);
assert!(
matches!(&inner[1], crate::messages::ContentBlock::Image { source: crate::messages::ImageSource::Base64 { media_type, data } } if media_type == "image/png" && data == "iVBOR")
matches!(&inner[1], crate::messages::ContentBlock::Image { source: crate::messages::ImageSource::Base64 { media_type, data } }
if media_type == "image/png" && data == "iVBOR")
);
}
@ -7770,7 +7775,8 @@ mod tests {
if let ConversationItem::ToolResult(t) = &back {
assert_eq!(t.images.len(), 1);
assert!(matches!(&t.images[0], ContentPart::Image { url } if url.contains("iVBOR")));
assert!(matches!(&t.images[0], ContentPart::Image { url }
if url.contains("iVBOR")));
} else {
panic!("Expected ToolResult");
}

View file

@ -214,13 +214,14 @@ impl SamplingError {
/// a new session.
pub fn is_encrypted_content_error(&self) -> bool {
matches!(
self,
SamplingError::Api {
status: StatusCode::BAD_REQUEST,
message,
..
} if message.contains("encrypted_content")
)
self,
SamplingError::Api {
status: StatusCode::BAD_REQUEST,
message,
..
}
if message.contains("encrypted_content")
)
}
/// The API rejected the request because an inline image could not be
@ -228,13 +229,14 @@ impl SamplingError {
/// Exact-case match — consistent with `is_encrypted_content_error`.
pub fn is_image_processing_error(&self) -> bool {
matches!(
self,
SamplingError::Api {
status,
message,
..
} if matches!(status.as_u16(), 400 | 500) && message.contains("Could not process image")
)
self,
SamplingError::Api {
status,
message,
..
}
if matches!(status.as_u16(), 400 | 500) && message.contains("Could not process image")
)
}
pub fn is_retryable(&self) -> bool {

View file

@ -1003,12 +1003,13 @@ mod tests {
let stop_handle = manager.take_stop_handle().unwrap();
assert!(matches!(
manager.status(),
CpuProfileStatus::Stopping {
svg_path: status_path,
..
} if status_path == svg_path
));
manager.status(),
CpuProfileStatus::Stopping {
svg_path: status_path,
..
}
if status_path == svg_path
));
let err = manager
.start_with_engine_for_test(
@ -1136,13 +1137,14 @@ mod tests {
let _stop_handle = manager.take_stop_handle().unwrap();
assert!(matches!(
manager.status(),
CpuProfileStatus::Stopping {
svg_path: status_path,
frequency_hz: DEFAULT_FREQUENCY_HZ,
..
} if status_path == svg_path
));
manager.status(),
CpuProfileStatus::Stopping {
svg_path: status_path,
frequency_hz: DEFAULT_FREQUENCY_HZ,
..
}
if status_path == svg_path
));
}
#[test]

View file

@ -334,6 +334,44 @@ Common log messages:
| `auth: external auth provider timed out (likely needs interactive auth), killing` | Binary didn't exit before the timeout (60s initial, 5s mid-session refresh) and was killed |
| `auth: failed to start external auth provider` | The command couldn't be spawned (e.g. binary not found) |
### Per-Model Auth Providers
`auth_provider_command` above replaces Grok's *session* auth: it mints the token sent to xAI's backend. If you instead want xAI models on normal xAI login while **other models** route through a gateway (LiteLLM, corporate proxy) whose bearer tokens rotate, use a named auth provider — the rotating-token analogue of a per-model `api_key`/`env_key`.
```toml
# ~/.grok/config.toml
[auth_provider.litellm]
command = "/usr/local/bin/litellm-token" # run via `sh -c`
token_ttl_secs = 3600 # optional: see below
timeout_secs = 10 # optional: command timeout (default 30)
[model.proxied-claude]
model = "claude-sonnet-4-5"
base_url = "https://litellm.corp.example/v1"
context_window = 200000
auth_provider = "litellm"
```
**Contract** (same stdout contract as `auth_provider_command`; the `issuer` field is accepted but unused here, and `refresh_token`, when present, is handed back to the command on refresh):
- Without `args`, the command runs via POSIX `sh -c`, so it can be a binary path, a script, or a pipeline. With `args = ["..."]`, the command runs directly with those arguments and no shell: `command` is a program name resolved via `PATH`, or a path. Use `args` to avoid shell quoting, and on Windows, where there is no `sh`.
- stdout: a bare token, or JSON `{"access_token": "...", "expires_in": 3600}`.
- stderr: logged when the command fails; exit 0 = success.
- `GROK_AUTH_EXPIRED=1` is set whenever Grok re-mints over a token still cached in memory, whether from near-expiry rotation or a rejection. The first mint on a cold cache runs without it.
**Token lifecycle:**
- Tokens are cached in memory per provider and shared by every model referencing the provider; nothing is written to disk. The command is a credential helper: it owns durable storage and OAuth2 refresh (keychain, its own dotdir, etc.), exactly like `gcloud auth print-access-token` or a git credential helper. On an in-session re-mint the last credential is handed back via `GROK_AUTH_PROVIDER_ACCESS_TOKEN` (and, when present, `GROK_AUTH_PROVIDER_REFRESH_TOKEN` / `GROK_AUTH_PROVIDER_EXPIRES_AT`), so a refresh-grant command can refresh instead of re-authenticating. The command must be non-interactive and fast; do any interactive login out of band, and Grok re-runs the command on restart to re-mint.
- Grok runs the command before a chat turn when the token is missing or within about a minute of expiring, and once more after the server rejects a token. A token rejected within 30 seconds of being fetched is not refetched again, so a broken helper surfaces one clear error instead of looping.
- Token lifetime comes from `expires_in` in the command's JSON output, else `token_ttl_secs`, else the token's own JWT expiry claim. With none of these, tokens are only replaced after the server rejects one.
- Commands run with a `timeout_secs` bound (default 30, clamped to 1..=600) and are killed on timeout. A turn waits on the run, so keep helpers fast and non-interactive.
- Active sessions pick up edits or removal of a provider table at the next model switch or new session. Once picked up, an edit invalidates the cached token, so the edited command runs at the next use; removal drops the cached token.
- Helper models (web search, session summary, image description) read the shared cache and never run the command; point them at providers your chat model keeps warm. Subagents refresh tokens the same way their parent session does.
**Interaction with other credentials:** a literal `api_key`/`env_key` on the model wins over its `auth_provider`. Provider-backed models are BYOK: your xAI session token is never sent to their endpoints, and a failing provider command fails the request rather than falling back to the session token.
**Security:** provider commands execute code, so they are honored only from trusted config layers (`~/.grok/config.toml`, managed config, requirements). A project's `.grok/config.toml` can never define one. Whatever layer sets a model's `base_url` decides where that model's minted token is sent, and `base_url` (unlike the provider table) is not stripped from remote or campaign patches, the same as for a static `env_key`. Keep provider tables and the model `base_url` in layers you trust. The command inherits Grok's environment (so it sees `PATH`, `HOME`, and any other secrets there), but Grok's own first-party credentials (`XAI_API_KEY`, `GROK_DEPLOYMENT_KEY`, and related keys) are removed so a BYOK helper never receives them; write helpers that read only what they need, and prefer the `GROK_AUTH_PROVIDER_*` handback for the prior credential.
### Using auth.json for API Access
If you've authenticated with `grok login`, you can use the stored credentials to call the CLI chat proxy directly via curl. The proxy requires specific headers that mirror what the grok CLI sends internally:
@ -1707,13 +1745,14 @@ name = "Display Name" # Shown in model picker
description = "Model description" # Optional description
api_key = "sk-..." # API key for this provider (optional)
env_key = "OPENAI_API_KEY" # Env var(s) holding the API key (string or array; first set wins)
auth_provider = "corp-gateway" # Named credential helper for rotating tokens (optional)
temperature = 0.7 # Sampling temperature (0.0-2.0)
top_p = 0.95 # Nucleus sampling parameter
max_completion_tokens = 8192 # Max tokens per response
context_window = 256000 # Total context window in tokens (for auto-compact)
```
**Credential resolution order:** `api_key``env_key``XAI_API_KEY`. If neither `api_key` nor `env_key` is set, Grok falls back to the global `XAI_API_KEY` environment variable.
**Credential resolution order:** `api_key``env_key`cached `auth_provider` token (terminal: a cache miss resolves to no credential, never the session token) → session token → `XAI_API_KEY`. See [Per-Model Auth Providers](#per-model-auth-providers).
The `context_window` parameter is used to calculate when auto-compact should trigger. If not specified, Grok falls back to built-in defaults for known models.

View file

@ -651,6 +651,25 @@ pub struct RuntimeResolutionContext<'a> {
/// CLI `--storage-mode` override. `None` = defer to env/remote/default.
pub storage_mode: Option<&'a str>,
}
/// First-party credential env vars scrubbed from a BYOK auth-provider helper's
/// environment so it can't inherit the keys Grok uses for its own first-party
/// requests. Keep in sync with every first-party credential env read across the
/// crate: `auth::manager` (`GROK_AUTH`/`GROK_AUTH_PATH`), `auth_method`
/// (`XAI_API_KEY`/legacy), and the credential-bearing `env_string(...)` reads in
/// `EndpointsConfig::default`. The `provider_helper_env_scrubs_first_party_credentials`
/// test pins this against an independent audited literal, so any change here must
/// be mirrored (and re-audited) there.
pub(crate) const FIRST_PARTY_CREDENTIAL_ENV_VARS: &[&str] = &[
crate::agent::auth_method::XAI_API_KEY_ENV_VAR,
crate::agent::auth_method::LEGACY_XAI_API_KEY_ENV_VAR,
"GROK_AUTH",
"GROK_AUTH_PATH",
"GROK_DEPLOYMENT_KEY",
"GROK_EXTRA_AUTH_KEY",
"GROK_TRACE_UPLOAD_CREDENTIALS_FILE",
"OTEL_EXPORTER_OTLP_HEADERS",
"GROK_INTERNAL_OTLP_HEADERS",
];
/// Read an env var as a trimmed string. Returns `None` if unset or empty/whitespace-only.
pub(crate) fn env_string(name: &str) -> Option<String> {
let value = std::env::var(name).ok()?;
@ -1280,10 +1299,15 @@ pub struct Config {
/// `[model.*]` overrides from config.toml. Resolve via `resolve_model_list()`.
#[serde(skip)]
pub config_models: IndexMap<String, ConfigModelOverride>,
/// Warnings from `[model.*]` parsing; surfaced by `grok inspect`.
/// Warnings from `[model.*]` and `[auth_provider.*]` parsing; surfaced by
/// `grok inspect`.
#[serde(skip)]
pub model_override_warnings: Vec<super::config_model_override_parse::ModelOverrideWarning>,
pub config_warnings: Vec<super::config_model_override_parse::ConfigWarning>,
pub grok_com_config: GrokComConfig,
/// `[auth_provider.<name>]` tables, populated by
/// [`parse_auth_providers`] from trusted config layers only.
#[serde(skip)]
pub auth_providers: IndexMap<String, crate::auth::AuthProviderConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shortcuts: Option<toml::Value>,
/// Written by the client via `config_toml_edit`; absorbed so it isn't
@ -1708,8 +1732,9 @@ impl Default for Config {
doom_loop_recovery: crate::util::config::DoomLoopRecoverySettings::default(),
auto_mode: AutoModeConfig::default(),
config_models: IndexMap::new(),
model_override_warnings: Vec::new(),
config_warnings: Vec::new(),
grok_com_config: GrokComConfig::default(),
auth_providers: IndexMap::new(),
shortcuts: None,
hints: None,
ui: UiConfig::default(),
@ -1792,6 +1817,101 @@ impl Default for Config {
cfg
}
}
/// Parse `[auth_provider.<name>]` tables leniently: a malformed entry warns
/// (surfaced by `grok inspect`) and is skipped, so it fails closed for the
/// models referencing it instead of failing the whole config.
fn parse_auth_providers(
raw_config: &toml::Value,
) -> (
IndexMap<String, crate::auth::AuthProviderConfig>,
Vec<super::config_model_override_parse::ConfigWarning>,
) {
use super::config_model_override_parse::{ConfigWarning, ConfigWarningKind};
let mut providers = IndexMap::new();
let mut warnings = Vec::new();
let Some(section) = raw_config.get("auth_provider") else {
return (providers, warnings);
};
let Some(table) = section.as_table() else {
warnings.push(ConfigWarning::auth_provider_section(
ConfigWarningKind::NotATable,
format!(
"`auth_provider` must be a table of [auth_provider.<name>] entries, got {}; \
all auth providers ignored",
section.type_str()
),
));
return (providers, warnings);
};
for (name, value) in table {
let mut unknown = Vec::new();
match serde_ignored::deserialize::<_, _, crate::auth::AuthProviderConfig>(
value.clone(),
|path| unknown.push(path.to_string()),
) {
Ok(provider) => {
for key in unknown {
warnings.push(ConfigWarning::auth_provider(
name,
Some(key.as_str()),
ConfigWarningKind::UnknownField,
"unrecognized key; field ignored".to_owned(),
));
}
if !provider.is_usable() {
warnings.push(ConfigWarning::auth_provider(
name,
Some("command"),
ConfigWarningKind::InvalidValue,
"missing or empty command; referencing models resolve \
with no credential"
.to_owned(),
));
}
let skew = crate::auth::PROVIDER_TOKEN_EXPIRY_SKEW_SECS;
if provider.token_ttl_secs.is_some_and(|ttl| ttl <= skew) {
warnings.push(ConfigWarning::auth_provider(
name,
Some("token_ttl_secs"),
ConfigWarningKind::InvalidValue,
format!(
"at or below the {skew}s refresh margin; the command will \
run before every turn"
),
));
}
if let Some(timeout) = provider.timeout_secs
&& !(1..=crate::auth::PROVIDER_TIMEOUT_CEILING_SECS).contains(&timeout)
{
let ceiling = crate::auth::PROVIDER_TIMEOUT_CEILING_SECS;
warnings.push(ConfigWarning::auth_provider(
name,
Some("timeout_secs"),
ConfigWarningKind::InvalidValue,
if timeout == 0 {
"below the 1 second minimum; clamped to 1".to_owned()
} else {
format!("above the {ceiling}s maximum; clamped to {ceiling}")
},
));
}
providers.insert(name.clone(), provider);
}
Err(error) => {
warnings.push(ConfigWarning::auth_provider(
name,
None,
ConfigWarningKind::InvalidValue,
format!(
"failed to parse ({error}); provider skipped, referencing models \
resolve with no credential"
),
));
}
}
}
(providers, warnings)
}
impl Config {
/// Reject invalid glob patterns in the model-filter lists at config load, so
/// a typo fails loudly instead of silently changing availability.
@ -1847,9 +1967,9 @@ impl Config {
let raw_config = &Self::expand_auth_alias(raw_config);
let super::config_model_override_parse::ParsedModelOverrides {
models: config_models,
warnings: model_override_warnings,
warnings: config_warnings,
} = super::config_model_override_parse::parse_model_overrides(raw_config);
super::config_model_override_parse::log_model_override_warnings(&model_override_warnings);
let (auth_providers, auth_provider_warnings) = parse_auth_providers(raw_config);
let mut base = toml::Value::try_from(Self::default()).map_err(|e| e.to_string())?;
if let toml::Value::Table(ref mut t) = base {
t.remove("model");
@ -1857,6 +1977,7 @@ impl Config {
let mut raw_without_model_sections = raw_config.clone();
if let toml::Value::Table(ref mut t) = raw_without_model_sections {
t.remove("model");
t.remove("auth_provider");
}
crate::config::deep_merge_toml(&mut base, &raw_without_model_sections);
let (mut config, user_unused) =
@ -1868,7 +1989,33 @@ impl Config {
);
}
config.config_models = config_models;
config.model_override_warnings = model_override_warnings;
config.config_warnings = config_warnings;
config.auth_providers = auth_providers;
config.config_warnings.extend(auth_provider_warnings);
let declared_provider_names: std::collections::HashSet<&str> = raw_config
.get("auth_provider")
.and_then(toml::Value::as_table)
.map(|t| t.keys().map(String::as_str).collect())
.unwrap_or_default();
for (model_key, model) in &config.config_models {
if let Some(ref name) = model.auth_provider
&& !config.auth_providers.contains_key(name)
&& !declared_provider_names.contains(name.as_str())
{
config.config_warnings.push(
super::config_model_override_parse::ConfigWarning::model(
model_key,
Some("auth_provider"),
super::config_model_override_parse::ConfigWarningKind::InvalidValue,
format!(
"references [auth_provider.{name}], which is not defined; \
the model resolves with no provider credential"
),
),
);
}
}
super::config_model_override_parse::log_config_warnings(&config.config_warnings);
if config.grok_com_config.oidc.is_none() {
config.grok_com_config.oidc = OidcAuthConfig::from_env();
}
@ -3196,11 +3343,24 @@ pub fn resolve_model_list(
let entry = model_override.apply(key, base, &cfg.endpoints);
tracing::debug!(
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
.api_key.is_some(), env_key = ? entry.env_key, had_base,
.api_key.is_some(), env_key = ? entry.env_key, auth_provider = entry
.auth_provider.as_ref().map(| p | p.name.as_str()), had_base,
"config model override applied"
);
resolved.insert(key.clone(), entry);
}
for (key, entry) in resolved.iter_mut() {
if let Some(ref mut provider) = entry.auth_provider {
let config = cfg.auth_providers.get(&provider.name);
if config.is_none() {
tracing::debug!(
model_key = % key, provider = % provider.name,
"model references an undefined [auth_provider.*] table"
);
}
provider.attach_trusted_config(config);
}
}
{
let default_cw = DEFAULT_CONTEXT_WINDOW;
let donors: std::collections::HashMap<String, (std::num::NonZeroU64, ApiBackend)> =
@ -3582,6 +3742,10 @@ pub struct ConfigModelOverride {
pub api_key: Option<String>,
/// Env var name(s) for the provider key — string or array in config.toml.
pub env_key: Option<EnvKeys>,
/// Name of a `[auth_provider.<name>]` credential helper that mints
/// this model's bearer token. Static `api_key` / `env_key` win when both
/// are set.
pub auth_provider: Option<String>,
pub api_base_url: Option<String>,
pub max_completion_tokens: Option<u32>,
pub temperature: Option<f32>,
@ -3708,10 +3872,15 @@ impl ConfigModelOverride {
if self.env_key.is_some() {
entry.env_key.clone_from(&self.env_key);
}
if let Some(ref name) = self.auth_provider {
entry.auth_provider = Some(crate::auth::AuthProviderRef::unresolved(name.clone()));
}
if self.api_base_url.is_some() {
entry.api_base_url.clone_from(&self.api_base_url);
}
if self.supported_in_api.is_none() && (self.api_key.is_some() || self.env_key.is_some()) {
if self.supported_in_api.is_none()
&& (self.api_key.is_some() || self.env_key.is_some() || self.auth_provider.is_some())
{
entry.info.supported_in_api = true;
}
entry
@ -3897,6 +4066,11 @@ pub struct ModelEntry {
pub info: ModelInfo,
pub api_key: Option<String>,
pub env_key: Option<EnvKeys>,
/// Named credential helper (`[model.<id>] auth_provider = "<name>"`),
/// resolved against `[auth_provider.<name>]` by `resolve_model_list`.
/// Config-file models only: the built-in catalog never carries one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_provider: Option<crate::auth::AuthProviderRef>,
/// When set, `base_url` is used for session auth, `api_base_url` for API-key auth.
pub api_base_url: Option<String>,
}
@ -3909,6 +4083,7 @@ impl ModelEntry {
info,
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
}
}
@ -3920,19 +4095,31 @@ impl ModelEntry {
info: ModelInfo::from_config(entry),
api_key: entry.api_key.clone(),
env_key: entry.env_key.clone(),
auth_provider: None,
api_base_url: entry.api_base_url.clone(),
}
}
/// Non-empty `api_key`, else first non-empty resolved `env_key`.
/// `None` → fall through to session / global key.
/// `None` → fall through to session / global key. Static only: never
/// consults auth-provider tokens.
pub(crate) fn own_credential(&self) -> Option<String> {
first_own_credential(self.api_key.as_deref(), self.env_key.as_ref())
}
/// `true` when the model has a non-empty `api_key` or an `env_key` that
/// resolves to a non-empty value.
/// Probes `std::env::var` at call time — result is not stable across env changes.
/// The provider governing this model's bearer: `None` when a static
/// `api_key`/`env_key` resolves. The turn paths consult this, so a
/// shadowed provider never runs.
pub(crate) fn effective_auth_provider(&self) -> Option<&crate::auth::AuthProviderRef> {
if self.own_credential().is_some() {
return None;
}
self.auth_provider.as_ref()
}
/// `true` when the model has a non-empty `api_key`, an `env_key` that
/// resolves to a non-empty value, or a named auth provider.
/// Probes `std::env::var` at call time: result is not stable across env
/// changes. Never executes a provider command.
pub fn has_own_credentials(&self) -> bool {
self.own_credential().is_some()
self.own_credential().is_some() || self.auth_provider.is_some()
}
}
impl std::ops::Deref for ModelEntry {
@ -4306,10 +4493,8 @@ pub(crate) fn first_own_credential(
.map(str::to_owned)
.or_else(|| env_key.and_then(EnvKeys::resolve_value))
}
/// Resolve credentials for a model.
/// Priority: model api_key/env_key > session token > XAI_API_KEY.
///
/// When `env_key` lists multiple names, the first set non-empty value is used.
/// Priority: model api_key/env_key > cached auth-provider token > session
/// token > XAI_API_KEY.
pub fn resolve_credentials(model: &ModelEntry, session_key: Option<&str>) -> ResolvedCredentials {
let info = model.info();
let (api_key, base_url, auth_type) = if let Some(key) = model.own_credential() {
@ -4318,6 +4503,13 @@ pub fn resolve_credentials(model: &ModelEntry, session_key: Option<&str>) -> Res
info.base_url.clone(),
xai_chat_state::AuthType::ApiKey,
)
} else if let Some(provider) = model.auth_provider.as_ref() {
debug_assert!(model.effective_auth_provider().is_some());
(
provider.cached_token(),
info.base_url.clone(),
xai_chat_state::AuthType::ApiKey,
)
} else if let Some(key) = session_key {
(
Some(key.to_owned()),
@ -4425,23 +4617,37 @@ pub struct ModelAuthFacts {
pub byok: ModelByok,
pub auth_scheme: AuthScheme,
}
/// Resolve `model_id` to its auth facts from one effective-config load.
/// Load/parse failure → `byok = Unknown`; model absent from the catalog →
/// `NotByok`. An empty `model_id` (no sampling config yet) → `Unknown`, not
/// `NotByok`, so the gate isn't activated for an unidentified model.
pub fn resolve_model_auth_facts(model_id: &str) -> ModelAuthFacts {
/// Resolve `model_id` to its auth facts and auth-provider reference from one
/// effective-config load; both ride the same memo (see
/// `SessionActor::model_auth_memo`). Load/parse failure → `byok = Unknown`;
/// model absent from the catalog → `NotByok`. An empty `model_id` (no sampling
/// config yet) → `Unknown`, not `NotByok`, so the gate isn't activated for an
/// unidentified model.
pub fn resolve_model_auth_facts_and_provider(
model_id: &str,
) -> (ModelAuthFacts, Option<crate::auth::AuthProviderRef>) {
if model_id.is_empty() {
return ModelAuthFacts {
byok: ModelByok::Unknown,
auth_scheme: AuthScheme::default(),
};
return (
ModelAuthFacts {
byok: ModelByok::Unknown,
auth_scheme: AuthScheme::default(),
},
None,
);
}
with_resolved_model(model_id, |lookup| ModelAuthFacts {
byok: byok_from_lookup(&lookup),
auth_scheme: match lookup {
ModelLookup::Loaded(Some(e)) => e.info().auth_scheme,
_ => AuthScheme::default(),
},
with_resolved_model(model_id, |lookup| {
let facts = ModelAuthFacts {
byok: byok_from_lookup(&lookup),
auth_scheme: match lookup {
ModelLookup::Loaded(Some(e)) => e.info().auth_scheme,
_ => AuthScheme::default(),
},
};
let provider = match lookup {
ModelLookup::Loaded(Some(e)) => e.effective_auth_provider().cloned(),
_ => None,
};
(facts, provider)
})
}
fn byok_from_lookup(lookup: &ModelLookup) -> ModelByok {
@ -4502,6 +4708,13 @@ pub fn resolve_aux_model_sampling_config(
if sampler.api_key.is_some() {
return Some(sampler);
}
if entry.effective_auth_provider().is_some() {
tracing::warn!(
model = % model_id,
"aux model uses an auth provider with no cached token; the caller falls back to its session default"
);
return None;
}
}
let xai_bearer = session_key
.map(|s| s.to_owned())
@ -4545,6 +4758,7 @@ pub fn resolve_aux_model_sampling_config(
},
api_key: Some(bearer),
env_key: None,
auth_provider: None,
api_base_url: None,
};
let credentials = resolve_credentials_enforced(&entry, session_key, disable_api_key_auth);
@ -4564,18 +4778,14 @@ pub fn resolve_aux_model_sampling_config(
);
None
}
/// Finalize image-describe model + sampler config for user attachments.
/// Shared so the aux resolve happy path and the
/// `None` fallback cannot diverge between those entry points.
///
/// On aux resolve `Some`, stamp session-local fields (client id, attribution, bearer,
/// retries) onto the helper config. On `None`, fall back to the active session model and
/// full config (not forcing `image_description_model` onto the agent endpoint, which 404s
/// on BYOK / non-proxy routes for internal slugs like `grok-build`).
/// Stamp the session-local fields (client id, attribution, bearer resolver,
/// retries) from the active session onto a routed aux `SamplerConfig` so a
/// helper model keeps the session's auth/attribution. Shared by image-describe
/// and the auto-mode classifier so the two can't drift.
///
/// The resolver gate is host-based, stricter than `session_token_auth_gate`:
/// a session-token deployment on a custom `models_base_url` loses aux-sampler
/// refresh, rather than risk the session bearer on a third-party endpoint.
pub fn stamp_session_local_sampler_fields(
cfg: &mut SamplerConfig,
active_session_config: &SamplerConfig,
@ -4584,9 +4794,19 @@ pub fn stamp_session_local_sampler_fields(
) {
cfg.client_identifier = client_identifier;
cfg.attribution_callback = active_session_config.attribution_callback.clone();
cfg.bearer_resolver = active_session_config.bearer_resolver.clone();
if crate::util::is_xai_api_bearer_url(&cfg.base_url) {
cfg.bearer_resolver = active_session_config.bearer_resolver.clone();
}
cfg.max_retries = max_retries;
}
/// Finalize image-describe model + sampler config for user attachments.
/// Shared so the aux resolve happy path and the `None` fallback cannot
/// diverge between those entry points.
///
/// On aux resolve `Some`, stamp session-local fields onto the helper config.
/// On `None`, fall back to the active session model and full config (not
/// forcing `image_description_model` onto the agent endpoint, which 404s on
/// BYOK / non-proxy routes for internal slugs like `grok-build`).
pub fn finalize_image_describe_sampler_config(
resolved_aux: Option<SamplerConfig>,
active_session_config: &SamplerConfig,
@ -4768,6 +4988,7 @@ fn resolve_hidden_default_web_search_sampling_config(
},
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
let credentials = resolve_credentials_enforced(&entry, session_key, disable_api_key_auth);
@ -4791,6 +5012,13 @@ pub fn resolve_web_search_sampling_config(
) -> Option<SamplerConfig> {
let resolved = if let Some(entry) = find_model_by_id(models, model_id).cloned() {
let credentials = resolve_credentials_enforced(&entry, session_key, disable_api_key_auth);
if credentials.api_key.is_none() && entry.effective_auth_provider().is_some() {
tracing::warn!(
web_search_model = % model_id,
"web search model uses an auth provider with no cached token; disabling web search"
);
return None;
}
Some(sampling_config_for_model(
&entry,
credentials,
@ -5330,6 +5558,256 @@ reasoning_effort = "low"
assert_eq!(resolved.base_url, "https://vendor.example/v1");
assert_eq!(resolved.api_key.as_deref(), Some("vendor-key"));
}
/// Cold cache falls back to the session model, never the xAI proxy;
/// warm cache serves the provider token at the provider endpoint.
#[tokio::test]
async fn aux_model_with_auth_provider_never_reroutes() {
let endpoints = EndpointsConfig::default();
let provider = crate::auth::AuthProviderRef::new(
"aux-provider-test".into(),
crate::auth::AuthProviderConfig {
command: "printf aux-token".into(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
let mut entry = test_model_entry("m", "https://litellm.example/v1", None, None, None);
entry.auth_provider = Some(provider.clone());
let mut catalog = IndexMap::new();
catalog.insert("proxied-aux".to_string(), entry);
assert!(
resolve_aux_model_sampling_config(
"proxied-aux",
&catalog,
&endpoints,
Some("session-jwt"),
false,
None,
None,
)
.is_none(),
"cold provider cache must not reroute the aux model through the xAI proxy"
);
let _ = provider.ensure_fresh_token(None).await;
let resolved = resolve_aux_model_sampling_config(
"proxied-aux",
&catalog,
&endpoints,
Some("session-jwt"),
false,
None,
None,
)
.expect("warm cache resolves");
assert_eq!(resolved.base_url, "https://litellm.example/v1");
assert_eq!(resolved.api_key.as_deref(), Some("aux-token"));
}
/// The session bearer resolver must never be stamped onto a third-party
/// sampler: the sampler substitutes the resolver's bearer at request
/// time.
#[test]
fn session_resolver_is_not_stamped_onto_third_party_samplers() {
#[derive(Debug)]
struct SessionResolver;
impl xai_grok_sampler::BearerResolver for SessionResolver {
fn current_bearer(&self) -> Option<String> {
Some("session-jwt".into())
}
}
let session_cfg = SamplerConfig {
bearer_resolver: Some(std::sync::Arc::new(SessionResolver)),
..SamplerConfig::default()
};
let mut third_party = SamplerConfig {
base_url: "https://litellm.corp.example/v1".into(),
..SamplerConfig::default()
};
stamp_session_local_sampler_fields(&mut third_party, &session_cfg, None, None);
assert!(
third_party.bearer_resolver.is_none(),
"a third-party endpoint must keep its resolved credential"
);
let mut first_party = SamplerConfig {
base_url: EndpointsConfig::default().resolve_inference_base_url(),
..SamplerConfig::default()
};
stamp_session_local_sampler_fields(&mut first_party, &session_cfg, None, None);
assert!(
first_party.bearer_resolver.is_some(),
"first-party aux samplers keep the session refresh behavior"
);
}
/// A cold cache disables web search rather than sending an
/// unauthenticated request.
#[tokio::test]
async fn web_search_with_auth_provider_requires_warm_cache() {
let endpoints = EndpointsConfig::default();
let provider = crate::auth::AuthProviderRef::new(
"web-search-provider-test".into(),
crate::auth::AuthProviderConfig {
command: "printf ws-token".into(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
let mut entry = test_model_entry("m", "https://litellm.example/v1", None, None, None);
entry.auth_provider = Some(provider.clone());
let mut catalog = IndexMap::new();
catalog.insert("proxied-search".to_string(), entry);
assert!(
resolve_web_search_sampling_config(
"proxied-search",
&catalog,
Some("session-jwt"),
false,
None,
None,
&endpoints,
)
.is_none(),
"a cold provider cache must disable web search, not send an unauthenticated request"
);
let _ = provider.ensure_fresh_token(None).await;
let resolved = resolve_web_search_sampling_config(
"proxied-search",
&catalog,
Some("session-jwt"),
false,
None,
None,
&endpoints,
)
.expect("warm cache resolves");
assert_eq!(resolved.api_key.as_deref(), Some("ws-token"));
}
/// The lenient parser warns per problem and never fails the whole
/// config.
#[test]
fn auth_provider_parse_warnings_are_lenient_and_specific() {
use super::super::config_model_override_parse::{ConfigWarningKind, WarningTarget};
let raw_config: toml::Value = toml::from_str(
r#"
[auth_provider.good]
command = "printf ok"
[auth_provider.bad-type]
command = "printf x"
token_ttl_secs = "not-a-number"
[auth_provider.typo]
command = "printf y"
timeout_seconds = 5
[auth_provider.commandless]
token_ttl_secs = 60
[auth_provider.short-ttl]
command = "printf x"
token_ttl_secs = 60
[auth_provider.zero-timeout]
command = "printf x"
timeout_secs = 0
[auth_provider.slow]
command = "printf x"
timeout_secs = 601
[model.orphaned]
model = "m"
base_url = "https://x.example/v1"
context_window = 200000
auth_provider = "does-not-exist"
"#,
)
.unwrap();
let cfg =
Config::new_from_toml_cfg(&raw_config).expect("one bad table must not fail the config");
assert!(cfg.auth_providers.contains_key("good"));
assert!(
!cfg.auth_providers.contains_key("bad-type"),
"malformed entry is skipped (fails closed)"
);
let has_provider = |name: &str, field: Option<&str>, kind: ConfigWarningKind| {
cfg.config_warnings.iter().any(|w| {
w.kind == kind
&& matches!(
& w.target, WarningTarget::AuthProvider { name : n, field : f
}
if n == name && f.as_deref() == field
)
})
};
assert!(has_provider(
"bad-type",
None,
ConfigWarningKind::InvalidValue
));
assert!(has_provider(
"typo",
Some("timeout_seconds"),
ConfigWarningKind::UnknownField
));
assert!(has_provider(
"commandless",
Some("command"),
ConfigWarningKind::InvalidValue
));
assert!(has_provider(
"short-ttl",
Some("token_ttl_secs"),
ConfigWarningKind::InvalidValue
));
assert!(has_provider(
"zero-timeout",
Some("timeout_secs"),
ConfigWarningKind::InvalidValue
));
assert!(has_provider(
"slow",
Some("timeout_secs"),
ConfigWarningKind::InvalidValue
));
let provider_reason = |name: &str| {
cfg.config_warnings
.iter()
.find(|w| {
matches!(
& w.target, WarningTarget::AuthProvider { name : n, field : f }
if n == name && f.as_deref() == Some("timeout_secs")
)
})
.map(|w| w.reason.as_str())
.unwrap_or_default()
.to_owned()
};
assert!(provider_reason("zero-timeout").contains("clamped to 1"));
assert!(provider_reason("slow").contains("clamped to 600"));
assert!(
cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::InvalidValue
&& matches!(& w.target, WarningTarget::Model
{ field, .. }
if field.as_deref() == Some("auth_provider"))
}),
"undefined reference warns at parse time: {:?}",
cfg.config_warnings
);
let raw_config: toml::Value = toml::from_str(r#"auth_provider = "oops""#).unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config)
.expect("a non-table auth_provider must not fail the config");
assert!(cfg.auth_providers.is_empty());
assert!(
cfg.config_warnings.iter().any(|w| {
matches!(w.target, WarningTarget::AuthProviderSection)
&& w.kind == ConfigWarningKind::NotATable
}),
"non-table section warns: {:?}",
cfg.config_warnings
);
}
#[test]
fn web_search_disable_api_key_auth_swaps_first_party_key_for_session() {
let endpoints = EndpointsConfig::default();
@ -5379,6 +5857,199 @@ reasoning_effort = "low"
assert_eq!(model.info.base_url, "https://api.example.com/v1");
assert_eq!(model.api_key, Some("sk-test-key-12345".to_string()));
}
#[test]
fn parses_auth_provider_tables_and_model_reference() {
let raw_config: toml::Value = toml::from_str(
r#"
[auth_provider.litellm]
command = "/usr/local/bin/litellm-token"
args = ["--scope", "corp"]
token_ttl_secs = 3600
timeout_secs = 10
[model.proxied-claude]
model = "claude-sonnet-4-5"
base_url = "https://litellm.corp.example/v1"
context_window = 200000
auth_provider = "litellm"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
assert_eq!(
cfg.auth_providers.get("litellm"),
Some(&crate::auth::AuthProviderConfig {
command: "/usr/local/bin/litellm-token".into(),
args: Some(vec!["--scope".into(), "corp".into()]),
token_ttl_secs: Some(3600),
timeout_secs: Some(10),
})
);
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("proxied-claude").expect("model should exist");
let provider = model
.auth_provider
.as_ref()
.expect("model should reference the provider");
assert_eq!(provider.name, "litellm");
assert_eq!(provider.config.command, "/usr/local/bin/litellm-token");
assert_eq!(provider.config.token_ttl_secs, Some(3600));
assert!(
model.has_own_credentials(),
"provider-backed models classify as BYOK (session token must not leak)"
);
assert!(
model.info.supported_in_api,
"declaring an auth provider implies supported_in_api"
);
}
/// A static key shadows a fully defined provider through the real
/// `resolve_model_list` + `attach_trusted_config` pipeline (not a
/// hand-built ref): the static key wins even with the provider cache warm.
#[tokio::test]
async fn static_key_shadows_defined_provider_through_pipeline() {
let raw_config: toml::Value = toml::from_str(
r#"
[auth_provider.understudy]
command = "printf provider-token"
token_ttl_secs = 3600
[model.dual-auth]
model = "m"
base_url = "https://switchboard.example/v1"
context_window = 200000
api_key = "sk-house-key"
auth_provider = "understudy"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("dual-auth").expect("model should exist");
assert_eq!(
model.effective_auth_provider().map(|p| p.name.as_str()),
None,
"a static key shadows the provider after real resolution"
);
let provider = model.auth_provider.as_ref().unwrap().clone();
let _ = provider.ensure_fresh_token(None).await;
let creds = resolve_credentials(model, Some("session-jwt"));
assert_eq!(creds.api_key.as_deref(), Some("sk-house-key"));
assert_eq!(creds.auth_type, xai_chat_state::AuthType::ApiKey);
assert_eq!(creds.base_url, "https://switchboard.example/v1");
}
#[test]
fn undefined_auth_provider_fails_closed() {
let raw_config: toml::Value = toml::from_str(
r#"
[model.orphan]
model = "m"
base_url = "https://third-party.example/v1"
context_window = 200000
auth_provider = "nope"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("orphan").expect("model should exist");
let provider = model.auth_provider.as_ref().unwrap();
assert_eq!(provider.name, "nope");
assert!(
provider.config.command.is_empty(),
"undefined provider keeps an empty command"
);
assert!(model.has_own_credentials());
let creds = resolve_credentials(model, Some("session-jwt"));
assert_eq!(creds.api_key, None);
}
#[tokio::test]
async fn resolve_credentials_serves_cached_provider_token() {
use xai_chat_state::AuthType;
let mut model = test_model_entry("m", "https://litellm.example/v1", None, None, None);
let provider = crate::auth::AuthProviderRef::new(
"resolve-creds-test".into(),
crate::auth::AuthProviderConfig {
command: "printf provider-minted-token".into(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
model.auth_provider = Some(provider.clone());
let creds = resolve_credentials(&model, Some("session-jwt"));
assert_eq!(creds.api_key, None, "cold cache must not run the command");
let _ = provider.ensure_fresh_token(None).await;
let creds = resolve_credentials(&model, Some("session-jwt"));
assert_eq!(creds.api_key.as_deref(), Some("provider-minted-token"));
assert_eq!(creds.auth_type, AuthType::ApiKey);
assert_eq!(creds.base_url, "https://litellm.example/v1");
}
/// A set `env_key` shadows even a warm provider cache at resolve time, so
/// the static credential wins on the wire and the provider never governs.
#[tokio::test]
async fn set_env_key_shadows_warm_provider_at_resolve_time() {
use xai_grok_test_support::EnvGuard;
let var = "GROK_TEST_ENVKEY_SHADOW";
let _guard = EnvGuard::set(var, "env-token");
let mut model = test_model_entry("m", "https://litellm.example/v1", None, Some(var), None);
let provider = crate::auth::AuthProviderRef::new(
"env-shadow-test".into(),
crate::auth::AuthProviderConfig {
command: "printf provider-token".into(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
model.auth_provider = Some(provider.clone());
let _ = provider.ensure_fresh_token(None).await;
assert_eq!(
model.effective_auth_provider().map(|p| p.name.as_str()),
None,
"a resolvable env_key shadows the provider"
);
let creds = resolve_credentials(&model, Some("session-jwt"));
assert_eq!(
creds.api_key.as_deref(),
Some("env-token"),
"a set env_key must win over a warm provider cache"
);
}
/// A catalog deserialized from bytes cannot smuggle a runnable command.
#[test]
fn prefetched_entry_provider_config_comes_from_trusted_tables_only() {
let mut entry = test_model_entry("m", "https://cache.example/v1", None, None, None);
let smuggled: crate::auth::AuthProviderRef = serde_json::from_str(
r#"{"name": "cache-smuggle-test", "config": {"command": "evil"}}"#,
)
.unwrap();
entry.auth_provider = Some(smuggled);
let mut prefetched = IndexMap::new();
prefetched.insert("cached-model".to_string(), entry);
let cfg = Config::default();
let resolved = resolve_model_list(&cfg, Some(prefetched.clone()));
let provider = resolved["cached-model"].auth_provider.as_ref().unwrap();
assert_eq!(
resolve_credentials(&resolved["cached-model"], Some("session-jwt")).api_key,
None,
"an unusable provider fails closed"
);
assert_eq!(provider.config, crate::auth::AuthProviderConfig::default());
let mut cfg = Config::default();
cfg.auth_providers.insert(
"cache-smuggle-test".to_string(),
crate::auth::AuthProviderConfig {
command: "printf local".to_string(),
args: None,
token_ttl_secs: None,
timeout_secs: None,
},
);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let provider = resolved["cached-model"].auth_provider.as_ref().unwrap();
assert_eq!(provider.config.command, "printf local");
}
fn test_model_entry(
model: &str,
base_url: &str,
@ -5421,6 +6092,7 @@ reasoning_effort = "low"
},
api_key: api_key.map(|s| s.to_string()),
env_key: env_key.map(EnvKeys::single),
auth_provider: None,
api_base_url: api_base_url.map(|s| s.to_string()),
}
}
@ -5957,7 +6629,10 @@ reasoning_effort = "low"
}
#[test]
fn resolve_model_auth_facts_empty_model_id_is_unknown() {
assert_eq!(resolve_model_auth_facts("").byok, ModelByok::Unknown);
assert_eq!(
resolve_model_auth_facts_and_provider("").0.byok,
ModelByok::Unknown
);
}
#[test]
fn user_override_adds_api_key_to_default_model() {
@ -10608,6 +11283,7 @@ default = "grok-4.5"
},
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
}
}

View file

@ -1,5 +1,8 @@
//! Resilient parsing for `[model.<id>]` TOML overrides.
//!
//! It also defines [`ConfigWarning`] and [`WarningTarget`], the shared warning
//! vocabulary; the `[auth_provider.*]` parser in `config.rs` emits them too.
//!
//! A model entry must survive a bad field: warn and skip the field, never
//! drop the model (managed configs must not lose catalog entries).
//!
@ -9,7 +12,7 @@
//! fail to parse on their own are pruned (one warning each) and the table is
//! parsed again. Non-table values are dropped with a warning.
//!
//! Warnings are retained on `Config::model_override_warnings` and surfaced by
//! Warnings are retained on `Config::config_warnings` and surfaced by
//! `grok inspect`.
use indexmap::IndexMap;
@ -17,10 +20,10 @@ use serde::Serialize;
use super::config::ConfigModelOverride;
/// Category for a [`ModelOverrideWarning`].
/// Category for a [`ConfigWarning`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ModelOverrideWarningKind {
pub enum ConfigWarningKind {
/// Field name not recognized; field ignored.
UnknownField,
/// Value failed to parse; field skipped.
@ -29,29 +32,127 @@ pub enum ModelOverrideWarningKind {
DuplicateAlias,
/// Entry value is not a TOML table; entry dropped.
NotATable,
/// Fields are individually valid but conflict (e.g. `auth_provider`
/// shadowed by `api_key`/`env_key`); all fields kept, one is inert.
ConflictingFields,
/// Entry failed to parse even after skipping invalid fields; the model
/// keeps an empty override.
UnparseableEntry,
}
/// One skipped field or dropped entry from `[model.*]` parsing.
/// What a [`ConfigWarning`] is about. Serialize-only: `grok inspect --json`
/// emits it, nothing deserializes it back.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(tag = "target", rename_all = "camelCase")]
pub enum WarningTarget {
/// The `[model]` section as a whole (e.g. not a table).
ModelSection,
/// A `[model.<key>]` entry; `field` names a key when the warning is
/// field-specific.
Model {
key: String,
#[serde(skip_serializing_if = "Option::is_none")]
field: Option<String>,
},
/// The `[auth_provider]` section as a whole.
AuthProviderSection,
/// An `[auth_provider.<name>]` table; `field` names a key when the
/// warning is field-specific.
AuthProvider {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
field: Option<String>,
},
}
impl WarningTarget {
/// The config path, e.g. `model."grok-4.5"` or `auth_provider."litellm"`.
pub(crate) fn label(&self) -> String {
match self {
Self::ModelSection => "model".to_owned(),
Self::Model { key, .. } => format!("model.\"{key}\""),
Self::AuthProviderSection => "auth_provider".to_owned(),
Self::AuthProvider { name, .. } => format!("auth_provider.\"{name}\""),
}
}
pub(crate) fn field(&self) -> Option<&str> {
match self {
Self::Model { field, .. } | Self::AuthProvider { field, .. } => field.as_deref(),
Self::ModelSection | Self::AuthProviderSection => None,
}
}
}
/// One skipped field or dropped entry from config parsing.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelOverrideWarning {
/// `None` when the warning is about the `[model]` section itself.
#[serde(skip_serializing_if = "Option::is_none")]
pub model_key: Option<String>,
/// `None` for warnings about the entry as a whole.
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
pub kind: ModelOverrideWarningKind,
pub struct ConfigWarning {
#[serde(flatten)]
pub target: WarningTarget,
pub kind: ConfigWarningKind,
pub reason: String,
}
/// Result of [`parse_model_overrides`].
impl ConfigWarning {
pub(crate) fn model(
key: &str,
field: Option<&str>,
kind: ConfigWarningKind,
reason: String,
) -> Self {
let target = WarningTarget::Model {
key: key.to_owned(),
field: field.map(str::to_owned),
};
Self {
target,
kind,
reason,
}
}
pub(crate) fn model_section(kind: ConfigWarningKind, reason: String) -> Self {
Self {
target: WarningTarget::ModelSection,
kind,
reason,
}
}
pub(crate) fn auth_provider(
name: &str,
field: Option<&str>,
kind: ConfigWarningKind,
reason: String,
) -> Self {
let target = WarningTarget::AuthProvider {
name: name.to_owned(),
field: field.map(str::to_owned),
};
Self {
target,
kind,
reason,
}
}
pub(crate) fn auth_provider_section(kind: ConfigWarningKind, reason: String) -> Self {
Self {
target: WarningTarget::AuthProviderSection,
kind,
reason,
}
}
pub(crate) fn field(&self) -> Option<&str> {
self.target.field()
}
}
pub(crate) struct ParsedModelOverrides {
pub models: IndexMap<String, ConfigModelOverride>,
pub warnings: Vec<ModelOverrideWarning>,
pub warnings: Vec<ConfigWarning>,
}
/// Parses every `[model.<id>]` entry in `raw_config`, returning the overrides
@ -63,28 +164,26 @@ pub(crate) fn parse_model_overrides(raw_config: &toml::Value) -> ParsedModelOver
return ParsedModelOverrides { models, warnings };
};
let Some(table) = section.as_table() else {
warnings.push(ModelOverrideWarning {
model_key: None,
field: None,
kind: ModelOverrideWarningKind::NotATable,
reason: format!(
warnings.push(ConfigWarning::model_section(
ConfigWarningKind::NotATable,
format!(
"`model` must be a table of [model.<id>] entries, got {}; all model overrides ignored",
section.type_str()
),
});
));
return ParsedModelOverrides { models, warnings };
};
for (model_key, value) in table {
let Some(entry_table) = value.as_table() else {
warnings.push(ModelOverrideWarning {
model_key: Some(model_key.clone()),
field: None,
kind: ModelOverrideWarningKind::NotATable,
reason: format!(
warnings.push(ConfigWarning::model(
model_key,
None,
ConfigWarningKind::NotATable,
format!(
"expected a table like [model.\"{model_key}\"], got {}; entry dropped",
value.type_str()
),
});
));
continue;
};
let (entry, entry_warnings) = parse_model_override_table(model_key, entry_table.clone());
@ -96,7 +195,7 @@ pub(crate) fn parse_model_overrides(raw_config: &toml::Value) -> ParsedModelOver
/// Logs the warnings when they differ from the previous parse, so a
/// persistently broken config logs once per process instead of once per parse.
pub(crate) fn log_model_override_warnings(warnings: &[ModelOverrideWarning]) {
pub(crate) fn log_config_warnings(warnings: &[ConfigWarning]) {
use std::hash::{Hash as _, Hasher as _};
use std::sync::atomic::{AtomicU64, Ordering};
@ -115,8 +214,8 @@ pub(crate) fn log_model_override_warnings(warnings: &[ModelOverrideWarning]) {
for warning in warnings {
tracing::warn!(
model = warning.model_key.as_deref().unwrap_or("(section)"),
field = warning.field.as_deref().unwrap_or("(entry)"),
path = %warning.target.label(),
field = warning.field().unwrap_or("(entry)"),
kind = ?warning.kind,
reason = %warning.reason,
"model_override: skipped invalid config"
@ -133,13 +232,13 @@ pub(crate) fn log_model_override_warnings(warnings: &[ModelOverrideWarning]) {
fn parse_model_override_table(
model_key: &str,
mut table: toml::map::Map<String, toml::Value>,
) -> (ConfigModelOverride, Vec<ModelOverrideWarning>) {
) -> (ConfigModelOverride, Vec<ConfigWarning>) {
let mut warnings = Vec::new();
dedupe_aliases(model_key, &mut table, &mut warnings);
// Unknown-field warnings come from whichever parse produces the returned
// entry, so both paths report them identically.
match deserialize_with_unknown_fields(table.clone()) {
let (entry, mut warnings) = match deserialize_with_unknown_fields(table.clone()) {
Ok((entry, unknown)) => {
warnings.extend(unknown_field_warnings(model_key, unknown));
(entry, warnings)
@ -155,19 +254,57 @@ fn parse_model_override_table(
// Reachable only when fields conflict jointly, e.g. an
// alias pair missing from `ALIASES`. Keep the model
// rather than dropping it.
warnings.push(ModelOverrideWarning {
model_key: Some(model_key.to_owned()),
field: None,
kind: ModelOverrideWarningKind::UnparseableEntry,
reason: format!(
warnings.push(ConfigWarning::model(
model_key,
None,
ConfigWarningKind::UnparseableEntry,
format!(
"failed to parse after skipping invalid fields ({error}); using empty override"
),
});
));
(ConfigModelOverride::default(), warnings)
}
}
}
};
if entry.auth_provider.is_some() {
// A non-empty `api_key` always shadows; an `env_key` only shadows when
// its variable resolves at runtime, which parse time can't know. Warn
// accordingly so the message matches what actually happens.
let has_static_api_key = entry
.api_key
.as_deref()
.map(str::trim)
.is_some_and(|k| !k.is_empty());
if has_static_api_key {
warnings.push(ConfigWarning::model(
model_key,
Some("auth_provider"),
ConfigWarningKind::ConflictingFields,
"auth_provider is shadowed by api_key on this model; the static \
key always takes precedence, so the provider never runs"
.to_owned(),
));
} else if entry
.env_key
.as_ref()
.and_then(crate::agent::config::EnvKeys::primary)
.is_some()
{
warnings.push(ConfigWarning::model(
model_key,
Some("auth_provider"),
ConfigWarningKind::ConflictingFields,
"auth_provider may be shadowed by env_key on this model; env_key \
takes precedence when its variable resolves to a value, \
otherwise the provider runs"
.to_owned(),
));
}
}
(entry, warnings)
}
/// `(canonical, legacy)` key pairs that serde rejects as duplicate fields
@ -181,7 +318,7 @@ const ALIASES: &[(&str, &str)] = &[("compactions_remaining", "send_compactions_r
fn dedupe_aliases(
model_key: &str,
table: &mut toml::map::Map<String, toml::Value>,
warnings: &mut Vec<ModelOverrideWarning>,
warnings: &mut Vec<ConfigWarning>,
) {
for &(canonical, legacy) in ALIASES {
if !(table.contains_key(canonical) && table.contains_key(legacy)) {
@ -190,21 +327,21 @@ fn dedupe_aliases(
match field_parse_error(canonical, &table[canonical]) {
None => {
table.remove(legacy);
warnings.push(ModelOverrideWarning {
model_key: Some(model_key.to_owned()),
field: Some(legacy.to_owned()),
kind: ModelOverrideWarningKind::DuplicateAlias,
reason: format!("legacy alias of {canonical}; skipped in favor of {canonical}"),
});
warnings.push(ConfigWarning::model(
model_key,
Some(legacy),
ConfigWarningKind::DuplicateAlias,
format!("legacy alias of {canonical}; skipped in favor of {canonical}"),
));
}
Some(error) => {
table.remove(canonical);
warnings.push(ModelOverrideWarning {
model_key: Some(model_key.to_owned()),
field: Some(canonical.to_owned()),
kind: ModelOverrideWarningKind::InvalidValue,
reason: format!("{error}; skipped in favor of {legacy}"),
});
warnings.push(ConfigWarning::model(
model_key,
Some(canonical),
ConfigWarningKind::InvalidValue,
format!("{error}; skipped in favor of {legacy}"),
));
}
}
}
@ -222,14 +359,16 @@ fn deserialize_with_unknown_fields(
Ok((entry, unknown))
}
fn unknown_field_warnings(model_key: &str, unknown: Vec<String>) -> Vec<ModelOverrideWarning> {
fn unknown_field_warnings(model_key: &str, unknown: Vec<String>) -> Vec<ConfigWarning> {
unknown
.into_iter()
.map(|field| ModelOverrideWarning {
model_key: Some(model_key.to_owned()),
field: Some(field),
kind: ModelOverrideWarningKind::UnknownField,
reason: "unknown field".to_owned(),
.map(|field| {
ConfigWarning::model(
model_key,
Some(field.as_str()),
ConfigWarningKind::UnknownField,
"unknown field".to_owned(),
)
})
.collect()
}
@ -239,17 +378,17 @@ fn unknown_field_warnings(model_key: &str, unknown: Vec<String>) -> Vec<ModelOve
fn prune_invalid_fields(
model_key: &str,
table: &mut toml::map::Map<String, toml::Value>,
warnings: &mut Vec<ModelOverrideWarning>,
warnings: &mut Vec<ConfigWarning>,
) {
table.retain(|field, value| match field_parse_error(field, value) {
None => true,
Some(error) => {
warnings.push(ModelOverrideWarning {
model_key: Some(model_key.to_owned()),
field: Some(field.to_owned()),
kind: ModelOverrideWarningKind::InvalidValue,
reason: error.to_string(),
});
warnings.push(ConfigWarning::model(
model_key,
Some(field),
ConfigWarningKind::InvalidValue,
error.to_string(),
));
false
}
});
@ -277,12 +416,7 @@ mod tests {
crate::agent::config::Config::new_from_toml_cfg(&raw).expect("config should parse")
}
fn parse_raw(
toml_str: &str,
) -> (
IndexMap<String, ConfigModelOverride>,
Vec<ModelOverrideWarning>,
) {
fn parse_raw(toml_str: &str) -> (IndexMap<String, ConfigModelOverride>, Vec<ConfigWarning>) {
let raw: toml::Value = toml::from_str(toml_str).unwrap();
let ParsedModelOverrides { models, warnings } = parse_model_overrides(&raw);
(models, warnings)
@ -307,9 +441,9 @@ mod tests {
model.compactions_remaining,
Some(CompactionsRemaining::Fixed(1))
);
assert!(cfg.model_override_warnings.iter().any(|w| {
w.kind == ModelOverrideWarningKind::DuplicateAlias
&& w.field.as_deref() == Some("send_compactions_remaining")
assert!(cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::DuplicateAlias
&& w.field() == Some("send_compactions_remaining")
}));
let resolved = crate::agent::config::resolve_model_list(&cfg, None);
assert!(resolved.contains_key("grok-4.5"));
@ -329,7 +463,7 @@ mod tests {
model.compactions_remaining,
Some(CompactionsRemaining::Fixed(2))
);
assert!(cfg.model_override_warnings.is_empty());
assert!(cfg.config_warnings.is_empty());
}
#[test]
@ -348,9 +482,8 @@ mod tests {
.expect("grok-4.5 must remain in catalog");
assert_eq!(model.model.as_deref(), Some("grok-4.5"));
assert!(model.reasoning_effort.is_none());
assert!(cfg.model_override_warnings.iter().any(|w| {
w.kind == ModelOverrideWarningKind::InvalidValue
&& w.field.as_deref() == Some("reasoning_effort")
assert!(cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::InvalidValue && w.field() == Some("reasoning_effort")
}));
}
@ -372,12 +505,12 @@ mod tests {
);
assert_eq!(
warnings,
vec![ModelOverrideWarning {
model_key: Some("grok-4.5".to_owned()),
field: Some("future_field".to_owned()),
kind: ModelOverrideWarningKind::UnknownField,
reason: "unknown field".to_owned(),
}]
vec![ConfigWarning::model(
"grok-4.5",
Some("future_field"),
ConfigWarningKind::UnknownField,
"unknown field".to_owned(),
)]
);
}
@ -389,7 +522,7 @@ mod tests {
let (_, warnings) = parse_raw(toml_str);
warnings
.into_iter()
.filter(|w| w.kind == ModelOverrideWarningKind::UnknownField)
.filter(|w| w.kind == ConfigWarningKind::UnknownField)
.collect::<Vec<_>>()
};
let fast = unknown_of(
@ -407,7 +540,7 @@ mod tests {
);
assert_eq!(fast, slow);
assert_eq!(fast.len(), 1);
assert_eq!(fast[0].field.as_deref(), Some("temprature"));
assert_eq!(fast[0].field(), Some("temprature"));
}
#[test]
@ -428,8 +561,7 @@ mod tests {
);
assert!(entry.temperature.is_none());
assert!(warnings.iter().any(|w| {
w.kind == ModelOverrideWarningKind::InvalidValue
&& w.field.as_deref() == Some("temperature")
w.kind == ConfigWarningKind::InvalidValue && w.field() == Some("temperature")
}));
// All fields invalid: the model stays, with an empty override.
@ -447,7 +579,7 @@ mod tests {
assert!(
warnings
.iter()
.all(|w| w.kind == ModelOverrideWarningKind::InvalidValue)
.all(|w| w.kind == ConfigWarningKind::InvalidValue)
);
}
@ -466,8 +598,8 @@ mod tests {
Some(CompactionsRemaining::Fixed(2))
);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ModelOverrideWarningKind::InvalidValue);
assert_eq!(warnings[0].field.as_deref(), Some("compactions_remaining"));
assert_eq!(warnings[0].kind, ConfigWarningKind::InvalidValue);
assert_eq!(warnings[0].field(), Some("compactions_remaining"));
}
#[test]
@ -475,9 +607,8 @@ mod tests {
let (models, warnings) = parse_raw(r#"model = "grok-4""#);
assert!(models.is_empty());
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ModelOverrideWarningKind::NotATable);
assert_eq!(warnings[0].model_key, None);
assert_eq!(warnings[0].field, None);
assert_eq!(warnings[0].kind, ConfigWarningKind::NotATable);
assert!(matches!(warnings[0].target, WarningTarget::ModelSection));
}
#[test]
@ -490,9 +621,12 @@ mod tests {
);
assert!(models.is_empty(), "a scalar cannot define a model");
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ModelOverrideWarningKind::NotATable);
assert_eq!(warnings[0].model_key.as_deref(), Some("oops"));
assert_eq!(warnings[0].field, None);
assert_eq!(warnings[0].kind, ConfigWarningKind::NotATable);
assert!(matches!(
&warnings[0].target,
WarningTarget::Model { key, field: None }
if key == "oops"
));
}
/// Exhaustive literal (no `..`): a new struct field is a compile error
@ -505,6 +639,7 @@ mod tests {
description: Some("desc".into()),
api_key: Some("key".into()),
env_key: Some(crate::agent::config::EnvKeys::single("ENV_KEY")),
auth_provider: Some("corp-gateway".into()),
api_base_url: Some("https://api.example.com".into()),
max_completion_tokens: Some(1024),
temperature: Some(0.5),
@ -541,10 +676,7 @@ mod tests {
fn parse_single_entry(
entry: toml::map::Map<String, toml::Value>,
) -> (
IndexMap<String, ConfigModelOverride>,
Vec<ModelOverrideWarning>,
) {
) -> (IndexMap<String, ConfigModelOverride>, Vec<ConfigWarning>) {
let mut model_table = toml::map::Map::new();
model_table.insert("m".to_owned(), toml::Value::Table(entry));
let mut root = toml::map::Map::new();
@ -555,14 +687,73 @@ mod tests {
}
#[test]
fn fully_populated_override_round_trips_without_warnings() {
fn fully_populated_override_round_trips_with_only_the_shadowing_warning() {
let serialized = toml::Value::try_from(fully_populated_override()).unwrap();
let (models, warnings) = parse_single_entry(serialized.as_table().unwrap().clone());
assert_eq!(warnings, Vec::new(), "no field may be skipped or unknown");
// The exhaustive literal deliberately sets `api_key`, `env_key`, AND
// `auth_provider`: the one legal-but-warned combination. Any other
// warning (skipped/unknown field) still fails the guard.
let unexpected: Vec<_> = warnings
.iter()
.filter(|w| w.kind != ConfigWarningKind::ConflictingFields)
.collect();
assert_eq!(unexpected, Vec::<&ConfigWarning>::new());
assert_eq!(warnings.len(), 1);
let reparsed = toml::Value::try_from(models.get("m").unwrap()).unwrap();
assert_eq!(reparsed, serialized, "round-trip must be lossless");
}
/// `auth_provider` alongside `api_key`/`env_key` warns (static keys
/// win in `resolve_credentials`, so the provider never runs) but keeps
/// both fields.
#[test]
fn auth_provider_shadowed_by_static_key_warns() {
let mut entry = toml::map::Map::new();
entry.insert("api_key".to_owned(), toml::Value::String("sk-x".into()));
entry.insert(
"auth_provider".to_owned(),
toml::Value::String("corp".into()),
);
let (models, warnings) = parse_single_entry(entry);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ConfigWarningKind::ConflictingFields);
assert_eq!(warnings[0].field(), Some("auth_provider"));
let parsed = models.get("m").unwrap();
assert_eq!(parsed.api_key.as_deref(), Some("sk-x"));
assert_eq!(parsed.auth_provider.as_deref(), Some("corp"));
// Provider alone: no warning.
let mut entry = toml::map::Map::new();
entry.insert(
"auth_provider".to_owned(),
toml::Value::String("corp".into()),
);
let (_, warnings) = parse_single_entry(entry);
assert_eq!(warnings, Vec::new());
// env_key is only a conditional shadow: warn, but as "may be shadowed".
let mut entry = toml::map::Map::new();
entry.insert("env_key".to_owned(), toml::Value::String("MY_KEY".into()));
entry.insert(
"auth_provider".to_owned(),
toml::Value::String("corp".into()),
);
let (_, warnings) = parse_single_entry(entry);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ConfigWarningKind::ConflictingFields);
assert!(warnings[0].reason.contains("may be shadowed"));
// An empty api_key does not shadow, so it must not warn.
let mut entry = toml::map::Map::new();
entry.insert("api_key".to_owned(), toml::Value::String(" ".into()));
entry.insert(
"auth_provider".to_owned(),
toml::Value::String("corp".into()),
);
let (_, warnings) = parse_single_entry(entry);
assert_eq!(warnings, Vec::new());
}
/// Drift guard: every `#[serde(alias)]` on [`ConfigModelOverride`] must
/// have a matching `ALIASES` pair, and vice versa. An unregistered alias
/// would send both-keys configs to the empty-override fallback.
@ -632,8 +823,8 @@ mod tests {
"canonical value must be retained"
);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ModelOverrideWarningKind::DuplicateAlias);
assert_eq!(warnings[0].field.as_deref(), Some(legacy));
assert_eq!(warnings[0].kind, ConfigWarningKind::DuplicateAlias);
assert_eq!(warnings[0].field(), Some(legacy));
}
}
}

View file

@ -1388,6 +1388,7 @@ fn build_prefetched_map(
info,
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: m.api_base_url.clone().or(api_base_url_override.clone()),
};
map.insert(key, entry);
@ -2015,6 +2016,7 @@ mod tests {
info: config::ModelInfo::fallback("fp-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
flagged.info.show_model_fingerprint = true;
@ -2027,6 +2029,7 @@ mod tests {
info: config::ModelInfo::fallback("plain-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
},
);
@ -2037,6 +2040,7 @@ mod tests {
info: config::ModelInfo::fallback("enterprise-slug"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
custom.info.show_model_fingerprint = true;
@ -2207,6 +2211,7 @@ mod tests {
info: config::ModelInfo::fallback("test-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
},
);
@ -2261,6 +2266,7 @@ mod tests {
info: config::ModelInfo::fallback("reasoning-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
reasoning_entry.info.supports_reasoning_effort = true;
@ -2283,6 +2289,7 @@ mod tests {
info: config::ModelInfo::fallback("plain-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
prefetched.insert("plain-model".to_string(), plain_entry);
@ -2310,6 +2317,7 @@ mod tests {
info: config::ModelInfo::fallback("grok-4.5"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
no_none.info.supports_reasoning_effort = true;
@ -2328,6 +2336,7 @@ mod tests {
info: config::ModelInfo::fallback("legacy-none"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
with_none.info.supports_reasoning_effort = true;
@ -2434,6 +2443,7 @@ mod tests {
info: config::ModelInfo::fallback("reasoning-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
reasoning_entry.info.supports_reasoning_effort = true;
@ -2443,6 +2453,7 @@ mod tests {
info: config::ModelInfo::fallback("plain-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
prefetched.insert("plain-model".to_string(), plain_entry);
@ -2485,6 +2496,7 @@ mod tests {
info: config::ModelInfo::fallback(model_id),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
}
}
@ -3268,6 +3280,7 @@ mod tests {
info: config::ModelInfo::fallback("static-one"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
},
);
@ -3295,6 +3308,7 @@ mod tests {
info: config::ModelInfo::fallback("oauth-only"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
oauth_only.info.supported_in_api = false;
@ -3304,6 +3318,7 @@ mod tests {
info: config::ModelInfo::fallback("public-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
catalog.insert("public-model".to_string(), public);

View file

@ -58,10 +58,12 @@ impl MvpAgent {
client_version,
) {
Some(mut cfg) => {
cfg.client_identifier = primary.client_identifier.clone();
cfg.attribution_callback = primary.attribution_callback.clone();
cfg.bearer_resolver = primary.bearer_resolver.clone();
cfg.max_retries = primary.max_retries;
crate::agent::config::stamp_session_local_sampler_fields(
&mut cfg,
primary,
primary.client_identifier.clone(),
primary.max_retries,
);
cfg
}
None => {

View file

@ -2114,6 +2114,7 @@ fn find_model_by_id_prefers_key_then_falls_back_to_slug() {
},
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
let mut models = indexmap::IndexMap::new();

View file

@ -509,24 +509,25 @@ where
let mut keepalive = tokio::time::interval(Duration::from_secs(KEEPALIVE_INTERVAL_SECS));
loop {
tokio::select! {
_ = cancel_write.cancelled() => break, msg_opt = from_agent_rx.recv() =>
{ match msg_opt { Some(msg) => { if
tracing::enabled!(tracing::Level::DEBUG) { if let Ok(json_val) =
serde_json::from_str::< serde_json::Value > (& msg) { let method =
json_val.get("method").and_then(| m | m.as_str()); let line_to_print =
match method { Some("session/update") => { let params = json_val
.get("params").unwrap_or(& serde_json::Value::Null);
format!("acp_outbound::session/update::{params}") } Some(m) =>
format!("acp_outbound::{m}"), None => "acp_outbound::response"
.to_string(), }; debug!("{line_to_print}"); } else {
debug!("acp_outbound::response"); } } if ! msg.is_empty() && let Err(e) =
ws_outbound.send(Message::Text(Utf8Bytes::from(msg))). await {
warn!(error = ? e, "failed to send to WS"); break; } } None => {
info!("Agent outbound channel closed"); break; } } } _ = keepalive.tick()
=> { tprintln!("ws::keep_alive_tick"); if let Err(e) = ws_outbound
.send(Message::Ping(Vec::new().into())). await {
tprintln!("ws::keep_alive::error::{:?}", & e); break; } }
}
_ = cancel_write.cancelled() => break, msg_opt = from_agent_rx.recv() =>
{ match msg_opt { Some(msg) => { if
tracing::enabled!(tracing::Level::DEBUG) { if let Ok(json_val) =
serde_json::from_str::< serde_json::Value > (& msg) { let method =
json_val.get("method").and_then(| m | m.as_str()); let line_to_print =
match method { Some("session/update") => { let params = json_val
.get("params").unwrap_or(& serde_json::Value::Null);
format!("acp_outbound::session/update::{params}") } Some(m) =>
format!("acp_outbound::{m}"), None => "acp_outbound::response"
.to_string(), }; debug!("{line_to_print}"); } else {
debug!("acp_outbound::response"); } }
if ! msg.is_empty() && let Err(e) =
ws_outbound.send(Message::Text(Utf8Bytes::from(msg))). await {
warn!(error = ? e, "failed to send to WS"); break; } } None => {
info!("Agent outbound channel closed"); break; } } } _ = keepalive.tick()
=> { tprintln!("ws::keep_alive_tick"); if let Err(e) = ws_outbound
.send(Message::Ping(Vec::new().into())). await {
tprintln!("ws::keep_alive::error::{:?}", & e); break; } }
}
}
anyhow::Ok(())
};

View file

@ -1115,7 +1115,8 @@ async fn cancel_with_outcome_returns_variant_for_active_finished_unknown() {
);
assert!(
matches!(coordinator.cancel_with_outcome("sub-done"),
SubagentCancelOutcome::AlreadyFinished { status } if status == "completed")
SubagentCancelOutcome::AlreadyFinished { status }
if status == "completed")
);
assert!(
matches!(coordinator.cancel_with_outcome("nonexistent"),
@ -1850,7 +1851,8 @@ fn resume_vs_fork_helper_shapes_differ() {
assert!(
! matches!(resumed.conversation.get(1), Some(ConversationItem::User(u)) if u
.content.iter().any(| p | matches!(p,
xai_grok_sampling_types::conversation::ContentPart::Text { text } if text
xai_grok_sampling_types::conversation::ContentPart::Text { text }
if text
.contains("<background_context>"))))
);
}
@ -1912,7 +1914,8 @@ fn verbatim_fork_keeps_items_byte_for_byte_when_small() {
.any(|i| {
matches!(
i, ConversationItem::User(u) if u.content.iter().any(| p |
matches!(p, ContentPart::Text { text } if text.contains(needle)))
matches!(p, ContentPart::Text { text }
if text.contains(needle)))
)
})
};
@ -1954,7 +1957,8 @@ fn verbatim_fork_falls_back_to_summary_on_incomplete_tail() {
assert_eq!(ctx.prefix_len, Some(2));
assert!(
ctx.conversation.iter().any(| i | { matches!(i, ConversationItem::User(u) if u
.content.iter().any(| p | matches!(p, ContentPart::Text { text } if text
.content.iter().any(| p | matches!(p, ContentPart::Text { text }
if text
.contains("<background_context>")))) }),
"summarized fallback must produce a background_context blob"
);
@ -1995,7 +1999,8 @@ fn verbatim_fork_falls_back_to_summary_when_oversize() {
.any(|i| {
matches!(
i, ConversationItem::User(u) if u.content.iter().any(| p | matches!(p,
ContentPart::Text { text } if text.contains("<background_context>")))
ContentPart::Text { text }
if text.contains("<background_context>")))
)
});
assert!(has_blob, "oversize fallback must produce a background_context blob");
@ -3305,6 +3310,7 @@ fn test_model_entry(model_id: &str) -> crate::agent::config::ModelEntry {
},
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
}
}

View file

@ -274,7 +274,8 @@ fn compaction_preserves_inherited_prefix() {
.any(|p| {
matches!(
p, xai_grok_sampling_types::conversation::ContentPart::Text {
text } if text.contains("<background_context>")
text }
if text.contains("<background_context>")
)
})
} else {
@ -2870,6 +2871,51 @@ async fn resolve_subagent_agent_definition_unknown_model_falls_through_to_inheri
assert_eq!(config.model, "grok-4.5");
assert_eq!(model_id.0.as_ref(), "grok-4.5");
}
/// Spawn-time credentials are cache-only: a cold spawn has no key,
/// never the parent session key.
#[tokio::test]
async fn subagent_override_provider_model_spawns_cache_only_credentials() {
use xai_grok_agent::config::ModelOverride;
let dir = tempfile::tempdir().unwrap();
let provider = crate::auth::test_counting_provider(
"test-subagent-spawn",
dir.path(),
);
let mut entry = test_model_entry("proxied-model");
entry.info.base_url = "https://gateway.example/v1".to_string();
entry.auth_provider = Some(provider.clone());
let mut models = indexmap::IndexMap::new();
models.insert("proxied".to_string(), entry);
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.sampling_config.model = "grok-4.5".to_string();
ctx.model_id = acp::ModelId::new("grok-4.5");
ctx.available_models = models;
ctx.auth = Some(crate::auth::GrokAuth {
key: "parent-session-jwt".to_string(),
..Default::default()
});
ctx.subagent_model_overrides.insert("explore".to_string(), "proxied".to_string());
let (config, model_id) = resolve_subagent_sampling_config(
"explore",
&ModelOverride::Inherit,
&ctx,
)
.await;
assert_eq!(model_id.0.as_ref(), "proxied");
assert_eq!(
config.api_key, None,
"a cold cache spawns with no key, never the parent session key"
);
provider.ensure_fresh_token(None).await.rotated().unwrap();
let (config, _) = resolve_subagent_sampling_config(
"explore",
&ModelOverride::Inherit,
&ctx,
)
.await;
assert_eq!(config.api_key.as_deref(), Some("tok-1"));
assert_eq!(config.base_url, "https://gateway.example/v1");
}
#[test]
fn key_prefix_truncates_to_8_chars() {
let key = Some("eyJ0eXAiOiJhbGciOiJSUzI1NiJ9".to_string());

View file

@ -0,0 +1,603 @@
//! Model auth providers (`[auth_provider.<name>]`).
//!
//! A model opts in with `auth_provider = "<name>"`; the named table declares a
//! command that prints a fresh bearer token, which this module mints, caches,
//! and rotates for that model's requests.
//!
//! The minted token stays in memory only ([`AUTH_PROVIDER_SLOTS`] and chat
//! state, never `auth.json`); the command is a credential helper that owns its
//! own durable storage and OAuth2 refresh. See "Where model auth providers fit
//! (and don't)"
//! in `docs/internal/AUTH.md`.
//!
//! This is distinct from the `AuthCredentialProvider` HTTP consumers in
//! [`crate::auth::credential_provider`].
use super::token_output::{expiry_after_seconds, parse_token_output};
/// One named `[auth_provider.<name>]` table, honored only from the trusted
/// config layers (`parse_auth_providers`). A new field here needs a
/// `parse_auth_providers` warning decision.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)]
#[serde(default)]
pub struct AuthProviderConfig {
/// Command that prints a bearer token on stdout, bare or as JSON
/// `{access_token, expires_in}`. Without `args` it runs via `sh -c`.
pub command: String,
/// Arguments for `command`. When present (even empty), the command runs
/// directly with no shell; `command` is a program name on `PATH`, or a path.
pub args: Option<Vec<String>>,
/// Fallback token lifetime in seconds, used when the command's output
/// carries no `expires_in`. Takes precedence over a JWT `exp` claim.
pub token_ttl_secs: Option<u64>,
/// Maximum seconds to wait for the command (default 30, clamped to 1..=600).
/// A turn waits up to this long on a mint, so keep helpers fast and
/// non-interactive.
pub timeout_secs: Option<u64>,
}
impl AuthProviderConfig {
pub(crate) fn is_usable(&self) -> bool {
!self.command.trim().is_empty()
}
}
/// A model's reference to a named auth provider, built by `resolve_model_list`.
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(from = "AuthProviderRefData", into = "AuthProviderRefData")]
pub struct AuthProviderRef {
pub(crate) name: String,
pub(crate) config: AuthProviderConfig,
slot: ProviderSlot,
/// `true` once the trusted table is attached. A ref revived from bytes is
/// `false` and never mints or reads until [`AuthProviderRef::attach_trusted_config`]
/// joins the shared slot for its name.
resolved: bool,
}
/// Serialized form: the name only, so persisted bytes never carry a command.
#[derive(serde::Serialize, serde::Deserialize)]
struct AuthProviderRefData {
name: String,
}
impl From<AuthProviderRefData> for AuthProviderRef {
fn from(data: AuthProviderRefData) -> Self {
AuthProviderRef::unresolved(data.name)
}
}
impl From<AuthProviderRef> for AuthProviderRefData {
fn from(provider: AuthProviderRef) -> Self {
Self {
name: provider.name,
}
}
}
impl AuthProviderRef {
/// Production uses `unresolved` + `attach_trusted_config`.
#[cfg(test)]
pub(crate) fn new(name: String, config: AuthProviderConfig) -> Self {
let slot = provider_slot(&name);
Self {
name,
config,
slot,
resolved: true,
}
}
/// The in-memory form of a ref revived from bytes;
/// [`AuthProviderRef::attach_trusted_config`] resolves it.
pub(crate) fn unresolved(name: String) -> Self {
Self {
name,
config: AuthProviderConfig::default(),
slot: ProviderSlot::default(),
resolved: false,
}
}
/// Re-attach the trusted config for this name at model resolution
/// (`None` = the table was removed, leaving an unusable config). The ref
/// becomes authoritative, joins the shared slot for its name, and may mint.
pub(crate) fn attach_trusted_config(&mut self, config: Option<&AuthProviderConfig>) {
self.config = config.cloned().unwrap_or_default();
self.slot = provider_slot(&self.name);
self.resolved = true;
}
}
/// Ignores the slot; a deserialized ref compares unequal until resolution
/// re-attaches its config.
impl PartialEq for AuthProviderRef {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.config == other.config
}
}
impl Eq for AuthProviderRef {}
impl std::fmt::Debug for AuthProviderRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthProviderRef")
.field("name", &self.name)
.field("config", &self.config)
.field("resolved", &self.resolved)
.finish_non_exhaustive()
}
}
struct MintedProviderToken {
token: String,
/// Handed back to the command on the next run; never sent on the wire.
refresh_token: Option<String>,
/// Drives the 401 fresh-mint guard.
minted_at: std::time::Instant,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
/// The table version that minted the token; a different version reads as
/// stale (see [`token_identity`]), so edits re-mint.
minted_with: AuthProviderConfig,
}
/// The async lock is held across the command run, single-flighting mints
/// per provider name (shared across sessions). This dedupes concurrent
/// successes; a persistently failing helper is retried per waiter, each bounded
/// by the timeout clamp.
type ProviderSlot = std::sync::Arc<tokio::sync::Mutex<Option<MintedProviderToken>>>;
/// Shared token slots, one per resolved provider name. Bounded by the configured
/// provider names (only `attach_trusted_config` and test `new` insert), so no
/// eviction.
static AUTH_PROVIDER_SLOTS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, ProviderSlot>>,
> = std::sync::OnceLock::new();
fn provider_slot(name: &str) -> ProviderSlot {
let map = AUTH_PROVIDER_SLOTS.get_or_init(Default::default);
let mut map = map.lock().unwrap_or_else(|e| e.into_inner());
map.entry(name.to_owned()).or_default().clone()
}
/// Pre-refresh margin: re-mint when the token expires within this window.
pub(crate) const PROVIDER_TOKEN_EXPIRY_SKEW_SECS: u64 = 60;
const PROVIDER_TOKEN_EXPIRY_SKEW: chrono::Duration =
chrono::Duration::seconds(PROVIDER_TOKEN_EXPIRY_SKEW_SECS as i64);
/// 401 fresh-mint guard: a token minted this recently is never re-minted on
/// rejection. Same idea as the guard in `unauthorized_recovery`, with a shorter
/// window because a provider mint is local and cheap.
const PROVIDER_TOKEN_FRESH_MINT_GUARD: std::time::Duration = std::time::Duration::from_secs(30);
const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 30;
/// The effective mint timeout is clamped to `[1, this]`. A configured value
/// outside the range is honored up to the bound and draws a parse warning,
/// since a turn waits on the mint.
pub(crate) const PROVIDER_TIMEOUT_CEILING_SECS: u64 = 600;
/// Caps on the helper's captured output so a runaway command can't exhaust
/// memory before the timeout fires. A bearer (even a large JWT) is far under
/// the stdout cap; stderr only ever appears truncated in the failure log.
const PROVIDER_STDOUT_CAP_BYTES: u64 = 1 << 20; // 1 MiB
const PROVIDER_STDERR_CAP_BYTES: u64 = 64 << 10; // 64 KiB
/// The table fields that shape the minted token; a cached token minted under a
/// different set reads as stale, so a config edit re-mints. Destructured so a
/// new `AuthProviderConfig` field is a compile error until it is classified as
/// token-shaping (add it here) or an execution knob like `timeout_secs`
/// (editing it never invalidates).
fn token_identity(config: &AuthProviderConfig) -> (&str, Option<&[String]>, Option<u64>) {
let AuthProviderConfig {
command,
args,
token_ttl_secs,
timeout_secs: _,
} = config;
(command, args.as_deref(), *token_ttl_secs)
}
fn minted_token_is_stale(minted: &MintedProviderToken, config: &AuthProviderConfig) -> bool {
token_identity(&minted.minted_with) != token_identity(config)
|| minted
.expires_at
.is_some_and(|at| chrono::Utc::now() + PROVIDER_TOKEN_EXPIRY_SKEW >= at)
}
/// Log the missing-command warning once per provider, then at debug, so a
/// misconfigured model doesn't warn on every turn.
fn warn_empty_command(name: &str) {
static WARNED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
let first = WARNED
.get_or_init(Default::default)
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(name.to_owned());
const MSG: &str = "auth provider has no usable command: the [auth_provider.*] table is \
missing from the trusted config layers, or its `command` is empty";
if first {
tracing::warn!(provider = %name, "{MSG}");
} else {
tracing::debug!(provider = %name, "{MSG}");
}
}
/// Read up to `keep` bytes into `buf`, then drain and discard any remainder so
/// the child never blocks on a full pipe. Memory stays bounded by `keep`.
async fn read_capped<R>(reader: R, keep: u64, buf: &mut Vec<u8>) -> std::io::Result<()>
where
R: tokio::io::AsyncRead + Unpin,
{
use tokio::io::AsyncReadExt;
let mut limited = reader.take(keep);
limited.read_to_end(buf).await?;
tokio::io::copy(&mut limited.into_inner(), &mut tokio::io::sink()).await?;
Ok(())
}
/// Remove every first-party credential from the helper's environment. BYOK
/// isolates these keys on the wire, so the helper (the agent puts them in its
/// own env at startup) must not inherit them.
fn scrub_first_party_credentials(cmd: &mut tokio::process::Command) {
for var in crate::agent::config::FIRST_PARTY_CREDENTIAL_ENV_VARS {
cmd.env_remove(var);
}
}
/// Spawn `cmd`, capture stdout/stderr with a byte cap (reading both
/// concurrently so a full pipe on one can't deadlock the other; a runaway helper
/// is drained to a sink past the cap so it can't wedge the wait), and bound the
/// whole run by `timeout`. Exceeding the stdout cap is an error.
///
/// On timeout the child's entire process group is killed. The helper is a group
/// leader (`detach_command`'s `setsid`), so a compound `sh -c` helper's
/// grandchildren -- and the `GROK_AUTH_PROVIDER_*` credentials in their env --
/// do not outlive the reported timeout; `kill_on_drop` alone would reap only the
/// direct child.
async fn run_capped(
cmd: &mut tokio::process::Command,
timeout: std::time::Duration,
) -> anyhow::Result<std::process::Output> {
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("command failed to start: {e}"))?;
// Enroll the child's process group so the timeout path can tear down the
// whole tree. Best-effort: if enrollment fails, `kill_on_drop` still reaps
// the direct child.
let mut group = xai_grok_tools::util::ProcessGroup::new()
.map_err(|e| anyhow::anyhow!("process group setup failed: {e}"))?;
if let Err(e) = group.attach(&child) {
tracing::debug!(error = %e, "auth provider: could not enroll helper process group");
}
let stdout = child.stdout.take().expect("stdout is piped");
let stderr = child.stderr.take().expect("stderr is piped");
let mut out_buf = Vec::new();
let mut err_buf = Vec::new();
// One extra stdout byte so an over-cap write is detectable, not truncated.
// The stderr read is advisory (it only feeds the failure log), so only
// stdout governs the mint.
let capture = async {
let (out_res, err_res) = tokio::join!(
read_capped(stdout, PROVIDER_STDOUT_CAP_BYTES + 1, &mut out_buf),
read_capped(stderr, PROVIDER_STDERR_CAP_BYTES, &mut err_buf),
);
if let Err(e) = err_res {
tracing::debug!(error = %e, "auth provider: stderr capture failed (advisory)");
}
out_res.map_err(|e| anyhow::anyhow!("reading command stdout: {e}"))?;
child
.wait()
.await
.map_err(|e| anyhow::anyhow!("waiting on command: {e}"))
};
let status = match tokio::time::timeout(timeout, capture).await {
Ok(res) => res?,
Err(_elapsed) => {
let _ = group.kill();
anyhow::bail!("command timed out after {}s", timeout.as_secs());
}
};
if out_buf.len() as u64 > PROVIDER_STDOUT_CAP_BYTES {
anyhow::bail!("command wrote more than {PROVIDER_STDOUT_CAP_BYTES} bytes to stdout");
}
Ok(std::process::Output {
status,
stdout: out_buf,
stderr: err_buf,
})
}
async fn mint_provider_token(
provider: &AuthProviderRef,
mark_expired: bool,
previous: Option<&MintedProviderToken>,
) -> anyhow::Result<MintedProviderToken> {
use std::process::Stdio;
let name = &provider.name;
let config = &provider.config;
// Clamp to [1, ceiling]: the slot lock is held across the run, so an
// unbounded timeout would let one hung helper stall every turn sharing this
// provider name. The ceiling is a hard bound, not just a parse warning.
let timeout_secs = config
.timeout_secs
.unwrap_or(DEFAULT_PROVIDER_TIMEOUT_SECS)
.clamp(1, PROVIDER_TIMEOUT_CEILING_SECS);
tracing::info!(
provider = %name,
mark_expired,
timeout_secs,
"auth provider: running helper command"
);
let mut cmd = match config.args {
Some(ref args) => {
// Direct exec: the program name is a PATH lookup, so trim stray
// whitespace that would otherwise fail to resolve.
let mut cmd = tokio::process::Command::new(config.command.trim());
cmd.args(args);
cmd
}
None => {
let mut cmd = tokio::process::Command::new("sh");
cmd.args(["-c", &config.command]);
cmd
}
};
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
// Capture stderr for the failure log; inheriting corrupts the TUI.
.stderr(Stdio::piped())
// Reaps the direct child if the future is dropped; `run_capped`
// additionally kills the whole process group on timeout.
.kill_on_drop(true);
if mark_expired {
cmd.env("GROK_AUTH_EXPIRED", "1");
}
// Git-credential-helper handback: give the command the last stored
// credential so it can refresh instead of re-authenticating.
if let Some(prev) = previous {
cmd.env("GROK_AUTH_PROVIDER_ACCESS_TOKEN", &prev.token);
if let Some(refresh) = &prev.refresh_token {
cmd.env("GROK_AUTH_PROVIDER_REFRESH_TOKEN", refresh);
}
if let Some(expires_at) = prev.expires_at {
cmd.env("GROK_AUTH_PROVIDER_EXPIRES_AT", expires_at.to_rfc3339());
}
}
xai_grok_tools::util::detach_command(&mut cmd);
cmd.envs(xai_grok_tools::util::pager_env());
// Scrub last so nothing above can reintroduce a first-party credential.
scrub_first_party_credentials(&mut cmd);
let output = run_capped(&mut cmd, std::time::Duration::from_secs(timeout_secs)).await?;
let parsed = match parse_token_output(&output) {
Ok(parsed) => parsed,
Err(e) => {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"{e} (stderr: {})",
crate::util::truncate(stderr.trim(), 300)
);
}
};
let expires_at = parsed
.expires_at
.or_else(|| config.token_ttl_secs.and_then(expiry_after_seconds))
.or_else(|| crate::auth::parse_jwt_expiration(&parsed.access_token));
tracing::info!(
provider = %name,
mark_expired,
expires_at = ?expires_at,
"auth provider minted token"
);
Ok(MintedProviderToken {
token: parsed.access_token,
refresh_token: parsed.refresh_token,
minted_at: std::time::Instant::now(),
expires_at,
minted_with: config.clone(),
})
}
#[derive(Debug, PartialEq, Eq)]
#[must_use = "a rotated token must be written to chat-state, or the wire keeps the stale key"]
pub(crate) enum ProviderRefreshOutcome {
/// `current_key` is already the fresh cached token; nothing to write.
Unchanged,
/// A token that should replace `current_key` on the wire.
Rotated(String),
/// The provider is unusable (unresolved or removed); already warned.
Unusable,
/// The mint ran and failed (logged).
MintFailed,
}
impl ProviderRefreshOutcome {
pub(crate) fn rotated(self) -> Option<String> {
match self {
Self::Rotated(token) => Some(token),
Self::Unchanged | Self::Unusable | Self::MintFailed => None,
}
}
}
impl AuthProviderRef {
/// The slot, locked for a mutating operation. A removed provider drops
/// its cached token and yields `None`, failing closed. An unresolved ref
/// (revived from bytes) fails closed without touching the shared slot.
async fn locked_slot(
&self,
) -> Option<tokio::sync::OwnedMutexGuard<Option<MintedProviderToken>>> {
if !self.resolved {
return None;
}
let mut slot = self.slot.clone().lock_owned().await;
if !self.config.is_usable() {
if slot.take().is_some() {
tracing::warn!(
provider = %self.name,
"auth provider removed from config: dropping its cached token"
);
}
warn_empty_command(&self.name);
return None;
}
Some(slot)
}
/// Cache-only read for sync resolution: never runs the command, blocks, or
/// mutates. `None` for an unresolved ref, a cold or stale cache, or a mint
/// in progress; minting happens pre-turn via [`AuthProviderRef::ensure_fresh_token`].
pub(crate) fn cached_token(&self) -> Option<String> {
if !self.resolved {
return None;
}
if !self.config.is_usable() {
warn_empty_command(&self.name);
return None;
}
// A mint in progress holds the lock; treat it as a miss rather than
// block the sync path.
let Ok(guard) = self.slot.try_lock() else {
tracing::debug!(provider = %self.name, "cache read skipped: mint in progress");
return None;
};
guard
.as_ref()
.filter(|m| !minted_token_is_stale(m, &self.config))
.map(|m| m.token.clone())
}
/// The token that should replace `current_key` on the wire: serves the
/// fresh cached token when chat-state lags behind a rotation, mints when
/// the cache is cold or stale. Mints or rotates a bearer; unrelated to an
/// OAuth refresh token.
pub(crate) async fn ensure_fresh_token(
&self,
current_key: Option<&str>,
) -> ProviderRefreshOutcome {
let Some(mut slot) = self.locked_slot().await else {
return ProviderRefreshOutcome::Unusable;
};
if let Some(ref minted) = *slot
&& !minted_token_is_stale(minted, &self.config)
{
return if current_key == Some(minted.token.as_str()) {
ProviderRefreshOutcome::Unchanged
} else {
ProviderRefreshOutcome::Rotated(minted.token.clone())
};
}
let mark_expired = slot.is_some();
let minted = match mint_provider_token(self, mark_expired, slot.as_ref()).await {
Ok(minted) => minted,
Err(e) => {
tracing::warn!(
provider = %self.name,
error = %e,
"auth provider pre-turn mint failed"
);
return ProviderRefreshOutcome::MintFailed;
}
};
let token = minted.token.clone();
*slot = Some(minted);
ProviderRefreshOutcome::Rotated(token)
}
/// The replacement for a server-rejected `rejected_key` (chat-state's
/// current key): a fresher cached token is adopted without a re-run,
/// otherwise the command runs once. `None` for a token minted moments ago
/// under the current table (the fresh-mint guard, which an edited table
/// bypasses).
pub(crate) async fn recover_rejected_token(&self, rejected_key: &str) -> Option<String> {
let mut slot = self.locked_slot().await?;
if let Some(ref minted) = *slot {
if minted.token != rejected_key && !minted_token_is_stale(minted, &self.config) {
return Some(minted.token.clone());
}
if minted.token == rejected_key
&& token_identity(&minted.minted_with) == token_identity(&self.config)
&& minted.minted_at.elapsed() < PROVIDER_TOKEN_FRESH_MINT_GUARD
{
tracing::warn!(
provider = %self.name,
"auth provider token rejected moments after mint: not \
re-running (fresh-mint guard); surfacing the 401"
);
return None;
}
}
tracing::info!(provider = %self.name, "auth provider token rejected: re-minting");
let minted = match mint_provider_token(self, true, slot.as_ref()).await {
Ok(minted) => minted,
Err(e) => {
tracing::warn!(
provider = %self.name,
error = %e,
"auth provider 401 re-mint failed"
);
// The server rejected the cached token and the re-mint failed;
// mark it stale so it is not re-served next turn (fail closed).
// The entry stays so its refresh token still feeds the next
// handback attempt.
if let Some(minted) = slot.as_mut() {
minted.expires_at = Some(chrono::Utc::now());
}
return None;
}
};
let token = minted.token.clone();
*slot = Some(minted);
Some(token)
}
}
/// Backdate a provider's mint time past the fresh-mint guard.
#[cfg(test)]
pub(crate) fn test_backdate_provider_mint(name: &str, age: std::time::Duration) {
let slot = provider_slot(name);
let mut slot = slot
.try_lock()
.expect("no mint in flight during test mutation");
if let Some(ref mut minted) = *slot {
minted.minted_at = std::time::Instant::now()
.checked_sub(age)
.expect("backdate before the process epoch");
}
}
/// A counting provider that prints "tok-1", "tok-2", ... on successive runs.
#[cfg(test)]
pub(crate) fn test_counting_provider(name: &str, dir: &std::path::Path) -> AuthProviderRef {
let counter = dir.join("count");
AuthProviderRef::new(
name.to_owned(),
AuthProviderConfig {
command: format!(
"echo run >> {c}; printf 'tok-%s' \"$(wc -l < {c} | tr -d ' ')\"",
c = counter.display()
),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
)
}
#[cfg(test)]
fn test_expire_provider_token(name: &str) {
let slot = provider_slot(name);
let mut slot = slot
.try_lock()
.expect("no mint in flight during test mutation");
if let Some(ref mut minted) = *slot {
minted.expires_at = Some(chrono::Utc::now() - chrono::Duration::seconds(1));
}
}
#[cfg(test)]
#[path = "auth_provider_tests.rs"]
mod tests;

View file

@ -0,0 +1,763 @@
// Slot names are process-global, so every test uses a unique name (no #[serial]
// needed). No test mutates the process env: the scrub test sets its leak values
// on the child command instead.
use super::test_counting_provider as counting_provider;
use super::*;
#[tokio::test]
async fn provider_token_is_cached_while_fresh() {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-cache", dir.path());
assert_eq!(
provider.cached_token(),
None,
"cache-only read must miss on a cold cache without running the command"
);
let first = provider.ensure_fresh_token(None).await.rotated().unwrap();
let second = provider.ensure_fresh_token(None).await.rotated().unwrap();
assert_eq!(first, "tok-1");
assert_eq!(second, "tok-1", "fresh token must be served from cache");
assert_eq!(
provider.cached_token().as_deref(),
Some("tok-1"),
"sync cache-only read must serve the warm cache"
);
}
#[tokio::test]
async fn provider_token_reminted_when_expired() {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-expiry", dir.path());
assert_eq!(
provider.ensure_fresh_token(None).await.rotated().unwrap(),
"tok-1"
);
test_expire_provider_token("test-expiry");
assert_eq!(
provider.cached_token(),
None,
"cache-only read must not serve a stale token"
);
assert_eq!(
provider.ensure_fresh_token(None).await.rotated().unwrap(),
"tok-2",
"expired token must be re-minted"
);
}
#[tokio::test]
async fn provider_pre_turn_refresh_semantics() {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-stale", dir.path());
let token = provider.ensure_fresh_token(None).await.rotated().unwrap();
assert_eq!(
provider.ensure_fresh_token(Some(&token)).await,
ProviderRefreshOutcome::Unchanged,
"fresh matching token must not be re-minted pre-turn"
);
assert_eq!(
provider
.ensure_fresh_token(Some("lagging-chat-state-key"))
.await
.rotated()
.as_deref(),
Some("tok-1"),
"chat-state lagging behind a rotation adopts the fresh cached token"
);
test_expire_provider_token("test-stale");
assert_eq!(
provider
.ensure_fresh_token(Some(&token))
.await
.rotated()
.as_deref(),
Some("tok-2"),
"stale token must be re-minted pre-turn"
);
}
#[tokio::test]
async fn provider_401_recovery_has_fresh_mint_guard() {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-401", dir.path());
let token = provider.ensure_fresh_token(None).await.rotated().unwrap();
assert_eq!(
provider.recover_rejected_token(&token).await,
None,
"a token minted moments ago must not be re-minted on 401 (loop guard)"
);
test_backdate_provider_mint("test-401", std::time::Duration::from_secs(60));
assert_eq!(
provider.recover_rejected_token(&token).await.as_deref(),
Some("tok-2"),
"an aged rejected token is re-minted once"
);
assert_eq!(
provider.recover_rejected_token(&token).await.as_deref(),
Some("tok-2"),
"a rejection of the already-replaced key adopts the fresh token without a re-run"
);
}
/// Regression: a warm cache must not outlive the provider's config.
#[tokio::test]
async fn provider_removed_from_config_drops_cached_token() {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-removed", dir.path());
let token = provider.ensure_fresh_token(None).await.rotated().unwrap();
let removed = AuthProviderRef::new("test-removed".to_owned(), AuthProviderConfig::default());
assert_eq!(
removed.cached_token(),
None,
"empty command must fail closed even with a warm cache"
);
assert_eq!(
removed.ensure_fresh_token(Some(&token)).await,
ProviderRefreshOutcome::Unusable
);
let restored = counting_provider("test-removed", dir.path());
assert_eq!(
restored
.ensure_fresh_token(Some(&token))
.await
.rotated()
.as_deref(),
Some("tok-2"),
"the removed provider's token must not survive in the slot"
);
}
#[tokio::test]
async fn provider_config_edit_invalidates_cached_token() {
let dir = tempfile::tempdir().unwrap();
let old = counting_provider("test-freshen", dir.path());
assert_eq!(
old.ensure_fresh_token(None).await.rotated().unwrap(),
"tok-1"
);
let edited = AuthProviderRef::new(
"test-freshen".to_owned(),
AuthProviderConfig {
command: "printf edited-token".to_owned(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
assert_eq!(
edited.cached_token(),
None,
"the unexpired old token must not be served under the edited table"
);
assert_eq!(
edited
.ensure_fresh_token(Some("tok-1"))
.await
.rotated()
.as_deref(),
Some("edited-token"),
"refresh must run the edited command without waiting for expiry"
);
}
/// The fresh-mint guard applies per table version.
#[tokio::test]
async fn provider_401_recovery_reminted_under_edited_config() {
let dir = tempfile::tempdir().unwrap();
let old = counting_provider("test-401-edited", dir.path());
let token = old.ensure_fresh_token(None).await.rotated().unwrap();
let edited = AuthProviderRef::new(
"test-401-edited".to_owned(),
AuthProviderConfig {
command: "printf new-config-token".to_owned(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
assert_eq!(
edited.recover_rejected_token(&token).await.as_deref(),
Some("new-config-token"),
"recovery must run the edited command, not adopt the old-table token"
);
}
/// Editing only `timeout_secs` keeps the token; it is not part of
/// `token_identity`.
#[tokio::test]
async fn provider_timeout_edit_does_not_invalidate_token() {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-timeout-edit", dir.path());
provider.ensure_fresh_token(None).await.rotated().unwrap();
let retimed = AuthProviderRef::new(
"test-timeout-edit".to_owned(),
AuthProviderConfig {
command: provider.config.command.clone(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: Some(5),
},
);
assert_eq!(
retimed.cached_token().as_deref(),
Some("tok-1"),
"a timeout-only edit must not invalidate the cached token"
);
}
#[tokio::test]
async fn attach_trusted_config_lets_a_revived_ref_mint() {
let dir = tempfile::tempdir().unwrap();
let template = counting_provider("test-attach", dir.path());
let mut revived: AuthProviderRef = serde_json::from_str(r#"{"name": "test-attach"}"#).unwrap();
assert_eq!(
revived.ensure_fresh_token(None).await,
ProviderRefreshOutcome::Unusable
);
revived.attach_trusted_config(Some(&template.config));
assert_eq!(
revived.ensure_fresh_token(None).await.rotated().as_deref(),
Some("tok-1"),
"a re-attached ref must be able to mint"
);
}
/// A ref revived from bytes never mutates the shared slot: a mutating
/// call fails closed and leaves a resolved ref's token intact.
#[tokio::test]
async fn deserialized_ref_never_drops_the_shared_token() {
let dir = tempfile::tempdir().unwrap();
let resolved = counting_provider("test-unresolved", dir.path());
resolved.ensure_fresh_token(None).await.rotated().unwrap();
let revived: AuthProviderRef = serde_json::from_str(r#"{"name": "test-unresolved"}"#).unwrap();
assert_eq!(
revived.ensure_fresh_token(None).await,
ProviderRefreshOutcome::Unusable
);
assert_eq!(revived.recover_rejected_token("tok-1").await, None);
assert_eq!(
resolved.cached_token().as_deref(),
Some("tok-1"),
"the resolved ref's token must survive a mutating call on the stub"
);
}
/// A ref serializes to its name only: the revived ref carries no command
/// and fails closed until re-attached, while the shared slot still serves
/// resolved refs of the same name.
#[tokio::test]
async fn provider_ref_serializes_name_only_and_drops_config() {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-serde", dir.path());
provider.ensure_fresh_token(None).await.rotated().unwrap();
let bytes = serde_json::to_string(&provider).unwrap();
assert!(bytes.contains("test-serde"));
assert!(
!bytes.contains("tok-%s") && !bytes.contains("command"),
"the serialized form must carry the name only: {bytes}"
);
let revived: AuthProviderRef = serde_json::from_str(&bytes).unwrap();
assert_eq!(revived.name, "test-serde");
assert_eq!(
revived.config,
AuthProviderConfig::default(),
"a serialized command must not survive deserialization"
);
assert_eq!(
revived.cached_token(),
None,
"an unresolved ref fails closed"
);
let same_name = counting_provider("test-serde", dir.path());
assert_eq!(
same_name.cached_token().as_deref(),
Some("tok-1"),
"the shared slot still serves refs constructed with the real config"
);
}
#[tokio::test]
async fn provider_refresh_sets_expired_env() {
let provider = AuthProviderRef::new(
"test-expired-env".to_owned(),
AuthProviderConfig {
command: "printf 'tok-%s' \"${GROK_AUTH_EXPIRED:-0}\"".to_owned(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
assert_eq!(
provider.ensure_fresh_token(None).await.rotated().as_deref(),
Some("tok-0"),
"first mint runs without GROK_AUTH_EXPIRED"
);
test_expire_provider_token("test-expired-env");
assert_eq!(
provider.ensure_fresh_token(None).await.rotated().as_deref(),
Some("tok-1"),
"re-mints run with GROK_AUTH_EXPIRED=1"
);
}
#[tokio::test]
async fn provider_concurrent_mints_single_flight() {
let dir = tempfile::tempdir().unwrap();
let counter = dir.path().join("count");
let provider = AuthProviderRef::new(
"test-single-flight".to_owned(),
AuthProviderConfig {
command: format!(
"sleep 0.3; echo run >> {c}; printf 'tok-%s' \"$(wc -l < {c} | tr -d ' ')\"",
c = counter.display()
),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
let (a, b) = tokio::join!(
provider.ensure_fresh_token(None),
provider.ensure_fresh_token(None)
);
assert_eq!(a.rotated().as_deref(), Some("tok-1"));
assert_eq!(
b.rotated().as_deref(),
Some("tok-1"),
"second caller adopts, never re-runs"
);
let runs = std::fs::read_to_string(&counter).unwrap().lines().count();
assert_eq!(runs, 1, "the command must run exactly once");
}
/// Proven by staleness: an expiry inside the 60s skew re-mints, a
/// distant one serves from cache.
#[tokio::test]
async fn provider_expiry_source_precedence() {
fn short_jwt() -> String {
// exp within the skew window: stale immediately if consumed.
jwt_with_exp(chrono::Utc::now().timestamp() + 30)
}
fn long_jwt() -> String {
jwt_with_exp(chrono::Utc::now().timestamp() + 7200)
}
fn jwt_with_exp(exp: i64) -> String {
jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&serde_json::json!({ "exp": exp }),
&jsonwebtoken::EncodingKey::from_secret(b"test"),
)
.unwrap()
}
async fn mints_after_first(
name: &str,
command: String,
token_ttl_secs: Option<u64>,
counter: &std::path::Path,
) -> usize {
let provider = AuthProviderRef::new(
name.to_owned(),
AuthProviderConfig {
command,
args: None,
token_ttl_secs,
timeout_secs: None,
},
);
let first = provider
.ensure_fresh_token(None)
.await
.rotated()
.expect("first mint");
let _ = provider.ensure_fresh_token(Some(&first)).await;
std::fs::read_to_string(counter).unwrap().lines().count()
}
let dir = tempfile::tempdir().unwrap();
// expires_in=10 (stale) wins over token_ttl_secs=3600 (fresh): re-mints.
let c1 = dir.path().join("c1");
let cmd1 = format!(
"echo run >> {}; printf '{{\"access_token\":\"t1\",\"expires_in\":10}}'",
c1.display()
);
assert_eq!(
mints_after_first("test-exp-expires-in", cmd1, Some(3600), &c1).await,
2,
"expires_in must win over token_ttl_secs"
);
// token_ttl_secs=1 (stale) wins over a 2h JWT exp (fresh): re-mints.
let c2 = dir.path().join("c2");
let cmd2 = format!("echo run >> {}; printf '{}'", c2.display(), long_jwt());
assert_eq!(
mints_after_first("test-exp-ttl", cmd2, Some(1), &c2).await,
2,
"token_ttl_secs must win over the JWT exp claim"
);
// JWT exp alone: a near-expiry claim (inside the skew) re-mints,
// proving the claim is consumed when nothing else is configured.
let c3 = dir.path().join("c3");
let cmd3 = format!("echo run >> {}; printf '{}'", c3.display(), short_jwt());
assert_eq!(
mints_after_first("test-exp-jwt", cmd3, None, &c3).await,
2,
"the JWT exp claim must apply when expires_in and token_ttl_secs are absent"
);
}
#[tokio::test]
async fn provider_unusable_expiry_still_mints() {
let provider = AuthProviderRef::new(
"test-overflow".to_owned(),
AuthProviderConfig {
command: format!(
"printf '{{\"access_token\":\"t\",\"expires_in\":{}}}'",
u64::MAX
),
args: None,
token_ttl_secs: Some(u64::MAX),
timeout_secs: None,
},
);
assert_eq!(
provider.ensure_fresh_token(None).await.rotated().as_deref(),
Some("t"),
"an unusable expiry still mints; the token just has no expiry"
);
assert_eq!(
provider.ensure_fresh_token(Some("t")).await,
ProviderRefreshOutcome::Unchanged,
"no expiry source: never proactively re-minted"
);
}
#[tokio::test]
async fn provider_args_run_without_a_shell() {
let provider = AuthProviderRef::new(
"test-args".to_owned(),
AuthProviderConfig {
command: "printf".to_owned(),
// Shell metacharacters stay literal under direct exec.
args: Some(vec!["tok-$HOME;42".to_owned()]),
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
assert_eq!(
provider.ensure_fresh_token(None).await.rotated().as_deref(),
Some("tok-$HOME;42"),
);
}
#[tokio::test]
async fn provider_command_times_out() {
let provider = AuthProviderRef::new(
"test-timeout".to_owned(),
AuthProviderConfig {
command: "sleep 20; printf never".to_owned(),
args: None,
token_ttl_secs: None,
timeout_secs: Some(1),
},
);
let start = std::time::Instant::now();
assert_eq!(
provider.ensure_fresh_token(None).await,
ProviderRefreshOutcome::MintFailed
);
assert!(
start.elapsed().as_secs() < 5,
"1s timeout_secs must bound the mint (took {}s)",
start.elapsed().as_secs()
);
}
#[tokio::test]
async fn provider_zero_timeout_clamps_to_one_second() {
// `timeout_secs = 0` clamps up to the 1s floor, so an instant helper mints
// rather than failing immediately.
let fast = AuthProviderRef::new(
"test-zero-timeout-fast".to_owned(),
AuthProviderConfig {
command: "printf tok".to_owned(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: Some(0),
},
);
assert_eq!(
fast.ensure_fresh_token(None).await.rotated().as_deref(),
Some("tok")
);
// ...and clamps down from the 30s default: a helper that runs past 1s times
// out, proving the effective bound is the clamp, not the default.
let slow = AuthProviderRef::new(
"test-zero-timeout-slow".to_owned(),
AuthProviderConfig {
command: "sleep 5; printf tok".to_owned(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: Some(0),
},
);
assert!(
matches!(
slow.ensure_fresh_token(None).await,
ProviderRefreshOutcome::MintFailed
),
"a >1s helper under timeout_secs=0 must time out at the 1s clamp"
);
}
/// The distinct mint-failure modes (timeout, spawn failure, ran-but-no-token)
/// surface distinct, greppable error messages so operators can triage them.
#[tokio::test]
async fn mint_error_messages_distinguish_failure_modes() {
let timed_out = AuthProviderRef::new(
"test-classify-timeout".to_owned(),
AuthProviderConfig {
command: "sleep 20".to_owned(),
args: None,
token_ttl_secs: None,
timeout_secs: Some(1),
},
);
let err = mint_provider_token(&timed_out, false, None)
.await
.err()
.expect("timeout must fail the mint");
assert!(err.to_string().contains("timed out"), "got: {err}");
let missing = AuthProviderRef::new(
"test-classify-spawn".to_owned(),
AuthProviderConfig {
command: "/nonexistent/provider-binary".to_owned(),
args: Some(vec![]),
token_ttl_secs: None,
timeout_secs: Some(5),
},
);
let err = mint_provider_token(&missing, false, None)
.await
.err()
.expect("spawn failure must fail the mint");
assert!(err.to_string().contains("failed to start"), "got: {err}");
let empty_output = AuthProviderRef::new(
"test-classify-permanent".to_owned(),
AuthProviderConfig {
command: "printf ''".to_owned(),
args: None,
token_ttl_secs: None,
timeout_secs: Some(5),
},
);
let err = mint_provider_token(&empty_output, false, None)
.await
.err()
.expect("empty output must fail the mint");
assert!(err.to_string().contains("no output"), "got: {err}");
}
/// On an in-session re-mint, the prior credential is handed back to the command
/// via `GROK_AUTH_PROVIDER_*`, so a refresh-grant command can refresh instead of
/// re-authenticating. Nothing is written to disk.
#[tokio::test]
async fn re_mint_hands_the_prior_token_back_to_the_command() {
let provider = AuthProviderRef::new(
"test-handback".to_owned(),
AuthProviderConfig {
command: "printf 'seen-%s' \"${GROK_AUTH_PROVIDER_ACCESS_TOKEN:-none}\"".to_owned(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
let first = provider.ensure_fresh_token(None).await.rotated().unwrap();
assert_eq!(first, "seen-none", "the first mint has no prior credential");
test_expire_provider_token("test-handback");
assert_eq!(
provider
.ensure_fresh_token(Some(&first))
.await
.rotated()
.as_deref(),
Some("seen-seen-none"),
"the re-mint must receive the prior access token via env"
);
}
/// A 401 whose re-mint fails invalidates the rejected token, so it is not
/// re-served next turn (fail closed) even while still locally unexpired.
#[tokio::test]
async fn failed_401_remint_invalidates_the_cached_token() {
let dir = tempfile::tempdir().unwrap();
let counter = dir.path().join("count");
// Mints tok-1 on the first run, then exits non-zero on every later run.
let provider = AuthProviderRef::new(
"test-401-invalidate".to_owned(),
AuthProviderConfig {
command: format!(
"echo run >> {c}; n=$(wc -l < {c} | tr -d ' '); \
[ \"$n\" = 1 ] && printf 'tok-1' || exit 1",
c = counter.display()
),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
let token = provider.ensure_fresh_token(None).await.rotated().unwrap();
assert_eq!(token, "tok-1");
// Age past the fresh-mint guard so recovery attempts a re-mint.
test_backdate_provider_mint("test-401-invalidate", PROVIDER_TOKEN_FRESH_MINT_GUARD * 2);
assert_eq!(
provider.recover_rejected_token(&token).await,
None,
"a failed re-mint surfaces the 401"
);
assert_eq!(
provider.cached_token(),
None,
"a rejected token whose re-mint failed must not be re-served"
);
}
/// A pre-turn re-mint that fails over a now-stale cached token leaves nothing
/// servable: the stale token is never handed to the wire (mirror of the 401
/// path, for the pre-turn path).
#[tokio::test]
async fn failed_pre_turn_mint_does_not_serve_the_stale_token() {
let dir = tempfile::tempdir().unwrap();
let counter = dir.path().join("count");
let provider = AuthProviderRef::new(
"test-pre-turn-stale".to_owned(),
AuthProviderConfig {
command: format!(
"echo run >> {c}; n=$(wc -l < {c} | tr -d ' '); \
[ \"$n\" = 1 ] && printf 'tok-1' || exit 1",
c = counter.display()
),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
let token = provider.ensure_fresh_token(None).await.rotated().unwrap();
assert_eq!(token, "tok-1");
// Make the cached token stale so the next pre-turn call re-mints (and fails).
test_expire_provider_token("test-pre-turn-stale");
assert!(matches!(
provider.ensure_fresh_token(Some(token.as_str())).await,
ProviderRefreshOutcome::MintFailed
));
assert_eq!(
provider.cached_token(),
None,
"a stale token whose pre-turn re-mint failed must not be served"
);
}
/// A helper that writes past the stdout cap fails closed (permanent), so a
/// runaway command can't exhaust memory or put a huge token on the wire.
#[tokio::test]
async fn provider_output_over_cap_fails_closed() {
let over = PROVIDER_STDOUT_CAP_BYTES + 4096;
let provider = AuthProviderRef::new(
"test-stdout-cap".to_owned(),
AuthProviderConfig {
command: format!("head -c {over} /dev/zero"),
args: None,
token_ttl_secs: None,
timeout_secs: Some(5),
},
);
let err = mint_provider_token(&provider, false, None)
.await
.err()
.expect("over-cap output must fail the mint");
assert!(
err.to_string().contains("more than"),
"an over-cap write must be reported as such, got: {err}"
);
assert_eq!(
provider.ensure_fresh_token(None).await,
ProviderRefreshOutcome::MintFailed
);
}
/// Every first-party credential env var is scrubbed from the helper, so a BYOK
/// helper never inherits the keys BYOK isolates on the wire.
///
/// The test drives its set/echo from an independent audited `EXPECTED` list, not
/// from the scrub const, so it is not tautological: dropping an entry from
/// `FIRST_PARTY_CREDENTIAL_ENV_VARS` alone leaves that var set on the command and
/// trips the assert below, and removing one from both requires deliberately
/// editing this audited list.
///
/// The leak values are set on the child command, not the process env, so the
/// test is hermetic: it needs no `#[serial]` and cannot race a sibling test that
/// reads a first-party credential (e.g. the `auth::manager` session tests).
#[tokio::test]
async fn provider_helper_env_scrubs_first_party_credentials() {
// The credentials a BYOK helper must never inherit. Editing this list is the
// audit checkpoint: it must equal the production scrub const.
const EXPECTED: &[&str] = &[
"XAI_API_KEY",
"GROK_CODE_XAI_API_KEY",
"GROK_AUTH",
"GROK_AUTH_PATH",
"GROK_DEPLOYMENT_KEY",
"GROK_EXTRA_AUTH_KEY",
"GROK_TRACE_UPLOAD_CREDENTIALS_FILE",
"OTEL_EXPORTER_OTLP_HEADERS",
"GROK_INTERNAL_OTLP_HEADERS",
];
assert_eq!(
crate::agent::config::FIRST_PARTY_CREDENTIAL_ENV_VARS,
EXPECTED,
"the scrub list changed: re-audit that every entry is a first-party \
credential a BYOK helper must not inherit, then update EXPECTED"
);
// Echo each expected var back; the scrub must leave every one empty. A
// scrub-const entry that EXPECTED still lists but production stopped removing
// stays at its leak value and surfaces here.
let echo = EXPECTED
.iter()
.map(|v| format!("${{{v}-}}"))
.collect::<Vec<_>>()
.join("");
let mut cmd = tokio::process::Command::new("sh");
cmd.args(["-c", &format!("printf 'tok[%s]' \"{echo}\"")]);
for var in EXPECTED {
cmd.env(var, "first-party-leak");
}
super::scrub_first_party_credentials(&mut cmd);
let output = cmd.output().await.expect("helper spawns");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"tok[]",
"no first-party credential may survive into the helper env"
);
}

View file

@ -1,60 +1,11 @@
use crate::auth::token_output::parse_token_output;
use crate::auth::{AuthMode, GrokAuth};
#[derive(serde::Deserialize)]
pub(crate) struct ExternalAuthOutput {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub expires_in: Option<u64>,
/// Token issuer. An xAI issuer marks the credential as first-party;
/// see [`GrokAuth::is_xai_auth`].
#[serde(default)]
pub issuer: Option<String>,
}
/// Parse process output (stdout) into a `GrokAuth`. Accepts bare token or JSON.
/// Parse stdout into a session-credential `GrokAuth`.
pub(crate) fn parse_output(output: &std::process::Output) -> anyhow::Result<GrokAuth> {
if !output.status.success() {
anyhow::bail!("exited with {}", output.status);
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if stdout.is_empty() {
anyhow::bail!("produced no output on stdout");
}
let (token, refresh_token, expires_at, issuer) =
if let Ok(parsed) = serde_json::from_str::<ExternalAuthOutput>(&stdout) {
tracing::debug!(
has_refresh_token = parsed.refresh_token.is_some(),
expires_in = ?parsed.expires_in,
issuer = ?parsed.issuer,
"auth: parsed external provider output as JSON"
);
let expires_at = parsed
.expires_in
.map(|secs| chrono::Utc::now() + chrono::Duration::seconds(secs as i64));
let issuer = parsed
.issuer
.map(|i| i.trim().to_owned())
.filter(|i| !i.is_empty());
(
parsed.access_token,
parsed.refresh_token,
expires_at,
issuer,
)
} else {
tracing::debug!(
stdout_len = stdout.len(),
"auth: treating output as bare token"
);
(stdout, None, None, None)
};
let parsed = parse_token_output(output)?;
Ok(GrokAuth {
key: token,
key: parsed.access_token,
auth_mode: AuthMode::External,
create_time: chrono::Utc::now(),
user_id: String::new(),
@ -74,20 +25,25 @@ pub(crate) fn parse_output(output: &std::process::Output) -> anyhow::Result<Grok
team_blocked_reasons: vec![],
coding_data_retention_opt_out: crate::auth::default_coding_data_retention_opt_out(),
has_grok_code_access: None,
refresh_token,
expires_at,
oidc_issuer: issuer,
refresh_token: parsed.refresh_token,
expires_at: parsed.expires_at,
oidc_issuer: parsed.issuer,
oidc_client_id: None,
})
}
/// Sync version for mid-session refresh. 5s timeout for refresh, 60s for initial.
pub(crate) fn run_external_auth_sync(command: &str, is_refresh: bool) -> Option<GrokAuth> {
let timeout_secs = if is_refresh { 5 } else { 60 };
run_auth_command(command, timeout_secs, is_refresh)
}
/// Runs `command` via `sh -c`; `mark_expired` sets `GROK_AUTH_EXPIRED=1` so the
/// helper can distinguish re-mints from first runs.
fn run_auth_command(command: &str, timeout_secs: u64, mark_expired: bool) -> Option<GrokAuth> {
use std::process::{Command, Stdio};
let timeout_secs = if is_refresh { 5 } else { 60 };
tracing::info!(cmd = %command, is_refresh, timeout_secs, "auth: running external auth provider (sync)");
tracing::info!(cmd = %command, mark_expired, timeout_secs, "auth: running external auth provider (sync)");
let mut cmd = Command::new("sh");
cmd.args(["-c", command])
@ -95,7 +51,7 @@ pub(crate) fn run_external_auth_sync(command: &str, is_refresh: bool) -> Option<
.stdout(Stdio::piped())
// Pipe stderr — inherit would corrupt the TUI alternate screen.
.stderr(Stdio::piped());
if is_refresh {
if mark_expired {
cmd.env("GROK_AUTH_EXPIRED", "1");
}
xai_grok_tools::util::detach_std_command(&mut cmd);
@ -224,14 +180,13 @@ mod tests {
}
#[test]
fn parse_output_malformed_json_falls_back_to_bare() {
fn parse_output_json_shaped_but_invalid_is_err() {
let output = std::process::Output {
status: std::process::Command::new("true").status().unwrap(),
stdout: b"{not valid json}".to_vec(),
stderr: vec![],
};
let auth = parse_output(&output).unwrap();
assert_eq!(auth.key, "{not valid json}");
assert!(parse_output(&output).is_err());
}
#[test]

View file

@ -1,4 +1,5 @@
pub(crate) mod attribution;
mod auth_provider;
mod config;
pub mod credential_provider;
#[path = "devbox_login_stub.rs"]
@ -15,7 +16,14 @@ pub(crate) mod recovery;
pub(crate) mod refresh;
pub(crate) mod single_flight;
mod storage;
mod token_output;
pub(crate) mod token_type;
pub use auth_provider::{AuthProviderConfig, AuthProviderRef};
pub(crate) use auth_provider::{
PROVIDER_TIMEOUT_CEILING_SECS, PROVIDER_TOKEN_EXPIRY_SKEW_SECS, ProviderRefreshOutcome,
};
#[cfg(test)]
pub(crate) use auth_provider::{test_backdate_provider_mint, test_counting_provider};
pub(crate) use config::LEGACY_AUTH_SCOPE;
pub use config::{
ForceLoginTeam, GrokComConfig, OAuth2ProviderConfig, OidcAuthConfig, PreferredAuthMethod,

View file

@ -0,0 +1,155 @@
//! Shared parser for an auth command's stdout.
//!
//! Both auth paths run a command that prints a bearer token and parse it here:
//! the session external-auth path ([`super::external_auth`]) and the per-model
//! provider mint ([`super::auth_provider`]).
#[derive(serde::Deserialize)]
pub(crate) struct ExternalAuthOutput {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub expires_in: Option<u64>,
/// An xAI issuer marks the credential as first-party
/// (see [`crate::auth::GrokAuth::is_xai_auth`]).
#[serde(default)]
pub issuer: Option<String>,
}
/// A bearer must be a single line: reject control characters (including an
/// interior newline) so a malformed token can never be smuggled onto an HTTP
/// header, rather than relying on the HTTP layer to reject it later.
fn reject_control_chars(token: &str) -> anyhow::Result<()> {
if token.contains(char::is_control) {
anyhow::bail!("token contains control characters");
}
Ok(())
}
/// `now + secs`, or `None` on overflow.
pub(crate) fn expiry_after_seconds(secs: u64) -> Option<chrono::DateTime<chrono::Utc>> {
let secs = i64::try_from(secs).ok()?;
chrono::Utc::now().checked_add_signed(chrono::Duration::try_seconds(secs)?)
}
pub(crate) struct ParsedTokenOutput {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub issuer: Option<String>,
}
/// Accepts a bare token or JSON `{access_token, expires_in, issuer, ...}`. A
/// non-zero exit, non-UTF-8 or empty stdout, an empty `access_token`, or
/// JSON-object output that is not a valid token payload are all errors, so a
/// malformed mint fails closed rather than putting garbage on the wire.
pub(crate) fn parse_token_output(
output: &std::process::Output,
) -> anyhow::Result<ParsedTokenOutput> {
if !output.status.success() {
anyhow::bail!("exited with {}", output.status);
}
let stdout = std::str::from_utf8(&output.stdout)
.map_err(|_| anyhow::anyhow!("produced non-UTF-8 output on stdout"))?
.trim();
if stdout.is_empty() {
anyhow::bail!("produced no output on stdout");
}
// Output that starts with `{` is meant to be a token payload: require it to
// parse and carry a non-empty access_token. Anything else is a bare token
// (JWTs and opaque tokens never start with `{`), so an error object like
// `{"error":"expired"}` can never be mistaken for a bearer.
if stdout.starts_with('{') {
let parsed: ExternalAuthOutput = serde_json::from_str(stdout)
.map_err(|e| anyhow::anyhow!("produced JSON that is not a token payload: {e}"))?;
let access_token = parsed.access_token.trim().to_owned();
if access_token.is_empty() {
anyhow::bail!("produced JSON with an empty access_token");
}
reject_control_chars(&access_token)?;
tracing::debug!(
has_refresh_token = parsed.refresh_token.is_some(),
expires_in = ?parsed.expires_in,
issuer = ?parsed.issuer,
"auth: parsed external provider output as JSON"
);
return Ok(ParsedTokenOutput {
access_token,
refresh_token: parsed.refresh_token,
expires_at: parsed.expires_in.and_then(expiry_after_seconds),
issuer: parsed
.issuer
.map(|i| i.trim().to_owned())
.filter(|i| !i.is_empty()),
});
}
reject_control_chars(stdout)?;
tracing::debug!(
stdout_len = stdout.len(),
"auth: treating output as bare token"
);
Ok(ParsedTokenOutput {
access_token: stdout.to_owned(),
refresh_token: None,
expires_at: None,
issuer: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expiry_after_seconds_returns_none_on_overflow() {
assert_eq!(expiry_after_seconds(u64::MAX), None);
assert_eq!(expiry_after_seconds(u64::try_from(i64::MAX).unwrap()), None);
assert!(expiry_after_seconds(3600).is_some());
}
/// The provider path reads `refresh_token`, which the bare-token fallback
/// cannot carry; only JSON output does.
#[test]
fn parse_token_output_reads_refresh_token_from_json_only() {
let ok = |stdout: &str| std::process::Output {
status: std::process::Command::new("true").status().unwrap(),
stdout: stdout.as_bytes().to_vec(),
stderr: vec![],
};
let parsed =
parse_token_output(&ok(r#"{"access_token":"a","refresh_token":"r"}"#)).unwrap();
assert_eq!(parsed.access_token, "a");
assert_eq!(parsed.refresh_token.as_deref(), Some("r"));
assert_eq!(parse_token_output(&ok("bare")).unwrap().refresh_token, None);
}
/// JSON-shaped output must be a valid, non-empty token payload; a botched or
/// error payload fails closed instead of going on the wire as a bearer.
#[test]
fn parse_token_output_rejects_invalid_json_payloads() {
let ok = |stdout: &str| std::process::Output {
status: std::process::Command::new("true").status().unwrap(),
stdout: stdout.as_bytes().to_vec(),
stderr: vec![],
};
assert!(parse_token_output(&ok(r#"{"access_token":""}"#)).is_err());
assert!(parse_token_output(&ok(r#"{"access_token":" "}"#)).is_err());
assert!(parse_token_output(&ok(r#"{"error":"expired"}"#)).is_err());
assert!(parse_token_output(&ok("{not valid json}")).is_err());
// A JSON payload's access_token is trimmed of surrounding whitespace.
let parsed = parse_token_output(&ok("{\"access_token\":\" tok \"}")).unwrap();
assert_eq!(parsed.access_token, "tok");
// An interior control character is rejected on both paths, so a
// malformed token can never reach an HTTP header.
assert!(parse_token_output(&ok("{\"access_token\":\"tok\\ninjected\"}")).is_err());
assert!(parse_token_output(&ok("tok\ninjected")).is_err());
}
}

View file

@ -2191,7 +2191,10 @@ extra_rule_dirs = ["/c/rules"]
let leaked: Vec<&RequirementSource> = r
.sources
.iter()
.filter(|s| matches!(s, RequirementSource::Settings { path } if path == &tempdir_claude))
.filter(|s| {
matches!(s, RequirementSource::Settings { path }
if path == &tempdir_claude)
})
.collect();
assert!(
leaked.is_empty(),

View file

@ -766,7 +766,8 @@ mod tests {
.expect("first event should dispatch within 2s")
.expect("channel open");
assert!(
matches!(update, ConfigUpdate::ProjectMcpServersChanged { cwd: ref c } if *c == cwd),
matches!(update, ConfigUpdate::ProjectMcpServersChanged { cwd: ref c }
if *c == cwd),
"first project event must dispatch"
);
@ -790,7 +791,8 @@ mod tests {
.expect("changed content should dispatch within 2s")
.expect("channel open");
assert!(
matches!(update, ConfigUpdate::ProjectMcpServersChanged { cwd: ref c } if *c == cwd),
matches!(update, ConfigUpdate::ProjectMcpServersChanged { cwd: ref c }
if *c == cwd),
"changed project config must dispatch"
);

View file

@ -2691,6 +2691,29 @@ fn config_layers_user_overrides_managed() {
Some(crate ::agent::config::TelemetryMode::Enabled), cfg.features.telemetry
);
}
/// A provider in a trusted disk layer resolves through the real
/// `ConfigLayers` → `effective_config_disk_only` → parse seam that the
/// direct-TOML parse tests bypass. (`ConfigLayers` has no project slot, so
/// a repo `.grok/config.toml` structurally cannot supply one.)
#[test]
fn auth_provider_honored_only_from_trusted_disk_layers() {
let layers = ConfigLayers {
managed: toml::from_str(
"[auth_provider.corp]\ncommand = \"/usr/local/bin/corp-token\"\n",
)
.unwrap(),
..Default::default()
};
let cfg = crate::agent::config::Config::new_from_toml_cfg(
&layers.effective_config_disk_only(),
)
.unwrap();
assert_eq!(
cfg.auth_providers.get("corp").map(| c | c.command.as_str()),
Some("/usr/local/bin/corp-token"),
"a provider in a trusted disk layer is honored"
);
}
/// REGRESSION: the real enterprise two-file merge —
/// `managed_config.toml` (proxy + BYO model host) layered with
/// `requirements.toml` (deployment key + S3 trace upload) via the actual

View file

@ -1447,9 +1447,10 @@ mod official_source_tests {
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].name, "my-plugins");
assert!(matches!(
&sources[0].kind,
xai_grok_plugin_marketplace::SourceKind::Local { path } if path == &dir
));
&sources[0].kind,
xai_grok_plugin_marketplace::SourceKind::Local { path }
if path == &dir
));
// The path must not be mangled into a git URL.
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(!raw.contains("git ="), "{raw}");

View file

@ -73,10 +73,9 @@ pub struct InspectReport {
pub lsp_servers: Vec<LspServerEntry>,
pub config_sources: ConfigSources,
pub external_compat: ExternalCompatReport,
/// Warnings from `[model.*]` parsing.
/// Warnings from `[model.*]` and `[auth_provider.*]` parsing.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub model_override_warnings:
Vec<crate::agent::config_model_override_parse::ModelOverrideWarning>,
pub config_warnings: Vec<crate::agent::config_model_override_parse::ConfigWarning>,
}
#[derive(Debug, Serialize)]
@ -380,9 +379,9 @@ async fn build_report(cwd: &Path) -> InspectReport {
}
let lsp = list_lsp_servers(cwd, &discovered_plugins);
let configs = list_config_sources(cwd);
let model_override_warnings = parsed_config
let config_warnings = parsed_config
.as_ref()
.map(|c| c.model_override_warnings.clone())
.map(|c| c.config_warnings.clone())
.unwrap_or_default();
InspectReport {
@ -405,7 +404,7 @@ async fn build_report(cwd: &Path) -> InspectReport {
lsp_servers: lsp,
config_sources: configs,
external_compat,
model_override_warnings,
config_warnings,
}
}
@ -1242,35 +1241,27 @@ fn disabled_compat_tags(
}
}
/// Renders the "Model Overrides" section of the human report; empty when
/// there are no warnings.
fn render_model_override_warnings(
warnings: &[crate::agent::config_model_override_parse::ModelOverrideWarning],
/// Renders the "Config Warnings" section of the human report; empty when
/// there are no warnings. Covers `[model.*]` overrides and the
/// `[auth_provider.*]` tables, which share the same warning channel.
fn render_config_warnings(
warnings: &[crate::agent::config_model_override_parse::ConfigWarning],
) -> String {
use std::fmt::Write as _;
if warnings.is_empty() {
return String::new();
}
let mut out = String::from("\n Model Overrides\n");
let _ = writeln!(
out,
" {TREE} {} warning(s) (models with invalid fields kept in catalog)",
warnings.len()
);
let mut out = String::from("\n Config Warnings\n");
let _ = writeln!(out, " {TREE} {} warning(s)", warnings.len());
for w in warnings {
let target = match w.model_key.as_deref() {
Some(key) => format!("[model.\"{key}\"]"),
None => "[model]".to_owned(),
};
match w.field.as_deref() {
Some(field) => {
let _ = writeln!(out, " {TREE} {target} {field} — {}", w.reason);
}
None => {
let _ = writeln!(out, " {TREE} {target} — {}", w.reason);
}
}
let field = w.field().map(|f| format!(" {f}")).unwrap_or_default();
let _ = writeln!(
out,
" {TREE} [{}]{field} — {}",
w.target.label(),
w.reason
);
}
out
}
@ -1545,10 +1536,7 @@ fn print_human(r: &InspectReport) {
println!(" {TREE} Project: (none)");
}
print!(
"{}",
render_model_override_warnings(&r.model_override_warnings)
);
print!("{}", render_config_warnings(&r.config_warnings));
print!("{}", render_harness_compatibility(&r.external_compat));
}
@ -1845,7 +1833,7 @@ mod tests {
/// Model-override warnings flow from an effective config through `Config`
/// to the human renderer and the JSON report.
#[test]
fn model_override_warnings_inspect_smoke() {
fn config_warnings_inspect_smoke() {
let effective: toml::Value = toml::from_str(
r#"
[model."grok-4.5"]
@ -1858,23 +1846,23 @@ mod tests {
)
.unwrap();
let cfg = crate::agent::config::Config::new_from_toml_cfg(&effective).unwrap();
let warnings = cfg.model_override_warnings;
let warnings = cfg.config_warnings;
assert!(
warnings
.iter()
.any(|w| w.field.as_deref() == Some("send_compactions_remaining")),
.any(|w| w.field() == Some("send_compactions_remaining")),
"duplicate alias should warn: {warnings:?}"
);
assert!(
warnings
.iter()
.any(|w| w.field.as_deref() == Some("reasoning_effort")),
.any(|w| w.field() == Some("reasoning_effort")),
"invalid enum should warn: {warnings:?}"
);
assert!(cfg.config_models.contains_key("grok-4.5"));
let human = render_model_override_warnings(&warnings);
assert!(human.contains("Model Overrides"), "{human}");
let human = render_config_warnings(&warnings);
assert!(human.contains("Config Warnings"), "{human}");
assert!(
human.contains("[model.\"grok-4.5\"] send_compactions_remaining"),
"{human}"
@ -1883,7 +1871,33 @@ mod tests {
human.contains("[model.\"grok-4.5\"] reasoning_effort"),
"{human}"
);
assert_eq!(render_model_override_warnings(&[]), "");
// Auth-provider warnings render under their own table syntax.
let provider_warning =
crate::agent::config_model_override_parse::ConfigWarning::auth_provider(
"litellm",
Some("command"),
crate::agent::config_model_override_parse::ConfigWarningKind::InvalidValue,
"missing or empty command".to_owned(),
);
let human = render_config_warnings(&[provider_warning]);
assert!(
human.contains("[auth_provider.\"litellm\"] command"),
"{human}"
);
// A dotted provider name renders whole; the field splits off the
// right.
let dotted = crate::agent::config_model_override_parse::ConfigWarning::auth_provider(
"corp.gateway",
Some("token_ttl_secs"),
crate::agent::config_model_override_parse::ConfigWarningKind::InvalidValue,
"at or below the refresh margin".to_owned(),
);
let human = render_config_warnings(&[dotted]);
assert!(
human.contains("[auth_provider.\"corp.gateway\"] token_ttl_secs"),
"{human}"
);
assert_eq!(render_config_warnings(&[]), "");
let json = serde_json::to_value(&warnings).unwrap();
let alias_warning = json
@ -1892,7 +1906,8 @@ mod tests {
.iter()
.find(|w| w["field"] == "send_compactions_remaining")
.expect("alias warning present in JSON");
assert_eq!(alias_warning["modelKey"], "grok-4.5");
assert_eq!(alias_warning["target"], "model");
assert_eq!(alias_warning["key"], "grok-4.5");
assert_eq!(alias_warning["kind"], "duplicate-alias");
assert!(
alias_warning["reason"]

View file

@ -858,13 +858,14 @@ mod tests {
match start_result {
Ok(started) => {
assert!(matches!(
started,
ControlPayload::CpuProfileStarted {
svg_path,
frequency_hz: 200,
..
} if svg_path == output_path
));
started,
ControlPayload::CpuProfileStarted {
svg_path,
frequency_hz: 200,
..
}
if svg_path == output_path
));
let status = client
.send_control(ControlCommand::CpuProfileStatus)
@ -872,15 +873,16 @@ mod tests {
.unwrap()
.unwrap();
assert!(matches!(
status,
ControlPayload::CpuProfileStatus {
active: true,
stopping: false,
svg_path: Some(path),
frequency_hz: Some(200),
..
} if path == output_path
));
status,
ControlPayload::CpuProfileStatus {
active: true,
stopping: false,
svg_path: Some(path),
frequency_hz: Some(200),
..
}
if path == output_path
));
let stopped = client
.send_control(ControlCommand::StopCpuProfile)
@ -888,9 +890,10 @@ mod tests {
.unwrap()
.unwrap();
assert!(matches!(
stopped,
ControlPayload::CpuProfileStopped { svg_path, .. } if svg_path == output_path
));
stopped,
ControlPayload::CpuProfileStopped { svg_path, .. }
if svg_path == output_path
));
assert!(output_path.exists());
}
Err(error) => {
@ -987,15 +990,16 @@ mod tests {
.unwrap()
.unwrap();
assert!(matches!(
status,
ControlPayload::CpuProfileStatus {
active: false,
stopping: true,
svg_path: Some(path),
frequency_hz: Some(200),
..
} if path == output_path
));
status,
ControlPayload::CpuProfileStatus {
active: false,
stopping: true,
svg_path: Some(path),
frequency_hz: Some(200),
..
}
if path == output_path
));
let leader_info = client_b
.send_control(ControlCommand::GetLeaderInfo)
@ -1033,9 +1037,10 @@ mod tests {
let stopped = stop_task.await.unwrap().unwrap().unwrap();
assert!(matches!(
stopped,
ControlPayload::CpuProfileStopped { svg_path, .. } if svg_path == output_path
));
stopped,
ControlPayload::CpuProfileStopped { svg_path, .. }
if svg_path == output_path
));
assert_eq!(
stop_calls.lock().unwrap().as_slice(),
std::slice::from_ref(&output_path)

View file

@ -445,15 +445,16 @@ mod tests {
let received: ClientMessage = read_message(&mut server).await.unwrap();
assert!(matches!(
received,
ClientMessage::Control {
request_id,
command: ControlCommand::StartCpuProfile {
output: Some(output),
frequency_hz: Some(250),
},
} if request_id == "req-1" && output == "/tmp/profile.folded"
));
received,
ClientMessage::Control {
request_id,
command: ControlCommand::StartCpuProfile {
output: Some(output),
frequency_hz: Some(250),
},
}
if request_id == "req-1" && output == "/tmp/profile.folded"
));
}
#[tokio::test]
@ -539,21 +540,22 @@ mod tests {
let json = serde_json::to_string(&msg).unwrap();
let decoded: ServerMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(
decoded,
ServerMessage::Registered {
client_id: 7,
ready: true,
leader_protocol_version: Some(LEADER_PROTOCOL_VERSION),
leader_binary_version: Some(_),
leader_capabilities: Some(LeaderCapabilities {
control_v1: true,
runtime_cpu_profile: true,
profile_formats,
workspace_exposure: true,
relaunch_v1: true,
}),
} if profile_formats == vec![ProfileArtifactFormat::Svg]
));
decoded,
ServerMessage::Registered {
client_id: 7,
ready: true,
leader_protocol_version: Some(LEADER_PROTOCOL_VERSION),
leader_binary_version: Some(_),
leader_capabilities: Some(LeaderCapabilities {
control_v1: true,
runtime_cpu_profile: true,
profile_formats,
workspace_exposure: true,
relaunch_v1: true,
}),
}
if profile_formats == vec![ProfileArtifactFormat::Svg]
));
}
#[test]
@ -637,14 +639,15 @@ mod tests {
let received: ClientMessage = read_message(&mut server).await.unwrap();
assert!(matches!(
received,
ClientMessage::Control {
request_id,
command: ControlCommand::WorkspaceStart { hub_url: Some(url), cwd },
} if request_id == "ws-1"
&& url == "wss://hub.example/v1/tools"
&& cwd == "/home/u/proj"
));
received,
ClientMessage::Control {
request_id,
command: ControlCommand::WorkspaceStart { hub_url: Some(url), cwd },
}
if request_id == "ws-1"
&& url == "wss://hub.example/v1/tools"
&& cwd == "/home/u/proj"
));
}
#[test]
@ -669,15 +672,16 @@ mod tests {
let json = r#"{"type":"workspace_status","state":"none","uptime_ms":0,"active_tool_calls":0,"pid":1}"#;
let decoded: ControlPayload = serde_json::from_str(json).unwrap();
assert!(matches!(
decoded,
ControlPayload::WorkspaceStatus {
state,
hub_url: None,
cwd: None,
sessions,
..
} if state == "none" && sessions.is_empty()
));
decoded,
ControlPayload::WorkspaceStatus {
state,
hub_url: None,
cwd: None,
sessions,
..
}
if state == "none" && sessions.is_empty()
));
}
#[test]

View file

@ -3065,7 +3065,8 @@ mod tests {
assert!(
matches!(response, ServerMessage::ControlResult { request_id, result :
Ok(ControlPayload::CpuProfileStatus { active : false, stopping : false,
started_at : None, svg_path : None, frequency_hz : None, }), } if request_id
started_at : None, svg_path : None, frequency_hz : None, }), }
if request_id
== "status-1")
);
assert!(

View file

@ -232,10 +232,11 @@ async fn fetch_managed_config(
token: &str,
source: ManagedConfigSource,
max_attempts: u32,
echo_principal: Option<&str>,
) -> Result<ManagedConfigResponse, ManagedConfigError> {
crate::http::send_with_retry_escaping_pool(
move |client: reqwest::Client| async move {
fetch_managed_config_once(&client, url, token, source).await
fetch_managed_config_once(&client, url, token, source, echo_principal).await
},
max_attempts,
|e: &ManagedConfigError| e.is_retryable(),
@ -324,14 +325,25 @@ async fn fetch_managed_config_once(
url: &str,
token: &str,
source: ManagedConfigSource,
echo_principal: Option<&str>,
) -> Result<ManagedConfigResponse, ManagedConfigError> {
let resp = match client
let mut request = client
.get(url)
.header("Authorization", format!("Bearer {}", token))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.timeout(std::time::Duration::from_secs(15));
// Replay-probe echo (telemetry only). Skip on invalid HeaderValue so a
// corrupt sidecar never bricks the fetch (echo is fail-open).
if let Some(nonce) = xai_grok_config::signed_policy::stored_envelope_nonce(
&crate::util::grok_home::grok_home(),
echo_principal,
) && let Ok(value) = reqwest::header::HeaderValue::from_str(&nonce)
{
request = request.header(
xai_grok_config::signed_policy::MANAGED_CONFIG_NONCE_ECHO_HEADER,
value,
);
}
let resp = match request.send().await {
Ok(r) if r.status().is_success() => r,
Ok(r) => {
let status = r.status().as_u16();
@ -544,7 +556,11 @@ async fn fetch_for_principal(
if let Some(dk) = resolve_deployment_key() {
let source = ManagedConfigSource::DeploymentKey;
match fetch_managed_config(&url, &dk, source, max_attempts).await {
// Echo binds to the deployment this key last synced (marker-bound; None
// on first sync or after a key rotation — then there is nothing to echo).
let echo_principal = crate::config::managed_deployment_id(&deployment_key_fingerprint(&dk));
match fetch_managed_config(&url, &dk, source, max_attempts, echo_principal.as_deref()).await
{
// A rejected dk (stale env/config) must not starve a valid team
// sign-in: fall through. Network/5xx do NOT — same unreachable
// server, double the latency for nothing.
@ -569,6 +585,7 @@ async fn fetch_for_principal(
&auth.key,
ManagedConfigSource::TeamOauth,
max_attempts,
auth.team_id.as_deref(),
)
.await?;
return Ok(FetchedConfig::Team {

View file

@ -410,6 +410,7 @@ fn served_principal_prefers_deployment_id() {
requirements: None,
fail_closed: false,
expires_at: 0,
nonce: String::new(),
key_id: "v1".into(),
};
assert_eq!(

View file

@ -43,6 +43,20 @@ pub struct RemoteSync {
}
impl RemoteSync {
#[cfg(test)]
pub(crate) fn test_observer() -> (Self, mpsc::UnboundedReceiver<acp::SessionNotification>) {
let (tx, mut rx) = mpsc::unbounded_channel();
let (observed_tx, observed_rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(message) = rx.recv().await {
if let SyncMsg::Queue(notification) = message {
let _ = observed_tx.send(*notification);
}
}
});
(Self { tx }, observed_rx)
}
/// Metadata is included on every flush to keep the backend session row current.
pub(crate) fn new(
session_id: String,

View file

@ -560,6 +560,14 @@ impl PreparedToolCall {
#[cfg(test)]
pub(crate) use crate::session::streaming_capture::STREAMING_CAPTURE_MAX_BYTES;
pub(crate) use crate::session::streaming_capture::StreamingTurnCapture;
/// One memoized model's auth state, keyed by model id; see
/// [`SessionActor::model_auth_memo`] for the invalidation contract.
#[derive(Clone)]
pub(crate) struct ModelAuthMemo {
pub(crate) model_id: String,
pub(crate) facts: crate::agent::config::ModelAuthFacts,
pub(crate) provider: Option<crate::auth::AuthProviderRef>,
}
/// Phase 3: Post-flight handling after dispatch (inline in execute_tool_calls for now).
pub(crate) struct SessionActor {
pub(crate) session_info: SessionInfo,
@ -569,10 +577,17 @@ pub(crate) struct SessionActor {
/// fresh, isolated handle seeded once at spawn (frozen for their lifetime).
/// `None` until the agent has selected a method.
pub(crate) auth_method_id: crate::agent::auth_method::SharedAuthMethodId,
/// Memoized per-model auth facts, keyed by model id — see
/// [`SessionActor::model_auth_facts`].
pub(crate) model_auth_facts:
std::cell::RefCell<Option<(String, crate::agent::config::ModelAuthFacts)>>,
/// Memoized per-model auth state, read through
/// [`SessionActor::model_auth_facts`] and
/// [`SessionActor::model_auth_provider`].
///
/// A fresh `Unknown` (config currently unparseable) falls back to the
/// last definite value for the same model rather than demoting a live
/// session to non-refreshable api-key mode. Because a config edit can
/// turn the selected model into a per-model BYOK model without changing
/// its id, keying on the id alone is insufficient: each model/credential
/// chokepoint must clear this memo (`replace(None)`).
pub(crate) model_auth_memo: std::cell::RefCell<Option<ModelAuthMemo>>,
/// 401-attribution callback. Joined with the bearer the
/// sampler sends on the wire to emit an `auth 401 attribution`
/// event at each of the six `OaiCompatClient` 401 arms in

View file

@ -74,7 +74,7 @@ impl SessionActor {
alpha_test_key: existing.alpha_test_key,
client_version: sampling_config.client_version.clone(),
});
self.model_auth_facts.replace(None);
self.invalidate_model_auth_memo();
self.signals_handle()
.record_model_usage(&sampling_config.model);
if apply_prompt_override && !skip_prompt_rewrite {

View file

@ -150,34 +150,129 @@ impl SessionActor {
let plan_active = self.plan_mode.lock().is_active();
filter_cursor_tools_by_plan_mode(defs, plan_active)
}
/// Memoized per-model [`ModelAuthFacts`](crate::agent::config::ModelAuthFacts),
/// keyed by `model_id`.
///
/// A fresh `Unknown` (config currently unparseable) falls back to the last
/// definite value for the same `model_id` rather than demoting a live session
/// to non-refreshable api-key mode. Because a config edit can turn the
/// currently-selected model into a per-model BYOK model without changing
/// `model_id`, keying on `model_id` alone is insufficient — each
/// model/credential chokepoint must clear this memo (`replace(None)`).
pub(super) fn model_auth_facts(&self, model_id: &str) -> crate::agent::config::ModelAuthFacts {
self.model_auth_state(model_id).0
}
pub(super) fn model_auth_provider(
&self,
model_id: &str,
) -> Option<crate::auth::AuthProviderRef> {
self.model_auth_state(model_id).1
}
/// Drop the memoized per-model auth state; see [`Self::model_auth_memo`]
/// for why each model/credential chokepoint must call this.
pub(crate) fn invalidate_model_auth_memo(&self) {
self.model_auth_memo.replace(None);
}
/// Reads and populates [`Self::model_auth_memo`]; a fresh `Unknown`
/// falls back to the last definite entry (see the field's contract).
fn model_auth_state(
&self,
model_id: &str,
) -> (
crate::agent::config::ModelAuthFacts,
Option<crate::auth::AuthProviderRef>,
) {
use crate::agent::auth_method::ModelByok;
if let Some((cached_id, facts)) = self.model_auth_facts.borrow().as_ref()
&& cached_id == model_id
&& facts.byok != ModelByok::Unknown
use crate::session::acp_session::ModelAuthMemo;
if let Some(memo) = self.model_auth_memo.borrow().as_ref()
&& memo.model_id == model_id
&& memo.facts.byok != ModelByok::Unknown
{
return *facts;
return (memo.facts, memo.provider.clone());
}
let fresh = crate::agent::config::resolve_model_auth_facts(model_id);
let (fresh, provider) =
crate::agent::config::resolve_model_auth_facts_and_provider(model_id);
if fresh.byok == ModelByok::Unknown {
if let Some((cached_id, facts)) = self.model_auth_facts.borrow().as_ref()
&& cached_id == model_id
if let Some(memo) = self.model_auth_memo.borrow().as_ref()
&& memo.model_id == model_id
{
return *facts;
return (memo.facts, memo.provider.clone());
}
return fresh;
return (fresh, provider);
}
*self.model_auth_facts.borrow_mut() = Some((model_id.to_string(), fresh));
fresh
*self.model_auth_memo.borrow_mut() = Some(ModelAuthMemo {
model_id: model_id.to_string(),
facts: fresh,
provider: provider.clone(),
});
(fresh, provider)
}
/// The single writer of a provider mint/rotation into chat-state credentials.
async fn set_chat_api_key(&self, new_key: String) {
let mut creds = self.chat_state_handle.get_credentials().await;
creds.api_key = Some(new_key);
self.chat_state_handle.update_credentials(creds);
}
/// Pre-turn arm for a provider-backed model: mint on a cold cache,
/// re-mint near expiry, and adopt a rotation chat-state missed. No-op
/// when `current_key` is already the fresh cached token.
async fn refresh_provider_token_pre_turn(
&self,
provider: &crate::auth::AuthProviderRef,
current_key: Option<&str>,
model_id: &str,
) {
match provider.ensure_fresh_token(current_key).await {
crate::auth::ProviderRefreshOutcome::Rotated(new_key) => {
tracing::info!(
model = % model_id, provider = % provider.name, cold = current_key
.is_none(), "auth provider token rotated pre-turn"
);
self.set_chat_api_key(new_key).await;
}
crate::auth::ProviderRefreshOutcome::Unchanged => {}
crate::auth::ProviderRefreshOutcome::MintFailed => {
tracing::warn!(
session_id = % self.session_info.id.0, provider = % provider.name,
model = % model_id, "auth provider pre-turn refresh failed"
);
xai_grok_telemetry::unified_log::warn(
"auth provider pre-turn refresh failed",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!(
{ "provider" : provider.name, "model" : model_id, "cold" :
current_key.is_none(), }
)),
);
}
crate::auth::ProviderRefreshOutcome::Unusable => {}
}
}
/// 401 arm for a provider-backed model: re-run the helper once and
/// resubmit. A missing key means the cold mint failed and the request
/// went out unauthenticated, so mint instead. Returns `false` when the
/// fresh-mint guard blocked the re-run or the helper failed; the 401
/// then surfaces as a terminal error.
async fn try_provider_401_recovery(&self, provider: &crate::auth::AuthProviderRef) -> bool {
let rejected_key = self.chat_state_handle.get_credentials().await.api_key;
let recovered = match rejected_key {
Some(ref rejected_key) => provider.recover_rejected_token(rejected_key).await,
None => provider.ensure_fresh_token(None).await.rotated(),
};
let Some(new_key) = recovered else {
tracing::warn!(
session_id = % self.session_info.id.0, provider = % provider.name,
"auth recovery: sampler 401, provider re-mint declined or failed"
);
xai_grok_telemetry::unified_log::warn(
"auth recovery: sampler 401, provider re-mint declined or failed",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!({ "provider" : provider.name })),
);
return false;
};
tracing::info!(
session_id = % self.session_info.id.0, provider = % provider.name,
"auth recovery: sampler 401, auth provider re-mint, retrying"
);
xai_grok_telemetry::unified_log::info(
"auth recovery: sampler 401, auth provider re-mint, retrying",
Some(self.session_info.id.0.as_ref()),
None,
);
self.set_chat_api_key(new_key).await;
true
}
/// Gate inputs for `model_id` routed to `base_url`. See
/// [`crate::agent::auth_method::session_token_auth_gate`] for the rationale
@ -642,17 +737,23 @@ impl SessionActor {
.data(detailed_message);
return Err(acp_err);
}
let (failed_model_id, failed_base_url) = self
.chat_state_handle
.get_sampling_config()
.await
.map(|c| (c.model, c.base_url))
.unwrap_or_default();
let auth_provider =
if matches!(error.kind, SamplingErrorKind::Auth) || error.status_code == Some(401) {
self.model_auth_provider(&failed_model_id)
} else {
None
};
let auth_recovery_eligible = matches!(error.kind, SamplingErrorKind::Auth) && {
let (model_id, base_url) = self
.chat_state_handle
.get_sampling_config()
.await
.map(|c| (c.model, c.base_url))
.unwrap_or_default();
let gate = self.auth_gate(&model_id, &base_url);
let gate = self.auth_gate(&failed_model_id, &failed_base_url);
let eligible = gate.active();
self.log_auth_gate_unknown("handle_sampling_failure", gate, &base_url);
if !eligible {
self.log_auth_gate_unknown("handle_sampling_failure", gate, &failed_base_url);
if !eligible && auth_provider.is_none() {
tracing::warn!(
session_id = % self.session_info.id.0, is_session_based = gate
.is_session_based, model_byok = gate.model_byok.as_str(),
@ -672,7 +773,14 @@ impl SessionActor {
}
eligible
};
if !matches!(error.kind, SamplingErrorKind::Auth) && error.status_code == Some(401) {
debug_assert!(
!(auth_recovery_eligible && auth_provider.is_some()),
"a provider-backed model must not be session-recovery-eligible"
);
if !matches!(error.kind, SamplingErrorKind::Auth)
&& error.status_code == Some(401)
&& auth_provider.is_none()
{
xai_grok_telemetry::unified_log::warn(
"auth recovery: sampler 401 not eligible (non-auth error kind)",
Some(self.session_info.id.0.as_ref()),
@ -735,6 +843,12 @@ impl SessionActor {
None,
);
}
if let Some(ref provider) = auth_provider
&& self.try_provider_401_recovery(provider).await
{
self.prepare_sampler_for_turn().await;
return Ok(SamplerFailureRecovery::RefreshAuthAndResubmit);
}
if matches!(error.kind, SamplingErrorKind::IdleTimeout) {
self.signals_handle().record_idle_timeout();
}
@ -807,6 +921,14 @@ impl SessionActor {
let mut msg = format!("{detailed_message}\n");
msg.push_str(&format!("\n Model: {current_model}"));
msg.push_str(&format!("\n Auth: {auth_mode_str}"));
if let Some(ref provider) = auth_provider {
msg.push_str(
&format!(
"\n Provider: [auth_provider.{}] (check the provider command and the debug log)",
provider.name
),
);
}
msg.push_str(&format!("\n Version: {client_version}"));
if available.is_empty() {
msg.push_str("\n Available: (none)");
@ -975,6 +1097,15 @@ impl SessionActor {
.await
.map(|c| c.model)
.unwrap_or_default();
if let Some(provider) = self.model_auth_provider(&current_model_id) {
self.refresh_provider_token_pre_turn(
&provider,
current_key.as_deref(),
&current_model_id,
)
.await;
return;
}
let Some(ref key) = current_key else { return };
if !is_jwt_expired_or_near(key, REFRESH_THRESHOLD) {
if let Some(exp) = parse_jwt_expiration(key) {

View file

@ -1151,7 +1151,7 @@ pub(crate) async fn spawn_session_actor(
let session = Arc::new_cyclic(|weak: &std::sync::Weak<SessionActor>| SessionActor {
session_info: session_info.clone(),
auth_method_id,
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback,
auth_manager,
state,

View file

@ -487,7 +487,8 @@ mod stop_gate_snapshot_tests {
]);
assert!(
matches!(&results[0], HookRunResult::Success { hook_name, .. } if hook_name == "gate"),
matches!(&results[0], HookRunResult::Success { hook_name, .. }
if hook_name == "gate"),
"a discarded decision must read as success, got {:?}",
results[0]
);

View file

@ -19,8 +19,9 @@ pub(crate) enum SamplerFailureRecovery {
/// Compaction ran. The turn loop should rebuild the request from
/// the compacted conversation and resubmit.
CompactAndResubmit,
/// Auth 401 recovery succeeded (devbox re-mint or OIDC refresh).
/// The turn loop should resubmit once with the fresh token.
/// Auth 401 recovery succeeded (devbox re-mint, OIDC refresh, or auth
/// provider re-mint). The turn loop should resubmit once with the
/// fresh token.
RefreshAuthAndResubmit,
}

View file

@ -243,11 +243,9 @@ async fn sampler_401_with_api_key_auth_skips_refresh_and_surfaces_error() {
.await;
}
/// Per-turn pre-flight refresh dispatches on `AuthManager`'s
/// `TokenType`, not `creds.auth_type`. Pins that a stale
/// When `creds.auth_type` is `ApiKey` (BYOK model), the pre-flight
/// refresh must NOT fire — the model's own API key must not be
/// overwritten by the session JWT.
/// Per-turn pre-flight refresh must not fire when `creds.auth_type` is
/// `ApiKey` (a BYOK model): the model's own API key must not be overwritten
/// by the session JWT.
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial(attribution_emit_count)]
async fn pre_flight_refresh_skips_api_key_auth_type() {
@ -659,12 +657,8 @@ async fn no_legacy_hint_for_oidc_auth() {
.await;
}
// Regression: a live OIDC session whose `creds.auth_type` has
// transiently collapsed to `ApiKey` (session-token cache miss + `XAI_API_KEY`)
// must still drive the live bearer resolver, be eligible for 401 retry, and get
// its stale `api_key` healed — the gate keys off the stable `auth_method_id`,
// not the collapsible `auth_type`.
// Regression group: a live session whose `auth_type` transiently reads `ApiKey`
// must still recover, because the gate keys off the stable `auth_method_id`.
#[test]
fn session_token_auth_gate_truth_table() {
use crate::agent::auth_method::{ModelByok, session_token_auth_gate as gate};
@ -904,13 +898,13 @@ async fn session_born_on_api_key_recovers_after_oidc_login_without_restart() {
.await;
}
// Per-model BYOK memo (`SessionActor::model_auth_facts`): a definite cached
// Per-model BYOK memo (`SessionActor::model_auth_memo`): a definite cached
// status is served without recomputing, and the memo keys on `model_id`.
/// The cache-hit branch is what lets a later config parse failure (`Unknown`)
/// fall back to the last-known-good status.
#[tokio::test(flavor = "current_thread")]
async fn model_auth_facts_memo_serves_cached_status_and_keys_on_model() {
async fn model_auth_memo_serves_cached_status_and_keys_on_model() {
use crate::agent::auth_method::ModelByok;
use crate::agent::config::ModelAuthFacts;
let local = tokio::task::LocalSet::new();
@ -924,13 +918,16 @@ async fn model_auth_facts_memo_serves_cached_status_and_keys_on_model() {
)
.await;
actor.model_auth_facts.replace(Some((
"model-a".to_string(),
ModelAuthFacts {
byok: ModelByok::Byok,
auth_scheme: Default::default(),
},
)));
actor
.model_auth_memo
.replace(Some(crate::session::acp_session::ModelAuthMemo {
model_id: "model-a".to_string(),
facts: ModelAuthFacts {
byok: ModelByok::Byok,
auth_scheme: Default::default(),
},
provider: None,
}));
// Cache hit: served without consulting config.
assert_eq!(actor.model_auth_facts("model-a").byok, ModelByok::Byok);
@ -965,13 +962,16 @@ async fn reconstruct_full_config_no_bearer_resolver_for_byok_model_on_session_me
.await
.map(|c| c.model)
.unwrap_or_default();
actor.model_auth_facts.replace(Some((
model,
ModelAuthFacts {
byok: ModelByok::Byok,
auth_scheme: Default::default(),
},
)));
actor
.model_auth_memo
.replace(Some(crate::session::acp_session::ModelAuthMemo {
model_id: model,
facts: ModelAuthFacts {
byok: ModelByok::Byok,
auth_scheme: Default::default(),
},
provider: None,
}));
let cfg = actor.reconstruct_full_config().await;
@ -1010,13 +1010,16 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
.map(|c| c.model)
.unwrap_or_default();
actor.model_auth_facts.replace(Some((
model.clone(),
ModelAuthFacts {
byok: ModelByok::NotByok,
auth_scheme: Default::default(),
},
)));
actor
.model_auth_memo
.replace(Some(crate::session::acp_session::ModelAuthMemo {
model_id: model.clone(),
facts: ModelAuthFacts {
byok: ModelByok::NotByok,
auth_scheme: Default::default(),
},
provider: None,
}));
// Switch to the same model_id, now a per-model BYOK model on a
// third-party endpoint.
@ -1054,10 +1057,330 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
.await;
assert!(
actor.model_auth_facts.borrow().is_none(),
actor.model_auth_memo.borrow().is_none(),
"a model switch must invalidate the per-model BYOK memo so the next \
reconstruct recomputes under the current config"
);
})
.await;
}
use crate::auth::test_counting_provider as counting_provider;
/// Seed the per-model memo so `model_auth_provider` resolves without a
/// config load.
async fn seed_provider_memo(actor: &Arc<SessionActor>, provider: crate::auth::AuthProviderRef) {
let model = actor
.chat_state_handle
.get_sampling_config()
.await
.map(|c| c.model)
.unwrap_or_default();
actor
.model_auth_memo
.replace(Some(crate::session::acp_session::ModelAuthMemo {
model_id: model,
facts: crate::agent::config::ModelAuthFacts {
byok: crate::agent::auth_method::ModelByok::Byok,
auth_scheme: Default::default(),
},
provider: Some(provider),
}));
}
/// Regression: switching from a provider-backed model to a first-party model
/// must drop the minted provider token from the chat credentials, so it can
/// never ride a later request to `api.x.ai`. Mirrors the forward direction in
/// `set_session_model_invalidates_byok_memo_for_same_model_id`.
#[tokio::test(flavor = "current_thread")]
async fn switch_to_first_party_model_drops_minted_provider_token() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("hall-pass", dir.path());
let token = provider.ensure_fresh_token(None).await.rotated().unwrap();
assert_eq!(token, "tok-1");
let (actor, _rx) =
make_actor_with_auth_and_credentials(None, xai_chat_state::AuthType::ApiKey, token)
.await;
seed_provider_memo(&actor, provider).await;
let model = actor
.chat_state_handle
.get_sampling_config()
.await
.map(|c| c.model)
.unwrap_or_default();
let cfg = xai_grok_sampler::SamplerConfig {
api_key: Some("session-jwt".to_string()),
base_url: "https://api.x.ai/v1".to_string(),
model,
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: crate::sampling::ApiBackend::ChatCompletions,
auth_scheme: Default::default(),
extra_headers: Default::default(),
context_window: 256_000,
client_version: None,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
client_identifier: None,
reasoning_effort: None,
deployment_id: None,
user_id: None,
origin_client: None,
attribution_callback: None,
bearer_resolver: None,
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
doom_loop_recovery: None,
header_injector: None,
};
let _ = actor
.handle_set_session_model(cfg, false, false, true, 85)
.await;
let creds = actor.chat_state_handle.get_credentials().await;
assert_eq!(
creds.api_key.as_deref(),
Some("session-jwt"),
"switching to a first-party model must install the session credential, \
not the minted provider token"
);
})
.await;
}
/// Arm 4c: a 401 on a provider-backed model re-mints once and resubmits.
#[tokio::test(flavor = "current_thread")]
async fn sampler_401_on_provider_model_remints_and_resubmits() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-4c-recover", dir.path());
let token = provider.ensure_fresh_token(None).await.rotated().unwrap();
assert_eq!(token, "tok-1");
let (actor, _rx) =
make_actor_with_auth_and_credentials(None, xai_chat_state::AuthType::ApiKey, token)
.await;
seed_provider_memo(&actor, provider).await;
crate::auth::test_backdate_provider_mint(
"test-4c-recover",
std::time::Duration::from_secs(60),
);
let result = actor.handle_sampling_failure(auth_error()).await;
assert!(
matches!(result, Ok(SamplerFailureRecovery::RefreshAuthAndResubmit)),
"provider 401 must re-mint and resubmit"
);
let creds = actor.chat_state_handle.get_credentials().await;
assert_eq!(
creds.api_key.as_deref(),
Some("tok-2"),
"chat-state credentials must carry the re-minted token"
);
})
.await;
}
/// Arm 4c also fires for a bare 401 that did not classify as `Auth`-kind.
#[tokio::test(flavor = "current_thread")]
async fn sampler_non_auth_kind_401_on_provider_model_still_recovers() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-4c-non-auth-kind", dir.path());
let token = provider.ensure_fresh_token(None).await.rotated().unwrap();
let (actor, _rx) =
make_actor_with_auth_and_credentials(None, xai_chat_state::AuthType::ApiKey, token)
.await;
seed_provider_memo(&actor, provider).await;
crate::auth::test_backdate_provider_mint(
"test-4c-non-auth-kind",
std::time::Duration::from_secs(60),
);
let mut error = auth_error();
error.kind = xai_grok_sampler::SamplingErrorKind::Api;
let result = actor.handle_sampling_failure(error).await;
assert!(
matches!(result, Ok(SamplerFailureRecovery::RefreshAuthAndResubmit)),
"a non-Auth-kind 401 on a provider model must still recover via 4c"
);
let creds = actor.chat_state_handle.get_credentials().await;
assert_eq!(creds.api_key.as_deref(), Some("tok-2"));
})
.await;
}
/// A 401 on a request that went out with no key mints instead of
/// recovering.
#[tokio::test(flavor = "current_thread")]
async fn sampler_401_with_no_key_on_provider_model_mints_and_resubmits() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-4c-no-key", dir.path());
let (actor, _rx) = make_actor_with_auth_and_credentials(
None,
xai_chat_state::AuthType::ApiKey,
"placeholder".to_string(),
)
.await;
let mut creds = actor.chat_state_handle.get_credentials().await;
creds.api_key = None;
actor.chat_state_handle.update_credentials(creds);
seed_provider_memo(&actor, provider).await;
let result = actor.handle_sampling_failure(auth_error()).await;
assert!(
matches!(result, Ok(SamplerFailureRecovery::RefreshAuthAndResubmit)),
"an unauthenticated 401 on a provider model must mint and resubmit"
);
let creds = actor.chat_state_handle.get_credentials().await;
assert_eq!(creds.api_key.as_deref(), Some("tok-1"));
})
.await;
}
/// A provider model's 401 goes through the provider, never the session
/// refresher (4a/4b vs 4c exclusivity). The actor uses a session-based method,
/// so the gate would be active for a non-BYOK model; the BYOK memo is what
/// shadows it, which is the invariant under test.
#[tokio::test(flavor = "current_thread")]
async fn sampler_401_on_provider_model_never_refreshes_session() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-4c-exclusive", dir.path());
let token = provider.ensure_fresh_token(None).await.rotated().unwrap();
let called = Arc::new(AtomicBool::new(false));
let refresher: Arc<dyn crate::auth::refresh::TokenRefresher> =
Arc::new(AlwaysSucceedRefresher {
called: called.clone(),
});
let (_dir, am) = auth_manager_with_refresher(refresher);
let (actor, _rx) = make_actor_with_method_and_credentials(
Some(am),
"cached_token",
xai_chat_state::AuthType::SessionToken,
token,
)
.await;
seed_provider_memo(&actor, provider).await;
crate::auth::test_backdate_provider_mint(
"test-4c-exclusive",
std::time::Duration::from_secs(60),
);
let result = actor.handle_sampling_failure(auth_error()).await;
assert!(
matches!(result, Ok(SamplerFailureRecovery::RefreshAuthAndResubmit)),
"the provider arm must recover"
);
assert!(
!called.load(Ordering::SeqCst),
"session refresh must never fire for a provider-backed model"
);
let creds = actor.chat_state_handle.get_credentials().await;
assert_eq!(creds.api_key.as_deref(), Some("tok-2"));
})
.await;
}
/// The pre-turn mirror of the exclusivity test: a cold cache mints the
/// provider token into chat-state, and the session refresher never fires. The
/// actor uses a session-based method, so the gate would be active for a
/// non-BYOK model; the BYOK memo is what keeps the refresher silent.
#[tokio::test(flavor = "current_thread")]
async fn pre_turn_on_provider_model_never_installs_session_token() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-preturn-exclusive", dir.path());
let called = Arc::new(AtomicBool::new(false));
let refresher: Arc<dyn crate::auth::refresh::TokenRefresher> =
Arc::new(AlwaysSucceedRefresher {
called: called.clone(),
});
let (_dir, am) = auth_manager_with_refresher(refresher);
let (actor, _rx) = make_actor_with_method_and_credentials(
Some(am),
"cached_token",
xai_chat_state::AuthType::SessionToken,
"placeholder".to_string(),
)
.await;
// Cold cache: no key on the wire yet.
let mut creds = actor.chat_state_handle.get_credentials().await;
creds.api_key = None;
actor.chat_state_handle.update_credentials(creds);
seed_provider_memo(&actor, provider).await;
actor.refresh_token_if_expired().await;
let creds = actor.chat_state_handle.get_credentials().await;
assert_eq!(
creds.api_key.as_deref(),
Some("tok-1"),
"the cold pre-turn hook must mint the provider token"
);
assert!(
!called.load(Ordering::SeqCst),
"the session refresher must never fire for a provider-backed model"
);
})
.await;
}
/// A token rejected moments after mint surfaces the 401 (fresh-mint
/// guard).
#[tokio::test(flavor = "current_thread")]
async fn sampler_401_on_fresh_provider_token_surfaces_error() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dir = tempfile::tempdir().unwrap();
let provider = counting_provider("test-4c-guard", dir.path());
let token = provider.ensure_fresh_token(None).await.rotated().unwrap();
let (actor, _rx) = make_actor_with_auth_and_credentials(
None,
xai_chat_state::AuthType::ApiKey,
token.clone(),
)
.await;
seed_provider_memo(&actor, provider).await;
let result = actor.handle_sampling_failure(auth_error()).await;
assert!(
result.is_err(),
"a fresh-minted rejected token must surface the 401, not loop"
);
let creds = actor.chat_state_handle.get_credentials().await;
assert_eq!(
creds.api_key.as_deref(),
Some(token.as_str()),
"credentials must be unchanged when the guard blocks the re-mint"
);
})
.await;
}

View file

@ -109,7 +109,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
let actor = Arc::new(SessionActor {
session_info,
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state: TokioMutex::new(State {
@ -561,7 +561,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
let actor = Arc::new(SessionActor {
session_info: session_info.clone(),
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state: TokioMutex::new(State {
@ -833,7 +833,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
cwd: cwd.as_str().to_string(),
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@ -2065,7 +2065,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
cwd: cwd.as_str().to_string(),
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@ -2325,10 +2325,11 @@ async fn skill_reminder_deferred_while_turn_running_flushed_when_idle() {
.iter()
.filter(|item| {
matches!(
item, ConversationItem::User(u) if u.content.iter().any(| p |
matches!(p, xai_grok_sampling_types::ContentPart::Text { text } if
text.contains("pdf-tools")))
)
item, ConversationItem::User(u) if u.content.iter().any(| p |
matches!(p, xai_grok_sampling_types::ContentPart::Text { text }
if
text.contains("pdf-tools")))
)
})
.count()
}

View file

@ -2714,6 +2714,7 @@ fn catalog_with(
info,
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
},
);

View file

@ -127,7 +127,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
},
attribution_callback: None,
auth_method_id: test_auth_method_id("cached_token"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
auth_manager: {
let dir = tempfile::tempdir().unwrap();
let mgr = std::sync::Arc::new(crate::auth::AuthManager::new(

View file

@ -70,7 +70,7 @@ async fn create_test_actor(
},
rebuild_spec: crate::session::agent_rebuild::test_rebuild_spec_default(),
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@ -503,7 +503,7 @@ async fn create_test_actor_with_memory(
},
rebuild_spec: crate::session::agent_rebuild::test_rebuild_spec_default(),
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@ -1255,7 +1255,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
},
rebuild_spec: crate::session::agent_rebuild::test_rebuild_spec_default(),
auth_method_id: test_auth_method_id("cached_token"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
auth_manager: {
let dir = tempfile::tempdir().unwrap();
let mgr = std::sync::Arc::new(crate::auth::AuthManager::new(

View file

@ -38,6 +38,7 @@ fn detector_entry(
info,
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
}
}

View file

@ -123,7 +123,7 @@ async fn create_test_actor_with_memory(
cwd: cwd.as_str().to_string(),
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,

View file

@ -180,6 +180,7 @@ async fn build_session_info_sources_show_model_fingerprint_from_catalog() {
info: ModelInfo::fallback("test"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
entry.info.show_model_fingerprint = false;

View file

@ -77,7 +77,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
cwd: cwd.as_str().to_string(),
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,

View file

@ -202,7 +202,7 @@ pub(crate) async fn create_test_actor_ex(
cwd: cwd.as_str().to_string(),
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,

View file

@ -2202,7 +2202,7 @@ mod inline_auto_compact_flow_tests {
cwd: cwd.as_str().to_string(),
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
model_auth_memo: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,

Some files were not shown because too many files have changed in this diff Show more