M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
//! Tracks open TUI sessions in `~/.kigi/active_sessions.json` for crash
|
||||
//! recovery. Clean exit removes the entry; crash leaves it behind. On next
|
||||
//! launch, [`collect_crashed`] finds orphaned entries (dead PIDs).
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use chrono::{DateTime, Utc};
|
||||
use fs2::FileExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ActiveSession {
|
||||
pub session_id: acp::SessionId,
|
||||
pub pid: u32,
|
||||
pub cwd: String,
|
||||
pub opened_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
const DATA_FILENAME: &str = "active_sessions.json";
|
||||
const LOCK_FILENAME: &str = "active_sessions.lock";
|
||||
const TMP_FILENAME: &str = "active_sessions.json.tmp";
|
||||
|
||||
// -- Public API (delegates to `_in` variants with default grok home) --------
|
||||
|
||||
/// Register a session as active (idempotent by session_id).
|
||||
pub fn register(session: ActiveSession) -> io::Result<()> {
|
||||
register_in(&crate::util::kigi_home::kigi_home(), session)
|
||||
}
|
||||
|
||||
/// Unregister a session (clean exit). No-op if not found.
|
||||
pub fn unregister(session_id: &acp::SessionId) -> io::Result<()> {
|
||||
unregister_in(&crate::util::kigi_home::kigi_home(), session_id)
|
||||
}
|
||||
|
||||
/// Non-blocking unregister for signal handlers. Returns `Ok(false)` on
|
||||
/// lock contention; the orphan is cleaned up by `collect_crashed` next launch.
|
||||
pub fn try_unregister(session_id: &acp::SessionId) -> io::Result<bool> {
|
||||
try_unregister_in(&crate::util::kigi_home::kigi_home(), session_id)
|
||||
}
|
||||
|
||||
/// Remove entries with dead PIDs and return them.
|
||||
pub fn collect_crashed() -> io::Result<Vec<ActiveSession>> {
|
||||
collect_crashed_in(&crate::util::kigi_home::kigi_home())
|
||||
}
|
||||
|
||||
// -- Injectable-root variants (`_in`) for testing ---------------------------
|
||||
|
||||
pub fn register_in(root: &Path, session: ActiveSession) -> io::Result<()> {
|
||||
with_locked_state(root, |sessions| {
|
||||
sessions.retain(|s| s.session_id != session.session_id);
|
||||
sessions.push(session);
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unregister_in(root: &Path, session_id: &acp::SessionId) -> io::Result<()> {
|
||||
with_locked_state(root, |sessions| {
|
||||
sessions.retain(|s| s.session_id != *session_id);
|
||||
})
|
||||
}
|
||||
|
||||
pub fn try_unregister_in(root: &Path, session_id: &acp::SessionId) -> io::Result<bool> {
|
||||
try_with_locked_state(root, |sessions| {
|
||||
sessions.retain(|s| s.session_id != *session_id);
|
||||
})
|
||||
.map(|opt| opt.is_some())
|
||||
}
|
||||
|
||||
pub fn collect_crashed_in(root: &Path) -> io::Result<Vec<ActiveSession>> {
|
||||
with_locked_state(root, |sessions| {
|
||||
let (alive, dead): (Vec<_>, Vec<_>) = sessions.drain(..).partition(|s| is_pid_alive(s.pid));
|
||||
*sessions = alive;
|
||||
dead
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_in(root: &Path) -> io::Result<Vec<ActiveSession>> {
|
||||
let data_path = root.join(DATA_FILENAME);
|
||||
read_data_file(&data_path)
|
||||
}
|
||||
|
||||
// -- Internal: locked read-modify-write -------------------------------------
|
||||
|
||||
fn with_locked_state<F, R>(root: &Path, mutate: F) -> io::Result<R>
|
||||
where
|
||||
F: FnOnce(&mut Vec<ActiveSession>) -> R,
|
||||
{
|
||||
let lock_path = root.join(LOCK_FILENAME);
|
||||
let data_path = root.join(DATA_FILENAME);
|
||||
let tmp_path = root.join(TMP_FILENAME);
|
||||
|
||||
fs::create_dir_all(root)?;
|
||||
let lock_file = open_lock_file(&lock_path)?;
|
||||
lock_file.lock_exclusive()?;
|
||||
|
||||
let result = locked_mutate(&data_path, &tmp_path, mutate);
|
||||
|
||||
let _ = lock_file.unlock();
|
||||
result
|
||||
}
|
||||
|
||||
/// Non-blocking variant for signal handlers.
|
||||
fn try_with_locked_state<F, R>(root: &Path, mutate: F) -> io::Result<Option<R>>
|
||||
where
|
||||
F: FnOnce(&mut Vec<ActiveSession>) -> R,
|
||||
{
|
||||
let lock_path = root.join(LOCK_FILENAME);
|
||||
let data_path = root.join(DATA_FILENAME);
|
||||
let tmp_path = root.join(TMP_FILENAME);
|
||||
|
||||
fs::create_dir_all(root)?;
|
||||
let lock_file = open_lock_file(&lock_path)?;
|
||||
|
||||
match lock_file.try_lock_exclusive() {
|
||||
Ok(()) => {
|
||||
let result = locked_mutate(&data_path, &tmp_path, mutate);
|
||||
let _ = lock_file.unlock();
|
||||
result.map(Some)
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn locked_mutate<F, R>(data_path: &Path, tmp_path: &Path, mutate: F) -> io::Result<R>
|
||||
where
|
||||
F: FnOnce(&mut Vec<ActiveSession>) -> R,
|
||||
{
|
||||
let mut sessions = read_data_file(data_path)?;
|
||||
let result = mutate(&mut sessions);
|
||||
write_data_file_atomic(tmp_path, data_path, &sessions)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn open_lock_file(path: &Path) -> io::Result<File> {
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
}
|
||||
|
||||
fn read_data_file(path: &Path) -> io::Result<Vec<ActiveSession>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) if bytes.is_empty() => Ok(Vec::new()),
|
||||
Ok(bytes) => match serde_json::from_slice::<Vec<ActiveSession>>(&bytes) {
|
||||
Ok(sessions) => Ok(sessions),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"active_sessions.json is corrupted, starting with empty list"
|
||||
);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_data_file_atomic(
|
||||
tmp_path: &Path,
|
||||
data_path: &Path,
|
||||
sessions: &[ActiveSession],
|
||||
) -> io::Result<()> {
|
||||
let json = serde_json::to_string_pretty(sessions)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
fs::write(tmp_path, json.as_bytes())?;
|
||||
fs::rename(tmp_path, data_path).inspect_err(|_| {
|
||||
let _ = fs::remove_file(tmp_path);
|
||||
})
|
||||
}
|
||||
|
||||
fn is_pid_alive(pid: u32) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid_i = match i32::try_from(pid) {
|
||||
Ok(p) if p > 0 => p,
|
||||
_ => return false,
|
||||
};
|
||||
let ret = unsafe { libc::kill(pid_i as libc::pid_t, 0) };
|
||||
if ret == 0 {
|
||||
return true;
|
||||
}
|
||||
io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) };
|
||||
match handle {
|
||||
Ok(h) => {
|
||||
let _ = unsafe { CloseHandle(h) };
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
// Conservative: assume alive if we can't check.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_session(id: &str, pid: u32) -> ActiveSession {
|
||||
ActiveSession {
|
||||
session_id: acp::SessionId::new(id),
|
||||
pid,
|
||||
cwd: "/tmp/test".into(),
|
||||
opened_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let s = make_session("s1", std::process::id());
|
||||
register_in(dir.path(), s.clone()).unwrap();
|
||||
register_in(dir.path(), s).unwrap();
|
||||
assert_eq!(list_in(dir.path()).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_crashed_partitions_by_pid_liveness() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
register_in(dir.path(), make_session("alive", std::process::id())).unwrap();
|
||||
register_in(dir.path(), make_session("dead", 2_000_000_000)).unwrap();
|
||||
|
||||
let crashed = collect_crashed_in(dir.path()).unwrap();
|
||||
assert_eq!(crashed.len(), 1);
|
||||
assert_eq!(&*crashed[0].session_id.0, "dead");
|
||||
assert_eq!(&*list_in(dir.path()).unwrap()[0].session_id.0, "alive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_registers_no_corruption() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().to_path_buf();
|
||||
std::thread::scope(|s| {
|
||||
for i in 0..10 {
|
||||
let p = path.clone();
|
||||
s.spawn(move || {
|
||||
register_in(&p, make_session(&format!("s{i}"), std::process::id())).unwrap()
|
||||
});
|
||||
}
|
||||
});
|
||||
assert_eq!(list_in(dir.path()).unwrap().len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_unregister_skips_if_locked() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let s = make_session("s1", std::process::id());
|
||||
register_in(dir.path(), s.clone()).unwrap();
|
||||
|
||||
let lock_file = open_lock_file(&dir.path().join(LOCK_FILENAME)).unwrap();
|
||||
lock_file.lock_exclusive().unwrap();
|
||||
assert!(!try_unregister_in(dir.path(), &s.session_id).unwrap());
|
||||
lock_file.unlock().unwrap();
|
||||
assert_eq!(list_in(dir.path()).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_recovers() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join(DATA_FILENAME), "garbage{{{").unwrap();
|
||||
assert!(list_in(dir.path()).unwrap().is_empty());
|
||||
register_in(dir.path(), make_session("s1", std::process::id())).unwrap();
|
||||
assert_eq!(list_in(dir.path()).unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
//! Send-safe view of the agent's in-flight work, shared with the leader's
|
||||
//! auto-update checker and `RelaunchForUpdate` drain (`tokio::spawn` tasks
|
||||
//! that cannot read the `!Send` `MvpAgent` state on the `LocalSet`).
|
||||
//!
|
||||
//! The leader's `agent_busy` flag only counts IPC (Unix-socket) requests;
|
||||
//! relay (grok.com WebSocket) traffic is bridged straight into the agent's
|
||||
//! ACP stdin and never sets it, so a relay-driven leader (devbox / remote)
|
||||
//! always looked idle and got restarted mid-turn on every update —
|
||||
//! surfacing as "Subagent result channel dropped".
|
||||
//!
|
||||
//! [`AgentActivity::is_busy`] derives busyness from agent state regardless
|
||||
//! of transport, and [`AgentActivity::flush_all_sessions`] lets the shutdown
|
||||
//! path end session actors gracefully instead of aborting them via
|
||||
//! `LocalSet` drop.
|
||||
//!
|
||||
//! ## Lifecycle: entries expire with their actor, not with agent bookkeeping
|
||||
//!
|
||||
//! The agent only ever **registers** sessions (at handle creation). There is
|
||||
//! deliberately no unregister: an entry is live exactly while its actor
|
||||
//! holds the command receiver (`!cmd_tx.is_closed()`), and closed entries
|
||||
//! are purged opportunistically. This sidesteps a whole class of races
|
||||
//! between `MvpAgent`'s map bookkeeping and actor lifetime — an actor
|
||||
//! removed from the agent's map but still winding down stays visible to
|
||||
//! `is_busy`/`flush_all_sessions` until it actually exits, and a session id
|
||||
//! rebuilt with a fresh actor is just a second (distinct) entry.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::session::pending_interaction::PendingInteractions;
|
||||
use crate::session::{SessionCommand, SessionHandle};
|
||||
|
||||
/// How often [`AgentActivity::flush_all_sessions`] re-polls actors that have
|
||||
/// not yet exited.
|
||||
const FLUSH_POLL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Per-session slice of state shared with the session actor (the same `Arc`s
|
||||
/// the actor mutates — see the matching `SessionHandle` fields).
|
||||
struct SessionActivityEntry {
|
||||
id: String,
|
||||
cmd_tx: tokio::sync::mpsc::UnboundedSender<SessionCommand>,
|
||||
/// `Some` while a turn is running (relay- or IPC-driven alike).
|
||||
current_prompt_id: Arc<Mutex<Option<String>>>,
|
||||
/// Non-empty while a blocking reverse-request (permission / question /
|
||||
/// plan approval) is parked.
|
||||
pending_interactions: PendingInteractions,
|
||||
}
|
||||
|
||||
impl SessionActivityEntry {
|
||||
/// The actor still holds the command receiver.
|
||||
fn is_live(&self) -> bool {
|
||||
!self.cmd_tx.is_closed()
|
||||
}
|
||||
|
||||
/// A running turn or a parked blocking interaction.
|
||||
fn is_busy(&self) -> bool {
|
||||
self.current_prompt_id
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.is_some()
|
||||
|| !self
|
||||
.pending_interactions
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ActivityInner {
|
||||
/// Self-expiring: entries are dead once the actor drops its receiver
|
||||
/// (see module docs), and are purged whenever the list is locked.
|
||||
sessions: Mutex<Vec<SessionActivityEntry>>,
|
||||
/// Subagents currently initializing or running; kept in sync by
|
||||
/// `SubagentCoordinator::sync_running_gauge`.
|
||||
subagents: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
/// Cheap-to-clone, `Send + Sync` handle. See module docs.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AgentActivity {
|
||||
inner: Arc<ActivityInner>,
|
||||
}
|
||||
|
||||
impl AgentActivity {
|
||||
/// Register a session's shared state at handle-creation time. No
|
||||
/// unregister exists — the entry expires when the actor exits.
|
||||
pub(crate) fn register_session(&self, id: &str, handle: &SessionHandle) {
|
||||
self.lock_live_sessions().push(SessionActivityEntry {
|
||||
id: id.to_string(),
|
||||
cmd_tx: handle.cmd_tx.clone(),
|
||||
current_prompt_id: handle.current_prompt_id.clone(),
|
||||
pending_interactions: handle.pending_interactions.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Shared gauge of initializing + running subagents; handed to the
|
||||
/// `SubagentCoordinator`, which recomputes it on every state change.
|
||||
pub(crate) fn subagent_gauge(&self) -> Arc<AtomicUsize> {
|
||||
self.inner.subagents.clone()
|
||||
}
|
||||
|
||||
/// Whether the agent has live work: a running turn, a parked blocking
|
||||
/// interaction, or an initializing/running subagent.
|
||||
///
|
||||
/// Known sub-tick window: queued-but-not-started prompts
|
||||
/// (`pending_inputs` in the actor) are not mirrored here, so a prompt
|
||||
/// submitted exactly at a turn boundary can read as idle (the same
|
||||
/// window `session_has_live_work` closes with an actor round-trip,
|
||||
/// which a sync `Send` probe cannot do). The flush's quiesce loop
|
||||
/// re-snapshots and still ends such an actor via its Shutdown arm.
|
||||
pub fn is_busy(&self) -> bool {
|
||||
self.inner.subagents.load(Ordering::Relaxed) > 0
|
||||
|| self.lock_live_sessions().iter().any(|e| e.is_busy())
|
||||
}
|
||||
|
||||
/// Number of live registered sessions (diagnostics/tests).
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.lock_live_sessions().len()
|
||||
}
|
||||
|
||||
/// Send [`SessionCommand::Shutdown`] to every live session actor
|
||||
/// (replay-buffer flush → hooks → memory save → actor returns) and wait
|
||||
/// up to `grace` for the actors to exit, observed via
|
||||
/// `cmd_tx.is_closed()`.
|
||||
///
|
||||
/// This is a quiesce loop, not a one-shot broadcast: each poll
|
||||
/// re-snapshots the registry and signals actors that appeared after the
|
||||
/// flush started (deduped by channel identity, so a session id rebuilt
|
||||
/// with a fresh actor gets its own signal), all against one deadline —
|
||||
/// `grace` bounds the **total** shutdown delay.
|
||||
///
|
||||
/// Call **before** cancelling the leader's root token so session state
|
||||
/// is durable before the `LocalSet` drop aborts remaining tasks. Actors
|
||||
/// that miss the grace are logged and abandoned.
|
||||
pub async fn flush_all_sessions(&self, grace: Duration) {
|
||||
let deadline = tokio::time::Instant::now() + grace;
|
||||
// Every distinct channel signaled so far (id kept for logging).
|
||||
let mut signaled: Vec<(String, tokio::sync::mpsc::UnboundedSender<SessionCommand>)> =
|
||||
Vec::new();
|
||||
|
||||
loop {
|
||||
let snapshot: Vec<_> = self
|
||||
.lock_live_sessions()
|
||||
.iter()
|
||||
.map(|e| (e.id.clone(), e.cmd_tx.clone()))
|
||||
.collect();
|
||||
for (id, tx) in snapshot {
|
||||
if !signaled.iter().any(|(_, s)| s.same_channel(&tx)) {
|
||||
tracing::info!(session_id = %id, "leader shutdown: flushing session");
|
||||
let _ = tx.send(SessionCommand::Shutdown);
|
||||
signaled.push((id, tx));
|
||||
}
|
||||
}
|
||||
|
||||
if signaled.iter().all(|(_, tx)| tx.is_closed()) {
|
||||
return; // nothing to flush, or all actors exited
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
for (id, tx) in &signaled {
|
||||
if !tx.is_closed() {
|
||||
tracing::warn!(
|
||||
session_id = %id,
|
||||
"leader shutdown: session actor did not exit within grace; proceeding"
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(FLUSH_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock the session list, dropping entries whose actor has exited.
|
||||
///
|
||||
/// Purging happens only here, so in modes with no periodic reader (no
|
||||
/// auto-update checker) a dead entry lingers until the next register —
|
||||
/// bounded and tiny (a sender handle + two `Arc`s per entry).
|
||||
fn lock_live_sessions(&self) -> std::sync::MutexGuard<'_, Vec<SessionActivityEntry>> {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.sessions
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner());
|
||||
guard.retain(SessionActivityEntry::is_live);
|
||||
guard
|
||||
}
|
||||
|
||||
/// Register a synthetic session from raw parts (no full `SessionHandle`).
|
||||
/// Returns the command receiver (the "actor" side) plus the shared
|
||||
/// running-turn and pending-interaction slots.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn register_for_test(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> (
|
||||
tokio::sync::mpsc::UnboundedReceiver<SessionCommand>,
|
||||
Arc<Mutex<Option<String>>>,
|
||||
PendingInteractions,
|
||||
) {
|
||||
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let current_prompt_id = Arc::new(Mutex::new(None));
|
||||
let pending_interactions: PendingInteractions =
|
||||
Arc::new(Mutex::new(std::collections::HashMap::new()));
|
||||
self.lock_live_sessions().push(SessionActivityEntry {
|
||||
id: id.to_string(),
|
||||
cmd_tx,
|
||||
current_prompt_id: current_prompt_id.clone(),
|
||||
pending_interactions: pending_interactions.clone(),
|
||||
});
|
||||
(cmd_rx, current_prompt_id, pending_interactions)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a registered entry from raw parts without a full SessionHandle.
|
||||
fn register_raw(
|
||||
activity: &AgentActivity,
|
||||
id: &str,
|
||||
) -> (
|
||||
tokio::sync::mpsc::UnboundedReceiver<SessionCommand>,
|
||||
Arc<Mutex<Option<String>>>,
|
||||
PendingInteractions,
|
||||
) {
|
||||
activity.register_for_test(id)
|
||||
}
|
||||
|
||||
/// Simulated session actor: exits (dropping its receiver) `delay` after
|
||||
/// receiving `Shutdown`; resolves to whether Shutdown was received.
|
||||
fn spawn_actor(
|
||||
mut rx: tokio::sync::mpsc::UnboundedReceiver<SessionCommand>,
|
||||
delay: Duration,
|
||||
) -> tokio::task::JoinHandle<bool> {
|
||||
tokio::spawn(async move {
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
if matches!(cmd, SessionCommand::Shutdown) {
|
||||
tokio::time::sleep(delay).await;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_by_default() {
|
||||
let activity = AgentActivity::default();
|
||||
assert!(!activity.is_busy());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn running_turn_marks_busy() {
|
||||
let activity = AgentActivity::default();
|
||||
let (_rx, prompt_id, _pending) = register_raw(&activity, "s1");
|
||||
assert!(!activity.is_busy());
|
||||
|
||||
*prompt_id.lock().unwrap() = Some("prompt-1".to_string());
|
||||
assert!(activity.is_busy());
|
||||
|
||||
*prompt_id.lock().unwrap() = None;
|
||||
assert!(!activity.is_busy());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_interaction_marks_busy() {
|
||||
let activity = AgentActivity::default();
|
||||
let (_rx, _prompt_id, pending) = register_raw(&activity, "s1");
|
||||
|
||||
pending.lock().unwrap().insert(
|
||||
"tc-1".to_string(),
|
||||
crate::session::pending_interaction::PendingKind::Permission,
|
||||
);
|
||||
assert!(activity.is_busy());
|
||||
|
||||
pending.lock().unwrap().clear();
|
||||
assert!(!activity.is_busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_gauge_marks_busy() {
|
||||
let activity = AgentActivity::default();
|
||||
let gauge = activity.subagent_gauge();
|
||||
assert!(!activity.is_busy());
|
||||
gauge.store(1, Ordering::Relaxed);
|
||||
assert!(activity.is_busy());
|
||||
gauge.store(0, Ordering::Relaxed);
|
||||
assert!(!activity.is_busy());
|
||||
}
|
||||
|
||||
/// An actor that is still running counts as busy even if the agent has
|
||||
/// dropped its handle — liveness comes from the channel, not from agent
|
||||
/// bookkeeping. Once the actor exits, the entry expires.
|
||||
#[tokio::test]
|
||||
async fn live_actor_counts_busy_until_it_exits() {
|
||||
let activity = AgentActivity::default();
|
||||
let (rx, prompt_id, _pending) = register_raw(&activity, "s1");
|
||||
*prompt_id.lock().unwrap() = Some("prompt-1".to_string());
|
||||
assert!(activity.is_busy());
|
||||
assert_eq!(activity.session_count(), 1);
|
||||
|
||||
// Actor exits (receiver dropped) → entry expires, even though the
|
||||
// shared prompt slot still says Some.
|
||||
drop(rx);
|
||||
assert!(!activity.is_busy());
|
||||
assert_eq!(activity.session_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn flush_sends_shutdown_and_waits_for_actor_exit() {
|
||||
let activity = AgentActivity::default();
|
||||
let (rx, _prompt_id, _pending) = register_raw(&activity, "s1");
|
||||
|
||||
// Simulated actor: exits (drops rx) when it receives Shutdown.
|
||||
let actor = spawn_actor(rx, Duration::ZERO);
|
||||
|
||||
activity.flush_all_sessions(Duration::from_secs(5)).await;
|
||||
assert!(actor.await.unwrap(), "actor should have received Shutdown");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn flush_grace_bounds_total_delay_across_sessions() {
|
||||
let activity = AgentActivity::default();
|
||||
// One wedged actor (receiver kept open) and one healthy actor.
|
||||
let (_wedged_rx, _p1, _i1) = register_raw(&activity, "wedged");
|
||||
let (rx, _p2, _i2) = register_raw(&activity, "healthy");
|
||||
let actor = spawn_actor(rx, Duration::ZERO);
|
||||
|
||||
// The wedged actor must not consume the healthy actor's budget, and
|
||||
// the total wait must be ~one grace period, not one per session.
|
||||
let start = tokio::time::Instant::now();
|
||||
activity.flush_all_sessions(Duration::from_secs(2)).await;
|
||||
let elapsed = start.elapsed();
|
||||
assert!(elapsed >= Duration::from_secs(2));
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(3),
|
||||
"grace must be shared, not serial: {elapsed:?}"
|
||||
);
|
||||
assert!(actor.await.unwrap(), "healthy actor should get Shutdown");
|
||||
}
|
||||
|
||||
/// A session id rebuilt with a fresh actor while the old actor is still
|
||||
/// winding down: both channels must be signaled and awaited.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn flush_awaits_both_channels_when_id_is_reused() {
|
||||
let activity = AgentActivity::default();
|
||||
let (old_rx, _p1, _i1) = register_raw(&activity, "s1");
|
||||
let (new_rx, _p2, _i2) = register_raw(&activity, "s1");
|
||||
|
||||
let old_actor = spawn_actor(old_rx, Duration::from_millis(500));
|
||||
let new_actor = spawn_actor(new_rx, Duration::ZERO);
|
||||
|
||||
activity.flush_all_sessions(Duration::from_secs(5)).await;
|
||||
assert!(old_actor.is_finished(), "flush must wait for the old actor");
|
||||
assert!(old_actor.await.unwrap());
|
||||
assert!(new_actor.await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn flush_signals_sessions_that_appear_mid_flush() {
|
||||
let activity = AgentActivity::default();
|
||||
// Actor 1: holds the flush open for a few polls, then exits.
|
||||
let (rx1, _p1, _i1) = register_raw(&activity, "s1");
|
||||
let actor1 = spawn_actor(rx1, Duration::from_millis(300));
|
||||
|
||||
// Actor 2 registers AFTER the flush has started (a relay-driven
|
||||
// prompt racing the shutdown) — it must still receive Shutdown.
|
||||
let activity_late = activity.clone();
|
||||
let late = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let (mut rx2, _p2, _i2) = activity_late.register_for_test("s2");
|
||||
while let Some(cmd) = rx2.recv().await {
|
||||
if matches!(cmd, SessionCommand::Shutdown) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
|
||||
activity.flush_all_sessions(Duration::from_secs(5)).await;
|
||||
assert!(actor1.await.unwrap());
|
||||
assert!(
|
||||
late.await.unwrap(),
|
||||
"session registered mid-flush must receive Shutdown"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn flush_gives_up_after_grace_when_actor_hangs() {
|
||||
let activity = AgentActivity::default();
|
||||
// Keep rx alive so the channel never closes (wedged actor).
|
||||
let (_rx, _prompt_id, _pending) = register_raw(&activity, "s1");
|
||||
|
||||
let start = tokio::time::Instant::now();
|
||||
activity.flush_all_sessions(Duration::from_secs(2)).await;
|
||||
assert!(
|
||||
start.elapsed() >= Duration::from_secs(2),
|
||||
"flush should wait out the grace period"
|
||||
);
|
||||
// Returned rather than hanging forever — that's the assertion.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flush_with_no_sessions_is_noop() {
|
||||
let activity = AgentActivity::default();
|
||||
activity.flush_all_sessions(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
//! grok.com chat-product model catalog: caches `/rest/modes` and maps modes to
|
||||
//! the `SessionModelState` returned by `load_chat_session` (the chat analogue of
|
||||
//! [`crate::agent::models::ModelsManager`]). NB: these "modes" populate the
|
||||
//! desktop MODEL picker, not the ACP session plan-modes in `LoadSessionResponse.modes`.
|
||||
use crate::auth::AuthManager;
|
||||
use crate::remote::chat_models_client::{
|
||||
ChatModelsClient, ChatModelsError, ListModesResponse, Mode,
|
||||
};
|
||||
use agent_client_protocol as acp;
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
/// ~54 min, matching grok-web's refetch cadence.
|
||||
const CACHE_TTL: Duration = Duration::from_secs(54 * 60);
|
||||
/// Cold-miss budget on the `session/load` critical path (warm/stale served instantly).
|
||||
const COLD_FETCH_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const DEFAULT_LOCALE: &str = "en";
|
||||
/// Process-wide flag set by the pager when started with `--chat` so initialize
|
||||
/// and early UI seed the chat `/rest/modes` catalog instead of build models.
|
||||
pub const KIGI_CHAT_MODE_ENV: &str = "KIGI_CHAT_MODE";
|
||||
/// True when the process is a gateway light-frontend (`--chat`) agent.
|
||||
/// Hard-off in release builds so it can't be enabled via env.
|
||||
pub fn process_chat_mode_enabled() -> bool {
|
||||
if true {
|
||||
return false;
|
||||
}
|
||||
match std::env::var(KIGI_CHAT_MODE_ENV) {
|
||||
Ok(v) => {
|
||||
let v = v.trim();
|
||||
!v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct CachedModes {
|
||||
/// Keyed by identity; a mismatch is a miss so one user's modes never leak to another.
|
||||
user_id: String,
|
||||
locale: String,
|
||||
fetched_at: Instant,
|
||||
response: ListModesResponse,
|
||||
}
|
||||
/// Thread-safe, cheaply-cloneable manager. Cloning bumps the inner `Arc`.
|
||||
#[derive(Clone)]
|
||||
pub struct ChatModesManager {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
struct Inner {
|
||||
auth: Arc<AuthManager>,
|
||||
cache: RwLock<Option<CachedModes>>,
|
||||
/// Single-flight guard so concurrent fetches coalesce.
|
||||
fetch_lock: tokio::sync::Mutex<()>,
|
||||
}
|
||||
impl ChatModesManager {
|
||||
pub fn new(auth: Arc<AuthManager>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
auth,
|
||||
cache: RwLock::new(None),
|
||||
fetch_lock: tokio::sync::Mutex::new(()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// The active grok.com identity, or `None` when unauthenticated. Modes are
|
||||
/// per-identity (tier/ACL), so every cache key and store is gated on it.
|
||||
fn current_user_id(&self) -> Option<String> {
|
||||
self.inner.auth.current_or_expired().map(|a| a.user_id)
|
||||
}
|
||||
/// Chat model state for a `session/load` response. On missing auth or fetch
|
||||
/// failure, serves last-good cache else empty — never the build catalog.
|
||||
pub async fn model_state(&self) -> acp::SessionModelState {
|
||||
let Some(user_id) = self.current_user_id() else {
|
||||
return empty_state();
|
||||
};
|
||||
let locale = DEFAULT_LOCALE;
|
||||
{
|
||||
let guard = self.inner.cache.read();
|
||||
if let Some(c) = guard.as_ref()
|
||||
&& c.user_id == user_id
|
||||
&& c.locale == locale
|
||||
{
|
||||
if c.fetched_at.elapsed() < CACHE_TTL {
|
||||
return modes_to_model_state(&c.response);
|
||||
}
|
||||
let stale = c.response.clone();
|
||||
drop(guard);
|
||||
self.spawn_refresh(user_id, locale);
|
||||
return modes_to_model_state(&stale);
|
||||
}
|
||||
}
|
||||
let _flight = self.inner.fetch_lock.lock().await;
|
||||
{
|
||||
let guard = self.inner.cache.read();
|
||||
if let Some(c) = guard.as_ref()
|
||||
&& c.user_id == user_id
|
||||
&& c.locale == locale
|
||||
&& c.fetched_at.elapsed() < CACHE_TTL
|
||||
{
|
||||
return modes_to_model_state(&c.response);
|
||||
}
|
||||
}
|
||||
match self.fetch(locale).await {
|
||||
Ok(resp) if !resp.modes.is_empty() => {
|
||||
if self.current_user_id().as_deref() != Some(user_id.as_str()) {
|
||||
return empty_state();
|
||||
}
|
||||
let mapped = modes_to_model_state(&resp);
|
||||
if mapped.available_models.is_empty() {
|
||||
tracing::warn!(
|
||||
raw_modes = resp.modes.len(),
|
||||
"chat modes: fetch returned modes but none selectable after availability filter"
|
||||
);
|
||||
}
|
||||
self.store(user_id, locale.to_owned(), resp);
|
||||
mapped
|
||||
}
|
||||
Ok(_) => empty_state(),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = % err, "chat modes fetch failed; serving cache/empty"
|
||||
);
|
||||
let guard = self.inner.cache.read();
|
||||
match guard.as_ref() {
|
||||
Some(c) if c.user_id == user_id => modes_to_model_state(&c.response),
|
||||
_ => empty_state(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn fetch(&self, locale: &str) -> Result<ListModesResponse, ChatModelsError> {
|
||||
let client = ChatModelsClient::new(self.inner.auth.clone());
|
||||
match tokio::time::timeout(COLD_FETCH_TIMEOUT, client.list_modes(locale)).await {
|
||||
Ok(result) => result,
|
||||
Err(_elapsed) => Err(ChatModelsError::Timeout),
|
||||
}
|
||||
}
|
||||
fn store(&self, user_id: String, locale: String, response: ListModesResponse) {
|
||||
*self.inner.cache.write() = Some(CachedModes {
|
||||
user_id,
|
||||
locale,
|
||||
fetched_at: Instant::now(),
|
||||
response,
|
||||
});
|
||||
}
|
||||
/// Best-effort stale refresh; skips if a fetch is already in flight.
|
||||
fn spawn_refresh(&self, user_id: String, locale: &'static str) {
|
||||
let me = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let Ok(_flight) = me.inner.fetch_lock.try_lock() else {
|
||||
return;
|
||||
};
|
||||
if me.current_user_id().as_deref() != Some(user_id.as_str()) {
|
||||
return;
|
||||
}
|
||||
if let Ok(resp) = me.fetch(locale).await
|
||||
&& !resp.modes.is_empty()
|
||||
&& me.current_user_id().as_deref() == Some(user_id.as_str())
|
||||
{
|
||||
me.store(user_id, locale.to_owned(), resp);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Kick a background `/rest/modes` fill when auth is already present so
|
||||
/// `--chat` initialize / first `session/new` hit a warm cache.
|
||||
pub fn warm_in_background(&self) {
|
||||
let Some(user_id) = self.current_user_id() else {
|
||||
return;
|
||||
};
|
||||
self.spawn_refresh(user_id, DEFAULT_LOCALE);
|
||||
}
|
||||
}
|
||||
fn empty_state() -> acp::SessionModelState {
|
||||
acp::SessionModelState::new(acp::ModelId::from(String::new()), Vec::new())
|
||||
}
|
||||
/// Maps grok.com modes → `SessionModelState`: keeps only `available` modes,
|
||||
/// reconciles `current_model_id` (default → first available → empty, never
|
||||
/// out-of-set), and stashes `badgeText`/`iconHint`/`tags` in `_meta`.
|
||||
pub fn modes_to_model_state(resp: &ListModesResponse) -> acp::SessionModelState {
|
||||
let available_models: Vec<acp::ModelInfo> = resp
|
||||
.modes
|
||||
.iter()
|
||||
.filter(|m| m.is_available())
|
||||
.map(mode_to_model_info)
|
||||
.collect();
|
||||
let current_model_id = reconcile_current(&resp.default_mode_id, &available_models);
|
||||
acp::SessionModelState::new(current_model_id, available_models)
|
||||
}
|
||||
fn mode_to_model_info(m: &Mode) -> acp::ModelInfo {
|
||||
let name = if m.title.trim().is_empty() {
|
||||
m.id.clone()
|
||||
} else {
|
||||
m.title.clone()
|
||||
};
|
||||
acp::ModelInfo::new(acp::ModelId::from(m.id.clone()), name)
|
||||
.description(if m.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(m.description.clone())
|
||||
})
|
||||
.meta(build_meta(m))
|
||||
}
|
||||
fn build_meta(m: &Mode) -> Option<acp::Meta> {
|
||||
let mut map = serde_json::Map::new();
|
||||
if let Some(badge) = m.badge_text.as_deref().filter(|s| !s.is_empty()) {
|
||||
map.insert("badgeText".to_owned(), serde_json::json!(badge));
|
||||
}
|
||||
if !m.icon_hint.is_empty() {
|
||||
map.insert("iconHint".to_owned(), serde_json::json!(m.icon_hint));
|
||||
}
|
||||
if !m.tags.is_empty() {
|
||||
map.insert("tags".to_owned(), serde_json::json!(m.tags));
|
||||
}
|
||||
if map.is_empty() { None } else { Some(map) }
|
||||
}
|
||||
fn reconcile_current(default_mode_id: &str, available: &[acp::ModelInfo]) -> acp::ModelId {
|
||||
let in_set = |id: &str| available.iter().any(|m| m.model_id.0.as_ref() == id);
|
||||
if !default_mode_id.is_empty() && in_set(default_mode_id) {
|
||||
acp::ModelId::from(default_mode_id.to_owned())
|
||||
} else if let Some(first) = available.first() {
|
||||
first.model_id.clone()
|
||||
} else {
|
||||
acp::ModelId::from(String::new())
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::remote::chat_models_client::ModeAvailability;
|
||||
fn available(id: &str, title: &str) -> Mode {
|
||||
Mode {
|
||||
id: id.to_owned(),
|
||||
title: title.to_owned(),
|
||||
availability: ModeAvailability {
|
||||
available: Some(serde_json::json!({})),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn requires_upgrade(id: &str) -> Mode {
|
||||
Mode {
|
||||
id: id.to_owned(),
|
||||
availability: ModeAvailability {
|
||||
requires_upgrade: Some(serde_json::json!({ "message" : "Upgrade" })),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn filters_to_available_modes() {
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![
|
||||
available("auto", "Auto"),
|
||||
requires_upgrade("heavy"),
|
||||
available("fast", "Fast"),
|
||||
],
|
||||
default_mode_id: "auto".to_owned(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
let ids: Vec<String> = state
|
||||
.available_models
|
||||
.iter()
|
||||
.map(|m| m.model_id.0.to_string())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["auto".to_string(), "fast".to_string()]);
|
||||
assert_eq!(state.current_model_id.0.as_ref(), "auto");
|
||||
}
|
||||
#[test]
|
||||
fn default_outside_filtered_set_falls_back_to_first_available() {
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![requires_upgrade("heavy"), available("fast", "Fast")],
|
||||
default_mode_id: "heavy".to_owned(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
assert_eq!(state.current_model_id.0.as_ref(), "fast");
|
||||
assert!(
|
||||
state
|
||||
.available_models
|
||||
.iter()
|
||||
.any(|m| m.model_id == state.current_model_id)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn empty_default_falls_back_to_first() {
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![available("a", "A"), available("b", "B")],
|
||||
default_mode_id: String::new(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
assert_eq!(state.current_model_id.0.as_ref(), "a");
|
||||
}
|
||||
#[test]
|
||||
fn no_available_modes_yields_empty_current() {
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![requires_upgrade("heavy")],
|
||||
default_mode_id: "heavy".to_owned(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
assert!(state.available_models.is_empty());
|
||||
assert_eq!(state.current_model_id.0.as_ref(), "");
|
||||
}
|
||||
#[test]
|
||||
fn maps_fields_and_meta() {
|
||||
let mut m = available("auto", "Auto");
|
||||
m.description = "Picks the best model".to_owned();
|
||||
m.badge_text = Some("New".to_owned());
|
||||
m.icon_hint = "rocket".to_owned();
|
||||
m.tags = vec!["TAG_PRIMARY".to_owned()];
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![m],
|
||||
default_mode_id: "auto".to_owned(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
let info = &state.available_models[0];
|
||||
assert_eq!(info.name, "Auto");
|
||||
assert_eq!(info.description.as_deref(), Some("Picks the best model"));
|
||||
let meta = info.meta.as_ref().unwrap();
|
||||
assert_eq!(meta["badgeText"], serde_json::json!("New"));
|
||||
assert_eq!(meta["iconHint"], serde_json::json!("rocket"));
|
||||
assert_eq!(meta["tags"], serde_json::json!(["TAG_PRIMARY"]));
|
||||
}
|
||||
#[test]
|
||||
fn name_falls_back_to_id_when_title_blank() {
|
||||
let mut m = available("grok-4.5", "");
|
||||
m.title = " ".to_owned();
|
||||
let resp = ListModesResponse {
|
||||
modes: vec![m],
|
||||
default_mode_id: String::new(),
|
||||
};
|
||||
let state = modes_to_model_state(&resp);
|
||||
assert_eq!(state.available_models[0].name, "grok-4.5");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,639 @@
|
||||
//! Resilient parsing for `[model.<id>]` TOML overrides.
|
||||
//!
|
||||
//! A model entry must survive a bad field: warn and skip the field, never
|
||||
//! drop the model (managed configs must not lose catalog entries).
|
||||
//!
|
||||
//! Every table is deserialized through `serde_ignored`, so unknown fields
|
||||
//! warn on every path and [`ConfigModelOverride`] stays the single source of
|
||||
//! truth for the field set. When the whole-table parse fails, fields that
|
||||
//! 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
|
||||
//! `grok inspect`.
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::config::ConfigModelOverride;
|
||||
|
||||
/// Category for a [`ModelOverrideWarning`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ModelOverrideWarningKind {
|
||||
/// Field name not recognized; field ignored.
|
||||
UnknownField,
|
||||
/// Value failed to parse; field skipped.
|
||||
InvalidValue,
|
||||
/// Legacy alias given alongside its canonical key; alias skipped.
|
||||
DuplicateAlias,
|
||||
/// Entry value is not a TOML table; entry dropped.
|
||||
NotATable,
|
||||
/// 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.
|
||||
#[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 reason: String,
|
||||
}
|
||||
|
||||
/// Result of [`parse_model_overrides`].
|
||||
pub(crate) struct ParsedModelOverrides {
|
||||
pub models: IndexMap<String, ConfigModelOverride>,
|
||||
pub warnings: Vec<ModelOverrideWarning>,
|
||||
}
|
||||
|
||||
/// Parses every `[model.<id>]` entry in `raw_config`, returning the overrides
|
||||
/// and a warning for each skipped field or dropped entry.
|
||||
pub(crate) fn parse_model_overrides(raw_config: &toml::Value) -> ParsedModelOverrides {
|
||||
let mut models = IndexMap::new();
|
||||
let mut warnings = Vec::new();
|
||||
let Some(section) = raw_config.get("model") else {
|
||||
return ParsedModelOverrides { models, warnings };
|
||||
};
|
||||
let Some(table) = section.as_table() else {
|
||||
warnings.push(ModelOverrideWarning {
|
||||
model_key: None,
|
||||
field: None,
|
||||
kind: ModelOverrideWarningKind::NotATable,
|
||||
reason: 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!(
|
||||
"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());
|
||||
warnings.extend(entry_warnings);
|
||||
models.insert(model_key.clone(), entry);
|
||||
}
|
||||
ParsedModelOverrides { models, warnings }
|
||||
}
|
||||
|
||||
/// 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]) {
|
||||
use std::hash::{Hash as _, Hasher as _};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static LAST_LOGGED: AtomicU64 = AtomicU64::new(0);
|
||||
// 0 means "no warnings"; real hashes are clamped to nonzero.
|
||||
let hash = if warnings.is_empty() {
|
||||
0
|
||||
} else {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
warnings.hash(&mut hasher);
|
||||
hasher.finish().max(1)
|
||||
};
|
||||
if LAST_LOGGED.swap(hash, Ordering::Relaxed) == hash {
|
||||
return;
|
||||
}
|
||||
|
||||
for warning in warnings {
|
||||
tracing::warn!(
|
||||
model = warning.model_key.as_deref().unwrap_or("(section)"),
|
||||
field = warning.field.as_deref().unwrap_or("(entry)"),
|
||||
kind = ?warning.kind,
|
||||
reason = %warning.reason,
|
||||
"model_override: skipped invalid config"
|
||||
);
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
tracing::warn!(
|
||||
warnings = warnings.len(),
|
||||
"model_override: parsed with warnings; run `grok inspect` for details"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_model_override_table(
|
||||
model_key: &str,
|
||||
mut table: toml::map::Map<String, toml::Value>,
|
||||
) -> (ConfigModelOverride, Vec<ModelOverrideWarning>) {
|
||||
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()) {
|
||||
Ok((entry, unknown)) => {
|
||||
warnings.extend(unknown_field_warnings(model_key, unknown));
|
||||
(entry, warnings)
|
||||
}
|
||||
Err(_) => {
|
||||
prune_invalid_fields(model_key, &mut table, &mut warnings);
|
||||
match deserialize_with_unknown_fields(table) {
|
||||
Ok((entry, unknown)) => {
|
||||
warnings.extend(unknown_field_warnings(model_key, unknown));
|
||||
(entry, warnings)
|
||||
}
|
||||
Err(error) => {
|
||||
// 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!(
|
||||
"failed to parse after skipping invalid fields ({error}); using empty override"
|
||||
),
|
||||
});
|
||||
(ConfigModelOverride::default(), warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(canonical, legacy)` key pairs that serde rejects as duplicate fields
|
||||
/// when both appear in one table. Keep in sync with the `#[serde(alias)]`
|
||||
/// attributes on [`ConfigModelOverride`].
|
||||
const ALIASES: &[(&str, &str)] = &[("compactions_remaining", "send_compactions_remaining")];
|
||||
|
||||
/// Removes one key of each [`ALIASES`] pair that appears twice in `table`.
|
||||
/// The canonical key wins; when its value doesn't parse, the legacy key is
|
||||
/// kept instead.
|
||||
fn dedupe_aliases(
|
||||
model_key: &str,
|
||||
table: &mut toml::map::Map<String, toml::Value>,
|
||||
warnings: &mut Vec<ModelOverrideWarning>,
|
||||
) {
|
||||
for &(canonical, legacy) in ALIASES {
|
||||
if !(table.contains_key(canonical) && table.contains_key(legacy)) {
|
||||
continue;
|
||||
}
|
||||
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}"),
|
||||
});
|
||||
}
|
||||
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}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserializes `table`, also returning the unknown field names that serde
|
||||
/// would otherwise silently discard.
|
||||
fn deserialize_with_unknown_fields(
|
||||
table: toml::map::Map<String, toml::Value>,
|
||||
) -> Result<(ConfigModelOverride, Vec<String>), toml::de::Error> {
|
||||
let mut unknown = Vec::new();
|
||||
let entry = serde_ignored::deserialize(toml::Value::Table(table), |path| {
|
||||
unknown.push(path.to_string());
|
||||
})?;
|
||||
Ok((entry, unknown))
|
||||
}
|
||||
|
||||
fn unknown_field_warnings(model_key: &str, unknown: Vec<String>) -> Vec<ModelOverrideWarning> {
|
||||
unknown
|
||||
.into_iter()
|
||||
.map(|field| ModelOverrideWarning {
|
||||
model_key: Some(model_key.to_owned()),
|
||||
field: Some(field),
|
||||
kind: ModelOverrideWarningKind::UnknownField,
|
||||
reason: "unknown field".to_owned(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Removes each field that fails to parse on its own, one warning per field.
|
||||
/// Unknown fields stay; the follow-up parse reports them.
|
||||
fn prune_invalid_fields(
|
||||
model_key: &str,
|
||||
table: &mut toml::map::Map<String, toml::Value>,
|
||||
warnings: &mut Vec<ModelOverrideWarning>,
|
||||
) {
|
||||
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(),
|
||||
});
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Parses `field` in isolation, returning the error if it fails.
|
||||
fn field_parse_error(field: &str, value: &toml::Value) -> Option<toml::de::Error> {
|
||||
let mut singleton = toml::map::Map::new();
|
||||
singleton.insert(field.to_owned(), value.clone());
|
||||
toml::Value::Table(singleton)
|
||||
.try_into::<ConfigModelOverride>()
|
||||
.err()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sampling::ApiBackend;
|
||||
use kigi_sampling_types::{
|
||||
CompactionAtTokens, CompactionsRemaining, ReasoningEffort, ReasoningEffortOption,
|
||||
};
|
||||
|
||||
fn parse_cfg(toml_str: &str) -> crate::agent::config::Config {
|
||||
let raw: toml::Value = toml::from_str(toml_str).unwrap();
|
||||
crate::agent::config::Config::new_from_toml_cfg(&raw).expect("config should parse")
|
||||
}
|
||||
|
||||
fn parse_raw(
|
||||
toml_str: &str,
|
||||
) -> (
|
||||
IndexMap<String, ConfigModelOverride>,
|
||||
Vec<ModelOverrideWarning>,
|
||||
) {
|
||||
let raw: toml::Value = toml::from_str(toml_str).unwrap();
|
||||
let ParsedModelOverrides { models, warnings } = parse_model_overrides(&raw);
|
||||
(models, warnings)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_compactions_keys_keeps_model() {
|
||||
let cfg = parse_cfg(
|
||||
r#"
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
env_key = "ANTHROPIC_AUTH_TOKEN"
|
||||
compactions_remaining = 1
|
||||
send_compactions_remaining = true
|
||||
"#,
|
||||
);
|
||||
let model = cfg
|
||||
.config_models
|
||||
.get("grok-4.5")
|
||||
.expect("grok-4.5 must remain in catalog");
|
||||
assert_eq!(
|
||||
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")
|
||||
}));
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, None);
|
||||
assert!(resolved.contains_key("grok-4.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_alone_parses_without_warning() {
|
||||
let cfg = parse_cfg(
|
||||
r#"
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
send_compactions_remaining = 2
|
||||
"#,
|
||||
);
|
||||
let model = cfg.config_models.get("grok-4.5").unwrap();
|
||||
assert_eq!(
|
||||
model.compactions_remaining,
|
||||
Some(CompactionsRemaining::Fixed(2))
|
||||
);
|
||||
assert!(cfg.model_override_warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_reasoning_effort_skips_field_keeps_model() {
|
||||
let cfg = parse_cfg(
|
||||
r#"
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
env_key = "ANTHROPIC_AUTH_TOKEN"
|
||||
reasoning_effort = "not-a-level"
|
||||
"#,
|
||||
);
|
||||
let model = cfg
|
||||
.config_models
|
||||
.get("grok-4.5")
|
||||
.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")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_field_warns_but_keeps_known_fields() {
|
||||
let (models, warnings) = parse_raw(
|
||||
r#"
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
env_key = "TOKEN"
|
||||
future_field = 1
|
||||
"#,
|
||||
);
|
||||
let entry = models.get("grok-4.5").unwrap();
|
||||
assert_eq!(entry.model.as_deref(), Some("grok-4.5"));
|
||||
assert_eq!(
|
||||
entry.env_key.as_ref().and_then(|k| k.primary()),
|
||||
Some("TOKEN")
|
||||
);
|
||||
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(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown field warns the same whether or not another field fails to
|
||||
/// parse.
|
||||
#[test]
|
||||
fn unknown_field_warning_is_path_independent() {
|
||||
let unknown_of = |toml_str: &str| {
|
||||
let (_, warnings) = parse_raw(toml_str);
|
||||
warnings
|
||||
.into_iter()
|
||||
.filter(|w| w.kind == ModelOverrideWarningKind::UnknownField)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let fast = unknown_of(
|
||||
r#"
|
||||
[model.m]
|
||||
temprature = 0.5
|
||||
"#,
|
||||
);
|
||||
let slow = unknown_of(
|
||||
r#"
|
||||
[model.m]
|
||||
temprature = 0.5
|
||||
reasoning_effort = "not-a-level"
|
||||
"#,
|
||||
);
|
||||
assert_eq!(fast, slow);
|
||||
assert_eq!(fast.len(), 1);
|
||||
assert_eq!(fast[0].field.as_deref(), Some("temprature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_skips_invalid_fields_and_keeps_the_rest() {
|
||||
// A valid nested table survives an invalid sibling.
|
||||
let (models, warnings) = parse_raw(
|
||||
r#"
|
||||
[model.m]
|
||||
temperature = "hot"
|
||||
[model.m.extra_headers]
|
||||
x-team = "codegen"
|
||||
"#,
|
||||
);
|
||||
let entry = models.get("m").unwrap();
|
||||
assert_eq!(
|
||||
entry.extra_headers.get("x-team").map(String::as_str),
|
||||
Some("codegen")
|
||||
);
|
||||
assert!(entry.temperature.is_none());
|
||||
assert!(warnings.iter().any(|w| {
|
||||
w.kind == ModelOverrideWarningKind::InvalidValue
|
||||
&& w.field.as_deref() == Some("temperature")
|
||||
}));
|
||||
|
||||
// All fields invalid: the model stays, with an empty override.
|
||||
let (models, warnings) = parse_raw(
|
||||
r#"
|
||||
[model.m]
|
||||
temperature = "hot"
|
||||
max_retries = "many"
|
||||
"#,
|
||||
);
|
||||
let entry = models.get("m").expect("model must remain in catalog");
|
||||
assert!(entry.temperature.is_none());
|
||||
assert!(entry.max_retries.is_none());
|
||||
assert_eq!(warnings.len(), 2);
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.all(|w| w.kind == ModelOverrideWarningKind::InvalidValue)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_canonical_key_falls_back_to_legacy_alias() {
|
||||
let (models, warnings) = parse_raw(
|
||||
r#"
|
||||
[model.m]
|
||||
compactions_remaining = "bad"
|
||||
send_compactions_remaining = 2
|
||||
"#,
|
||||
);
|
||||
let entry = models.get("m").unwrap();
|
||||
assert_eq!(
|
||||
entry.compactions_remaining,
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_table_model_section_warns_and_is_ignored() {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_table_entry_is_dropped_with_warning() {
|
||||
let (models, warnings) = parse_raw(
|
||||
r#"
|
||||
[model]
|
||||
oops = 5
|
||||
"#,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
/// Exhaustive literal (no `..`): a new struct field is a compile error
|
||||
/// here until the drift-guard tests cover it.
|
||||
fn fully_populated_override() -> ConfigModelOverride {
|
||||
ConfigModelOverride {
|
||||
model: Some("m".into()),
|
||||
base_url: Some("https://example.com".into()),
|
||||
name: Some("Model M".into()),
|
||||
description: Some("desc".into()),
|
||||
api_key: Some("key".into()),
|
||||
env_key: Some(crate::agent::config::EnvKeys::single("ENV_KEY")),
|
||||
api_base_url: Some("https://api.example.com".into()),
|
||||
max_completion_tokens: Some(1024),
|
||||
temperature: Some(0.5),
|
||||
top_p: Some(0.9),
|
||||
api_backend: Some(ApiBackend::Messages),
|
||||
extra_headers: [("x-team".to_owned(), "codegen".to_owned())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
context_window: Some(200_000),
|
||||
auto_compact_threshold_percent: Some(80),
|
||||
system_prompt_label: Some("label".into()),
|
||||
use_concise: Some(true),
|
||||
agent_type: Some("agent".into()),
|
||||
inference_idle_timeout_secs: Some(60),
|
||||
max_retries: Some(3),
|
||||
hidden: Some(false),
|
||||
supported_in_api: Some(true),
|
||||
reasoning_effort: Some(ReasoningEffort::High),
|
||||
supports_reasoning_effort: Some(true),
|
||||
reasoning_efforts: vec![ReasoningEffortOption {
|
||||
id: "deep".to_string(),
|
||||
value: ReasoningEffort::High,
|
||||
label: "Deep".to_string(),
|
||||
description: Some("Deep reasoning".to_string()),
|
||||
default: true,
|
||||
}],
|
||||
supports_backend_search: Some(false),
|
||||
compactions_remaining: Some(CompactionsRemaining::Fixed(1)),
|
||||
compaction_at_tokens: Some(CompactionAtTokens::Fixed(100_000)),
|
||||
show_model_fingerprint: Some(true),
|
||||
stream_tool_calls: Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_single_entry(
|
||||
entry: toml::map::Map<String, toml::Value>,
|
||||
) -> (
|
||||
IndexMap<String, ConfigModelOverride>,
|
||||
Vec<ModelOverrideWarning>,
|
||||
) {
|
||||
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();
|
||||
root.insert("model".to_owned(), toml::Value::Table(model_table));
|
||||
let ParsedModelOverrides { models, warnings } =
|
||||
parse_model_overrides(&toml::Value::Table(root));
|
||||
(models, warnings)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_populated_override_round_trips_without_warnings() {
|
||||
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");
|
||||
let reparsed = toml::Value::try_from(models.get("m").unwrap()).unwrap();
|
||||
assert_eq!(reparsed, serialized, "round-trip must be lossless");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn every_struct_alias_is_registered_in_aliases() {
|
||||
let source = include_str!("config.rs");
|
||||
let start = source
|
||||
.find("pub struct ConfigModelOverride {")
|
||||
.expect("ConfigModelOverride definition in config.rs");
|
||||
let block = &source[start..];
|
||||
let block = &block[..block.find("\n}").expect("struct end")];
|
||||
|
||||
let mut found = Vec::new();
|
||||
let mut rest = block;
|
||||
while let Some(pos) = rest.find("#[serde(alias = \"") {
|
||||
let after = &rest[pos + "#[serde(alias = \"".len()..];
|
||||
let legacy = &after[..after.find('"').expect("closing quote")];
|
||||
let field = &after[after.find("pub ").expect("field after alias") + 4..];
|
||||
let canonical = &field[..field.find(':').expect("field type colon")];
|
||||
found.push((canonical.to_owned(), legacy.to_owned()));
|
||||
rest = after;
|
||||
}
|
||||
assert_eq!(
|
||||
block.matches("alias").count(),
|
||||
found.len(),
|
||||
"an alias on ConfigModelOverride was not recognized; write it as \
|
||||
`#[serde(alias = \"...\")]` on its own line, or update this scan"
|
||||
);
|
||||
found.sort();
|
||||
|
||||
let mut registered: Vec<(String, String)> = ALIASES
|
||||
.iter()
|
||||
.map(|&(c, l)| (c.to_owned(), l.to_owned()))
|
||||
.collect();
|
||||
registered.sort();
|
||||
assert_eq!(
|
||||
found, registered,
|
||||
"#[serde(alias)] attributes on ConfigModelOverride and ALIASES must match"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drift guard for `ALIASES`, in both directions: every pair must be a
|
||||
/// real serde alias (a both-keys table fails a plain parse), and the
|
||||
/// parser must resolve it to the canonical key with a single warning.
|
||||
#[test]
|
||||
fn every_aliases_pair_is_a_real_serde_alias_and_dedupes() {
|
||||
let reference = toml::Value::try_from(fully_populated_override()).unwrap();
|
||||
for &(canonical, legacy) in ALIASES {
|
||||
let value = reference
|
||||
.get(canonical)
|
||||
.unwrap_or_else(|| panic!("{canonical} missing from fully_populated_override"));
|
||||
let mut entry = toml::map::Map::new();
|
||||
entry.insert(canonical.to_owned(), value.clone());
|
||||
entry.insert(legacy.to_owned(), value.clone());
|
||||
assert!(
|
||||
toml::Value::Table(entry.clone())
|
||||
.try_into::<ConfigModelOverride>()
|
||||
.is_err(),
|
||||
"{canonical}/{legacy} is not a serde alias pair; remove it from ALIASES"
|
||||
);
|
||||
|
||||
let (models, warnings) = parse_single_entry(entry);
|
||||
let parsed = toml::Value::try_from(models.get("m").unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
parsed.get(canonical),
|
||||
Some(value),
|
||||
"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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Wire-shape parsers for ext-notification params handled by `MvpAgent`.
|
||||
//!
|
||||
//! Pure parsing only (params JSON → `SessionCommand`); session lookup and
|
||||
//! command dispatch stay in `mvp_agent::ext_notification`.
|
||||
|
||||
use crate::session::SessionCommand;
|
||||
|
||||
/// Parse a `x.ai/queue/{remove,reorder,clear,edit,interject}` ext-notification's
|
||||
/// params into the corresponding [`SessionCommand`].
|
||||
/// `owner` is the resolved attribution (params `owner`/`clientIdentifier`) used
|
||||
/// to scope remove/clear to the requesting client's own items, and recorded as
|
||||
/// `last_editor` for in-place text edits. Returns `None` for unrecognized
|
||||
/// methods or for `edit` when `newText` is missing.
|
||||
pub(super) fn parse_queue_edit_command(
|
||||
method: &str,
|
||||
params: &serde_json::Value,
|
||||
owner: Option<String>,
|
||||
) -> Option<SessionCommand> {
|
||||
match method {
|
||||
"x.ai/queue/remove" => {
|
||||
let id = params.get("id").and_then(|v| v.as_str())?.to_string();
|
||||
// The client supplies the version it last saw; the handler removes
|
||||
// only on an exact match (stale = benign no-op + rebroadcast).
|
||||
// Default 0 covers never-edited prompts (the common case).
|
||||
let expected_version = params
|
||||
.get("expectedVersion")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
Some(SessionCommand::RemoveQueuedPrompt {
|
||||
id,
|
||||
expected_version,
|
||||
owner,
|
||||
})
|
||||
}
|
||||
"x.ai/queue/reorder" => {
|
||||
let ordered_ids = params
|
||||
.get("orderedIds")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(SessionCommand::ReorderQueue { ordered_ids })
|
||||
}
|
||||
"x.ai/queue/clear" => Some(SessionCommand::ClearQueue { owner }),
|
||||
"x.ai/queue/interject" => {
|
||||
let id = params.get("id").and_then(|v| v.as_str())?.to_string();
|
||||
// The client supplies the version it last saw; the handler acts
|
||||
// only on an exact match (stale = benign no-op + rebroadcast).
|
||||
let expected_version = params
|
||||
.get("expectedVersion")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
// Optional client-edited replacement text (atomic edit+interject).
|
||||
// Blank overrides are dropped (degrade to the stored queue text) —
|
||||
// never interject an empty prompt on a malformed client param.
|
||||
let new_text = params
|
||||
.get("newText")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(str::to_string);
|
||||
Some(SessionCommand::InterjectQueuedPrompt {
|
||||
id,
|
||||
expected_version,
|
||||
owner,
|
||||
new_text,
|
||||
})
|
||||
}
|
||||
"x.ai/queue/edit" => {
|
||||
let id = params.get("id").and_then(|v| v.as_str())?.to_string();
|
||||
let new_text = params.get("newText").and_then(|v| v.as_str())?.to_string();
|
||||
// `owner` is the resolved attribution; for edit it represents the
|
||||
// most recent editor (recorded as `last_editor`), not the original
|
||||
// enqueuer.
|
||||
Some(SessionCommand::EditQueuedPrompt {
|
||||
id,
|
||||
new_text,
|
||||
editor: owner,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Each `x.ai/queue/*` ext-notification maps to the
|
||||
/// correct versioned/idempotent `SessionCommand`.
|
||||
#[test]
|
||||
fn parse_queue_edit_command_maps_each_method() {
|
||||
// remove: id + expectedVersion + owner.
|
||||
let p = serde_json::json!({
|
||||
"sessionId": "s1", "id": "p7", "expectedVersion": 3
|
||||
});
|
||||
match parse_queue_edit_command("x.ai/queue/remove", &p, Some("grok-tui".into())) {
|
||||
Some(SessionCommand::RemoveQueuedPrompt {
|
||||
id,
|
||||
expected_version,
|
||||
owner,
|
||||
}) => {
|
||||
assert_eq!(id, "p7");
|
||||
assert_eq!(expected_version, 3);
|
||||
assert_eq!(owner.as_deref(), Some("grok-tui"));
|
||||
}
|
||||
_ => panic!("expected RemoveQueuedPrompt"),
|
||||
}
|
||||
|
||||
// remove without expectedVersion defaults to 0.
|
||||
let p = serde_json::json!({ "sessionId": "s1", "id": "p8" });
|
||||
match parse_queue_edit_command("x.ai/queue/remove", &p, None) {
|
||||
Some(SessionCommand::RemoveQueuedPrompt {
|
||||
expected_version, ..
|
||||
}) => assert_eq!(expected_version, 0),
|
||||
_ => panic!("expected RemoveQueuedPrompt"),
|
||||
}
|
||||
|
||||
// reorder: orderedIds array.
|
||||
let p = serde_json::json!({ "sessionId": "s1", "orderedIds": ["a", "b", "c"] });
|
||||
match parse_queue_edit_command("x.ai/queue/reorder", &p, None) {
|
||||
Some(SessionCommand::ReorderQueue { ordered_ids }) => {
|
||||
assert_eq!(ordered_ids, vec!["a", "b", "c"]);
|
||||
}
|
||||
_ => panic!("expected ReorderQueue"),
|
||||
}
|
||||
|
||||
// clear: owner-scoped.
|
||||
match parse_queue_edit_command(
|
||||
"x.ai/queue/clear",
|
||||
&serde_json::json!({ "sessionId": "s1" }),
|
||||
Some("grok-tui".into()),
|
||||
) {
|
||||
Some(SessionCommand::ClearQueue { owner }) => {
|
||||
assert_eq!(owner.as_deref(), Some("grok-tui"));
|
||||
}
|
||||
_ => panic!("expected ClearQueue"),
|
||||
}
|
||||
|
||||
// edit: id + newText + editor (resolved via owner/clientIdentifier).
|
||||
let p = serde_json::json!({
|
||||
"sessionId": "s1", "id": "p9", "newText": "replacement text"
|
||||
});
|
||||
match parse_queue_edit_command("x.ai/queue/edit", &p, Some("grok-vscode".into())) {
|
||||
Some(SessionCommand::EditQueuedPrompt {
|
||||
id,
|
||||
new_text,
|
||||
editor,
|
||||
}) => {
|
||||
assert_eq!(id, "p9");
|
||||
assert_eq!(new_text, "replacement text");
|
||||
assert_eq!(editor.as_deref(), Some("grok-vscode"));
|
||||
}
|
||||
_ => panic!("expected EditQueuedPrompt"),
|
||||
}
|
||||
|
||||
// edit without editor (no owner/clientIdentifier) → editor: None.
|
||||
match parse_queue_edit_command(
|
||||
"x.ai/queue/edit",
|
||||
&serde_json::json!({ "sessionId": "s1", "id": "p9", "newText": "x" }),
|
||||
None,
|
||||
) {
|
||||
Some(SessionCommand::EditQueuedPrompt { editor, .. }) => {
|
||||
assert!(editor.is_none());
|
||||
}
|
||||
_ => panic!("expected EditQueuedPrompt"),
|
||||
}
|
||||
|
||||
// edit without newText → None (can't replace text we don't have).
|
||||
assert!(
|
||||
parse_queue_edit_command(
|
||||
"x.ai/queue/edit",
|
||||
&serde_json::json!({ "sessionId": "s1", "id": "p9" }),
|
||||
None,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// edit without id → None (can't target an entry).
|
||||
assert!(
|
||||
parse_queue_edit_command(
|
||||
"x.ai/queue/edit",
|
||||
&serde_json::json!({ "sessionId": "s1", "newText": "x" }),
|
||||
None,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// interject: id + expectedVersion + owner (mirrors remove).
|
||||
let p = serde_json::json!({
|
||||
"sessionId": "s1", "id": "p10", "expectedVersion": 2
|
||||
});
|
||||
match parse_queue_edit_command("x.ai/queue/interject", &p, Some("grok-tui".into())) {
|
||||
Some(SessionCommand::InterjectQueuedPrompt {
|
||||
id,
|
||||
expected_version,
|
||||
owner,
|
||||
new_text,
|
||||
}) => {
|
||||
assert_eq!(id, "p10");
|
||||
assert_eq!(expected_version, 2);
|
||||
assert_eq!(owner.as_deref(), Some("grok-tui"));
|
||||
assert_eq!(new_text, None, "newText absent → None");
|
||||
}
|
||||
_ => panic!("expected InterjectQueuedPrompt"),
|
||||
}
|
||||
|
||||
// interject with newText (client-edited row) carries the override.
|
||||
let p = serde_json::json!({
|
||||
"sessionId": "s1", "id": "p10", "expectedVersion": 2, "newText": "edited"
|
||||
});
|
||||
match parse_queue_edit_command("x.ai/queue/interject", &p, None) {
|
||||
Some(SessionCommand::InterjectQueuedPrompt { new_text, .. }) => {
|
||||
assert_eq!(new_text.as_deref(), Some("edited"));
|
||||
}
|
||||
_ => panic!("expected InterjectQueuedPrompt"),
|
||||
}
|
||||
|
||||
// Blank newText is dropped → degrades to the stored queue text.
|
||||
let p = serde_json::json!({
|
||||
"sessionId": "s1", "id": "p10", "expectedVersion": 2, "newText": " "
|
||||
});
|
||||
match parse_queue_edit_command("x.ai/queue/interject", &p, None) {
|
||||
Some(SessionCommand::InterjectQueuedPrompt { new_text, .. }) => {
|
||||
assert_eq!(new_text, None, "blank override must be dropped");
|
||||
}
|
||||
_ => panic!("expected InterjectQueuedPrompt"),
|
||||
}
|
||||
|
||||
// interject without expectedVersion defaults to 0.
|
||||
match parse_queue_edit_command(
|
||||
"x.ai/queue/interject",
|
||||
&serde_json::json!({ "sessionId": "s1", "id": "p11" }),
|
||||
None,
|
||||
) {
|
||||
Some(SessionCommand::InterjectQueuedPrompt {
|
||||
expected_version, ..
|
||||
}) => assert_eq!(expected_version, 0),
|
||||
_ => panic!("expected InterjectQueuedPrompt"),
|
||||
}
|
||||
|
||||
// interject without id → None (can't target an entry).
|
||||
assert!(
|
||||
parse_queue_edit_command("x.ai/queue/interject", &serde_json::json!({}), None)
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// unknown method → None.
|
||||
assert!(
|
||||
parse_queue_edit_command("x.ai/queue/bogus", &serde_json::json!({}), None).is_none()
|
||||
);
|
||||
// remove without id → None (can't target an entry).
|
||||
assert!(
|
||||
parse_queue_edit_command("x.ai/queue/remove", &serde_json::json!({}), None).is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
pub(crate) mod model_switch;
|
||||
pub(crate) mod session;
|
||||
pub(crate) mod workspaces;
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Applies a model switch to a session — the ungated path. `set_session_model`
|
||||
//! enforces the `allowed_models` gate before delegating here; internal callers
|
||||
//! (`new_session`, `load_session`) call `apply` directly.
|
||||
use crate::agent::config;
|
||||
use crate::agent::mvp_agent::{
|
||||
MvpAgent, agent_name_after_model_switch, harnesses_are_compatible, resolve_required_agent_type,
|
||||
};
|
||||
use crate::session::SessionCommand;
|
||||
use agent_client_protocol::{self as acp};
|
||||
use kigi_sampling_types::parse_reasoning_effort_meta;
|
||||
use tokio::sync::oneshot;
|
||||
/// Apply a model switch to a session (no gate — `set_session_model` gates first).
|
||||
pub(crate) async fn apply(
|
||||
agent: &MvpAgent,
|
||||
args: acp::SetSessionModelRequest,
|
||||
) -> Result<acp::SetSessionModelResponse, acp::Error> {
|
||||
tracing::info!("Received set session model request {args:?}");
|
||||
kigi_log::unified_log::info(
|
||||
"model changed",
|
||||
Some(args.session_id.0.as_ref()),
|
||||
Some(serde_json::json!({ "model" : args.model_id.0.as_ref() })),
|
||||
);
|
||||
tracing::debug!("session_session_model::mvp_agent: {:?}", &args);
|
||||
let effort_override = parse_reasoning_effort_meta(args.meta.as_ref());
|
||||
let acp::SetSessionModelRequest {
|
||||
session_id,
|
||||
model_id,
|
||||
..
|
||||
} = args;
|
||||
let handle = agent
|
||||
.session_handle_waiting_for_load(&session_id)
|
||||
.await
|
||||
.ok_or_else(|| acp::Error::invalid_params().data("unknown session id"))?;
|
||||
let model = agent.resolve_model_id(&model_id)?;
|
||||
let use_concise = model.info().use_concise;
|
||||
let session_default = handle
|
||||
.session_default_agent_profile
|
||||
.as_deref()
|
||||
.unwrap_or(&handle.agent_name);
|
||||
let required_agent_type =
|
||||
resolve_required_agent_type(Some(model.info().agent_type.as_str()), session_default);
|
||||
let previous_model_id = handle.model_id.0.clone();
|
||||
let mut pending_rebuild_definition: Option<kigi_agent::AgentDefinition> = None;
|
||||
{
|
||||
let required = &required_agent_type;
|
||||
let turn_count = handle
|
||||
.signals_handle
|
||||
.snapshot()
|
||||
.await
|
||||
.map(|s| s.turn_count)
|
||||
.unwrap_or(0);
|
||||
let (agent_tx, agent_rx) = oneshot::channel();
|
||||
let _ = handle.cmd_tx.send(SessionCommand::GetActiveAgent {
|
||||
responds_to: agent_tx,
|
||||
});
|
||||
let active_agent_type = agent_rx.await.ok().flatten();
|
||||
let is_mismatch = active_agent_type
|
||||
.as_ref()
|
||||
.is_some_and(|active| !harnesses_are_compatible(active, required));
|
||||
tracing::info!(
|
||||
session_id = % session_id.0, model_id = % model_id.0, ? required_agent_type,
|
||||
? active_agent_type, turn_count, is_mismatch,
|
||||
"set_session_model: agent type compatibility check"
|
||||
);
|
||||
if is_mismatch && turn_count > 0 {
|
||||
tracing::warn!(
|
||||
session_id = % session_id.0, model_id = % model_id.0, active_agent = ?
|
||||
active_agent_type, required_agent = % required, turn_count,
|
||||
"set_session_model: agent type mismatch rejected"
|
||||
);
|
||||
let err_payload = config::ModelSwitchIncompatibleAgentError {
|
||||
code: config::MODEL_SWITCH_INCOMPATIBLE_AGENT.to_string(),
|
||||
active_agent_type: active_agent_type.unwrap_or_else(|| "unknown".to_owned()),
|
||||
required_agent_type: required.clone(),
|
||||
model_id: model_id.0.to_string(),
|
||||
suggestion: "start_new_session".to_string(),
|
||||
};
|
||||
return Err(err_payload.into_acp_error());
|
||||
}
|
||||
if is_mismatch && turn_count == 0 {
|
||||
let cwd = handle.tool_context.cwd.as_path();
|
||||
let resolved = kigi_agent::discovery::by_name_in_cwd_with_plugins(
|
||||
required,
|
||||
cwd,
|
||||
agent.plugin_registry_handle.snapshot().as_deref(),
|
||||
);
|
||||
match resolved {
|
||||
Some(def) => {
|
||||
tracing::info!(
|
||||
session_id = % session_id.0, model_id = % model_id.0,
|
||||
required_agent_type = % required, agent_def_name = % def.name,
|
||||
"set_session_model: zero-turn harness switch — queued agent rebuild"
|
||||
);
|
||||
pending_rebuild_definition = Some(def);
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
session_id = % session_id.0, model_id = % model_id.0,
|
||||
required_agent_type = % required,
|
||||
"set_session_model: zero-turn harness switch — could not resolve agent definition; proceeding with stale harness"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut model_sampling =
|
||||
agent.prepare_sampling_config_for_model(&model, handle.origin_client.clone());
|
||||
if let Some(eff) = effort_override {
|
||||
if agent
|
||||
.models_manager
|
||||
.model_supports_reasoning_effort(model_id.0.as_ref())
|
||||
{
|
||||
tracing::info!(
|
||||
session_id = % session_id.0, effort = % eff,
|
||||
"set_session_model: applying reasoning_effort override from meta"
|
||||
);
|
||||
model_sampling.reasoning_effort = Some(eff);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
session_id = % session_id.0, model_id = % model_id.0, effort = % eff,
|
||||
"set_session_model: ignoring reasoning_effort override — model does not support it"
|
||||
);
|
||||
}
|
||||
}
|
||||
let applied_effort = model_sampling.reasoning_effort;
|
||||
let gate_closed = !handle
|
||||
.gateway_enabled
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let apply_prompt_override = !gate_closed;
|
||||
if gate_closed {
|
||||
tracing::info!(
|
||||
session_id = % session_id.0, model_id = % model_id.0,
|
||||
"set_session_model: gateway gate closed, prompt override suppressed"
|
||||
);
|
||||
pending_rebuild_definition = None;
|
||||
}
|
||||
let did_rebuild = if let Some(def) = pending_rebuild_definition {
|
||||
let (rebuild_tx, rebuild_rx) = oneshot::channel();
|
||||
let _ = handle
|
||||
.cmd_tx
|
||||
.send(SessionCommand::RebuildAgentForDefinition {
|
||||
definition: def,
|
||||
responds_to: rebuild_tx,
|
||||
});
|
||||
let rebuild_result = rebuild_rx
|
||||
.await
|
||||
.map_err(|_| acp::Error::internal_error().data("rebuild_agent: actor closed"))?;
|
||||
match rebuild_result {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
session_id = % session_id.0, model_id = % model_id.0, error = ? e,
|
||||
"set_session_model: zero-turn harness rebuild failed; aborting model switch"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let model_unchanged = previous_model_id == model_id.0;
|
||||
let new_threshold = {
|
||||
let cfg = agent.cfg.borrow();
|
||||
let models = agent.models_manager.models();
|
||||
let model = config::find_model_by_id(&models, model_sampling.model.as_str());
|
||||
crate::util::config::resolve_auto_compact_threshold_percent(
|
||||
&cfg,
|
||||
model_sampling.model.as_str(),
|
||||
model.map(|e| &e.info),
|
||||
)
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = handle.cmd_tx.send(SessionCommand::SetSessionModel {
|
||||
sampling_config: model_sampling,
|
||||
use_concise,
|
||||
apply_prompt_override,
|
||||
skip_prompt_rewrite: did_rebuild || model_unchanged,
|
||||
auto_compact_threshold_percent: new_threshold,
|
||||
responds_to: tx,
|
||||
});
|
||||
let updated_model = rx
|
||||
.await
|
||||
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))?;
|
||||
if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) {
|
||||
handle.model_id = model_id.clone();
|
||||
handle.reasoning_effort = applied_effort;
|
||||
handle.agent_name =
|
||||
agent_name_after_model_switch(did_rebuild, &required_agent_type, &handle.agent_name);
|
||||
}
|
||||
broadcast_model_changed(
|
||||
agent,
|
||||
&session_id,
|
||||
model_id.0.as_ref(),
|
||||
applied_effort.map(|eff| eff.to_string()),
|
||||
);
|
||||
if agent.cfg.borrow().mode != config::AgentMode::Leader {
|
||||
agent.models_manager.set_current_model_id(model_id);
|
||||
agent
|
||||
.models_manager
|
||||
.set_current_reasoning_effort(applied_effort);
|
||||
}
|
||||
Ok(acp::SetSessionModelResponse::new().meta(
|
||||
serde_json::json!({ "model" : updated_model, })
|
||||
.as_object()
|
||||
.cloned(),
|
||||
))
|
||||
}
|
||||
/// Broadcast a `ModelChanged` to every client subscribed to this session so
|
||||
/// followers mirror the new model. The originating client ignores its own echo
|
||||
/// (gated by `model_switch_pending`). Broadcast-only — no eventId, not persisted.
|
||||
fn broadcast_model_changed(
|
||||
agent: &MvpAgent,
|
||||
session_id: &acp::SessionId,
|
||||
model_id: &str,
|
||||
reasoning_effort: Option<String>,
|
||||
) {
|
||||
let notification = crate::extensions::notification::SessionNotification {
|
||||
session_id: session_id.clone(),
|
||||
update: crate::extensions::notification::SessionUpdate::ModelChanged {
|
||||
model_id: model_id.to_owned(),
|
||||
reasoning_effort,
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
if let Ok(params) = serde_json::value::to_raw_value(¬ification) {
|
||||
agent
|
||||
.gateway
|
||||
.forward_fire_and_forget(acp::ExtNotification::new(
|
||||
"x.ai/session_notification",
|
||||
params.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Session meta-information handlers.
|
||||
//!
|
||||
//! Router pattern: single `handle()` dispatches by method name.
|
||||
//! Business logic delegates to pure functions or MvpAgent methods.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_client_protocol::{self as acp};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::super::mvp_agent::MvpAgent;
|
||||
use crate::session::persistence::{Summary, list_recent_summaries, list_summaries};
|
||||
use crate::session::{
|
||||
AllSessionOverviewRequest, AllSessionOverviewResponse, ContextInfo, ExtMethodResult,
|
||||
SessionCommand, SessionInfoData, SessionInfoResponse, SessionListRequest, SessionListResponse,
|
||||
};
|
||||
|
||||
/// Mirrors the display title (`generated_title`, else `session_summary`) into
|
||||
/// `session_summary` so clients that only read that field show the same title
|
||||
/// as `display_title()` — including after a `/rename` that updated only
|
||||
/// `generated_title`. Mutates the response copy only; never persisted.
|
||||
fn backfill_session_summary(summary: &mut Summary) {
|
||||
let display = summary.display_title().to_owned();
|
||||
if !display.is_empty() && display != summary.session_summary {
|
||||
summary.session_summary = display;
|
||||
}
|
||||
}
|
||||
|
||||
/// Router for x.ai/session/* and x.ai/session_summaries/* methods.
|
||||
pub async fn handle(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/session/info" => handle_session_info(agent, args).await,
|
||||
"x.ai/session/close" => handle_session_close(agent, args).await,
|
||||
"x.ai/session/list" => handle_session_list(agent, args).await,
|
||||
"x.ai/sessions/list" => handle_roster_list(agent, args).await,
|
||||
m if m.starts_with("x.ai/session_summaries/") => {
|
||||
handle_session_summaries(agent, args).await
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `x.ai/sessions/list` — the FleetView roster. Returns every
|
||||
/// resident session plus recently-touched on-disk `Dormant` sessions. Clients
|
||||
/// poll this while the dashboard is open and reconcile against the
|
||||
/// `x.ai/sessions/changed` broadcast.
|
||||
async fn handle_roster_list(
|
||||
agent: &MvpAgent,
|
||||
_args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
let sessions = agent.build_roster().await;
|
||||
ExtMethodResult::success(crate::agent::roster::RosterListResponse { sessions })
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SessionInfoRequest {
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RecentSessionsRequest {
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
async fn handle_session_info(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
let req: SessionInfoRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
|
||||
let session_id = req.session_id.or_else(|| {
|
||||
agent
|
||||
.sessions
|
||||
.borrow()
|
||||
.keys()
|
||||
.next()
|
||||
.map(|id| id.0.to_string())
|
||||
});
|
||||
|
||||
let Some(session_id) = session_id else {
|
||||
return ExtMethodResult::success(serde_json::json!({}))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()));
|
||||
};
|
||||
|
||||
let sid = acp::SessionId::new(session_id.clone());
|
||||
let Some(session) = agent.sessions.borrow().get(&sid).cloned() else {
|
||||
return ExtMethodResult::success(serde_json::json!({}))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()));
|
||||
};
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let _ = session
|
||||
.cmd_tx
|
||||
.send(SessionCommand::GetSessionInfo { responds_to: tx });
|
||||
let info = rx.await.ok();
|
||||
|
||||
// Construct display data for `/session-info`.
|
||||
let mut data = info.unwrap_or_else(|| SessionInfoData {
|
||||
agent_name: None,
|
||||
model: None,
|
||||
model_display_name: None,
|
||||
resolved_model_id: None,
|
||||
model_fingerprint: None,
|
||||
show_model_fingerprint: false,
|
||||
api_backend: None,
|
||||
conversation_id: None,
|
||||
turns: 0,
|
||||
turn_index: 0,
|
||||
context: ContextInfo {
|
||||
auto_compact_threshold_percent:
|
||||
crate::util::config::DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT,
|
||||
..ContextInfo::default()
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate the model's display name.
|
||||
data.model_display_name = agent
|
||||
.models_manager
|
||||
.models()
|
||||
.get(session.model_id.0.as_ref())
|
||||
.and_then(|entry| entry.info.name.clone());
|
||||
|
||||
// Construct `SessionInfoResponse`.
|
||||
let response = SessionInfoResponse {
|
||||
session_id,
|
||||
cwd: session.info.cwd.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
// Wrap `SessionInfoResponse` in `ExtMethodResult` and return it.
|
||||
ExtMethodResult::success(serde_json::to_value(&response).unwrap_or_default())
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
async fn handle_session_close(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CloseRequest {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
let req: CloseRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
|
||||
let sid = acp::SessionId::new(req.session_id.clone());
|
||||
let existed = agent.sessions.borrow().contains_key(&sid);
|
||||
if existed {
|
||||
// Explicit terminal close: shut the actor down and finalize the cloud
|
||||
// replica (genuine session end). Distinct from a mere client disconnect,
|
||||
// which detaches but keeps the session resumable and never finalizes
|
||||
// (see `MvpAgent::handle_evict_sessions` / `close_session_explicit`).
|
||||
agent.request_session_shutdown(&sid);
|
||||
agent.close_session_explicit(&sid);
|
||||
tracing::info!(session_id = %req.session_id, "session closed via x.ai/session/close");
|
||||
} else {
|
||||
tracing::debug!(session_id = %req.session_id, "session/close: session not found (already closed)");
|
||||
}
|
||||
|
||||
ExtMethodResult::success(serde_json::json!({ "success": true }))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
async fn handle_session_summaries(
|
||||
_agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/session_summaries/session_list" => {
|
||||
let req = serde_json::from_str::<SessionListRequest>(args.params.get())?;
|
||||
let cwd = req.workspace_directory.to_string_lossy().to_string();
|
||||
|
||||
let _timer = crate::instrumentation_timer!("session.list_sessions_for_workspace");
|
||||
|
||||
let mut summaries = list_summaries(Some(&cwd)).await.map_err(|e| {
|
||||
acp::Error::internal_error().data(format!("failed to list sessions: {e}"))
|
||||
})?;
|
||||
for s in &mut summaries {
|
||||
backfill_session_summary(s);
|
||||
}
|
||||
|
||||
let value = serde_json::to_value(SessionListResponse {
|
||||
session_summaries: summaries,
|
||||
})
|
||||
.map(|v| serde_json::value::to_raw_value(&v).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
"x.ai/session_summaries/workspace_list" => {
|
||||
tracing::debug!("xai/session_summaries/workspace_list is working");
|
||||
let _req = serde_json::from_str::<AllSessionOverviewRequest>(args.params.get())?;
|
||||
|
||||
let _timer = crate::instrumentation_timer!("session.list_sessions_for_load");
|
||||
|
||||
let summaries = list_summaries(None).await.map_err(|e| {
|
||||
acp::Error::internal_error().data(format!("failed to list workspaces: {e}"))
|
||||
})?;
|
||||
|
||||
summaries_to_overview_response(summaries)
|
||||
}
|
||||
"x.ai/session_summaries/workspace_list_recent" => {
|
||||
let req = serde_json::from_str::<RecentSessionsRequest>(args.params.get())?;
|
||||
|
||||
let _timer = crate::instrumentation_timer!("session.list_sessions_recent");
|
||||
|
||||
let limit = req.limit.min(10_000);
|
||||
let mut summaries = list_recent_summaries(limit).await.map_err(|e| {
|
||||
acp::Error::internal_error().data(format!("failed to list workspaces: {e}"))
|
||||
})?;
|
||||
for s in &mut summaries {
|
||||
backfill_session_summary(s);
|
||||
}
|
||||
|
||||
let value = serde_json::to_value(&summaries)
|
||||
.map(|v| serde_json::value::to_raw_value(&v).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Group summaries by cwd and serialize into an [`AllSessionOverviewResponse`].
|
||||
fn summaries_to_overview_response(summaries: Vec<Summary>) -> Result<acp::ExtResponse, acp::Error> {
|
||||
let mut by_cwd: BTreeMap<String, Vec<Summary>> = Default::default();
|
||||
for mut s in summaries {
|
||||
backfill_session_summary(&mut s);
|
||||
by_cwd.entry(s.info.cwd.clone()).or_default().push(s);
|
||||
}
|
||||
|
||||
let value = serde_json::to_value(AllSessionOverviewResponse {
|
||||
all_sessions: by_cwd
|
||||
.into_iter()
|
||||
.map(|(k, v)| (PathBuf::from(k), v))
|
||||
.collect(),
|
||||
})
|
||||
.map(|v| serde_json::value::to_raw_value(&v).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
// ── Merged session list (local + remote) ─────────────────────────────
|
||||
|
||||
async fn handle_session_list(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
use crate::session::unified_list;
|
||||
|
||||
// Under chat mode `parse_list_req` REPLACES any client-sent `kind` facet
|
||||
// (never union) so every list surface is conversations-only.
|
||||
let req = unified_list::parse_list_req(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
tracing::debug!(
|
||||
chat_mode_forced_kind = crate::agent::chat_modes::process_chat_mode_enabled(),
|
||||
"session/list"
|
||||
);
|
||||
|
||||
let registry_client = agent.session_registry_client();
|
||||
let conversations_client = agent.conversations_client();
|
||||
let result = unified_list::build_unified_list(
|
||||
registry_client.as_ref(),
|
||||
conversations_client.as_ref(),
|
||||
req,
|
||||
)
|
||||
.await;
|
||||
|
||||
ExtMethodResult::success(unified_list::ext_list_response(result))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use agent_client_protocol::{self as acp};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::super::mvp_agent::MvpAgent;
|
||||
use crate::remote::{ListWorkspacesPage, WsError, WsQuery};
|
||||
use crate::session::ExtMethodResult;
|
||||
|
||||
const DEFAULT_PAGE_SIZE: i64 = 50;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspacesListRequest {
|
||||
#[serde(default)]
|
||||
page_size: Option<i64>,
|
||||
#[serde(default)]
|
||||
page_token: Option<String>,
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspaceRow {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
create_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspacesListResponse {
|
||||
workspaces: Vec<WorkspaceRow>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next_page_token: Option<String>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
meta: Option<WorkspacesMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WorkspacesMeta {
|
||||
#[serde(rename = "x.ai/partial")]
|
||||
partial: PartialInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PartialInfo {
|
||||
workspaces: bool,
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
agent: &MvpAgent,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Result<acp::ExtResponse, acp::Error> {
|
||||
let req: WorkspacesListRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
|
||||
let q = WsQuery {
|
||||
// Clamp to a sane positive page size: a missing, zero, or negative
|
||||
// `pageSize` falls back to the default rather than being forwarded
|
||||
// verbatim to `/rest/workspaces`.
|
||||
page_size: match req.page_size {
|
||||
Some(n) if n > 0 => n,
|
||||
_ => DEFAULT_PAGE_SIZE,
|
||||
},
|
||||
page_token: req.page_token,
|
||||
query: req.query,
|
||||
kind: req.kind,
|
||||
};
|
||||
|
||||
let response = match agent.workspaces_client().list_workspaces(&q).await {
|
||||
Ok(page) => success_response(page),
|
||||
Err(WsError::NoOauth) => degraded_response("no_oauth"),
|
||||
Err(e) => {
|
||||
// Degrade to a partial result, but don't silently swallow the
|
||||
// cause — log it so field failures are diagnosable.
|
||||
tracing::warn!("workspaces/list fetch failed: {e}");
|
||||
degraded_response("error")
|
||||
}
|
||||
};
|
||||
|
||||
ExtMethodResult::success(response)
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
fn success_response(page: ListWorkspacesPage) -> WorkspacesListResponse {
|
||||
WorkspacesListResponse {
|
||||
workspaces: page
|
||||
.workspaces
|
||||
.into_iter()
|
||||
.map(|w| WorkspaceRow {
|
||||
id: w.workspace_id,
|
||||
name: w.name,
|
||||
kind: w.kind,
|
||||
create_time: w.create_time,
|
||||
})
|
||||
.collect(),
|
||||
next_page_token: page.next_page_token,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn degraded_response(reason: &'static str) -> WorkspacesListResponse {
|
||||
WorkspacesListResponse {
|
||||
workspaces: Vec::new(),
|
||||
next_page_token: None,
|
||||
meta: Some(WorkspacesMeta {
|
||||
partial: PartialInfo {
|
||||
workspaces: true,
|
||||
reason,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::remote::Workspace;
|
||||
|
||||
#[test]
|
||||
fn request_parses_camelcase_and_defaults_page_size() {
|
||||
let req: WorkspacesListRequest =
|
||||
serde_json::from_value(serde_json::json!({})).expect("empty params parse");
|
||||
assert!(req.page_size.is_none());
|
||||
|
||||
let req: WorkspacesListRequest = serde_json::from_value(serde_json::json!({
|
||||
"pageSize": 10,
|
||||
"pageToken": "tok",
|
||||
"query": "gpu",
|
||||
"kind": "WORKSPACE_KIND_IMAGINE"
|
||||
}))
|
||||
.expect("full params parse");
|
||||
assert_eq!(req.page_size, Some(10));
|
||||
assert_eq!(req.page_token.as_deref(), Some("tok"));
|
||||
assert_eq!(req.query.as_deref(), Some("gpu"));
|
||||
assert_eq!(req.kind.as_deref(), Some("WORKSPACE_KIND_IMAGINE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_response_projects_grok_workspace_fields() {
|
||||
let page = ListWorkspacesPage {
|
||||
workspaces: vec![Workspace {
|
||||
workspace_id: "ws_1".into(),
|
||||
name: "Research".into(),
|
||||
create_time: Some("2026-06-18T17:30:00Z".into()),
|
||||
kind: Some("WORKSPACE_KIND_IMAGINE".into()),
|
||||
}],
|
||||
next_page_token: Some("tok2".into()),
|
||||
};
|
||||
let value = serde_json::to_value(success_response(page)).unwrap();
|
||||
assert_eq!(value["workspaces"][0]["id"], "ws_1");
|
||||
assert_eq!(value["workspaces"][0]["name"], "Research");
|
||||
assert_eq!(value["workspaces"][0]["kind"], "WORKSPACE_KIND_IMAGINE");
|
||||
assert_eq!(value["workspaces"][0]["createTime"], "2026-06-18T17:30:00Z");
|
||||
assert_eq!(value["nextPageToken"], "tok2");
|
||||
assert!(value.get("_meta").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_response_carries_partial_reason() {
|
||||
let value = serde_json::to_value(degraded_response("no_oauth")).unwrap();
|
||||
assert_eq!(value["workspaces"].as_array().unwrap().len(), 0);
|
||||
assert!(value.get("nextPageToken").is_none());
|
||||
assert_eq!(value["_meta"]["x.ai/partial"]["workspaces"], true);
|
||||
assert_eq!(value["_meta"]["x.ai/partial"]["reason"], "no_oauth");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Agent bootstrap and lifecycle hooks.
|
||||
//!
|
||||
//! [`bootstrap`] runs the full init sequence (config resolution, process
|
||||
//! singletons, model catalog) and returns a resolved config + `ModelsManager`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use crate::agent::config::{self, Config as AgentConfig, ModelEntry};
|
||||
use crate::agent::models::ModelsManager;
|
||||
use crate::auth::AuthManager;
|
||||
use crate::config::StorageMode;
|
||||
|
||||
/// Resolve config, init process singletons, build the model catalog.
|
||||
///
|
||||
/// The `ModelsManager` is `Clone + Send`, so callers that need a handle
|
||||
/// for the config watcher can clone it before passing it to
|
||||
/// `MvpAgent::with_models`.
|
||||
pub fn bootstrap(
|
||||
cfg: &AgentConfig,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
prefetched: Option<IndexMap<String, ModelEntry>>,
|
||||
) -> Result<(AgentConfig, ModelsManager), String> {
|
||||
// Fail closed before any policy is read: a tampered managed policy must not run unmanaged.
|
||||
crate::managed_config::managed_policy_gate()?;
|
||||
let cfg = resolve_config(cfg, auth_manager);
|
||||
cfg.validate_model_filters()?;
|
||||
init_process(&cfg, auth_manager);
|
||||
let models_manager = ModelsManager::from_config(&cfg, prefetched, auth_manager.clone())?;
|
||||
|
||||
// Refresh on every auth refresh — the FSEvents watcher can silently die after
|
||||
// macOS sleep, stranding the catalog on bundled defaults.
|
||||
models_manager.start_auth_refresh_watcher(auth_manager.refresh_notifier());
|
||||
|
||||
Ok((cfg, models_manager))
|
||||
}
|
||||
|
||||
/// Print a `bootstrap`/`MvpAgent::new` config error and exit (process boundary).
|
||||
///
|
||||
/// Restores native stderr first: a managed-policy refusal on the ACP/server path reaches here
|
||||
/// while fd 2 may still point at the `/dev/null` the TUI's `redirect_native_stderr()` set, which
|
||||
/// would swallow the message. No-op when stderr was never redirected (headless).
|
||||
pub(crate) fn exit_on_config_error<T>(e: String) -> T {
|
||||
kigi_tty_utils::restore_native_stderr();
|
||||
eprintln!("\nConfiguration error:\n\n {e}\n");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
/// Config transform: apply managed settings, fetch remote settings,
|
||||
/// resolve storage mode.
|
||||
fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig {
|
||||
let mut cfg = cfg.clone();
|
||||
|
||||
if let Ok(layers) = crate::config::ConfigLayers::load()
|
||||
&& layers.has_managed()
|
||||
{
|
||||
let origins = crate::config::config_origins(&layers);
|
||||
let managed_keys: Vec<&str> = origins
|
||||
.iter()
|
||||
.filter(|(_, s)| matches!(s, config::ConfigSource::ManagedConfig))
|
||||
.map(|(k, _)| k.as_str())
|
||||
.collect();
|
||||
if !managed_keys.is_empty() {
|
||||
tracing::info!(keys = ?managed_keys, "managed_config.toml fields");
|
||||
}
|
||||
}
|
||||
|
||||
let managed_enforced = crate::config::apply_managed_settings_features(&mut cfg);
|
||||
let requirements_enforced = crate::config::apply_requirements(&mut cfg);
|
||||
|
||||
for e in managed_enforced.iter().chain(&requirements_enforced) {
|
||||
tracing::info!(field = %e.path, value = %e.value, source = %e.source, "policy override");
|
||||
}
|
||||
|
||||
// Fallback: if the client didn't pre-supply remote settings, fetch them
|
||||
// now so remote-settings-gated features work regardless of which client
|
||||
// spawned us. Clients that already call `start_early_prefetch()` and
|
||||
// thread the result into `cfg.remote_settings` skip this entirely.
|
||||
if cfg.remote_settings.is_none()
|
||||
&& let Some(handle) =
|
||||
crate::agent::models::start_early_prefetch(Some(cfg.grok_com_config.clone()))
|
||||
{
|
||||
match handle.join() {
|
||||
Ok(result) => {
|
||||
cfg.remote_settings = result.settings;
|
||||
crate::util::config::set_remote_campaigns_from_settings(
|
||||
cfg.remote_settings.as_ref(),
|
||||
);
|
||||
tracing::info!("remote_settings fetched as shell-level fallback");
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("remote_settings fallback prefetch thread panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::util::config::sync_campaign_fields(&mut cfg);
|
||||
crate::agent::config::apply_remote_settings_side_effects(cfg.remote_settings.as_ref());
|
||||
|
||||
// env var > remote settings > Local. Skip remote settings for Generic (grok -p, subagents).
|
||||
if cfg.storage_mode == StorageMode::Local
|
||||
&& cfg.mode != crate::agent::config::AgentMode::Generic
|
||||
{
|
||||
cfg.storage_mode = StorageMode::resolve(None, cfg.remote_settings.as_ref());
|
||||
}
|
||||
// Writeback talks to the code backend; requires grok.com auth.
|
||||
if cfg.storage_mode == StorageMode::Writeback
|
||||
&& !auth_manager.current().is_some_and(|a| a.is_xai_auth())
|
||||
{
|
||||
tracing::info!("Writeback is disabled: requires auth with grok.com");
|
||||
cfg.storage_mode = StorageMode::Local;
|
||||
}
|
||||
|
||||
if let Some(rs) = cfg.remote_settings.as_ref()
|
||||
&& let Some(v) = rs.path_not_found_hints
|
||||
{
|
||||
cfg.path_not_found_hints = v;
|
||||
}
|
||||
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Initialize process-level singletons (deployment sync, bundled files).
|
||||
/// `Once`-guarded: only the first call takes effect.
|
||||
fn init_process(cfg: &AgentConfig, auth_manager: &AuthManager) {
|
||||
use std::sync::Once;
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
if !cfg!(test) {
|
||||
// Clear a logged-out team's files before the background sync runs.
|
||||
crate::managed_config::clear_orphan();
|
||||
crate::managed_config::spawn_sync(tokio_util::sync::CancellationToken::new());
|
||||
}
|
||||
|
||||
let kigi_home = crate::util::kigi_home::kigi_home();
|
||||
crate::builtin::extract_bundled_files(&kigi_home);
|
||||
|
||||
let feedback = cfg.resolve_feedback();
|
||||
let feedback_url = cfg.endpoints.resolve_feedback_base_url();
|
||||
tracing::info!(
|
||||
feedback = %feedback,
|
||||
feedback_url = %feedback_url,
|
||||
feedback_url_custom = cfg.endpoints.feedback_base_url.is_some(),
|
||||
"data capture config resolved",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
pub mod activity;
|
||||
pub mod app;
|
||||
pub mod auth_method;
|
||||
pub mod chat_modes;
|
||||
pub mod config;
|
||||
pub mod config_model_override_parse;
|
||||
mod ext_parsers;
|
||||
pub mod feedback_client;
|
||||
pub mod folder_trust;
|
||||
pub(crate) mod handlers;
|
||||
pub mod init;
|
||||
pub mod models;
|
||||
pub mod mvp_agent;
|
||||
pub(crate) mod proxy;
|
||||
pub(crate) mod restore_code;
|
||||
pub mod roster;
|
||||
pub mod server;
|
||||
pub mod session_config;
|
||||
pub mod session_registry_client;
|
||||
pub(crate) mod subagent;
|
||||
pub(crate) mod subscription_check;
|
||||
pub(crate) mod update_chunk_merge;
|
||||
|
||||
pub use mvp_agent::MvpAgent;
|
||||
pub use server::{ServerConfig, run_agent_server};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
//! Code-navigation eligibility gating and codebase-index management for [`MvpAgent`].
|
||||
//! Co-located child of `mvp_agent` (`use super::*`).
|
||||
|
||||
use super::*;
|
||||
|
||||
impl MvpAgent {
|
||||
/// Parse the `x.ai/codeNavigation.enabled` capability from an initialize
|
||||
/// request. Returns `false` if the field is absent or not `true`.
|
||||
pub(crate) fn parse_code_nav_capability(init: &acp::InitializeRequest) -> bool {
|
||||
init.client_capabilities
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("x.ai/codeNavigation"))
|
||||
.and_then(|v| v.get("enabled"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Start (or reuse) the codebase index for an eligible code-nav request.
|
||||
///
|
||||
/// Returns `Some((handle, was_newly_started))` on success or `None` when
|
||||
/// config/git-root checks prevent starting. The bool is the authoritative
|
||||
/// "first spawn vs reuse" signal threaded up from `CodebaseIndexManager`.
|
||||
///
|
||||
/// This is the narrow `pub(crate)` entry point for lazy index startup
|
||||
/// from `extensions/code_nav.rs`. Callers must verify eligibility with
|
||||
/// [`code_nav_eligibility_for_request`] before calling this.
|
||||
pub(crate) fn start_codebase_index_for_code_nav(
|
||||
&self,
|
||||
session_id: Option<&acp::SessionId>,
|
||||
cwd: &std::path::Path,
|
||||
) -> Option<(std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>, bool)> {
|
||||
let (handle, was_newly_started) = self.resolve_codebase_index(cwd)?;
|
||||
// Pin the index to the requesting session so the Weak in
|
||||
// CodebaseIndexManager doesn't orphan it immediately.
|
||||
if let Some(sid) = session_id {
|
||||
self.session_index_claims
|
||||
.borrow_mut()
|
||||
.insert(sid.clone(), std::sync::Arc::clone(&handle));
|
||||
}
|
||||
Some((handle, was_newly_started))
|
||||
}
|
||||
|
||||
/// Core eligibility check — pure function that accepts explicit client
|
||||
/// context rather than reading global agent state.
|
||||
///
|
||||
/// This is the single place that applies all four gates. Call it via
|
||||
/// [`code_nav_eligibility_for_request`] (leader-mode safe) or
|
||||
/// [`code_nav_eligibility`] (global state, non-leader use only).
|
||||
pub(super) fn code_nav_eligibility_inner(
|
||||
&self,
|
||||
cwd: &std::path::Path,
|
||||
client_type: ClientType,
|
||||
code_nav_enabled: bool,
|
||||
) -> Result<(), CodeNavEligibility> {
|
||||
use crate::agent::config::CodebaseIndexingSetting;
|
||||
|
||||
// Gate 1: client type
|
||||
if !matches!(client_type, ClientType::GrokWeb) {
|
||||
tracing::info!(
|
||||
client_type = ?client_type,
|
||||
gate = "client_type",
|
||||
skip_reason = "client_not_web",
|
||||
"code-nav eligibility check: skipping (client type not eligible)"
|
||||
);
|
||||
return Err(CodeNavEligibility::ClientNotWeb);
|
||||
}
|
||||
|
||||
// Gate 2: capability advertised
|
||||
if !code_nav_enabled {
|
||||
tracing::info!(
|
||||
gate = "capability",
|
||||
skip_reason = "capability_not_advertised",
|
||||
"code-nav eligibility check: skipping (x.ai/codeNavigation.enabled not advertised)"
|
||||
);
|
||||
return Err(CodeNavEligibility::CapabilityNotAdvertised);
|
||||
}
|
||||
|
||||
// Gate 3: config
|
||||
let setting = self.cfg.borrow().features.codebase_indexing.clone();
|
||||
if let CodebaseIndexingSetting::Enabled(false) = &setting {
|
||||
tracing::info!(
|
||||
gate = "config",
|
||||
skip_reason = "disabled_by_config",
|
||||
"code-nav eligibility check: skipping (codebase_indexing disabled in config)"
|
||||
);
|
||||
return Err(CodeNavEligibility::DisabledByConfig);
|
||||
}
|
||||
|
||||
// Gate 4: git root / config globs
|
||||
let git_root = kigi_workspace::session::git::find_git_root_from_path(cwd).ok();
|
||||
match &setting {
|
||||
CodebaseIndexingSetting::Enabled(true) => {
|
||||
if git_root.is_none() {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
gate = "git_root",
|
||||
skip_reason = "not_git_repo",
|
||||
"code-nav eligibility check: skipping (not inside a git repo)"
|
||||
);
|
||||
return Err(CodeNavEligibility::NotGitRepo);
|
||||
}
|
||||
}
|
||||
CodebaseIndexingSetting::Patterns(_) => {
|
||||
let check_path = git_root.as_deref().unwrap_or(cwd);
|
||||
if !setting.should_index(check_path) {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
gate = "config_globs",
|
||||
skip_reason = "disabled_by_config",
|
||||
"code-nav eligibility check: skipping (not matched by config globs)"
|
||||
);
|
||||
return Err(CodeNavEligibility::DisabledByConfig);
|
||||
}
|
||||
}
|
||||
CodebaseIndexingSetting::Enabled(false) => {} // handled above
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check eligibility using per-session context (leader-mode safe).
|
||||
///
|
||||
/// When `session_id` is provided, reads the session's own client type
|
||||
/// and code-nav capability — the values that were in effect when that
|
||||
/// specific client created the session. This is correct in leader mode
|
||||
/// where multiple clients share one agent process and `initialize()` is
|
||||
/// called once per connection; the global fields on `MvpAgent` reflect
|
||||
/// only the **last** client to call `initialize()`.
|
||||
///
|
||||
/// Falls back to global agent state when no session_id is given.
|
||||
pub fn code_nav_eligibility_for_request(
|
||||
&self,
|
||||
session_id: Option<&acp::SessionId>,
|
||||
cwd: &std::path::Path,
|
||||
) -> Result<(), CodeNavEligibility> {
|
||||
let session_id = match session_id {
|
||||
Some(sid) => sid,
|
||||
// No session_id: per-client capability cannot be determined without a
|
||||
// session. Reject with SessionRequired rather than fall back to shared
|
||||
// global state. Callers must provide sessionId for x.ai/code/* requests.
|
||||
None => return Err(CodeNavEligibility::SessionRequired),
|
||||
};
|
||||
|
||||
let sessions = self.sessions.borrow();
|
||||
let (client_type, code_nav_enabled) = if let Some(handle) = sessions.get(session_id) {
|
||||
let ct = crate::http::client_type_from_origin(handle.origin_client.as_ref());
|
||||
(ct, handle.code_nav_enabled)
|
||||
} else {
|
||||
// Session not found (evicted/unknown): reject rather than silently
|
||||
// falling back to shared global state — that would reintroduce the
|
||||
// last-client-wins bug for stale session IDs in leader mode.
|
||||
return Err(CodeNavEligibility::SessionRequired);
|
||||
};
|
||||
drop(sessions);
|
||||
self.code_nav_eligibility_inner(cwd, client_type, code_nav_enabled)
|
||||
}
|
||||
|
||||
/// Check eligibility using the stored initialize_request context.
|
||||
///
|
||||
/// **Not safe in leader mode** — reads the last `initialize()` call's
|
||||
/// client_type and capability. Prefer [`code_nav_eligibility_for_request`]
|
||||
/// when a session_id is available.
|
||||
pub fn code_nav_eligibility(&self, cwd: &std::path::Path) -> Result<(), CodeNavEligibility> {
|
||||
let client_type = *self.client_type.borrow();
|
||||
let code_nav_enabled = self.code_nav_enabled.get();
|
||||
self.code_nav_eligibility_inner(cwd, client_type, code_nav_enabled)
|
||||
}
|
||||
|
||||
/// Resolve and get-or-create the codebase index for `cwd`, applying config
|
||||
/// and git-root eligibility checks.
|
||||
///
|
||||
/// Returns `Some((handle, was_newly_started))` when an index is available,
|
||||
/// `None` when config or git-root checks rule it out. The bool is the
|
||||
/// authoritative "was this a first spawn?" signal from the manager.
|
||||
pub(super) fn resolve_codebase_index(
|
||||
&self,
|
||||
cwd: &std::path::Path,
|
||||
) -> Option<(std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>, bool)> {
|
||||
use crate::agent::config::CodebaseIndexingSetting;
|
||||
|
||||
let setting = self.cfg.borrow().features.codebase_indexing.clone();
|
||||
let git_root = kigi_workspace::session::git::find_git_root_from_path(cwd).ok();
|
||||
|
||||
match (&setting, &git_root) {
|
||||
(CodebaseIndexingSetting::Enabled(false), _) => {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
skip_reason = "disabled_by_config",
|
||||
"code-nav: skipping index creation (disabled in config)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
(CodebaseIndexingSetting::Enabled(true), None) => {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
skip_reason = "not_git_repo",
|
||||
"code-nav: skipping index creation (not inside a git repo)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
(CodebaseIndexingSetting::Patterns(_), _) => {
|
||||
let check_path = git_root.as_deref().unwrap_or(cwd);
|
||||
if !setting.should_index(check_path) {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
skip_reason = "disabled_by_config",
|
||||
"code-nav: skipping index creation (not matched by config globs)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
(CodebaseIndexingSetting::Enabled(true), Some(_)) => {}
|
||||
}
|
||||
|
||||
let target = git_root.unwrap_or_else(|| cwd.to_path_buf());
|
||||
// get_or_create returns the authoritative (handle, was_newly_started) pair.
|
||||
// Log only on actual first spawn so reuse requests are not misleadingly
|
||||
// labelled as "starting".
|
||||
let (handle, was_newly_started) = self.get_or_create_codebase_index(target.clone());
|
||||
if was_newly_started {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
index_target = %target.display(),
|
||||
event = "index_first_spawn",
|
||||
"code-nav: first lazy spawn of codebase index"
|
||||
);
|
||||
}
|
||||
Some((handle, was_newly_started))
|
||||
}
|
||||
|
||||
pub(super) fn indexed_roots_for(&self, cwd: &std::path::Path) -> Vec<String> {
|
||||
if self.get_codebase_index(cwd).is_some() {
|
||||
return vec![cwd.to_string_lossy().into_owned()];
|
||||
}
|
||||
if let Ok(git_root) = kigi_workspace::session::git::find_git_root_from_path(cwd)
|
||||
&& self.get_codebase_index(&git_root).is_some()
|
||||
{
|
||||
return vec![git_root.to_string_lossy().into_owned()];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Returns `(handle, was_newly_started)` — the bool is the authoritative
|
||||
/// "did this call spawn a new actor?" bit from `CodebaseIndexManager::get_or_create`.
|
||||
pub(super) fn get_or_create_codebase_index(
|
||||
&self,
|
||||
cwd: PathBuf,
|
||||
) -> (std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>, bool) {
|
||||
self.codebase_indexes.lock().get_or_create(cwd)
|
||||
}
|
||||
|
||||
/// Get an existing codebase index for the given cwd.
|
||||
/// Returns None if no index exists for this cwd.
|
||||
pub fn get_codebase_index(
|
||||
&self,
|
||||
cwd: &std::path::Path,
|
||||
) -> Option<std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>> {
|
||||
self.codebase_indexes.lock().get(cwd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Interactive folder-trust prompt: a dormant agent→GUI-client ACP round-trip
|
||||
//! (`x.ai/folder_trust/request`) that asks a GUI client (grok-desktop) to decide
|
||||
//! trust for an untrusted-with-configs workspace, then grants + reloads the
|
||||
//! now-trusted project servers without a restart.
|
||||
//!
|
||||
//! DORMANT in production: it only fires when the connected client advertised
|
||||
//! `x.ai/folderTrust.interactive` AND the folder-trust feature flag is on AND the
|
||||
//! verdict is [`kigi_workspace::folder_trust::TrustOutcome::Prompt`]. No
|
||||
//! client advertises the capability until the desktop UI ships — so this is
|
||||
//! inert by default even with the feature flag on. The TUI/headless clients never
|
||||
//! advertise it (they self-gate trust client-side), so they are never
|
||||
//! double-prompted. Co-located child of `mvp_agent` (`use super::*`).
|
||||
//!
|
||||
//! Post-grant reload scope: MCP, plugins, and each session's own project hooks
|
||||
//! are hot-reloaded in place — for EVERY session sharing the granted workspace
|
||||
//! (same `workspace_key`), each reloaded against its OWN cwd. Project LSP is NOT
|
||||
//! hot-reloaded — the LSP backend is baked into the agent's tool bridge at build
|
||||
//! time (one-shot startup coordinator, no in-place reconfigure API), so repo-local
|
||||
//! `.kigi/lsp.json` servers start on the NEXT session open (the durable grant
|
||||
//! makes the re-spawn trusted). `lsp` is still REPORTED in the prompt's
|
||||
//! `configKinds` (it is a real reason the folder is gated) — only the post-grant
|
||||
//! hot-reload skips it.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Max wait for a GUI client's trust decision before giving up (fail-closed).
|
||||
/// Generous because it is a human decision, but bounds the detached task so a
|
||||
/// connected-but-silent client (modal left open / client bug) can't leak it for
|
||||
/// the whole connection lifetime.
|
||||
const TRUST_PROMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60);
|
||||
|
||||
/// ACP `x.ai/folder_trust/request` payload (agent → GUI client). Serialized as
|
||||
/// `camelCase` for the ACP JSON-RPC wire format.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct FolderTrustRequest {
|
||||
/// The session this prompt belongs to. REQUIRED for leader Tier-2 routing:
|
||||
/// non-interaction reverse-requests are delivered to the driver keyed on
|
||||
/// `params.sessionId`; omitting it makes the leader silently drop the message,
|
||||
/// so the prompt would never reach the client.
|
||||
pub session_id: String,
|
||||
/// The session cwd whose workspace is being gated.
|
||||
pub cwd: String,
|
||||
/// Display path of the canonical workspace key (the trust grant's scope).
|
||||
pub workspace: String,
|
||||
/// Detected repo-local config kinds (e.g. `mcp`, `hooks`, `lsp`) — the
|
||||
/// reasons the folder is gated — for the prompt UI. Display-only, NOT the
|
||||
/// trust gate; derived from the same scan as the gate. `lsp` may appear: it
|
||||
/// is a real reason to prompt, but project LSP applies on the NEXT session
|
||||
/// open rather than hot-reloading on grant (see module docs).
|
||||
pub config_kinds: Vec<String>,
|
||||
}
|
||||
|
||||
/// Outcome of the trust prompt (GUI client → agent). Fail-closed: any value
|
||||
/// other than `"trust"` (including unknown strings, via `#[serde(other)]`)
|
||||
/// decodes to [`FolderTrustOutcome::Reject`], so only an explicit grant unblocks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum FolderTrustOutcome {
|
||||
Trust,
|
||||
#[serde(other)]
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// ACP `x.ai/folder_trust/request` response (GUI client → agent).
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub(crate) struct FolderTrustResponse {
|
||||
pub outcome: FolderTrustOutcome,
|
||||
}
|
||||
|
||||
impl MvpAgent {
|
||||
/// Parse the `x.ai/folderTrust.interactive` capability from an initialize
|
||||
/// request. Returns `false` if absent or not `true`. Mirrors
|
||||
/// [`Self::parse_code_nav_capability`].
|
||||
pub(crate) fn parse_interactive_trust_capability(init: &acp::InitializeRequest) -> bool {
|
||||
init.client_capabilities
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("x.ai/folderTrust"))
|
||||
.and_then(|v| v.get("interactive"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Ask a GUI client to decide trust for `session_id`'s workspace, then grant
|
||||
/// + reload on accept. DORMANT no-op unless the client advertised
|
||||
/// `x.ai/folderTrust.interactive` AND [`folder_trust::prompt_warranted`]
|
||||
/// (feature on + untrusted + repo configs present).
|
||||
///
|
||||
/// Non-blocking: the session was already created with project servers GATED
|
||||
/// (the untrusted resolve in `new_session`/`load_session`), so nothing
|
||||
/// repo-local spawns while the prompt is open. The round-trip + reload run in
|
||||
/// a detached `spawn_local` task, so the `new_session` response is not
|
||||
/// delayed by the (potentially long) user decision. At most one outstanding
|
||||
/// request per workspace per process (dedup), and the await is bounded by
|
||||
/// [`TRUST_PROMPT_TIMEOUT`].
|
||||
pub(crate) fn maybe_spawn_interactive_trust_prompt(
|
||||
&self,
|
||||
session_id: &acp::SessionId,
|
||||
cwd: &std::path::Path,
|
||||
remote: Option<&crate::util::config::RemoteSettings>,
|
||||
) {
|
||||
if !self.interactive_trust_client.get() {
|
||||
return;
|
||||
}
|
||||
if !folder_trust::prompt_warranted(cwd, remote) {
|
||||
return;
|
||||
}
|
||||
let key = kigi_workspace::trust::workspace_key(cwd);
|
||||
// Dedup: skip if this workspace was already prompted/decided (reconnect)
|
||||
// or has a prompt in flight (concurrent same-workspace session). `insert`
|
||||
// returns false when already present. Agent-owned set (no process
|
||||
// global), captured into the task for release on failure/timeout.
|
||||
let prompted = self.interactive_trust_prompted.clone();
|
||||
if !prompted.borrow_mut().insert(key.clone()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture EVERY session sharing the GRANTED WORKSPACE (same
|
||||
// `workspace_key` — the grant's actual scope, aligned with the dedup key),
|
||||
// each with its OWN cwd, so a grant reloads every sibling against its own
|
||||
// project config — exactly like the per-cwd `handle_reload_project_mcp_servers`
|
||||
// / `broadcast_plugin_registry_to_sessions`. `&self` can't be borrowed
|
||||
// across the `spawn_local` boundary, so capture owned clones now.
|
||||
//
|
||||
// INTENTIONAL fail-safe limitation: this is a one-time snapshot taken at
|
||||
// prompt-spawn. A same-workspace session created WHILE the modal is open is
|
||||
// deduped (no second prompt) and is not in this set, so the grant won't
|
||||
// reload it — it stays GATED until its own next session (secure, never
|
||||
// over-exposed). Re-querying at grant time would need the `sessions` map
|
||||
// (a non-`Rc` `RefCell` field) shared into the detached task, which isn't
|
||||
// available here; the fail-safe stale-session window is accepted instead.
|
||||
let targets: Vec<ReloadTarget> = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.values()
|
||||
.filter(|h| {
|
||||
kigi_workspace::trust::workspace_key(std::path::Path::new(&h.info.cwd)) == key
|
||||
})
|
||||
.map(|h| ReloadTarget {
|
||||
cmd_tx: h.cmd_tx.clone(),
|
||||
initial_client_mcp_servers: h.initial_client_mcp_servers.clone(),
|
||||
cwd: PathBuf::from(&h.info.cwd),
|
||||
})
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
prompted.borrow_mut().remove(&key);
|
||||
return;
|
||||
}
|
||||
|
||||
let gateway = self.gateway.clone();
|
||||
let plugin_handle = self.plugin_registry_handle.clone();
|
||||
let managed_mcp_cache = self.managed_mcp_cache.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let can_fetch_managed = self.can_fetch_managed_mcps();
|
||||
let proxy_url = self.cfg.borrow().endpoints.proxy_url();
|
||||
let compat = self.cfg.borrow().compat_resolved;
|
||||
let remote = remote.cloned();
|
||||
let cwd = cwd.to_path_buf();
|
||||
let workspace = key.display().to_string();
|
||||
let config_kinds = folder_trust::detected_config_kinds(&cwd);
|
||||
let session_id = session_id.0.to_string();
|
||||
// Regression guard: every reverse-request must carry a
|
||||
// non-empty sessionId or leader Tier-2 routing silently drops it.
|
||||
debug_assert!(
|
||||
!session_id.is_empty(),
|
||||
"folder_trust reverse-request must carry a non-empty sessionId (design §5.4)"
|
||||
);
|
||||
|
||||
tokio::task::spawn_local(async move {
|
||||
let request = FolderTrustRequest {
|
||||
session_id,
|
||||
cwd: cwd.to_string_lossy().into_owned(),
|
||||
workspace,
|
||||
config_kinds,
|
||||
};
|
||||
// Non-panicking: a struct of String/Vec<String> can't fail to
|
||||
// serialize, but avoid `expect` in prod — bail (and release the dedup
|
||||
// key) on the impossible error rather than aborting the task thread.
|
||||
let raw_params = match serde_json::value::to_raw_value(&request) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "folder trust: request serialization failed");
|
||||
prompted.borrow_mut().remove(&key);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let ext_request = acp::ExtRequest::new("x.ai/folder_trust/request", raw_params.into());
|
||||
|
||||
use agent_client_protocol::Client as _;
|
||||
let outcome = match tokio::time::timeout(
|
||||
TRUST_PROMPT_TIMEOUT,
|
||||
gateway.ext_method(ext_request),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// A decodable response carries the user's decision. An
|
||||
// undecodable success payload is a client/protocol error, not a
|
||||
// decision: stay gated (fail-closed) but release the dedup key so
|
||||
// a later session can re-prompt — same as transport/timeout below.
|
||||
Ok(Ok(raw)) => match serde_json::from_str::<FolderTrustResponse>(raw.0.get()) {
|
||||
Ok(r) => r.outcome,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "folder trust: undecodable trust response; staying gated, releasing dedup key");
|
||||
prompted.borrow_mut().remove(&key);
|
||||
return;
|
||||
}
|
||||
},
|
||||
Ok(Err(e)) => {
|
||||
// Client disconnected / transport error: not a decision —
|
||||
// release the key so a later session can re-prompt.
|
||||
tracing::debug!(error = %e, "folder trust: client trust request failed");
|
||||
prompted.borrow_mut().remove(&key);
|
||||
return;
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
// Connected but silent past the deadline: stay gated, release
|
||||
// the key so a future session may re-prompt.
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
"folder trust: no client decision before timeout; staying gated"
|
||||
);
|
||||
prompted.borrow_mut().remove(&key);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if outcome != FolderTrustOutcome::Trust {
|
||||
// Decided "reject": keep the dedup key so the user is not
|
||||
// re-prompted for this workspace on every reconnect.
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
"folder trust: GUI client declined; workspace stays gated"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check the dedup key before granting. `HooksAction::Untrust`
|
||||
// removes this workspace's key (and revokes asynchronously) when the
|
||||
// user untrusts. If that fired while the modal was open, the key is
|
||||
// gone — honor the untrust and drop this now-stale "trust" rather
|
||||
// than re-persisting a grant the user just revoked. The single-
|
||||
// threaded LocalSet makes this check + grant atomic w.r.t. the
|
||||
// untrust task (no await in between).
|
||||
if !prompted.borrow().contains(&key) {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
"folder trust: workspace untrusted while prompt was open; ignoring stale grant"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the grant, then flip the cached untrusted verdict to trusted
|
||||
// (the `Some(false)` arm of `resolve_and_record` re-reads the store).
|
||||
folder_trust::grant_folder_trust(&cwd);
|
||||
folder_trust::resolve_and_record(&cwd, remote.as_ref(), false);
|
||||
|
||||
reload_project_servers_after_grant(ReloadAfterGrant {
|
||||
gateway: &gateway,
|
||||
targets,
|
||||
plugin_handle: &plugin_handle,
|
||||
managed_mcp_cache: &managed_mcp_cache,
|
||||
auth_manager: &auth_manager,
|
||||
can_fetch_managed,
|
||||
proxy_url: &proxy_url,
|
||||
compat: &compat,
|
||||
prompt_cwd: &cwd,
|
||||
})
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
"folder trust: granted via GUI client; reloaded project servers"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One session to reload after a grant, with ITS OWN cwd (so the MCP merge +
|
||||
/// plugin build use the session's own project config — matching the per-cwd
|
||||
/// canonical reloaders, not the prompt's cwd).
|
||||
struct ReloadTarget {
|
||||
cmd_tx: tokio::sync::mpsc::UnboundedSender<crate::session::SessionCommand>,
|
||||
initial_client_mcp_servers: Vec<acp::McpServer>,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
/// Inputs for [`reload_project_servers_after_grant`], bundled to keep the
|
||||
/// orchestrator free of a long positional arg list.
|
||||
struct ReloadAfterGrant<'a> {
|
||||
gateway: &'a GatewaySender,
|
||||
/// Every session sharing the granted workspace, each with its own cwd.
|
||||
targets: Vec<ReloadTarget>,
|
||||
plugin_handle: &'a kigi_agent::plugins::SharedPluginRegistryHandle,
|
||||
managed_mcp_cache: &'a crate::session::managed_mcp::ManagedMcpStateHandle,
|
||||
auth_manager: &'a std::sync::Arc<AuthManager>,
|
||||
can_fetch_managed: bool,
|
||||
proxy_url: &'a str,
|
||||
compat: &'a kigi_tools::types::CompatConfig,
|
||||
/// The prompting session's cwd — used only for the client catalog push.
|
||||
prompt_cwd: &'a std::path::Path,
|
||||
}
|
||||
|
||||
/// Reload each granted-workspace session's now-trusted project servers in place
|
||||
/// (no restart), driving the canonical primitives the normal spawn/reload paths
|
||||
/// use — PER SESSION CWD, like `handle_reload_project_mcp_servers` /
|
||||
/// `broadcast_plugin_registry_to_sessions`: `fetch_managed_mcp_configs` +
|
||||
/// `merge_managed_mcp_servers` (`SessionCommand::UpdateMcpServers`), `build_for_cwd`
|
||||
/// (`SessionCommand::ReloadPlugins`), and `reload_hooks_impl`
|
||||
/// (`SessionCommand::ReloadHooks`), then push the refreshed MCP catalog. LSP is
|
||||
/// spawn-baked and applies on the next session open (see module docs). Caller
|
||||
/// must have granted + recorded trust first.
|
||||
async fn reload_project_servers_after_grant(ctx: ReloadAfterGrant<'_>) {
|
||||
// Managed (gateway/Toolbox) servers must survive the re-merge; fetch them once
|
||||
// (cwd-independent) via the shared helper (single-sources the auth-key dance
|
||||
// with `MvpAgent::get_managed_mcp_configs`). The plugin MCP snapshot is also
|
||||
// global, so it is fine to reuse across cwds for the merge.
|
||||
let managed = if ctx.can_fetch_managed {
|
||||
crate::session::managed_mcp::fetch_managed_mcp_configs(
|
||||
ctx.managed_mcp_cache,
|
||||
ctx.proxy_url,
|
||||
ctx.auth_manager,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let plugin_snapshot = ctx.plugin_handle.snapshot();
|
||||
|
||||
for target in ctx.targets {
|
||||
// Per-session cwd: a sibling session in a subdir of the granted workspace
|
||||
// must get ITS OWN project config, not the prompt's.
|
||||
let session_cwd = target.cwd.as_path();
|
||||
// MCP: `merge_managed_mcp_servers` re-reads disk + runs
|
||||
// `filter_untrusted_project_mcp`, which now KEEPS project servers because
|
||||
// the cached verdict was flipped to trusted (same workspace key).
|
||||
let merged = crate::session::managed_mcp::merge_managed_mcp_servers(
|
||||
target.initial_client_mcp_servers,
|
||||
session_cwd,
|
||||
&managed,
|
||||
plugin_snapshot.as_deref(),
|
||||
ctx.compat,
|
||||
);
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let _ = target
|
||||
.cmd_tx
|
||||
.send(crate::session::SessionCommand::UpdateMcpServers {
|
||||
mcp_servers: merged,
|
||||
respond_to: tx,
|
||||
});
|
||||
// Plugins (+ plugin-contributed hooks) built for this session's own cwd
|
||||
// on the folder-trust verdict (mirrors `broadcast_plugin_registry_to_sessions`);
|
||||
// the grant + resolve_and_record above flipped the cached verdict to trusted.
|
||||
let disk_cfg =
|
||||
crate::config::resolve_effective_plugins_config(session_cwd).to_discovery_config();
|
||||
let project_trusted = folder_trust::project_scope_allowed(session_cwd);
|
||||
// Session `_meta.pluginDirs` are re-merged by the receiving actor
|
||||
// (`preserve_session_plugin_dirs` on `ReloadPlugins`).
|
||||
let registry =
|
||||
ctx.plugin_handle
|
||||
.build_for_cwd(session_cwd, &disk_cfg, &[], project_trusted);
|
||||
let _ = target
|
||||
.cmd_tx
|
||||
.send(crate::session::SessionCommand::ReloadPlugins { registry });
|
||||
// The session's OWN project hooks (`.kigi/hooks`, `.cursor/hooks.json`),
|
||||
// which `ReloadPlugins` does NOT touch — re-discovered against the actor's
|
||||
// own `session_info.cwd` on the now-trusted verdict by `reload_hooks_impl`.
|
||||
let _ = target
|
||||
.cmd_tx
|
||||
.send(crate::session::SessionCommand::ReloadHooks);
|
||||
}
|
||||
|
||||
// Push the refreshed MCP catalog (for the prompting session's cwd) so the
|
||||
// client UI reflects the now-trusted repo-local servers.
|
||||
let local = folder_trust::filter_untrusted_project_mcp(
|
||||
ctx.prompt_cwd,
|
||||
crate::util::config::load_mcp_servers(ctx.prompt_cwd, ctx.compat),
|
||||
);
|
||||
crate::extensions::mcp::notify_servers_updated(ctx.gateway, &managed, &local).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn init_with_meta(meta: Option<serde_json::Value>) -> acp::InitializeRequest {
|
||||
// Production reads `client_capabilities.meta`, not top-level request meta.
|
||||
let mut caps = acp::ClientCapabilities::new()
|
||||
.fs(acp::FileSystemCapabilities::new())
|
||||
.terminal(false);
|
||||
if let Some(m) = meta
|
||||
&& let Some(map) = m.as_object().cloned()
|
||||
{
|
||||
caps = caps.meta(map);
|
||||
}
|
||||
acp::InitializeRequest::new(acp::ProtocolVersion::V1).client_capabilities(caps)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_interactive_trust_capability_present_and_true() {
|
||||
let mut meta = serde_json::Map::new();
|
||||
meta.insert(
|
||||
"x.ai/folderTrust".to_string(),
|
||||
serde_json::json!({ "interactive": true }),
|
||||
);
|
||||
let init = init_with_meta(Some(serde_json::Value::Object(meta)));
|
||||
assert!(MvpAgent::parse_interactive_trust_capability(&init));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_interactive_trust_capability_absent_returns_false() {
|
||||
let init = init_with_meta(None);
|
||||
assert!(!MvpAgent::parse_interactive_trust_capability(&init));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_interactive_trust_capability_false_returns_false() {
|
||||
let mut meta = serde_json::Map::new();
|
||||
meta.insert(
|
||||
"x.ai/folderTrust".to_string(),
|
||||
serde_json::json!({ "interactive": false }),
|
||||
);
|
||||
let init = init_with_meta(Some(serde_json::Value::Object(meta)));
|
||||
assert!(!MvpAgent::parse_interactive_trust_capability(&init));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_serializes_camel_case_with_session_id() {
|
||||
let req = FolderTrustRequest {
|
||||
session_id: "sess-1".into(),
|
||||
cwd: "/repo".into(),
|
||||
workspace: "/repo".into(),
|
||||
config_kinds: vec!["mcp".into()],
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert!(json.get("configKinds").is_some());
|
||||
assert!(json.get("config_kinds").is_none());
|
||||
// Leader Tier-2 routing reads `params.sessionId`; it must be present and
|
||||
// non-empty (regression guard for the silently-dropped-in-leader bug).
|
||||
assert_eq!(json["sessionId"], "sess-1");
|
||||
assert!(!json["sessionId"].as_str().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_decodes_trust_reject_and_unknown_fail_closed() {
|
||||
let trust: FolderTrustResponse = serde_json::from_str(r#"{"outcome":"trust"}"#).unwrap();
|
||||
assert_eq!(trust.outcome, FolderTrustOutcome::Trust);
|
||||
let reject: FolderTrustResponse = serde_json::from_str(r#"{"outcome":"reject"}"#).unwrap();
|
||||
assert_eq!(reject.outcome, FolderTrustOutcome::Reject);
|
||||
// Unknown outcome must fail closed to Reject (never silently "trust").
|
||||
let unknown: FolderTrustResponse = serde_json::from_str(r#"{"outcome":"banana"}"#).unwrap();
|
||||
assert_eq!(unknown.outcome, FolderTrustOutcome::Reject);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
use super::{PromptResponseMetaArgs, build_prompt_response_meta};
|
||||
use kigi_sampling_types::TokenUsage;
|
||||
|
||||
/// Baseline args with no usage, cancellation, or structured output.
|
||||
fn args<'a>(
|
||||
session_id: &'a str,
|
||||
prompt_id: &'a str,
|
||||
total_tokens: u64,
|
||||
model_id: &'a str,
|
||||
) -> PromptResponseMetaArgs<'a> {
|
||||
PromptResponseMetaArgs {
|
||||
session_id,
|
||||
prompt_id,
|
||||
total_tokens,
|
||||
model_id,
|
||||
last_turn_usage: None,
|
||||
prompt_usage: None,
|
||||
cancellation_category: None,
|
||||
cancel_trigger: None,
|
||||
structured_output: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_baseline_keys_without_usage() {
|
||||
let meta = build_prompt_response_meta(args("sess-1", "prompt-1", 42_000, "grok-4.5"));
|
||||
assert_eq!(meta["sessionId"], "sess-1");
|
||||
assert_eq!(meta["requestId"], "prompt-1");
|
||||
assert_eq!(meta["promptId"], "prompt-1");
|
||||
assert_eq!(meta["totalTokens"], 42_000);
|
||||
assert_eq!(meta["modelId"], "grok-4.5");
|
||||
// No per-turn keys when usage is absent.
|
||||
assert!(meta.get("inputTokens").is_none());
|
||||
assert!(meta.get("outputTokens").is_none());
|
||||
assert!(meta.get("cachedReadTokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enriches_meta_with_camelcase_token_keys() {
|
||||
let usage = TokenUsage {
|
||||
prompt_tokens: 1500,
|
||||
completion_tokens: 200,
|
||||
total_tokens: 1700,
|
||||
reasoning_tokens: 75,
|
||||
cached_prompt_tokens: 1000,
|
||||
};
|
||||
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
last_turn_usage: Some(&usage),
|
||||
..args("sess-1", "prompt-1", 1_700, "grok-4.5")
|
||||
});
|
||||
// Bot's _META_TOKEN_KEY_MAP expects exactly these camelCase keys.
|
||||
assert_eq!(meta["inputTokens"], 1500);
|
||||
assert_eq!(meta["outputTokens"], 200);
|
||||
assert_eq!(meta["cachedReadTokens"], 1000);
|
||||
// Reasoning tokens carried through for diagnostic visibility.
|
||||
assert_eq!(meta["reasoningTokens"], 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_zero_token_values() {
|
||||
// Responses API hits with no cache return cached_prompt_tokens=0.
|
||||
// The key is still emitted as 0 so the bot can distinguish "no cache
|
||||
// hit" from "no usage data". (The bot's _merge_meta_usage requires
|
||||
// the key to be present and integer-typed.)
|
||||
let usage = TokenUsage {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 110,
|
||||
reasoning_tokens: 0,
|
||||
cached_prompt_tokens: 0,
|
||||
};
|
||||
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
last_turn_usage: Some(&usage),
|
||||
..args("s", "p", 110, "m")
|
||||
});
|
||||
assert_eq!(meta["cachedReadTokens"], 0);
|
||||
assert_eq!(meta["reasoningTokens"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_object_lands_on_meta() {
|
||||
let mut ledger = kigi_chat_state::UsageLedger::default();
|
||||
ledger.record_main_loop_call(
|
||||
"m",
|
||||
&TokenUsage {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 999_999,
|
||||
reasoning_tokens: 0,
|
||||
cached_prompt_tokens: 0,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
prompt_usage: Some(crate::extensions::notification::PromptUsage::from(&ledger)),
|
||||
..args("s", "p", 110, "m")
|
||||
});
|
||||
assert_eq!(meta["usage"]["totalTokens"], 110);
|
||||
assert_eq!(meta["usage"]["modelUsage"]["m"]["inputTokens"], 100);
|
||||
assert!(
|
||||
build_prompt_response_meta(args("s", "p", 0, "m"))
|
||||
.get("usage")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_trigger_lands_as_camelcase_meta_key() {
|
||||
// A send-now cancelled turn's PromptResponse `_meta` carries `cancelTrigger: "send_now"`.
|
||||
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
cancel_trigger: Some("send_now".to_string()),
|
||||
..args("s", "p", 0, "m")
|
||||
});
|
||||
assert_eq!(meta["cancelTrigger"], "send_now");
|
||||
|
||||
// Absent for non-cancel completions — the key must not appear.
|
||||
let none = build_prompt_response_meta(args("s", "p", 0, "m"));
|
||||
assert!(none.get("cancelTrigger").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_output_maps_to_camelcase_meta_keys() {
|
||||
// Success carries the validated value under `structuredOutput`; no error key.
|
||||
let ok = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
structured_output: Some(Ok(serde_json::json!({"name": "ada"}))),
|
||||
..args("s", "p", 0, "m")
|
||||
});
|
||||
assert_eq!(ok["structuredOutput"]["name"], "ada");
|
||||
assert!(ok.get("structuredOutputError").is_none());
|
||||
|
||||
// Failure carries the message under `structuredOutputError`; no value key.
|
||||
let err = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
structured_output: Some(Err("output does not match the required schema".to_string())),
|
||||
..args("s", "p", 0, "m")
|
||||
});
|
||||
assert_eq!(
|
||||
err["structuredOutputError"],
|
||||
"output does not match the required schema"
|
||||
);
|
||||
assert!(err.get("structuredOutput").is_none());
|
||||
|
||||
// No schema requested → neither key present.
|
||||
let none = build_prompt_response_meta(args("s", "p", 0, "m"));
|
||||
assert!(none.get("structuredOutput").is_none());
|
||||
assert!(none.get("structuredOutputError").is_none());
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
//! Session lifecycle, roster deltas, and the idle-session supervisor for [`MvpAgent`].
|
||||
//! Co-located `#[path]`-style child of `mvp_agent` (`use super::*`) so the `impl`
|
||||
//! block keeps access to `MvpAgent`'s private fields.
|
||||
use super::*;
|
||||
impl MvpAgent {
|
||||
/// Ask a live session actor to shut down.
|
||||
pub(crate) fn request_session_shutdown(&self, id: &acp::SessionId) {
|
||||
if let Some(handle) = self.sessions.borrow().get(id) {
|
||||
let _ = handle.cmd_tx.send(SessionCommand::Shutdown);
|
||||
}
|
||||
}
|
||||
/// Finalize the cloud session replica (fire-and-forget, "Hook 4").
|
||||
///
|
||||
/// Marks the session **done** upstream, so this MUST only run on a genuine
|
||||
/// session end — a terminal/explicit close (`x.ai/session/close`). It must
|
||||
/// NOT run on a mere client disconnect or a dead-actor reap: those leave the
|
||||
/// conversation resumable on disk, and finalizing would wrongly mark a still
|
||||
/// running/resumable session "done".
|
||||
pub(super) fn finalize_session_replica(&self, id: &acp::SessionId) {
|
||||
#[cfg(test)]
|
||||
self.finalize_spy.borrow_mut().push(id.0.to_string());
|
||||
if let Some(client) = self.session_registry_client() {
|
||||
let sid = id.0.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = client.finalize(&sid).await {
|
||||
tracing::warn!(
|
||||
error = % e, "session registry finalize failed (non-fatal)"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Remove a session and its thread handle **without** finalizing the cloud
|
||||
/// replica.
|
||||
///
|
||||
/// Used for dead-actor reaping and idle-unload: the conversation stays
|
||||
/// resumable on disk, so it must NOT be marked "done" upstream. Genuine
|
||||
/// terminal closes go through [`MvpAgent::close_session_explicit`]. Also
|
||||
/// drops the `session_live_state` entry so that map stays bounded.
|
||||
pub(crate) fn remove_session(&self, id: &acp::SessionId) {
|
||||
self.sessions.borrow_mut().remove(id);
|
||||
self.prompt_intake_locks.borrow_mut().remove(id);
|
||||
self.session_threads.borrow_mut().remove(id);
|
||||
self.session_index_claims.borrow_mut().remove(id);
|
||||
self.require_gateway_sessions.borrow_mut().remove(id);
|
||||
self.session_live_state.borrow_mut().remove(id);
|
||||
}
|
||||
/// Get-or-create the per-session prompt-intake lock (see
|
||||
/// [`Self::prompt_intake_locks`]). Cheap clone of the shared `Rc`.
|
||||
pub(super) fn prompt_intake_lock(
|
||||
&self,
|
||||
id: &acp::SessionId,
|
||||
) -> std::rc::Rc<tokio::sync::Mutex<()>> {
|
||||
self.prompt_intake_locks
|
||||
.borrow_mut()
|
||||
.entry(id.clone())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
/// Close a session in response to an **explicit** terminal close
|
||||
/// (`x.ai/session/close`). Finalizes the cloud replica (genuine session
|
||||
/// end), then removes the session terminally as `Completed`.
|
||||
pub(crate) fn close_session_explicit(&self, id: &acp::SessionId) {
|
||||
self.finalize_session_replica(id);
|
||||
self.remove_session_terminal(id, SessionLiveState::Completed);
|
||||
}
|
||||
/// Record the coarse lifecycle state for a session.
|
||||
pub(super) fn set_session_live_state(&self, id: &acp::SessionId, state: SessionLiveState) {
|
||||
self.session_live_state
|
||||
.borrow_mut()
|
||||
.insert(id.clone(), state);
|
||||
}
|
||||
/// Read the recorded lifecycle state for a session (test observability).
|
||||
#[cfg(test)]
|
||||
pub(super) fn session_live_state_for(&self, id: &acp::SessionId) -> Option<SessionLiveState> {
|
||||
self.session_live_state.borrow().get(id).copied()
|
||||
}
|
||||
/// Roster-delta hook for a terminally removed session. Broadcasts an
|
||||
/// `x.ai/sessions/changed` notification with the session in `removed` so
|
||||
/// every attached dashboard drops the row promptly. Also
|
||||
/// records the call site (and the terminal state) for test observability,
|
||||
/// since the `session_live_state` entry is dropped on removal.
|
||||
pub(super) fn record_roster_delta(&self, id: &acp::SessionId, final_state: SessionLiveState) {
|
||||
#[cfg(test)]
|
||||
self.roster_delta_spy
|
||||
.borrow_mut()
|
||||
.push((id.0.to_string(), final_state));
|
||||
tracing::debug!(
|
||||
session_id = % id.0, ? final_state, "roster delta: session removed"
|
||||
);
|
||||
self.emit_roster_changed(Vec::new(), vec![id.0.to_string()]);
|
||||
}
|
||||
/// Roster-delta hook for a newly-resident / changed session. Broadcasts an
|
||||
/// `x.ai/sessions/changed` notification with the current entry in
|
||||
/// `upserted` so dashboards add/refresh the row.
|
||||
pub(crate) fn push_roster_delta_upserted(&self, id: &acp::SessionId) {
|
||||
if let Some(entry) = self.resident_roster_entry(id) {
|
||||
self.emit_roster_changed(vec![entry], Vec::new());
|
||||
}
|
||||
}
|
||||
/// Emit an `x.ai/sessions/changed` upsert for a resident session with an
|
||||
/// explicit `activity`, so every attached dashboard reflects a
|
||||
/// turn-boundary transition (Working / Idle / NeedsInput) *immediately*
|
||||
/// rather than waiting for the ≤1s roster poll (deltas are emitted
|
||||
/// at turn-start/turn-end). Without this, a viewer client that holds no
|
||||
/// local `AgentView` for the session only learns its activity from the
|
||||
/// poll, so a turn driven by another client shows as `Idle` for up to a
|
||||
/// poll interval — and not at all while that viewer's poll is dormant.
|
||||
///
|
||||
/// The `activity` is supplied by the caller rather than read from
|
||||
/// `resident_activity` because at turn-start the actor may not have
|
||||
/// published `current_prompt_id` yet (it is set asynchronously once the
|
||||
/// actor dequeues the `SessionCommand::Prompt`), so a natural read would
|
||||
/// still observe `Idle`. The authoritative entry (cwd / worktree / model /
|
||||
/// yolo) is built by `resident_roster_entry`, so it never diverges from
|
||||
/// the polled entry; only the `activity` field is overridden.
|
||||
pub(super) fn push_roster_activity_delta(
|
||||
&self,
|
||||
id: &acp::SessionId,
|
||||
activity: crate::agent::roster::RosterActivity,
|
||||
) {
|
||||
if let Some(mut entry) = self.resident_roster_entry(id) {
|
||||
entry.activity = activity;
|
||||
self.emit_roster_changed(vec![entry], Vec::new());
|
||||
}
|
||||
}
|
||||
/// Fan an `x.ai/sessions/changed` delta out to every attached client.
|
||||
///
|
||||
/// This is a roster-wide notification (no `sessionId`), so the leader IPC
|
||||
/// server broadcasts it to all clients rather than routing by session (see
|
||||
/// the `x.ai/sessions/changed` special-case in `leader/server.rs`).
|
||||
pub(super) fn emit_roster_changed(
|
||||
&self,
|
||||
upserted: Vec<crate::agent::roster::RosterEntry>,
|
||||
removed: Vec<String>,
|
||||
) {
|
||||
if upserted.is_empty() && removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let payload = crate::agent::roster::RosterChanged { upserted, removed };
|
||||
if let Ok(params) = serde_json::value::to_raw_value(&payload) {
|
||||
self.gateway
|
||||
.forward_fire_and_forget(acp::ExtNotification::new(
|
||||
crate::agent::roster::SESSIONS_CHANGED_METHOD,
|
||||
params.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
/// Coarse activity of a resident session for the dashboard status column.
|
||||
///
|
||||
/// Precedence: a non-empty pending-interaction map →
|
||||
/// `NeedsInput` (wins even over a running turn — a session awaiting a
|
||||
/// permission *mid-turn* is "needs input"); else a running turn →
|
||||
/// `Working`; else map the coarse `SessionLiveState`.
|
||||
pub(super) fn resident_activity(
|
||||
&self,
|
||||
id: &acp::SessionId,
|
||||
) -> crate::agent::roster::RosterActivity {
|
||||
use crate::agent::roster::RosterActivity;
|
||||
let (needs_input, turn_running) = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(id)
|
||||
.map(|h| {
|
||||
let needs_input = h
|
||||
.pending_interactions
|
||||
.lock()
|
||||
.map(|g| !g.is_empty())
|
||||
.unwrap_or(false);
|
||||
let turn_running = h
|
||||
.current_prompt_id
|
||||
.lock()
|
||||
.map(|g| g.is_some())
|
||||
.unwrap_or(false);
|
||||
(needs_input, turn_running)
|
||||
})
|
||||
.unwrap_or((false, false));
|
||||
if needs_input {
|
||||
return RosterActivity::NeedsInput;
|
||||
}
|
||||
if turn_running {
|
||||
return RosterActivity::Working;
|
||||
}
|
||||
match self.session_live_state.borrow().get(id).copied() {
|
||||
Some(SessionLiveState::Completed) => RosterActivity::Completed,
|
||||
Some(SessionLiveState::DeadFailed) => RosterActivity::Dead,
|
||||
Some(SessionLiveState::Dormant) => RosterActivity::Dormant,
|
||||
_ => RosterActivity::Idle,
|
||||
}
|
||||
}
|
||||
/// Build a single roster entry for a resident session, or `None` if it is
|
||||
/// not currently resident.
|
||||
pub(super) fn resident_roster_entry(
|
||||
&self,
|
||||
id: &acp::SessionId,
|
||||
) -> Option<crate::agent::roster::RosterEntry> {
|
||||
let session_id = id.0.to_string();
|
||||
let (cwd, is_worktree, model_id, reasoning_effort, yolo) = {
|
||||
let sessions = self.sessions.borrow();
|
||||
let h = sessions.get(id)?;
|
||||
(
|
||||
h.display_cwd.clone().unwrap_or_else(|| h.info.cwd.clone()),
|
||||
h.display_cwd.is_some(),
|
||||
Some(h.model_id.0.to_string()),
|
||||
h.reasoning_effort,
|
||||
h.yolo_mode,
|
||||
)
|
||||
};
|
||||
Some(crate::agent::roster::RosterEntry {
|
||||
title: self
|
||||
.resident_roster_titles
|
||||
.borrow()
|
||||
.get(&session_id)
|
||||
.cloned(),
|
||||
session_id,
|
||||
cwd,
|
||||
is_worktree,
|
||||
model_id,
|
||||
reasoning_effort,
|
||||
yolo,
|
||||
activity: self.resident_activity(id),
|
||||
resident: true,
|
||||
last_change_unix_ms: chrono::Utc::now().timestamp_millis(),
|
||||
origin: crate::agent::roster::RosterOrigin::Local,
|
||||
})
|
||||
}
|
||||
/// Snapshot all resident sessions as roster entries (synchronous; no disk).
|
||||
pub(super) fn resident_roster_entries(&self) -> Vec<crate::agent::roster::RosterEntry> {
|
||||
let ids: Vec<acp::SessionId> = self.sessions.borrow().keys().cloned().collect();
|
||||
ids.iter()
|
||||
.filter_map(|id| self.resident_roster_entry(id))
|
||||
.collect()
|
||||
}
|
||||
/// Build the full roster: resident actors plus recently-touched on-disk
|
||||
/// (`Dormant`) sessions. Resident wins on an id collision; hidden sessions
|
||||
/// are excluded.
|
||||
pub(crate) async fn build_roster(&self) -> Vec<crate::agent::roster::RosterEntry> {
|
||||
let resident = self.resident_roster_entries();
|
||||
let summaries = crate::session::persistence::list_recent_summaries(200)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let entries = crate::agent::roster::merge_roster(resident, summaries);
|
||||
self.cache_resident_titles(&entries);
|
||||
entries
|
||||
}
|
||||
/// Refresh `resident_roster_titles` from the freshly-built roster.
|
||||
pub(super) fn cache_resident_titles(&self, entries: &[crate::agent::roster::RosterEntry]) {
|
||||
*self.resident_roster_titles.borrow_mut() = entries
|
||||
.iter()
|
||||
.filter(|e| e.resident)
|
||||
.filter_map(|e| Some((e.session_id.clone(), e.title.clone()?)))
|
||||
.collect();
|
||||
}
|
||||
/// Terminally remove a session: emit the roster delta with its final state,
|
||||
/// then drop it from all maps (no finalize — callers that need finalize do
|
||||
/// it first, see `close_session_explicit`).
|
||||
pub(super) fn remove_session_terminal(
|
||||
&self,
|
||||
id: &acp::SessionId,
|
||||
final_state: SessionLiveState,
|
||||
) {
|
||||
self.record_roster_delta(id, final_state);
|
||||
self.remove_session(id);
|
||||
}
|
||||
/// Reap a session whose **resident** actor thread exited unexpectedly
|
||||
/// (panic / load failure). Demotes it to `DeadFailed`, emits the roster
|
||||
/// delta, and removes it WITHOUT finalize — the conversation persists on
|
||||
/// disk and stays resumable (reaping a dead actor is harmless;
|
||||
/// it demotes to Dormant).
|
||||
pub(super) fn reap_dead_session(&self, id: &acp::SessionId) {
|
||||
self.remove_session_terminal(id, SessionLiveState::DeadFailed);
|
||||
}
|
||||
/// Sweep `session_threads` for finished threads and clean them up.
|
||||
///
|
||||
/// A finished thread has two distinct meanings, and conflating them
|
||||
/// corrupts the `SessionLiveState` roster source:
|
||||
///
|
||||
/// - **Still resident in `sessions`** → the actor exited unexpectedly while
|
||||
/// the session was hosted (panic / load failure). Reap as `DeadFailed`.
|
||||
/// - **Not resident** (already idle-unloaded → `Dormant`, or explicitly
|
||||
/// closed) → this is the *expected* clean exit. The `SessionThread` was
|
||||
/// kept only so `drain_old_session_thread` could wait on it; now that it
|
||||
/// has finished there is nothing left to drain, so just drop the leftover
|
||||
/// `SessionThread`/state entries. Do **not** demote to `DeadFailed` and do
|
||||
/// **not** emit a second roster delta.
|
||||
///
|
||||
/// `JoinHandle::is_finished()` is non-blocking and cannot distinguish a
|
||||
/// clean exit from a panic on its own, which is exactly why the residency
|
||||
/// check is required. Runs both opportunistically and from the join-handle
|
||||
/// supervisor (`ensure_session_supervisor`).
|
||||
pub(super) fn sweep_dead_sessions(&self) {
|
||||
let dead: Vec<acp::SessionId> = self
|
||||
.session_threads
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|(_, t)| t.is_finished())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in dead {
|
||||
if self.sessions.borrow().contains_key(&id) {
|
||||
tracing::warn!(
|
||||
session_id = % id.0,
|
||||
"Resident session actor exited unexpectedly; reaping as DeadFailed"
|
||||
);
|
||||
self.reap_dead_session(&id);
|
||||
} else {
|
||||
self.session_threads.borrow_mut().remove(&id);
|
||||
self.session_live_state.borrow_mut().remove(&id);
|
||||
tracing::debug!(
|
||||
session_id = % id.0,
|
||||
"Reaped finished thread for non-resident session (clean exit)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Start the join-handle supervisor. **Idempotent.**
|
||||
///
|
||||
/// A single `spawn_local` task periodically reaps actor threads that have
|
||||
/// exited (panicked or finished) so a dead actor never lingers as a roster
|
||||
/// zombie. `std::thread::JoinHandle` is not awaitable, so we poll
|
||||
/// `is_finished()` on a tick — the same mechanism `drain_old_session_thread`
|
||||
/// and `sweep_dead_sessions` already use. A panicked actor is therefore
|
||||
/// reaped within one [`SESSION_SUPERVISOR_TICK`].
|
||||
///
|
||||
/// The sweep body is wrapped in `catch_unwind` so a single panicking sweep
|
||||
/// can never terminate the loop (which would silently disable reaping for
|
||||
/// the rest of the process). The task holds a `LocalRef` (raw pointer) to
|
||||
/// `self` for the lifetime of the `LocalSet`; this is sound because the
|
||||
/// agent owns the `LocalSet` and outlives it (same contract as
|
||||
/// `start_subagent_coordinator`), and `LocalRef` is `!Send`.
|
||||
pub(super) fn ensure_session_supervisor(&self) {
|
||||
if self.supervisor_started.replace(true) {
|
||||
return;
|
||||
}
|
||||
#[cfg(test)]
|
||||
self.supervisor_spawn_count
|
||||
.set(self.supervisor_spawn_count.get() + 1);
|
||||
let agent_ref = LocalRef::new(self);
|
||||
tokio::task::spawn_local(async move {
|
||||
loop {
|
||||
tokio::time::sleep(SESSION_SUPERVISOR_TICK).await;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
agent_ref.get().sweep_dead_sessions();
|
||||
}));
|
||||
if result.is_err() {
|
||||
tracing::error!("session supervisor sweep panicked; continuing supervision");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Coarse "any work pending" check for the idle-unload stub.
|
||||
/// Returns `true` while the session has work in flight.
|
||||
///
|
||||
/// Three layers:
|
||||
/// 1. **Fast path (sync):** the shared `current_prompt_id` slot, which the
|
||||
/// actor sets while a turn is running (`maybe_start_running_task`) and
|
||||
/// clears via its RAII guard. A poisoned lock is treated as busy → never
|
||||
/// unload.
|
||||
/// 1b. **Parked plan-approval (sync):** the shared `pending_interactions`
|
||||
/// slot. The parked plan-approval resume re-park is the one outstanding work with no
|
||||
/// running turn, so it needs its own sync check (the same shared-`Arc`
|
||||
/// idiom as `current_prompt_id`) rather than the async round-trip below.
|
||||
/// 2. **Queue check (async):** when no turn is running, the actor is between
|
||||
/// turns and responsive, so we ask it whether `pending_inputs` is
|
||||
/// non-empty (a prompt queued at the turn boundary). This closes the
|
||||
/// sub-tick window where `current_prompt_id` is momentarily `None` but a
|
||||
/// queued input is about to be drained. On timeout we keep the session
|
||||
/// resident (conservative).
|
||||
///
|
||||
/// TODO(PR-4): once the aggregate `SessionActivity` signal exists, also
|
||||
/// consult the autonomous background sources so a detached session is never
|
||||
/// idle-unloaded (→ `Shutdown` → `KillOnDrop`) while they are live:
|
||||
/// `monitor_event_buffer`, pending scheduler fires,
|
||||
/// `ToolContext.background_tasks`, and background subagent sessions. Until
|
||||
/// then those background-only sessions rely on the keep-resident default and
|
||||
/// the `current_prompt_id` auto-wake turn being active.
|
||||
///
|
||||
/// TODO(PR-4): this is also inherently a *check-then-act* across the
|
||||
/// actor-thread boundary — work can arrive (a new `Prompt`/auto-wake) in the
|
||||
/// gap between this `IsBusy` answer and the caller's subsequent `Shutdown`,
|
||||
/// so an idle-unload can still race a just-arrived turn. The actor processes
|
||||
/// its mailbox in order, so the lost work is bounded and recoverable on
|
||||
/// reload; PR-4 closes the gap properly by gating the unload inside the
|
||||
/// actor (a single `Unload`-if-idle command) rather than check-then-send.
|
||||
pub(super) async fn session_has_live_work(&self, id: &acp::SessionId) -> bool {
|
||||
let Some(handle) = self.sessions.borrow().get(id).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let turn_running = handle
|
||||
.current_prompt_id
|
||||
.lock()
|
||||
.map(|g| g.is_some())
|
||||
.unwrap_or(true);
|
||||
if turn_running {
|
||||
return true;
|
||||
}
|
||||
if crate::session::pending_interaction::has_parked_plan_approval(
|
||||
&handle.pending_interactions,
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
tokio::time::timeout(IDLE_QUERY_TIMEOUT, handle.is_busy())
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
//! Subagent coordinator drain task and spawn-context construction for [`MvpAgent`].
|
||||
//! Co-located child of `mvp_agent` (`use super::*`); tested by `tests/subagent_spawn_context_tests.rs`.
|
||||
use super::*;
|
||||
impl MvpAgent {
|
||||
/// Start the subagent coordinator drain task.
|
||||
///
|
||||
/// Takes the `subagent_event_rx` receiver (once) and spawns a `spawn_local` task
|
||||
/// that receives `SubagentRequest`s and delegates each to
|
||||
/// `handle_subagent_request()` on its own `spawn_local` task.
|
||||
///
|
||||
/// Uses `LocalRef` to reference `self` from
|
||||
/// `spawn_local` closures. Idempotent: subsequent calls are no-ops.
|
||||
pub(super) fn start_subagent_coordinator(&self) {
|
||||
let Some(mut rx) = self.subagent_event_rx.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
let agent_ref = LocalRef::new(self);
|
||||
use crate::agent::subagent::{BlockWaitSlot, is_running, resolve_snapshot};
|
||||
use kigi_tools::implementations::grok_build::task::types::{
|
||||
SubagentCancelOutcome, SubagentCancelTarget, SubagentEvent,
|
||||
};
|
||||
tokio::task::spawn_local({
|
||||
let agent_ref = agent_ref.clone();
|
||||
async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
SubagentEvent::Spawn(boxed) => {
|
||||
let request = *boxed;
|
||||
let agent_ref = agent_ref.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
let this = agent_ref.get();
|
||||
let parent_sid = request.parent_session_id.clone();
|
||||
let mut ctx = this.build_subagent_spawn_context(&parent_sid);
|
||||
let parent_handle = {
|
||||
let parent_sid_acp = acp::SessionId::new(parent_sid.clone());
|
||||
this.sessions.borrow().get(&parent_sid_acp).cloned()
|
||||
};
|
||||
if let Some(handle) = parent_handle {
|
||||
ctx.parent_mcp_pool = handle.snapshot_mcp_pool().await;
|
||||
ctx.client_hooks = handle.snapshot_client_hooks().await;
|
||||
let parent_tools = handle.snapshot_tool_definitions().await;
|
||||
ctx.parent_tool_snapshot =
|
||||
(!parent_tools.is_empty()).then_some(parent_tools);
|
||||
}
|
||||
crate::agent::subagent::handle_subagent_request(
|
||||
request,
|
||||
ctx,
|
||||
&this.subagent_coordinator,
|
||||
&this.gateway,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
SubagentEvent::Query(query) => {
|
||||
let agent_ref = agent_ref.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
let subagent_id = query.subagent_id;
|
||||
let block = query.block;
|
||||
let timeout_ms = query.timeout_ms;
|
||||
let slot: BlockWaitSlot = std::rc::Rc::new(
|
||||
std::cell::RefCell::new(Some(query.respond_to)),
|
||||
);
|
||||
let send_via_slot =
|
||||
|slot: &BlockWaitSlot, snap| match slot.borrow_mut().take() {
|
||||
Some(tx) => tx.send(snap).is_ok(),
|
||||
None => false,
|
||||
};
|
||||
let lookup = {
|
||||
let this = agent_ref.get();
|
||||
let result =
|
||||
this.subagent_coordinator.borrow().lookup(&subagent_id);
|
||||
if block && result.is_some() {
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.register_block_wait(&subagent_id, slot.clone());
|
||||
}
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.evict_stale_completed();
|
||||
result
|
||||
};
|
||||
let snapshot = resolve_snapshot(lookup).await;
|
||||
let should_block =
|
||||
block && snapshot.as_ref().is_some_and(is_running);
|
||||
if should_block {
|
||||
let timeout_ms = timeout_ms.unwrap_or(30_000);
|
||||
let deadline = tokio::time::Instant::now()
|
||||
+ tokio::time::Duration::from_millis(timeout_ms);
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200))
|
||||
.await;
|
||||
let receiver_gone =
|
||||
slot.borrow().as_ref().is_none_or(|tx| tx.is_closed());
|
||||
if receiver_gone {
|
||||
let this = agent_ref.get();
|
||||
let mut coord = this.subagent_coordinator.borrow_mut();
|
||||
coord.clear_block_waited(&subagent_id);
|
||||
coord.unregister_block_wait(&subagent_id, &slot);
|
||||
return;
|
||||
}
|
||||
let lookup = {
|
||||
let this = agent_ref.get();
|
||||
this.subagent_coordinator.borrow().lookup(&subagent_id)
|
||||
};
|
||||
let snap = resolve_snapshot(lookup).await;
|
||||
let still_running = snap.as_ref().is_some_and(is_running);
|
||||
if !still_running || tokio::time::Instant::now() >= deadline
|
||||
{
|
||||
{
|
||||
let this = agent_ref.get();
|
||||
let mut coord =
|
||||
this.subagent_coordinator.borrow_mut();
|
||||
if still_running {
|
||||
coord.clear_block_waited(&subagent_id);
|
||||
}
|
||||
coord.unregister_block_wait(&subagent_id, &slot);
|
||||
}
|
||||
if !send_via_slot(&slot, snap) && !still_running {
|
||||
let this = agent_ref.get();
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.clear_block_waited(&subagent_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let delivered = send_via_slot(&slot, snapshot);
|
||||
if block {
|
||||
let this = agent_ref.get();
|
||||
let mut coord = this.subagent_coordinator.borrow_mut();
|
||||
coord.unregister_block_wait(&subagent_id, &slot);
|
||||
if !delivered {
|
||||
coord.clear_block_waited(&subagent_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
SubagentEvent::Cancel(request) => {
|
||||
let this = agent_ref.get();
|
||||
let outcome = {
|
||||
let mut coord = this.subagent_coordinator.borrow_mut();
|
||||
match request.target {
|
||||
SubagentCancelTarget::SubagentId(ref subagent_id) => {
|
||||
coord.mark_explicitly_killed(subagent_id);
|
||||
coord.cancel_with_outcome(subagent_id)
|
||||
}
|
||||
SubagentCancelTarget::ParentPromptId(ref parent_prompt_id) => {
|
||||
coord.cancel_by_parent_prompt_id(parent_prompt_id);
|
||||
SubagentCancelOutcome::Cancelled
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = request.respond_to.send(outcome);
|
||||
}
|
||||
SubagentEvent::ListActive(request) => {
|
||||
let this = agent_ref.get();
|
||||
let summaries = this
|
||||
.subagent_coordinator
|
||||
.borrow()
|
||||
.active_summaries_for(&request.parent_session_id);
|
||||
let _ = request.respond_to.send(summaries);
|
||||
}
|
||||
SubagentEvent::Completions(request) => {
|
||||
let this = agent_ref.get();
|
||||
let mut completions = this
|
||||
.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.drain_pending_completions();
|
||||
completions.retain(|c| !request.suppress_ids.contains(&c.subagent_id));
|
||||
let _ = request.respond_to.send(completions);
|
||||
}
|
||||
SubagentEvent::Outstanding(request) => {
|
||||
let this = agent_ref.get();
|
||||
let reply = this
|
||||
.subagent_coordinator
|
||||
.borrow()
|
||||
.outstanding_reply_for_prompt(&request.prompt_id);
|
||||
let _ = request.respond_to.send(reply);
|
||||
}
|
||||
SubagentEvent::ClearUsageNotApplied(request) => {
|
||||
let this = agent_ref.get();
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.clear_subagent_usage_not_applied(&request.prompt_id);
|
||||
}
|
||||
SubagentEvent::MarkUsageNotApplied(request) => {
|
||||
let this = agent_ref.get();
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.mark_subagent_usage_not_applied(&request.prompt_id);
|
||||
let _ = request.respond_to.send(());
|
||||
}
|
||||
SubagentEvent::ValidateType(request) => {
|
||||
let agent_ref = agent_ref.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
let this = agent_ref.get();
|
||||
let ctx = this
|
||||
.build_subagent_validation_context(&request.parent_session_id);
|
||||
let outcome = crate::agent::subagent::validate_subagent_type(
|
||||
&request.subagent_type,
|
||||
&ctx,
|
||||
);
|
||||
let _ = request.respond_to.send(outcome);
|
||||
});
|
||||
}
|
||||
SubagentEvent::DescribeType(request) => {
|
||||
let agent_ref = agent_ref.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
use kigi_tools::implementations::grok_build::task::types::SubagentDescribeOutcome;
|
||||
let this = agent_ref.get();
|
||||
let outcome = match this
|
||||
.try_build_subagent_spawn_context(&request.parent_session_id)
|
||||
{
|
||||
Some(ctx) => crate::agent::subagent::describe_subagent_type(
|
||||
&request.subagent_type,
|
||||
request.harness_agent_type.as_deref(),
|
||||
&ctx,
|
||||
),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
parent_session_id = % request.parent_session_id,
|
||||
subagent_type = % request.subagent_type,
|
||||
"DescribeType for unknown/evicted parent session, replying Unavailable",
|
||||
);
|
||||
SubagentDescribeOutcome::Unavailable
|
||||
}
|
||||
};
|
||||
let _ = request.respond_to.send(outcome);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Lightweight context for the `SubagentEvent::ValidateType` drain arm;
|
||||
/// tolerates evicted parent sessions (returns built-in defaults + warns).
|
||||
pub(super) fn build_subagent_validation_context(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> crate::agent::subagent::SubagentValidationContext {
|
||||
let parent_sid = acp::SessionId::new(parent_session_id);
|
||||
let (parent_cwd, allowed_subagent_types) = {
|
||||
let sessions = self.sessions.borrow();
|
||||
let ps = sessions.get(&parent_sid);
|
||||
warn_on_missing_parent_session_for_validate_type(parent_session_id, ps.is_some());
|
||||
(
|
||||
ps.map(|h| std::path::PathBuf::from(&h.info.cwd))
|
||||
.unwrap_or_default(),
|
||||
ps.and_then(|h| h.allowed_subagent_types.clone()),
|
||||
)
|
||||
};
|
||||
let cli_agent_names: Vec<String> = {
|
||||
let cfg = self.cfg.borrow();
|
||||
cfg.cli_agents.iter().map(|d| d.name.clone()).collect()
|
||||
};
|
||||
crate::agent::subagent::SubagentValidationContext {
|
||||
parent_cwd,
|
||||
plugin_registry: self.plugin_registry_handle.snapshot(),
|
||||
subagent_toggle: self.subagent_toggle.clone(),
|
||||
allowed_subagent_types,
|
||||
cli_agent_names,
|
||||
}
|
||||
}
|
||||
/// Build a `SubagentSpawnContext` from the current agent state and the
|
||||
/// parent session's shared resources.
|
||||
///
|
||||
/// This is the ONLY subagent-related method on MvpAgent besides the
|
||||
/// coordinator startup.
|
||||
/// Build a spawn context for a real subagent spawn. The parent session is
|
||||
/// guaranteed present here because the parent just issued the spawn request,
|
||||
/// so a missing parent is a real invariant violation and panics. Read-only
|
||||
/// callers that can race a parent teardown (e.g. `DescribeType`) must use
|
||||
/// [`Self::try_build_subagent_spawn_context`] instead.
|
||||
pub(super) fn build_subagent_spawn_context(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> crate::agent::subagent::SubagentSpawnContext {
|
||||
self.try_build_subagent_spawn_context(parent_session_id)
|
||||
.expect("parent session must exist when spawning subagents")
|
||||
}
|
||||
/// Fallible variant of [`Self::build_subagent_spawn_context`]: returns
|
||||
/// `None` when the parent `SessionHandle` is absent (evicted / torn down)
|
||||
/// instead of panicking, so read-only paths that can race a teardown can
|
||||
/// fail open.
|
||||
pub(super) fn try_build_subagent_spawn_context(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> Option<crate::agent::subagent::SubagentSpawnContext> {
|
||||
let parent_sid = acp::SessionId::new(parent_session_id);
|
||||
let (
|
||||
parent_model_id,
|
||||
parent_chat_state,
|
||||
parent_cmd_tx,
|
||||
parent_cwd,
|
||||
yolo_mode,
|
||||
parent_depth,
|
||||
hunk_tracker_handle,
|
||||
hunk_tracking_enabled,
|
||||
fs,
|
||||
terminal,
|
||||
session_env,
|
||||
parent_attribution_callback,
|
||||
parent_agent_name,
|
||||
parent_managed_mcp_proxy_base_url,
|
||||
) = {
|
||||
let sessions = self.sessions.borrow();
|
||||
let ps = sessions.get(&parent_sid);
|
||||
(
|
||||
ps.map(|h| h.model_id.clone())
|
||||
.unwrap_or_else(|| self.models_manager.current_model_id()),
|
||||
ps.map(|h| h.chat_state_handle.clone()),
|
||||
ps.map(|h| h.cmd_tx.clone()),
|
||||
ps.map(|h| std::path::PathBuf::from(&h.info.cwd))
|
||||
.unwrap_or_default(),
|
||||
ps.map(|h| h.yolo_mode).unwrap_or(self.default_yolo_mode),
|
||||
ps.map(|h| h.tool_context.subagent_depth).unwrap_or(0),
|
||||
ps.map(|h| h.tool_context.hunk_tracker_handle.clone())
|
||||
.unwrap_or_else(kigi_hunk_tracker::HunkTrackerHandle::noop),
|
||||
ps.map(|h| h.tool_context.hunk_tracking_enabled)
|
||||
.unwrap_or(false),
|
||||
ps.map(|h| h.tool_context.fs.inner().clone())
|
||||
.unwrap_or_else(|| {
|
||||
let cwd = ps
|
||||
.map(|h| std::path::PathBuf::from(&h.info.cwd))
|
||||
.unwrap_or_default();
|
||||
std::sync::Arc::new(kigi_workspace::file_system::LocalFs::new(cwd))
|
||||
}),
|
||||
ps.map(|h| h.tool_context.terminal.clone())
|
||||
.unwrap_or_else(|| {
|
||||
std::sync::Arc::new(crate::terminal::TerminalRunner::new(
|
||||
std::sync::Arc::new(self.gateway.clone()),
|
||||
parent_sid.clone(),
|
||||
))
|
||||
}),
|
||||
ps.map(|h| h.tool_context.session_env.clone())
|
||||
.unwrap_or_else(|| std::sync::Arc::new(std::collections::HashMap::new())),
|
||||
ps.and_then(|h| h.attribution_callback.clone()),
|
||||
ps.map(|h| h.agent_name.clone()),
|
||||
ps.map(|h| h.managed_mcp_proxy_base_url.clone()),
|
||||
)
|
||||
};
|
||||
let (
|
||||
parent_workspace_ops,
|
||||
parent_terminal_backend,
|
||||
parent_notification_handle,
|
||||
parent_scheduler_handle,
|
||||
) = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions.get(&parent_sid).map(|ps| {
|
||||
(
|
||||
ps.workspace_ops.clone(),
|
||||
ps.terminal_backend.clone(),
|
||||
ps.tools_notification_handle.clone(),
|
||||
ps.scheduler_handle.clone(),
|
||||
)
|
||||
})
|
||||
}?;
|
||||
let available_models = self.models_manager.models();
|
||||
let parent_lsp = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.and_then(|h| h.tool_context.lsp.clone())
|
||||
};
|
||||
let am = self.auth_manager.clone();
|
||||
let inference_idle_timeout_secs = {
|
||||
let per_model = config::find_model_by_id(&available_models, parent_model_id.0.as_ref())
|
||||
.and_then(|e| e.info.inference_idle_timeout_secs);
|
||||
let cfg = self.cfg.borrow();
|
||||
let remote = cfg
|
||||
.remote_settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.inference_idle_timeout_secs);
|
||||
per_model.or(remote).unwrap_or(600).max(10)
|
||||
};
|
||||
let parent_hook_registry = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.and_then(|h| h.hook_registry.clone())
|
||||
};
|
||||
let parent_max_turns = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions.get(&parent_sid).and_then(|h| h.max_turns)
|
||||
};
|
||||
let parent_model_agent_type =
|
||||
config::find_model_by_id(&available_models, parent_model_id.0.as_ref())
|
||||
.map(|e| e.info.agent_type.clone());
|
||||
let ask_user_question_enabled = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.ask_user_question_enabled)
|
||||
.unwrap_or_else(|| self.cfg.borrow().resolve_ask_user_question().value)
|
||||
};
|
||||
Some(crate::agent::subagent::SubagentSpawnContext {
|
||||
lsp: parent_lsp,
|
||||
gateway: self.gateway.clone(),
|
||||
client_hooks: Default::default(),
|
||||
sampling_config: self.sampling_config.borrow().clone(),
|
||||
managed_mcp_proxy_base_url: parent_managed_mcp_proxy_base_url
|
||||
.unwrap_or_else(|| self.cli_chat_proxy_base_url()),
|
||||
alpha_test_key: self.alpha_test_key(),
|
||||
auth_method_id: self
|
||||
.auth_method_id
|
||||
.load()
|
||||
.as_deref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| acp::AuthMethodId::new("default")),
|
||||
model_id: parent_model_id,
|
||||
storage_mode: self.storage_mode,
|
||||
auth: self.current_or_buffered_auth(),
|
||||
parent_cwd: parent_cwd.clone(),
|
||||
parent_session_id: parent_session_id.to_string(),
|
||||
yolo_mode,
|
||||
subagent_event_tx: self.subagent_event_tx.clone(),
|
||||
parent_depth,
|
||||
inference_idle_timeout_secs,
|
||||
auto_compact_threshold_tiers:
|
||||
crate::agent::subagent::AutoCompactThresholdTiers::capture(&self.cfg.borrow()),
|
||||
hunk_tracker_handle,
|
||||
hunk_tracking_enabled,
|
||||
fs,
|
||||
terminal,
|
||||
session_env,
|
||||
memory_config: self.memory_config.clone(),
|
||||
web_search_sampling_config: self.prepare_web_search_sampling_config(),
|
||||
web_fetch_config: self.prepare_web_fetch_config(),
|
||||
image_gen_config: self.prepare_image_gen_config(),
|
||||
video_gen_config: self.prepare_video_gen_config(),
|
||||
app_builder_deployer_config: self.prepare_app_builder_deployer_config(),
|
||||
write_file_enabled: self.cfg.borrow().resolve_write_file().value,
|
||||
goal_enabled: self.cfg.borrow().resolve_goal().value,
|
||||
ask_user_question_enabled,
|
||||
parent_cmd_tx: parent_cmd_tx.clone(),
|
||||
parent_session_info: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| crate::session::info::Info {
|
||||
id: parent_sid.clone(),
|
||||
cwd: h.info.cwd.clone(),
|
||||
})
|
||||
},
|
||||
parent_chat_state,
|
||||
parent_max_turns,
|
||||
available_models,
|
||||
subagent_model_overrides: self.subagent_model_overrides.clone(),
|
||||
subagent_toggle: self.subagent_toggle.clone(),
|
||||
subagent_roles: self.subagent_roles.clone(),
|
||||
subagent_personas: self.subagent_personas.clone(),
|
||||
persona_io_summaries: self.persona_io_summaries.clone(),
|
||||
disable_web_search: self.cfg.borrow().disable_web_search,
|
||||
todo_gate: self.cfg.borrow().todo_gate,
|
||||
remote_settings: self.cfg.borrow().remote_settings.clone(),
|
||||
laziness_debug_log: self.cfg.borrow().laziness_debug_log.clone(),
|
||||
backend_tools_enabled: self.cfg.borrow().resolve_backend_tools().value,
|
||||
respect_gitignore: self.cfg.borrow().respect_gitignore,
|
||||
path_not_found_hints: self.cfg.borrow().path_not_found_hints,
|
||||
plugin_registry: self.plugin_registry_handle.snapshot(),
|
||||
models_manager: self.models_manager.clone(),
|
||||
file_tool_overrides: {
|
||||
let cfg = self.cfg.borrow();
|
||||
let effective = cfg
|
||||
.toolset
|
||||
.resolve_file_toolset(cfg.remote_settings.as_ref());
|
||||
if effective != crate::tools::FileToolset::Standard {
|
||||
effective.tool_configs(&cfg.toolset.hashline).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
agent_config: Some(self.cfg.borrow().clone()),
|
||||
hook_registry: parent_hook_registry,
|
||||
hook_workspace_root: String::new(),
|
||||
permission_handle: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.permission_handle.clone())
|
||||
},
|
||||
worktree_type: self.worktree_type,
|
||||
api_key_provider: Some(Arc::new(crate::auth::manager::SharedAuthKeyProvider(
|
||||
am.clone(),
|
||||
))),
|
||||
image_description_model: self.resolve_image_description_model(),
|
||||
workspace_ops: parent_workspace_ops.clone(),
|
||||
auth_manager: am.clone(),
|
||||
attribution_callback: parent_attribution_callback,
|
||||
parent_agent_name,
|
||||
parent_model_agent_type,
|
||||
allowed_subagent_types: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.and_then(|h| h.allowed_subagent_types.clone())
|
||||
},
|
||||
parent_mcp_configs: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.mcp_servers.clone())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
managed_mcp_state: self.managed_mcp_cache.clone(),
|
||||
parent_mcp_pool: None,
|
||||
parent_tool_snapshot: None,
|
||||
parent_skills: None,
|
||||
parent_skills_config: self.cfg.borrow().skills.clone(),
|
||||
parent_compat: self.cfg.borrow().compat_resolved,
|
||||
auto_wake_delivered: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.and_then(|h| h.tool_context.auto_wake_delivered.clone())
|
||||
},
|
||||
task_output_tool_name: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.tool_context.task_output_tool_name.clone())
|
||||
.unwrap_or_else(|| {
|
||||
kigi_tools::reminders::task_completion::DEFAULT_TASK_OUTPUT_TOOL
|
||||
.to_string()
|
||||
})
|
||||
},
|
||||
auto_wake_enabled: self.cfg.borrow().auto_wake_enabled,
|
||||
goal_loop_active: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.tool_context.goal_loop_active_gate.clone())
|
||||
.unwrap_or_else(|| {
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false))
|
||||
})
|
||||
},
|
||||
parent_blocking_wait_depth: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.tool_context.blocking_wait_depth.clone())
|
||||
.unwrap_or_else(|| std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)))
|
||||
},
|
||||
parent_terminal_backend: parent_terminal_backend.clone(),
|
||||
parent_notification_handle: parent_notification_handle.clone(),
|
||||
parent_scheduler_handle: parent_scheduler_handle.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
//! Subagent spawn-context inheritance: a child session must inherit the parent's
|
||||
//! permission handle and goal-loop gate so policy and run-state can't be bypassed
|
||||
//! by delegating to a subagent.
|
||||
|
||||
use super::{build_minimal_agent_for_tests, make_test_handle};
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
|
||||
/// Subagents inherit the parent permission handle, so a managed `Read(**/.env)`
|
||||
/// deny still blocks the child — direct read and the `cat .env` shell equivalent.
|
||||
#[tokio::test]
|
||||
async fn subagent_spawn_context_inherits_parent_permission_handle() {
|
||||
use kigi_workspace::permission::types::{
|
||||
PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
|
||||
};
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
let sid = acp::SessionId::new("parent-permission");
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let gateway = GatewaySender::new(tx);
|
||||
let cwd = kigi_paths::AbsPathBuf::new(std::path::PathBuf::from("/tmp"))
|
||||
.expect("absolute cwd");
|
||||
let (permission_handle, _events_rx) =
|
||||
kigi_workspace::permission::spawn_permission_manager(
|
||||
sid.clone(),
|
||||
gateway,
|
||||
cwd,
|
||||
kigi_workspace::permission::types::ClientType::Generic,
|
||||
Some(PermissionConfig::new(vec![PermissionRule {
|
||||
action: RuleAction::Deny,
|
||||
tool: ToolFilter::Read,
|
||||
pattern: Some("**/.env".to_owned()),
|
||||
pattern_mode: PatternMode::Glob,
|
||||
}])),
|
||||
Vec::new(), // deny_read_globs
|
||||
Vec::new(),
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
let mut handle = make_test_handle("test-model", false, None);
|
||||
handle.permission_handle = permission_handle;
|
||||
agent.sessions.borrow_mut().insert(sid.clone(), handle);
|
||||
|
||||
let ctx = agent.build_subagent_spawn_context(sid.0.as_ref());
|
||||
let inherited = ctx
|
||||
.permission_handle
|
||||
.expect("subagent context must inherit parent permission handle");
|
||||
|
||||
// Direct file read and the shell equivalent both hit the parent deny.
|
||||
for access in [
|
||||
kigi_workspace::permission::AccessKind::Read(Some(".env".into())),
|
||||
kigi_workspace::permission::AccessKind::Bash("cat .env".into()),
|
||||
] {
|
||||
let decision = inherited
|
||||
.request(
|
||||
access.clone(),
|
||||
acp::ToolCallUpdate::new(acp::ToolCallId::new("tc"), Default::default()),
|
||||
Some("child-session".to_owned()),
|
||||
Some("general-purpose".to_owned()),
|
||||
Some("permission inheritance regression".to_owned()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
decision,
|
||||
kigi_workspace::permission::Decision::PolicyDeny(_)
|
||||
),
|
||||
"subagent-inherited handle must enforce parent deny for {access:?}, got {decision:?}"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// A subagent shares the parent's `goal_loop_active_gate` Arc, so flipping the
|
||||
/// parent gate is observed through the child context (same allocation).
|
||||
#[tokio::test]
|
||||
async fn subagent_spawn_context_shares_parent_goal_loop_gate() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
let sid = acp::SessionId::new("parent-goal");
|
||||
let handle = make_test_handle("test-model", false, None);
|
||||
// Clone the parent's live gate before the handle moves into `sessions`.
|
||||
let parent_gate = handle.tool_context.goal_loop_active_gate.clone();
|
||||
agent.sessions.borrow_mut().insert(sid.clone(), handle);
|
||||
|
||||
let ctx = agent.build_subagent_spawn_context(sid.0.as_ref());
|
||||
|
||||
// Flipping the parent gate must surface through the child flag (shared Arc).
|
||||
assert!(!ctx.goal_loop_active.load(Relaxed));
|
||||
parent_gate.store(true, Relaxed);
|
||||
assert!(
|
||||
ctx.goal_loop_active.load(Relaxed),
|
||||
"subagent context must observe the parent's goal-loop gate (same Arc)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A subagent inherits the parent session's `ask_user_question` gate, so
|
||||
/// `--no-ask-user` strips the tool from subagents too, while the default keeps it.
|
||||
#[tokio::test]
|
||||
async fn subagent_spawn_context_inherits_parent_ask_user_question_gate() {
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
|
||||
// Parent with the tool disabled (the `--no-ask-user` case) → child off.
|
||||
let sid_off = acp::SessionId::new("parent-no-ask");
|
||||
let mut handle_off = make_test_handle("test-model", false, None);
|
||||
handle_off.ask_user_question_enabled = false;
|
||||
agent
|
||||
.sessions
|
||||
.borrow_mut()
|
||||
.insert(sid_off.clone(), handle_off);
|
||||
let ctx_off = agent.build_subagent_spawn_context(sid_off.0.as_ref());
|
||||
assert!(
|
||||
!ctx_off.ask_user_question_enabled,
|
||||
"subagent must inherit the parent's disabled ask_user_question gate (--no-ask-user)"
|
||||
);
|
||||
|
||||
// Parent with the tool enabled (the default) → child on.
|
||||
let sid_on = acp::SessionId::new("parent-ask");
|
||||
let handle_on = make_test_handle("test-model", false, None);
|
||||
agent
|
||||
.sessions
|
||||
.borrow_mut()
|
||||
.insert(sid_on.clone(), handle_on);
|
||||
let ctx_on = agent.build_subagent_spawn_context(sid_on.0.as_ref());
|
||||
assert!(
|
||||
ctx_on.ask_user_question_enabled,
|
||||
"subagent must inherit the parent's enabled ask_user_question gate"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
//! HTTP CONNECT proxy support for WebSocket connections.
|
||||
//!
|
||||
//! When running behind a corporate egress proxy,
|
||||
//! `tokio-tungstenite`'s `connect_async` cannot reach external
|
||||
//! hosts directly because it does not read the standard `HTTPS_PROXY` /
|
||||
//! `HTTP_PROXY` environment variables.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - [`resolve_proxy_for_host`]: reads proxy env vars and `NO_PROXY`, returning
|
||||
//! the proxy URL to use for a given target host (or `None` for direct).
|
||||
//! - [`connect_via_proxy`]: opens a TCP connection to the proxy, sends an HTTP
|
||||
//! CONNECT request to create a tunnel, wraps the result in TLS, and returns a
|
||||
//! stream suitable for `tokio_tungstenite::client_async`.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_tungstenite::MaybeTlsStream;
|
||||
use tracing::debug;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment-variable resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read proxy configuration from the environment and decide whether `target_host`
|
||||
/// should be connected through a proxy.
|
||||
///
|
||||
/// Resolution order (matches `curl` / `reqwest` behaviour):
|
||||
/// 1. If `NO_PROXY` contains `target_host` (or a matching domain suffix / CIDR),
|
||||
/// return `None`.
|
||||
/// 2. If `HTTPS_PROXY` (or `https_proxy`) is set, return its value.
|
||||
/// 3. If `HTTP_PROXY` (or `http_proxy`) is set, return its value.
|
||||
/// 4. Otherwise return `None`.
|
||||
pub fn resolve_proxy_for_host(target_host: &str) -> Option<String> {
|
||||
resolve_proxy_for_host_with(target_host, |key| std::env::var(key))
|
||||
}
|
||||
|
||||
/// Testable inner implementation that accepts a custom env-var reader.
|
||||
fn resolve_proxy_for_host_with<F>(target_host: &str, env: F) -> Option<String>
|
||||
where
|
||||
F: for<'a> Fn(&'a str) -> Result<String, std::env::VarError>,
|
||||
{
|
||||
// Check NO_PROXY / no_proxy.
|
||||
let no_proxy = env("NO_PROXY")
|
||||
.or_else(|_| env("no_proxy"))
|
||||
.unwrap_or_default();
|
||||
if is_host_bypassed(target_host, &no_proxy) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// HTTPS_PROXY takes precedence (our target is always wss://).
|
||||
if let Ok(url) = env("HTTPS_PROXY").or_else(|_| env("https_proxy")) {
|
||||
let url = url.trim().to_string();
|
||||
if !url.is_empty() {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to HTTP_PROXY.
|
||||
if let Ok(url) = env("HTTP_PROXY").or_else(|_| env("http_proxy")) {
|
||||
let url = url.trim().to_string();
|
||||
if !url.is_empty() {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check whether `host` is in the `no_proxy` list.
|
||||
///
|
||||
/// The `no_proxy` value is a comma-separated list of hostnames, domain
|
||||
/// suffixes (with or without a leading dot), IP addresses, or CIDR ranges.
|
||||
/// The special value `*` matches everything.
|
||||
fn is_host_bypassed(host: &str, no_proxy: &str) -> bool {
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
for entry in no_proxy.split(',') {
|
||||
let entry = entry.trim().to_ascii_lowercase();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Wildcard — bypass all hosts.
|
||||
if entry == "*" {
|
||||
return true;
|
||||
}
|
||||
// Exact match.
|
||||
if host_lower == entry {
|
||||
return true;
|
||||
}
|
||||
// Domain suffix match: ".example.com" matches "foo.example.com".
|
||||
// Also handle the common convention of omitting the leading dot:
|
||||
// "example.com" in NO_PROXY should match "sub.example.com".
|
||||
let matches_suffix = if entry.starts_with('.') {
|
||||
host_lower.ends_with(entry.as_str())
|
||||
} else {
|
||||
host_lower.len() > entry.len()
|
||||
&& host_lower.ends_with(entry.as_str())
|
||||
&& host_lower.as_bytes()[host_lower.len() - entry.len() - 1] == b'.'
|
||||
};
|
||||
if matches_suffix {
|
||||
return true;
|
||||
}
|
||||
// CIDR / IP matching is intentionally omitted here — our target host
|
||||
// is always a DNS name, not an IP literal. Keeping this simple avoids
|
||||
// pulling in a CIDR parsing dependency.
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP CONNECT tunnel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Establish a TLS-wrapped TCP stream through an HTTP CONNECT proxy.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Parse the proxy URL to get host + port.
|
||||
/// 2. Open a TCP connection to the proxy and perform the CONNECT handshake.
|
||||
/// 3. Wrap the tunnel in TLS (using rustls with native root certificates).
|
||||
/// 4. Return the stream as `MaybeTlsStream<TcpStream>` so it is compatible
|
||||
/// with `tokio_tungstenite::client_async`.
|
||||
pub async fn connect_via_proxy(
|
||||
proxy_url: &str,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
) -> anyhow::Result<MaybeTlsStream<TcpStream>> {
|
||||
let stream = open_connect_tunnel(proxy_url, target_host, target_port).await?;
|
||||
let tls_stream = tls_wrap(stream, target_host).await?;
|
||||
Ok(MaybeTlsStream::Rustls(tls_stream))
|
||||
}
|
||||
|
||||
/// Open a raw TCP tunnel through an HTTP CONNECT proxy (no TLS).
|
||||
///
|
||||
/// 1. Parse the proxy URL to get host + port.
|
||||
/// 2. Open a plain TCP connection to the proxy.
|
||||
/// 3. Send `CONNECT target_host:target_port HTTP/1.1\r\n\r\n`.
|
||||
/// 4. Read the proxy's response; expect `HTTP/1.x 200 …`.
|
||||
/// 5. Return the raw `TcpStream` positioned after the CONNECT response.
|
||||
async fn open_connect_tunnel(
|
||||
proxy_url: &str,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
) -> anyhow::Result<TcpStream> {
|
||||
// 1. Parse proxy URL.
|
||||
let (proxy_host, proxy_port) = parse_proxy_url(proxy_url)?;
|
||||
|
||||
// 2. TCP connect to proxy.
|
||||
let proxy_addr = format!("{proxy_host}:{proxy_port}");
|
||||
debug!(proxy_addr = %proxy_addr, "Opening TCP to proxy");
|
||||
let stream = TcpStream::connect(&proxy_addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to connect to proxy at {proxy_addr}: {e}"))?;
|
||||
|
||||
// 3. Send HTTP CONNECT.
|
||||
let connect_req = format!(
|
||||
"CONNECT {target_host}:{target_port} HTTP/1.1\r\n\
|
||||
Host: {target_host}:{target_port}\r\n\
|
||||
\r\n"
|
||||
);
|
||||
let (reader_half, mut writer_half) = stream.into_split();
|
||||
writer_half.write_all(connect_req.as_bytes()).await?;
|
||||
writer_half.flush().await?;
|
||||
|
||||
// 4. Read the status line from the proxy.
|
||||
let mut reader = BufReader::new(reader_half);
|
||||
let mut status_line = String::new();
|
||||
reader.read_line(&mut status_line).await?;
|
||||
debug!(status_line = %status_line.trim(), "Proxy CONNECT response");
|
||||
|
||||
if !status_line.starts_with("HTTP/1.1 200") && !status_line.starts_with("HTTP/1.0 200") {
|
||||
anyhow::bail!("Proxy CONNECT failed: {}", status_line.trim());
|
||||
}
|
||||
|
||||
// Consume remaining response headers (until empty line).
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await?;
|
||||
if line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Assert the BufReader's internal buffer is empty before reuniting.
|
||||
// BufReader::read_line may have read ahead into its buffer. If extra
|
||||
// bytes were consumed beyond the HTTP headers (e.g., from a proxy that
|
||||
// eagerly forwards data or coalesced TCP segments), dropping them would
|
||||
// corrupt the subsequent TLS handshake.
|
||||
let remaining = reader.buffer();
|
||||
if !remaining.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Proxy sent {} unexpected byte(s) after CONNECT response headers",
|
||||
remaining.len()
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Reunite the split halves back into a TcpStream.
|
||||
let stream = reader.into_inner().reunite(writer_half)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Lazily-initialized TLS client configuration.
|
||||
///
|
||||
/// Loading native root certificates involves syscalls (reading `/etc/ssl/certs/`
|
||||
/// or the macOS Keychain) and the cert store never changes at runtime. We build
|
||||
/// the `ClientConfig` once and reuse it across all proxy connections / reconnects.
|
||||
///
|
||||
/// Stores `Ok(config)` on success or `Err(message)` if cert loading fails.
|
||||
static TLS_CONFIG: OnceLock<Result<Arc<rustls::ClientConfig>, String>> = OnceLock::new();
|
||||
|
||||
/// Build (or return the cached) TLS client configuration.
|
||||
fn get_tls_config() -> anyhow::Result<Arc<rustls::ClientConfig>> {
|
||||
let result = TLS_CONFIG.get_or_init(|| {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
let cert_result = rustls_native_certs::load_native_certs();
|
||||
if cert_result.certs.is_empty() {
|
||||
let errors: Vec<_> = cert_result.errors.iter().map(|e| e.to_string()).collect();
|
||||
return Err(format!(
|
||||
"No native root certificates found. Errors: {}",
|
||||
if errors.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
errors.join("; ")
|
||||
}
|
||||
));
|
||||
}
|
||||
for cert in cert_result.certs {
|
||||
if let Err(e) = root_store.add(cert) {
|
||||
tracing::warn!(error = %e, "Skipping unparseable native root certificate");
|
||||
}
|
||||
}
|
||||
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
Ok(Arc::new(config))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(config) => Ok(config.clone()),
|
||||
Err(msg) => anyhow::bail!("{msg}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a TLS handshake over an existing TCP stream using rustls with
|
||||
/// native root certificates (cached via [`TLS_CONFIG`]).
|
||||
async fn tls_wrap(
|
||||
stream: TcpStream,
|
||||
server_name: &str,
|
||||
) -> anyhow::Result<tokio_rustls::client::TlsStream<TcpStream>> {
|
||||
let tls_config = get_tls_config()?;
|
||||
let connector = tokio_rustls::TlsConnector::from(tls_config);
|
||||
let dns_name = rustls::pki_types::ServerName::try_from(server_name.to_string())
|
||||
.map_err(|e| anyhow::anyhow!("Invalid TLS server name '{server_name}': {e}"))?;
|
||||
|
||||
let tls_stream = connector
|
||||
.connect(dns_name, stream)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("TLS handshake through proxy failed: {e}"))?;
|
||||
|
||||
Ok(tls_stream)
|
||||
}
|
||||
|
||||
/// Parse a proxy URL into (host, port).
|
||||
///
|
||||
/// Accepted formats:
|
||||
/// - `http://host:port`
|
||||
/// - `http://host` (defaults to port 80)
|
||||
/// - `host:port`
|
||||
fn parse_proxy_url(url: &str) -> anyhow::Result<(String, u16)> {
|
||||
// Strip scheme if present.
|
||||
let without_scheme = url
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| url.strip_prefix("https://"))
|
||||
.unwrap_or(url);
|
||||
|
||||
// Strip trailing path/slash.
|
||||
let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
|
||||
|
||||
if let Some((host, port_str)) = authority.rsplit_once(':') {
|
||||
let port: u16 = port_str
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid proxy port in '{url}'"))?;
|
||||
Ok((host.to_string(), port))
|
||||
} else {
|
||||
// No port — default to 80 for HTTP proxies.
|
||||
Ok((authority.to_string(), 80))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
// ===== parse_proxy_url =====
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_with_scheme_and_port() {
|
||||
let (host, port) = parse_proxy_url("http://proxy.example.com:3140").unwrap();
|
||||
assert_eq!(host, "proxy.example.com");
|
||||
assert_eq!(port, 3140);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_without_scheme() {
|
||||
let (host, port) = parse_proxy_url("proxy.example.com:8080").unwrap();
|
||||
assert_eq!(host, "proxy.example.com");
|
||||
assert_eq!(port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_without_port() {
|
||||
let (host, port) = parse_proxy_url("http://proxy.example.com").unwrap();
|
||||
assert_eq!(host, "proxy.example.com");
|
||||
assert_eq!(port, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_with_trailing_slash() {
|
||||
let (host, port) = parse_proxy_url("http://proxy.example.com:3140/").unwrap();
|
||||
assert_eq!(host, "proxy.example.com");
|
||||
assert_eq!(port, 3140);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_https_scheme() {
|
||||
let (host, port) = parse_proxy_url("https://secure-proxy:443").unwrap();
|
||||
assert_eq!(host, "secure-proxy");
|
||||
assert_eq!(port, 443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_multi_label_host() {
|
||||
let (host, port) =
|
||||
parse_proxy_url("http://http-proxy.services.internal.example:3128").unwrap();
|
||||
assert_eq!(host, "http-proxy.services.internal.example");
|
||||
assert_eq!(port, 3128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_invalid_port() {
|
||||
assert!(parse_proxy_url("http://proxy:notaport").is_err());
|
||||
}
|
||||
|
||||
// ===== is_host_bypassed =====
|
||||
|
||||
#[test]
|
||||
fn test_bypass_exact_match() {
|
||||
assert!(is_host_bypassed("localhost", "localhost,127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_domain_suffix_with_dot() {
|
||||
assert!(is_host_bypassed(
|
||||
"api.corp.example",
|
||||
"localhost,.corp.example"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_domain_suffix_without_dot() {
|
||||
// Common convention: "example.com" in NO_PROXY matches "api.example.com".
|
||||
assert!(is_host_bypassed("api.example.com", "localhost,example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_wildcard() {
|
||||
assert!(is_host_bypassed("anything.example.com", "*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_bypass_when_not_listed() {
|
||||
assert!(!is_host_bypassed(
|
||||
"api.external.example",
|
||||
"localhost,127.0.0.1,.corp.example,.internal.example"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_case_insensitive() {
|
||||
assert!(is_host_bypassed("API.Corp.EXAMPLE", ".corp.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_empty_no_proxy() {
|
||||
assert!(!is_host_bypassed("api.external.example", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_spaces_in_entries() {
|
||||
assert!(is_host_bypassed(
|
||||
"foo.example.com",
|
||||
" localhost , .example.com , .other.com "
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_cidr_not_matched_for_dns_names() {
|
||||
// CIDR entries like 10.0.0.0/8 should not match DNS names.
|
||||
assert!(!is_host_bypassed("api.external.example", "10.0.0.0/8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_combined_no_proxy_list() {
|
||||
// A typical corporate NO_PROXY mixes loopback, private CIDRs, and domain suffixes.
|
||||
let no_proxy = "localhost,127.0.0.1,10.0.0.0/8,.internal.example,.corp.example";
|
||||
assert!(!is_host_bypassed("api.external.example", no_proxy));
|
||||
assert!(is_host_bypassed("db.internal.example", no_proxy));
|
||||
assert!(is_host_bypassed("git.corp.example", no_proxy));
|
||||
assert!(is_host_bypassed("localhost", no_proxy));
|
||||
}
|
||||
|
||||
// ===== resolve_proxy_for_host_with =====
|
||||
|
||||
#[test]
|
||||
fn test_resolve_no_proxy_vars_set() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |_| {
|
||||
Err(std::env::VarError::NotPresent)
|
||||
});
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_https_proxy_used() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"HTTPS_PROXY" => Ok("http://proxy.example.com:3128".to_string()),
|
||||
"NO_PROXY" => Err(std::env::VarError::NotPresent),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, Some("http://proxy.example.com:3128".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_http_proxy_fallback() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"HTTP_PROXY" => Ok("http://proxy.example.com:8080".to_string()),
|
||||
"NO_PROXY" => Err(std::env::VarError::NotPresent),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, Some("http://proxy.example.com:8080".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_no_proxy_bypasses() {
|
||||
let result = resolve_proxy_for_host_with("api.corp.example", |key| match key {
|
||||
"HTTPS_PROXY" => Ok("http://proxy.example.com:3128".to_string()),
|
||||
"NO_PROXY" => Ok("localhost,.corp.example".to_string()),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_https_proxy_takes_precedence() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"HTTPS_PROXY" => Ok("http://https-proxy.example.com:443".to_string()),
|
||||
"HTTP_PROXY" => Ok("http://http-proxy.example.com:80".to_string()),
|
||||
"NO_PROXY" => Err(std::env::VarError::NotPresent),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("http://https-proxy.example.com:443".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_lowercase_env_vars() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"https_proxy" => Ok("http://proxy.example.com:3128".to_string()),
|
||||
"no_proxy" => Err(std::env::VarError::NotPresent),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, Some("http://proxy.example.com:3128".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_empty_proxy_ignored() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"HTTPS_PROXY" => Ok(" ".to_string()),
|
||||
"HTTP_PROXY" => Ok("http://proxy.example.com:8080".to_string()),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, Some("http://proxy.example.com:8080".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_respects_no_proxy_when_proxy_set() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"HTTPS_PROXY" | "HTTP_PROXY" => Ok("http://proxy.example.com:3128".to_string()),
|
||||
"NO_PROXY" => {
|
||||
Ok("localhost,127.0.0.1,10.0.0.0/8,.internal.example,.corp.example".to_string())
|
||||
}
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, Some("http://proxy.example.com:3128".to_string()));
|
||||
}
|
||||
|
||||
// ===== HTTP CONNECT tunnel (integration-style) =====
|
||||
|
||||
/// Helper: spawn a mock HTTP CONNECT proxy that accepts one connection.
|
||||
///
|
||||
/// On receiving a CONNECT request, it validates the request format,
|
||||
/// replies with `status_line`, and then echoes data (simulating a tunnel).
|
||||
/// Returns the proxy's listen address.
|
||||
async fn spawn_mock_proxy(status_line: &'static str) -> std::net::SocketAddr {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
|
||||
// Read CONNECT request (read until \r\n\r\n).
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut total = 0;
|
||||
loop {
|
||||
let n = stream.read(&mut buf[total..]).await.unwrap();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
total += n;
|
||||
let so_far = std::str::from_utf8(&buf[..total]).unwrap_or("");
|
||||
if so_far.contains("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let request = std::str::from_utf8(&buf[..total]).unwrap().to_string();
|
||||
assert!(
|
||||
request.contains("CONNECT ") && request.contains(" HTTP/1.1"),
|
||||
"Expected CONNECT request, got: {request}"
|
||||
);
|
||||
|
||||
// Reply with the provided status line.
|
||||
stream.write_all(status_line.as_bytes()).await.unwrap();
|
||||
|
||||
// Echo loop (simulates the transparent tunnel).
|
||||
let mut echo_buf = [0u8; 1024];
|
||||
loop {
|
||||
let n = match stream.read(&mut echo_buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
};
|
||||
if stream.write_all(&echo_buf[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addr
|
||||
}
|
||||
|
||||
/// Tests that `open_connect_tunnel` sends a correct CONNECT request,
|
||||
/// parses the proxy's 200 response, and returns a usable tunnel stream.
|
||||
#[tokio::test]
|
||||
async fn test_open_connect_tunnel_success() {
|
||||
let addr =
|
||||
spawn_mock_proxy("HTTP/1.1 200 Connection Established\r\nServer: mock\r\n\r\n").await;
|
||||
let proxy_url = format!("http://{addr}");
|
||||
|
||||
// Call the real function under test.
|
||||
let mut stream = open_connect_tunnel(&proxy_url, "example.com", 443)
|
||||
.await
|
||||
.expect("tunnel should succeed");
|
||||
|
||||
// Verify the tunnel works by echoing data through it.
|
||||
stream.write_all(b"hello tunnel").await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
|
||||
let mut response = vec![0u8; 12];
|
||||
stream.read_exact(&mut response).await.unwrap();
|
||||
assert_eq!(&response, b"hello tunnel");
|
||||
}
|
||||
|
||||
/// Tests that `open_connect_tunnel` with a non-default port sends the
|
||||
/// correct CONNECT target.
|
||||
#[tokio::test]
|
||||
async fn test_open_connect_tunnel_custom_port() {
|
||||
let addr = spawn_mock_proxy("HTTP/1.1 200 OK\r\n\r\n").await;
|
||||
let proxy_url = format!("http://{addr}");
|
||||
|
||||
let stream = open_connect_tunnel(&proxy_url, "internal.example.com", 8443).await;
|
||||
assert!(stream.is_ok(), "tunnel should succeed for custom port");
|
||||
}
|
||||
|
||||
/// Tests that `open_connect_tunnel` returns an error when the proxy
|
||||
/// rejects the CONNECT request with a non-200 status.
|
||||
#[tokio::test]
|
||||
async fn test_open_connect_tunnel_proxy_rejects() {
|
||||
let addr = spawn_mock_proxy("HTTP/1.1 403 Forbidden\r\n\r\n").await;
|
||||
let proxy_url = format!("http://{addr}");
|
||||
|
||||
let result = open_connect_tunnel(&proxy_url, "blocked.example.com", 443).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("403"),
|
||||
"Error should mention 403: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Tests that `open_connect_tunnel` returns an error when connecting
|
||||
/// to a proxy that isn't listening.
|
||||
#[tokio::test]
|
||||
async fn test_open_connect_tunnel_proxy_unreachable() {
|
||||
let result = open_connect_tunnel("http://127.0.0.1:1", "example.com", 443).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Failed to connect to proxy"),
|
||||
"Error should mention proxy connection failure: {err_msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Thin wire-format adapter that wraps the shared
|
||||
//! [`kigi_workspace::session::git::build_restore_decision`] helper
|
||||
//! into the JSON shape emitted by `LoadSession` on `_meta.codeRestore`.
|
||||
use kigi_workspace::session::git::{CheckoutSessionOutcome, RestoreKind, build_restore_decision};
|
||||
use serde_json::Value;
|
||||
/// Build the `codeRestore` JSON meta, or `None` when no restore should
|
||||
/// be reported (no checkout AND no archive applied). The shared
|
||||
/// [`build_restore_decision`] is the source of truth; this function
|
||||
/// only adapts the result into the wire JSON shape used by the
|
||||
/// non-worktree path.
|
||||
pub(crate) fn build_code_restore_meta(
|
||||
target_sha: &str,
|
||||
outcome: &CheckoutSessionOutcome,
|
||||
kind: RestoreKind,
|
||||
) -> Option<Value> {
|
||||
let decision = build_restore_decision(Some(target_sha), outcome, kind);
|
||||
let summary = decision.summary?;
|
||||
Some(serde_json::json!(
|
||||
{ "restored" : decision.restored, "summary" : summary, "degree" : decision
|
||||
.degree, }
|
||||
))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn outcome(
|
||||
checked_out: bool,
|
||||
stash_ref: Option<&str>,
|
||||
skipped: Option<&str>,
|
||||
) -> CheckoutSessionOutcome {
|
||||
CheckoutSessionOutcome {
|
||||
checked_out,
|
||||
stash_ref: stash_ref.map(str::to_owned),
|
||||
stash_skipped_reason: skipped.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn checkout_failed_emits_restored_false_meta() {
|
||||
let meta = build_code_restore_meta(
|
||||
"0123456789abcdef",
|
||||
&outcome(false, None, Some("MERGE_HEAD present")),
|
||||
RestoreKind::RegistryOff,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(meta["restored"], false);
|
||||
assert!(meta["degree"].is_null());
|
||||
let s = meta["summary"].as_str().unwrap();
|
||||
assert!(s.contains("restore aborted"));
|
||||
assert!(s.contains("MERGE_HEAD present"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Roster types for the multi-client FleetView dashboard.
|
||||
//!
|
||||
//! The roster is a list
|
||||
//! of dashboard-sized summaries of every session the leader hosts (resident
|
||||
//! actors) plus recently-touched on-disk (`Dormant`) sessions. Clients read it
|
||||
//! two ways:
|
||||
//!
|
||||
//! - request/response `x.ai/sessions/list` → `{ "sessions": [RosterEntry, …] }`
|
||||
//! - broadcast notification `x.ai/sessions/changed` →
|
||||
//! `{ "upserted": [RosterEntry, …], "removed": ["sess-abc", …] }`
|
||||
//!
|
||||
//! The wire shape is intentionally small and current-state only — no event
|
||||
//! fold or materialized snapshot is required (the snapshot is deferred).
|
||||
|
||||
use kigi_sampling_types::ReasoningEffort;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::session::persistence::Summary;
|
||||
|
||||
/// Coarse activity of a session as rendered in the dashboard's status column.
|
||||
///
|
||||
/// Mirrors the design's `SessionActivity` at dashboard granularity. A full
|
||||
/// background-work breakdown (bg tasks / monitors / scheduler / subagents)
|
||||
/// lands with a richer `SessionActivity`; the dashboard only needs this
|
||||
/// coarse signal to pick a status glyph.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RosterActivity {
|
||||
/// A turn (user-originated or autonomous) is running.
|
||||
Working,
|
||||
/// Resident, no turn in flight.
|
||||
Idle,
|
||||
/// A permission / question / plan-approval is pending.
|
||||
NeedsInput,
|
||||
/// On disk, not resident.
|
||||
Dormant,
|
||||
/// Finished and resumable.
|
||||
Completed,
|
||||
/// Actor panicked / load failed.
|
||||
Dead,
|
||||
}
|
||||
|
||||
/// Where the session lives. Only `Local` is produced today; `Remote` is
|
||||
/// reserved for cross-machine roster aggregation.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum RosterOrigin {
|
||||
Local,
|
||||
Remote { host: String },
|
||||
}
|
||||
|
||||
/// One dashboard row.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RosterEntry {
|
||||
pub session_id: String,
|
||||
/// Generated/display title, if known. Clients fall back to cwd / id.
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
pub cwd: String,
|
||||
pub is_worktree: bool,
|
||||
#[serde(default)]
|
||||
pub model_id: Option<String>,
|
||||
/// Per-session reasoning effort for `model_id`. Carried alongside the model
|
||||
/// so clients can render the session's effort in the roster without a
|
||||
/// separate `model_state` fetch. `None` means "use the model/global
|
||||
/// default" (or the session predates per-session effort persistence).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
pub yolo: bool,
|
||||
pub activity: RosterActivity,
|
||||
/// `true` while a resident actor hosts the session (vs. read from disk).
|
||||
pub resident: bool,
|
||||
/// Best-effort last-change timestamp (unix millis). Used for sort order.
|
||||
pub last_change_unix_ms: i64,
|
||||
pub origin: RosterOrigin,
|
||||
}
|
||||
|
||||
/// Response payload for `x.ai/sessions/list`.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct RosterListResponse {
|
||||
pub sessions: Vec<RosterEntry>,
|
||||
}
|
||||
|
||||
/// Params payload for the `x.ai/sessions/changed` broadcast notification.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct RosterChanged {
|
||||
#[serde(default)]
|
||||
pub upserted: Vec<RosterEntry>,
|
||||
#[serde(default)]
|
||||
pub removed: Vec<String>,
|
||||
}
|
||||
|
||||
/// JSON-RPC method names for the roster API.
|
||||
pub const SESSIONS_LIST_METHOD: &str = "x.ai/sessions/list";
|
||||
pub const SESSIONS_CHANGED_METHOD: &str = "x.ai/sessions/changed";
|
||||
|
||||
/// Merge live `resident` rows with on-disk `summaries` into the sorted roster.
|
||||
/// Pure, so it is unit-testable without disk or a live actor.
|
||||
///
|
||||
/// Resident rows own the live state but carry no title or last-active time, so
|
||||
/// each adopts those from its summary — except a `Working` row keeps its "now"
|
||||
/// timestamp. Summaries with no resident row become `Dormant`; keying by id
|
||||
/// dedups them. Hidden summaries are excluded.
|
||||
pub(crate) fn merge_roster(
|
||||
mut entries: Vec<RosterEntry>,
|
||||
summaries: Vec<Summary>,
|
||||
) -> Vec<RosterEntry> {
|
||||
let mut by_id: std::collections::HashMap<String, Summary> = summaries
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_hidden())
|
||||
.map(|s| (s.info.id.0.to_string(), s))
|
||||
.collect();
|
||||
|
||||
// Backfill resident rows; remove the summary so it isn't re-emitted below.
|
||||
for entry in &mut entries {
|
||||
let Some(summary) = by_id.remove(&entry.session_id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(title) = summary.display_title_opt() {
|
||||
entry.title = Some(title);
|
||||
}
|
||||
if entry.activity != RosterActivity::Working {
|
||||
entry.last_change_unix_ms = summary.last_change_unix_ms();
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining summaries have no resident row: emit them as dormant.
|
||||
entries.extend(by_id.into_values().map(|summary| RosterEntry {
|
||||
session_id: summary.info.id.0.to_string(),
|
||||
title: summary.display_title_opt(),
|
||||
cwd: summary.info.cwd.clone(),
|
||||
is_worktree: summary.session_kind.as_deref() == Some("worktree")
|
||||
|| summary.source_workspace_dir.is_some(),
|
||||
model_id: Some(summary.current_model_id.0.to_string()),
|
||||
reasoning_effort: summary.reasoning_effort,
|
||||
yolo: false,
|
||||
activity: RosterActivity::Dormant,
|
||||
resident: false,
|
||||
last_change_unix_ms: summary.last_change_unix_ms(),
|
||||
origin: RosterOrigin::Local,
|
||||
}));
|
||||
|
||||
// Most-recently-changed first.
|
||||
entries.sort_by_key(|entry| std::cmp::Reverse(entry.last_change_unix_ms));
|
||||
entries
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod merge_roster_tests {
|
||||
use super::*;
|
||||
use crate::session::info::Info;
|
||||
use crate::session::persistence::default_model_id;
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
fn summary(id: &str, title: Option<&str>, last_active_ms: i64) -> Summary {
|
||||
let mut s = Summary::new(
|
||||
&Info {
|
||||
id: acp::SessionId::new(id),
|
||||
cwd: format!("/repo/{id}"),
|
||||
},
|
||||
default_model_id(),
|
||||
)
|
||||
.expect("summary");
|
||||
s.generated_title = title.map(String::from);
|
||||
s.last_active_at = chrono::DateTime::from_timestamp_millis(last_active_ms);
|
||||
s
|
||||
}
|
||||
|
||||
fn resident(id: &str, activity: RosterActivity, last_change_unix_ms: i64) -> RosterEntry {
|
||||
RosterEntry {
|
||||
session_id: id.to_string(),
|
||||
title: None,
|
||||
cwd: format!("/live/{id}"),
|
||||
is_worktree: false,
|
||||
model_id: Some("grok-4".into()),
|
||||
reasoning_effort: None,
|
||||
yolo: false,
|
||||
activity,
|
||||
resident: true,
|
||||
last_change_unix_ms,
|
||||
origin: RosterOrigin::Local,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_resident_adopts_persisted_title_and_last_active() {
|
||||
let now = 9_000;
|
||||
let out = merge_roster(
|
||||
vec![resident("a", RosterActivity::Idle, now)],
|
||||
vec![summary("a", Some("Fix the roster"), 1_234)],
|
||||
);
|
||||
assert_eq!(out.len(), 1, "resident must not be duplicated as dormant");
|
||||
assert_eq!(out[0].title.as_deref(), Some("Fix the roster"));
|
||||
assert_eq!(out[0].last_change_unix_ms, 1_234, "idle adopts last-active");
|
||||
assert!(out[0].resident);
|
||||
assert_eq!(out[0].cwd, "/live/a", "live cwd is preserved");
|
||||
assert_eq!(out[0].activity, RosterActivity::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_resident_keeps_now_but_adopts_title() {
|
||||
let now = 9_000;
|
||||
let out = merge_roster(
|
||||
vec![resident("a", RosterActivity::Working, now)],
|
||||
vec![summary("a", Some("Busy turn"), 1_234)],
|
||||
);
|
||||
assert_eq!(out[0].title.as_deref(), Some("Busy turn"));
|
||||
assert_eq!(out[0].last_change_unix_ms, now, "Working stays 'now'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_resident_without_summary_stays_titleless_now() {
|
||||
let now = 9_000;
|
||||
let out = merge_roster(vec![resident("a", RosterActivity::Idle, now)], vec![]);
|
||||
assert_eq!(out[0].title, None);
|
||||
assert_eq!(out[0].last_change_unix_ms, now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_persisted_title_leaves_row_untitled() {
|
||||
let out = merge_roster(
|
||||
vec![resident("a", RosterActivity::Idle, 9_000)],
|
||||
vec![summary("a", Some(" "), 1_234)],
|
||||
);
|
||||
assert_eq!(out[0].title, None, "blank title normalizes to None");
|
||||
assert_eq!(out[0].last_change_unix_ms, 1_234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dormant_sessions_are_emitted_and_sorted_after_residents() {
|
||||
let out = merge_roster(
|
||||
vec![resident("live", RosterActivity::Idle, 5_000)],
|
||||
vec![
|
||||
summary("live", Some("Live one"), 4_000),
|
||||
summary("old", Some("Dormant one"), 1_000),
|
||||
summary("new", Some("Newer dormant"), 8_000),
|
||||
],
|
||||
);
|
||||
let ids: Vec<&str> = out.iter().map(|e| e.session_id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["new", "live", "old"],
|
||||
"sorted by last-change desc"
|
||||
);
|
||||
let dormant = out.iter().find(|e| e.session_id == "new").unwrap();
|
||||
assert_eq!(dormant.activity, RosterActivity::Dormant);
|
||||
assert!(!dormant.resident);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_summaries_are_deduped() {
|
||||
let out = merge_roster(
|
||||
vec![],
|
||||
vec![
|
||||
summary("dup", Some("First"), 1_000),
|
||||
summary("dup", Some("Second"), 2_000),
|
||||
],
|
||||
);
|
||||
assert_eq!(out.len(), 1, "duplicate ids collapse to one row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_summaries_are_excluded() {
|
||||
let mut hidden = summary("sub", Some("Subagent"), 5_000);
|
||||
hidden.session_kind = Some("subagent".into());
|
||||
let out = merge_roster(vec![], vec![hidden]);
|
||||
assert!(out.is_empty(), "hidden/subagent summaries are dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dormant_row_carries_persisted_reasoning_effort() {
|
||||
let mut s = summary("dorm", Some("Dormant"), 1_000);
|
||||
s.reasoning_effort = Some(ReasoningEffort::Xhigh);
|
||||
let out = merge_roster(vec![], vec![s]);
|
||||
assert_eq!(out[0].reasoning_effort, Some(ReasoningEffort::Xhigh));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_effort_is_taken_from_the_live_row_not_the_summary() {
|
||||
// The live handle is authoritative for a resident session, so the
|
||||
// resident row's effort must survive the summary backfill.
|
||||
let mut live = resident("a", RosterActivity::Idle, 9_000);
|
||||
live.reasoning_effort = Some(ReasoningEffort::High);
|
||||
let mut s = summary("a", Some("Title"), 1_234);
|
||||
s.reasoning_effort = Some(ReasoningEffort::Low);
|
||||
let out = merge_roster(vec![live], vec![s]);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].reasoning_effort, Some(ReasoningEffort::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_serializes_as_camel_case_and_skips_when_none() {
|
||||
let with_effort = RosterEntry {
|
||||
reasoning_effort: Some(ReasoningEffort::Xhigh),
|
||||
..resident("a", RosterActivity::Idle, 1)
|
||||
};
|
||||
let json = serde_json::to_string(&with_effort).unwrap();
|
||||
assert!(
|
||||
json.contains("\"reasoningEffort\":\"xhigh\""),
|
||||
"effort must be camelCase and snake_case-valued: {json}"
|
||||
);
|
||||
|
||||
let without = resident("b", RosterActivity::Idle, 1);
|
||||
let json = serde_json::to_string(&without).unwrap();
|
||||
assert!(
|
||||
!json.contains("reasoningEffort"),
|
||||
"a None effort must not be serialized: {json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! WebSocket server for remote agent connections.
|
||||
//!
|
||||
//! This module provides a WebSocket server that allows remote TUI clients to
|
||||
//! connect to a grok agent running on a different machine.
|
||||
//!
|
||||
//! The agent persists across WebSocket reconnections: a single MvpAgent instance
|
||||
//! is created on first connection and reused for all subsequent connections. This
|
||||
//! ensures that session actors (and any in-flight prompts) survive client
|
||||
//! disconnects — when a client reconnects and loads an existing session, ongoing
|
||||
//! work continues to stream to the new connection.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::net::SocketAddr;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{
|
||||
ConnectInfo, Query, State,
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, simplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Duration;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_acp_lib::{
|
||||
AcpAgentGatewayReceiver as GatewayReceiver, AcpAgentGatewaySender as GatewaySender,
|
||||
AcpClientMessage, LineBufferedRead,
|
||||
};
|
||||
|
||||
use crate::agent::config::{Config as AgentConfig, ModelEntry};
|
||||
use crate::agent::models::{ModelFetchAuth, prefetch_models_blocking};
|
||||
use crate::agent::mvp_agent::MvpAgent;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
/// Swappable destination for the relay task.
|
||||
///
|
||||
/// Points at the current ACP connection's gateway sender. When no client is
|
||||
/// connected, the value is `None` and outbound messages are silently dropped
|
||||
/// (matching the old behaviour where the gateway channel's receiver was simply
|
||||
/// gone).
|
||||
type RelayDest = Rc<RefCell<Option<mpsc::UnboundedSender<AcpClientMessage>>>>;
|
||||
|
||||
const MAX_BUFFER_SIZE: usize = 8 * 1024 * 1024;
|
||||
const KEEPALIVE_INTERVAL_SECS: u64 = 15;
|
||||
|
||||
/// Configuration for the agent WebSocket server.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfig {
|
||||
/// Address to bind the server to
|
||||
pub bind_addr: SocketAddr,
|
||||
/// Secret token for client authentication (required)
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
/// Shared state for the WebSocket server.
|
||||
struct ServerState {
|
||||
agent_config: AgentConfig,
|
||||
secret: String,
|
||||
/// Channel to send new WebSocket connections to the persistent agent thread.
|
||||
/// Lazily initialised on first connection; protected by a tokio Mutex so the
|
||||
/// axum handler (which is `Send`) can acquire it.
|
||||
agent_conn_tx: tokio::sync::Mutex<Option<mpsc::UnboundedSender<NewConnectionChannels>>>,
|
||||
}
|
||||
|
||||
/// Channels bridging a single WebSocket connection to the agent thread.
|
||||
struct NewConnectionChannels {
|
||||
from_ws_rx: mpsc::UnboundedReceiver<String>,
|
||||
to_ws_tx: mpsc::UnboundedSender<String>,
|
||||
}
|
||||
|
||||
/// Query parameters for WebSocket connection.
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
pub struct WsQueryParams {
|
||||
#[serde(rename = "server-key")]
|
||||
pub server_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Validate the bearer token from request headers or query parameters.
|
||||
fn validate_auth(headers: &HeaderMap, query: &WsQueryParams, expected_secret: &str) -> bool {
|
||||
// Try Authorization header
|
||||
if let Some(token) = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
{
|
||||
return token == expected_secret;
|
||||
}
|
||||
|
||||
// Fall back to query parameter for browser connections
|
||||
if let Some(ref key) = query.server_key {
|
||||
return key == expected_secret;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// WebSocket upgrade handler with authentication.
|
||||
async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<ServerState>>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WsQueryParams>,
|
||||
) -> Response {
|
||||
// Validate secret token from header or query param
|
||||
if !validate_auth(&headers, &query, &state.secret) {
|
||||
warn!("Unauthorized connection attempt from {}", addr);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid or missing authorization token",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!("Authenticated WebSocket connection from {}", addr);
|
||||
ws.on_upgrade(move |socket| handle_connection(socket, state, addr))
|
||||
}
|
||||
|
||||
/// Handle an authenticated WebSocket connection.
|
||||
///
|
||||
/// On first connection, spawns a persistent agent thread that owns the MvpAgent.
|
||||
/// On subsequent connections (reconnects), sends new WS channels to the existing
|
||||
/// agent thread so that session actors can continue streaming to the new client.
|
||||
async fn handle_connection(ws: WebSocket, state: Arc<ServerState>, peer_addr: SocketAddr) {
|
||||
info!("New WebSocket connection from {}", peer_addr);
|
||||
|
||||
let (mut ws_write, mut ws_read) = ws.split();
|
||||
|
||||
// Channels for bridging WS <-> Agent thread
|
||||
let (to_agent_tx, to_agent_rx) = mpsc::unbounded_channel::<String>();
|
||||
let (from_agent_tx, mut from_agent_rx) = mpsc::unbounded_channel::<String>();
|
||||
|
||||
// Ensure the persistent agent thread is running (lazy init on first connection).
|
||||
// If the previous agent thread died (panic, etc.), clear the stale sender so we
|
||||
// respawn a fresh one.
|
||||
{
|
||||
let mut agent_tx_guard = state.agent_conn_tx.lock().await;
|
||||
|
||||
// Check if existing sender is still alive (receiver not dropped)
|
||||
if let Some(ref tx) = *agent_tx_guard
|
||||
&& tx.is_closed()
|
||||
{
|
||||
warn!("Persistent agent thread died — will respawn");
|
||||
*agent_tx_guard = None;
|
||||
}
|
||||
|
||||
if agent_tx_guard.is_none() {
|
||||
let (conn_tx, conn_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let agent_config = state.agent_config.clone();
|
||||
let _agent_thread = thread::Builder::new()
|
||||
.name("agent-persistent".to_string())
|
||||
.spawn(move || {
|
||||
// Prefetch models before creating the runtime (blocking is OK here)
|
||||
let auth = agent_config.create_auth_manager().current();
|
||||
let fetch_auth =
|
||||
ModelFetchAuth::resolve(&agent_config.endpoints, auth.is_some());
|
||||
let prefetched_models = if auth.is_some()
|
||||
|| agent_config.endpoints.has_custom_endpoint()
|
||||
|| fetch_auth != ModelFetchAuth::Session
|
||||
{
|
||||
prefetch_models_blocking(&agent_config.endpoints, auth.as_ref(), fetch_auth)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
info!("Prefetched models: {:?}", prefetched_models);
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to create runtime for agent");
|
||||
|
||||
let local_set = tokio::task::LocalSet::new();
|
||||
local_set.block_on(&rt, async move {
|
||||
run_persistent_agent(agent_config, conn_rx, prefetched_models).await
|
||||
});
|
||||
|
||||
warn!("Persistent agent thread exiting");
|
||||
});
|
||||
|
||||
*agent_tx_guard = Some(conn_tx);
|
||||
info!("Persistent agent thread spawned");
|
||||
}
|
||||
|
||||
// Send new WS channels to the agent thread
|
||||
if let Some(ref tx) = *agent_tx_guard
|
||||
&& tx
|
||||
.send(NewConnectionChannels {
|
||||
from_ws_rx: to_agent_rx,
|
||||
to_ws_tx: from_agent_tx,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
warn!("Failed to send connection channels to agent thread");
|
||||
}
|
||||
}
|
||||
|
||||
// Task: Read from WS, send to agent thread
|
||||
let read_task = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_read.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
let text_str: &str = text.as_ref();
|
||||
let trimmed = text_str.trim_end_matches(['\r', '\n']);
|
||||
// Skip browser keepalive pings (non-JSON text)
|
||||
if trimmed == "ping" || trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if to_agent_tx.send(trimmed.to_string()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Binary(bin)) => {
|
||||
if let Ok(s) = std::str::from_utf8(&bin) {
|
||||
let trimmed = s.trim_end_matches(['\r', '\n']);
|
||||
if trimmed == "ping" || trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if to_agent_tx.send(trimmed.to_string()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(frame)) => {
|
||||
if let Some(f) = frame {
|
||||
info!(
|
||||
"WebSocket close from {}: {} {}",
|
||||
peer_addr, f.code, f.reason
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
|
||||
Err(e) => {
|
||||
warn!("WebSocket read error from {}: {:?}", peer_addr, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Task: Read from agent thread, send to WS (with keepalive)
|
||||
let write_task = tokio::spawn(async move {
|
||||
let mut keepalive = tokio::time::interval(Duration::from_secs(KEEPALIVE_INTERVAL_SECS));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(msg) = from_agent_rx.recv() => {
|
||||
if ws_write.send(Message::Text(msg.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = keepalive.tick() => {
|
||||
if ws_write.send(Message::Ping(vec![].into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either task to complete
|
||||
tokio::select! {
|
||||
_ = read_task => {}
|
||||
_ = write_task => {}
|
||||
}
|
||||
|
||||
info!("WebSocket connection ended for {}", peer_addr);
|
||||
}
|
||||
|
||||
/// Run the persistent agent on a dedicated thread with LocalSet.
|
||||
///
|
||||
/// The MvpAgent is created **once** and reused across WebSocket reconnections.
|
||||
/// A persistent gateway channel ensures that session actors (which hold cloned
|
||||
/// `GatewaySender` handles) can always send notifications. A relay task forwards
|
||||
/// messages from the persistent channel to the *current* ACP connection's channel,
|
||||
/// so notifications reach whichever client is currently connected.
|
||||
async fn run_persistent_agent(
|
||||
agent_config: AgentConfig,
|
||||
mut connection_rx: mpsc::UnboundedReceiver<NewConnectionChannels>,
|
||||
prefetched_models: Option<IndexMap<String, ModelEntry>>,
|
||||
) {
|
||||
// Persistent gateway channel — the MvpAgent and all session actors hold
|
||||
// clones of `gw_tx`. This channel survives across reconnections.
|
||||
let (gw_tx, mut gw_rx) = tokio::sync::mpsc::unbounded_channel::<AcpClientMessage>();
|
||||
let gateway = GatewaySender::new(gw_tx);
|
||||
|
||||
// Create MvpAgent ONCE -- it persists for the lifetime of the server.
|
||||
let auth_manager = Arc::new(agent_config.create_auth_manager());
|
||||
// Proactive token refresh; runs until process exit.
|
||||
auth_manager.start_proactive_refresh(tokio_util::sync::CancellationToken::new());
|
||||
// Restore managed policy right before bootstrap reads it — the agent is created lazily here,
|
||||
// so an earlier restore could go stale before the gate.
|
||||
crate::managed_config::ensure_managed_policy_present(&auth_manager).await;
|
||||
let agent = Rc::new(
|
||||
MvpAgent::new(gateway, &agent_config, auth_manager, prefetched_models)
|
||||
.unwrap_or_else(crate::agent::init::exit_on_config_error),
|
||||
);
|
||||
|
||||
let relay_dest: RelayDest = Rc::new(RefCell::new(None));
|
||||
|
||||
// Relay task: reads from the persistent gateway channel and forwards to
|
||||
// whichever ACP connection is currently active.
|
||||
let relay_dest_for_task = relay_dest.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
while let Some(msg) = gw_rx.recv().await {
|
||||
let maybe_tx = relay_dest_for_task.borrow().clone();
|
||||
if let Some(tx) = maybe_tx
|
||||
&& tx.send(msg).is_err()
|
||||
{
|
||||
// Connection's gateway receiver was dropped — clear it.
|
||||
*relay_dest_for_task.borrow_mut() = None;
|
||||
}
|
||||
// If no connection, the message (and its response_tx) is dropped.
|
||||
// The caller (session actor) gets a send error which is already
|
||||
// handled with `let _ = ...`.
|
||||
}
|
||||
});
|
||||
|
||||
// Accept new connections in a loop
|
||||
while let Some(channels) = connection_rx.recv().await {
|
||||
info!("Agent thread: setting up new ACP connection (reconnect)");
|
||||
setup_acp_connection(agent.clone(), channels, relay_dest.clone());
|
||||
}
|
||||
|
||||
info!("Agent thread: connection channel closed, exiting");
|
||||
}
|
||||
|
||||
/// Set up a new ACP connection for a WebSocket connection, reusing the existing
|
||||
/// MvpAgent. The relay destination is updated so that session actor notifications
|
||||
/// flow to the new client.
|
||||
fn setup_acp_connection(
|
||||
agent: Rc<MvpAgent>,
|
||||
channels: NewConnectionChannels,
|
||||
relay_dest: RelayDest,
|
||||
) {
|
||||
let NewConnectionChannels {
|
||||
mut from_ws_rx,
|
||||
to_ws_tx,
|
||||
} = channels;
|
||||
|
||||
// Create new simplex IO streams for this ACP connection
|
||||
let (agent_read_rx, mut agent_read_tx) = simplex(MAX_BUFFER_SIZE);
|
||||
let (agent_write_rx, agent_write_tx) = simplex(MAX_BUFFER_SIZE);
|
||||
|
||||
let incoming = agent_read_rx.compat();
|
||||
let outgoing = agent_write_tx.compat_write();
|
||||
|
||||
// Create a per-connection gateway channel for the GatewayReceiver.
|
||||
// The relay task will forward persistent-channel messages here.
|
||||
let (conn_gw_tx, conn_gw_rx) = tokio::sync::mpsc::unbounded_channel::<AcpClientMessage>();
|
||||
|
||||
// Point the relay at this new connection's channel
|
||||
*relay_dest.borrow_mut() = Some(conn_gw_tx);
|
||||
|
||||
// Create new ACP connection reusing the same MvpAgent (via Rc clone).
|
||||
// `Agent` is implemented for `Rc<T: Agent>` so this works.
|
||||
let incoming = LineBufferedRead::spawn_local(incoming);
|
||||
let (conn, handle_io) = acp::AgentSideConnection::new(agent, outgoing, incoming, |fut| {
|
||||
tokio::task::spawn_local(fut);
|
||||
});
|
||||
tokio::task::spawn_local(
|
||||
GatewayReceiver::new(conn_gw_rx, conn)
|
||||
.with_on_meta(kigi_file_utils::trace_context::span_from_meta_traceparent)
|
||||
.run(),
|
||||
);
|
||||
|
||||
// Task: Forward WS messages → agent (incoming ACP bytes)
|
||||
tokio::task::spawn_local(async move {
|
||||
while let Some(msg) = from_ws_rx.recv().await {
|
||||
// Log messages that lack both `id` and `method` — the ACP layer
|
||||
// only prints "received message with neither id nor method" without
|
||||
// the payload, making debugging impossible.
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&msg)
|
||||
&& v.get("id").is_none()
|
||||
&& v.get("method").is_none()
|
||||
{
|
||||
warn!(
|
||||
len = msg.len(),
|
||||
"incoming WS message has neither id nor method"
|
||||
);
|
||||
}
|
||||
if agent_read_tx.write_all(msg.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if agent_read_tx.write_all(b"\n").await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// WS disconnected — the simplex writer is dropped, causing `handle_io`
|
||||
// to complete. The GatewayReceiver for this connection will also stop.
|
||||
// But the MvpAgent and session actors stay alive, ready for the next
|
||||
// connection.
|
||||
});
|
||||
|
||||
// Task: Forward agent messages → WS (outgoing ACP bytes)
|
||||
tokio::task::spawn_local(async move {
|
||||
let mut reader = BufReader::new(agent_write_rx);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let msg = line.trim_end_matches(['\r', '\n']);
|
||||
if !msg.is_empty() && to_ws_tx.send(msg.to_string()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run the ACP IO handler — fire-and-forget since we don't block the
|
||||
// connection loop. It completes when the WS disconnects.
|
||||
tokio::task::spawn_local(async move {
|
||||
let _ = handle_io.await;
|
||||
info!("ACP connection IO handler completed");
|
||||
});
|
||||
}
|
||||
|
||||
/// Run the agent WebSocket server.
|
||||
///
|
||||
/// This starts a WebSocket server that accepts authenticated connections from
|
||||
/// remote TUI clients. A single agent instance is shared across all connections
|
||||
/// (persisted across reconnections) so that in-flight session work survives
|
||||
/// client disconnects.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - Server configuration (bind address and secret)
|
||||
/// * `agent_config` - Agent configuration to use for each connection
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let server_config = ServerConfig {
|
||||
/// bind_addr: "0.0.0.0:9000".parse().unwrap(),
|
||||
/// secret: "my-secret-token".to_string(),
|
||||
/// };
|
||||
/// run_agent_server(server_config, agent_config).await?;
|
||||
/// ```
|
||||
pub async fn run_agent_server(
|
||||
config: ServerConfig,
|
||||
agent_config: AgentConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = Arc::new(ServerState {
|
||||
agent_config,
|
||||
secret: config.secret,
|
||||
agent_conn_tx: tokio::sync::Mutex::new(None),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ws", get(ws_handler))
|
||||
.with_state(state);
|
||||
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
info!("Agent server listening on ws://{}/ws", config.bind_addr);
|
||||
info!(
|
||||
"Clients should connect with: --remote ws://{}:{}/ws --secret <token>",
|
||||
config.bind_addr.ip(),
|
||||
config.bind_addr.port()
|
||||
);
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_sampling_types::{ReasoningEffort, ReasoningEffortOption};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::session::unified_list::SessionKind;
|
||||
|
||||
pub(crate) const SELECTABLE_REASONING_EFFORTS: [ReasoningEffort; 5] = [
|
||||
ReasoningEffort::Minimal,
|
||||
ReasoningEffort::Low,
|
||||
ReasoningEffort::Medium,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffort::Xhigh,
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionConfigOption {
|
||||
pub id: String,
|
||||
pub category: String,
|
||||
pub label: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GrokSessionDetail {
|
||||
pub session_id: String,
|
||||
pub kind: String,
|
||||
pub cwd: String,
|
||||
pub current_model_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
impl GrokSessionDetail {
|
||||
pub fn build(
|
||||
session_id: String,
|
||||
cwd: String,
|
||||
current_model_id: String,
|
||||
title: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
kind: SessionKind::Build.as_str().to_string(),
|
||||
cwd,
|
||||
current_model_id,
|
||||
title,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn effort_label(effort: ReasoningEffort) -> String {
|
||||
match effort {
|
||||
ReasoningEffort::None => "None",
|
||||
ReasoningEffort::Minimal => "Minimal",
|
||||
ReasoningEffort::Low => "Low",
|
||||
ReasoningEffort::Medium => "Medium",
|
||||
ReasoningEffort::High => "High",
|
||||
ReasoningEffort::Xhigh => "X-High",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The built-in session-picker modes used when the model has no server list.
|
||||
/// Reproduces the historical five rows and their labels.
|
||||
pub(crate) fn legacy_session_effort_options() -> Vec<ReasoningEffortOption> {
|
||||
SELECTABLE_REASONING_EFFORTS
|
||||
.iter()
|
||||
.map(|&effort| ReasoningEffortOption {
|
||||
id: effort.as_str().to_string(),
|
||||
value: effort,
|
||||
label: effort_label(effort),
|
||||
description: None,
|
||||
default: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn build_session_config_options(
|
||||
available_models: &[acp::ModelInfo],
|
||||
current_model_id: &acp::ModelId,
|
||||
effort_options: &[ReasoningEffortOption],
|
||||
current_effort: Option<ReasoningEffort>,
|
||||
) -> Vec<SessionConfigOption> {
|
||||
let mut options = Vec::with_capacity(available_models.len() + effort_options.len());
|
||||
|
||||
for model in available_models {
|
||||
let label = if model.name.is_empty() {
|
||||
model.model_id.0.to_string()
|
||||
} else {
|
||||
model.name.clone()
|
||||
};
|
||||
options.push(SessionConfigOption {
|
||||
id: model.model_id.0.to_string(),
|
||||
category: "model".to_string(),
|
||||
label,
|
||||
description: None,
|
||||
selected: model.model_id == *current_model_id,
|
||||
});
|
||||
}
|
||||
|
||||
for effort in effort_options {
|
||||
options.push(SessionConfigOption {
|
||||
id: effort.id.clone(),
|
||||
category: "mode".to_string(),
|
||||
label: effort.label.clone(),
|
||||
description: effort.description.clone(),
|
||||
selected: Some(effort.value) == current_effort,
|
||||
});
|
||||
}
|
||||
|
||||
options
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn model(id: &'static str, name: &str) -> acp::ModelInfo {
|
||||
acp::ModelInfo::new(acp::ModelId::new(id), name.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_have_one_selected_model_and_a_mode_per_effort() {
|
||||
let models = [
|
||||
model("grok-build", "Grok Build"),
|
||||
model("grok-4.5", "Grok 4.5"),
|
||||
];
|
||||
let current = acp::ModelId::from("grok-build");
|
||||
let opts = build_session_config_options(
|
||||
&models,
|
||||
¤t,
|
||||
&legacy_session_effort_options(),
|
||||
Some(ReasoningEffort::High),
|
||||
);
|
||||
|
||||
let model_opts: Vec<_> = opts.iter().filter(|o| o.category == "model").collect();
|
||||
assert_eq!(model_opts.len(), 2);
|
||||
let selected_models: Vec<_> = model_opts.iter().filter(|o| o.selected).collect();
|
||||
assert_eq!(selected_models.len(), 1);
|
||||
assert_eq!(selected_models[0].id, "grok-build");
|
||||
|
||||
let mode_opts: Vec<_> = opts.iter().filter(|o| o.category == "mode").collect();
|
||||
assert_eq!(mode_opts.len(), SELECTABLE_REASONING_EFFORTS.len());
|
||||
let selected_modes: Vec<_> = mode_opts.iter().filter(|o| o.selected).collect();
|
||||
assert_eq!(selected_modes.len(), 1);
|
||||
assert_eq!(selected_modes[0].id, "high");
|
||||
assert_eq!(selected_modes[0].label, "High");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_effort_is_not_a_user_selectable_mode() {
|
||||
assert!(!SELECTABLE_REASONING_EFFORTS.contains(&ReasoningEffort::None));
|
||||
let models = [model("grok-build", "Grok Build")];
|
||||
let current = acp::ModelId::from("grok-build");
|
||||
let opts = build_session_config_options(
|
||||
&models,
|
||||
¤t,
|
||||
&legacy_session_effort_options(),
|
||||
Some(ReasoningEffort::None),
|
||||
);
|
||||
let modes: Vec<_> = opts.iter().filter(|o| o.category == "mode").collect();
|
||||
assert!(modes.iter().all(|o| o.id != "none"));
|
||||
assert!(modes.iter().all(|o| !o.selected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mode_options_when_model_lacks_effort_support() {
|
||||
let models = [model("grok-build", "Grok Build")];
|
||||
let current = acp::ModelId::from("grok-build");
|
||||
let opts = build_session_config_options(&models, ¤t, &[], None);
|
||||
assert_eq!(opts.len(), 1);
|
||||
assert!(opts.iter().all(|o| o.category == "model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_label_falls_back_to_id_when_name_empty() {
|
||||
let models = [model("grok-build", "")];
|
||||
let current = acp::ModelId::from("grok-build");
|
||||
let opts = build_session_config_options(&models, ¤t, &[], None);
|
||||
assert_eq!(opts[0].label, "grok-build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_config_option_serializes_camel_case() {
|
||||
let opt = SessionConfigOption {
|
||||
id: "grok-build".to_string(),
|
||||
category: "model".to_string(),
|
||||
label: "Grok Build".to_string(),
|
||||
description: None,
|
||||
selected: true,
|
||||
};
|
||||
let v = serde_json::to_value(&opt).expect("serialize");
|
||||
assert_eq!(v["id"], "grok-build");
|
||||
assert_eq!(v["category"], "model");
|
||||
assert_eq!(v["label"], "Grok Build");
|
||||
assert_eq!(v["selected"], true);
|
||||
assert!(v.get("description").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_session_detail_serializes_camel_case() {
|
||||
let detail = GrokSessionDetail::build(
|
||||
"sess-1".to_string(),
|
||||
"/Users/me/xai".to_string(),
|
||||
"grok-build".to_string(),
|
||||
None,
|
||||
);
|
||||
let v = serde_json::to_value(&detail).expect("serialize");
|
||||
assert_eq!(v["sessionId"], "sess-1");
|
||||
assert_eq!(v["kind"], "build");
|
||||
assert_eq!(v["cwd"], "/Users/me/xai");
|
||||
assert_eq!(v["currentModelId"], "grok-build");
|
||||
assert!(v.get("title").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
//! REST client for the session replicas registry (cli-chat-proxy).
|
||||
//!
|
||||
//! Handles registering, updating, finalizing, searching, and downloading
|
||||
//! session replicas for cross-host session replication. Write methods
|
||||
//! (register/update/finalize) are fire-and-forget safe. Read methods
|
||||
//! (search/get/download_file) return typed results.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================================
|
||||
// Request / response types (local — not in cli-chat-proxy since these
|
||||
// are only used by the agent, not consumed by other crates)
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisterRequest {
|
||||
pub session_id: String,
|
||||
pub cwd: String,
|
||||
pub gcs_trace_prefix: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_remote_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_branch: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_head_at_start: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hostname: Option<String>,
|
||||
/// Opaque per-machine device id (telemetry `agent_id()`) for machine disambiguation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_session_id: Option<String>,
|
||||
// --- Subagent-specific fields (optional, backward-compatible) ---
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_persona: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_role: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fork_context_source: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_depth: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub first_prompt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_turn_number: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_head_at_end: Option<String>,
|
||||
/// Latest turn whose restore artifacts are confirmed durable.
|
||||
/// Omitted from the wire when `None` — old servers ignore unknown fields.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub restorable_turn_number: Option<i32>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionRecord {
|
||||
pub session_id: String,
|
||||
pub summary: String,
|
||||
pub first_prompt: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub last_turn_number: i32,
|
||||
/// Present on servers that have applied the restorable-turn migration.
|
||||
/// `None` when talking to an older server — callers should fall back to
|
||||
/// `last_turn_number` in that case.
|
||||
#[serde(default)]
|
||||
pub restorable_turn_number: Option<i32>,
|
||||
pub cwd: String,
|
||||
pub repo_remote_url: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub status: String,
|
||||
pub gcs_trace_prefix: String,
|
||||
pub gcs_bucket: String,
|
||||
#[serde(default)]
|
||||
pub last_active_at: Option<String>,
|
||||
}
|
||||
|
||||
impl From<crate::session::persistence::Summary> for SessionRecord {
|
||||
fn from(s: crate::session::persistence::Summary) -> Self {
|
||||
Self {
|
||||
session_id: s.info.id.to_string(),
|
||||
summary: s.session_summary,
|
||||
first_prompt: None,
|
||||
model_id: Some(s.current_model_id.to_string()),
|
||||
created_at: s.created_at.to_rfc3339(),
|
||||
updated_at: s.updated_at.to_rfc3339(),
|
||||
last_turn_number: s.num_messages as i32,
|
||||
restorable_turn_number: None,
|
||||
cwd: s.info.cwd,
|
||||
repo_remote_url: None,
|
||||
hostname: None,
|
||||
status: "local".to_string(),
|
||||
gcs_trace_prefix: String::new(),
|
||||
gcs_bucket: String::new(),
|
||||
last_active_at: s.last_active_at.map(|t| t.to_rfc3339()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResponse {
|
||||
pub sessions: Vec<SessionRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadResponse {
|
||||
pub download_url: String,
|
||||
pub file: String,
|
||||
pub turn: i32,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Client
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionRegistryClient {
|
||||
raw_client: reqwest::Client,
|
||||
client: reqwest_middleware::ClientWithMiddleware,
|
||||
base_url: String,
|
||||
credentials: crate::util::grok_auth_credentials::GrokAuthCredentials,
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionRegistryClient {
|
||||
pub fn new(base_url: impl Into<String>, user_token: impl Into<String>) -> Self {
|
||||
let http_client = crate::http::shared_client();
|
||||
Self {
|
||||
raw_client: http_client.clone(),
|
||||
client: reqwest_middleware::ClientBuilder::new(http_client).build(),
|
||||
base_url: base_url.into(),
|
||||
credentials: crate::util::grok_auth_credentials::GrokAuthCredentials::new(Some(
|
||||
user_token.into(),
|
||||
)),
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_deployment_key(mut self, key: Option<String>) -> Self {
|
||||
self.credentials.deployment_key = key;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_alpha_test_key(mut self, key: Option<String>) -> Self {
|
||||
self.credentials.alpha_test_key = key;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
|
||||
self.session_id = Some(session_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach an `AuthManager` so the request signing and 401
|
||||
/// recovery go through the consolidated auth path.
|
||||
pub fn with_auth(mut self, auth_manager: std::sync::Arc<crate::auth::AuthManager>) -> Self {
|
||||
let provider: std::sync::Arc<dyn kigi_auth::AuthCredentialProvider> = std::sync::Arc::new(
|
||||
crate::auth::credential_provider::ShellAuthCredentialProvider::new(
|
||||
auth_manager.clone(),
|
||||
self.credentials.deployment_key.clone(),
|
||||
self.credentials.alpha_test_key.clone(),
|
||||
),
|
||||
);
|
||||
self.credentials = self.credentials.with_auth_manager(auth_manager);
|
||||
self.client = crate::http::with_auth_retry(self.raw_client.clone(), provider);
|
||||
self
|
||||
}
|
||||
|
||||
async fn send_authed(
|
||||
&self,
|
||||
builder: RequestBuilder,
|
||||
op: &'static str,
|
||||
) -> Result<reqwest::Response> {
|
||||
let builder = kigi_file_utils::trace_context::inject_trace_context_into_request(builder);
|
||||
let request = builder.build().context(op)?;
|
||||
self.client.execute(request).await.map_err(|e| match e {
|
||||
reqwest_middleware::Error::Middleware(e) => e.context(op),
|
||||
reqwest_middleware::Error::Reqwest(e) => anyhow::Error::from(e).context(op),
|
||||
})
|
||||
}
|
||||
|
||||
/// Non-auth headers only -- the `Authorization` header lives in
|
||||
/// `send_authed` so it picks up freshly-refreshed tokens.
|
||||
fn add_common_headers(&self, builder: RequestBuilder) -> RequestBuilder {
|
||||
builder
|
||||
}
|
||||
|
||||
fn check_response(&self, response: reqwest::Response, op: &str) -> anyhow::Error {
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
self.record_401_attribution(op);
|
||||
anyhow::anyhow!("{op}: {}", self.credentials.auth_error_hint())
|
||||
} else {
|
||||
anyhow::anyhow!("{op} failed: {}", response.status())
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a single `auth 401 attribution` log entry tagged with
|
||||
/// `consumer = "SessionRegistryClient.<op>"`. The op string is the
|
||||
/// operation name passed to `check_response` (e.g.,
|
||||
/// `"session register"`).
|
||||
fn record_401_attribution(&self, op: &str) {
|
||||
if let Some(manager) = self.credentials.auth_manager() {
|
||||
let resolved = self.credentials.resolve();
|
||||
let sent = resolved
|
||||
.deployment_key
|
||||
.clone()
|
||||
.or(resolved.user_token.clone());
|
||||
crate::auth::attribution::record_consumer_401(
|
||||
manager.as_ref(),
|
||||
self.session_id.as_deref(),
|
||||
crate::auth::attribution::ConsumerKind::SessionRegistryClient,
|
||||
op,
|
||||
sent.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn post(&self, url: &str) -> RequestBuilder {
|
||||
self.add_common_headers(self.raw_client.post(url))
|
||||
}
|
||||
|
||||
fn get(&self, url: &str) -> RequestBuilder {
|
||||
self.add_common_headers(self.raw_client.get(url))
|
||||
}
|
||||
|
||||
/// POST /v1/sessions/register (idempotent via ON CONFLICT)
|
||||
pub async fn register(&self, req: &RegisterRequest) -> Result<()> {
|
||||
let url = format!("{}/sessions/register", self.base_url);
|
||||
let response = self
|
||||
.send_authed(self.post(&url).json(req), "session register")
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(self.check_response(response, "session register"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /v1/sessions/{id}/replicas/update
|
||||
pub async fn update(&self, session_id: &str, req: &UpdateRequest) -> Result<()> {
|
||||
let url = format!("{}/sessions/{}/replicas/update", self.base_url, session_id);
|
||||
let response = self
|
||||
.send_authed(self.post(&url).json(req), "session update")
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(self.check_response(response, "session update"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /v1/sessions/{id}/replicas/finalize
|
||||
pub async fn finalize(&self, session_id: &str) -> Result<()> {
|
||||
let url = format!(
|
||||
"{}/sessions/{}/replicas/finalize",
|
||||
self.base_url, session_id
|
||||
);
|
||||
let response = self
|
||||
.send_authed(self.post(&url), "session finalize")
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(self.check_response(response, "session finalize"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GET /v1/sessions/search
|
||||
pub async fn search(&self, query: Option<&str>, limit: i64) -> Result<Vec<SessionRecord>> {
|
||||
let url = format!("{}/sessions/search", self.base_url);
|
||||
let mut builder = self.get(&url).query(&[("limit", limit.to_string())]);
|
||||
if let Some(q) = query {
|
||||
builder = builder.query(&[("query", q)]);
|
||||
}
|
||||
let response = self.send_authed(builder, "session search").await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(self.check_response(response, "session search"));
|
||||
}
|
||||
let resp: SearchResponse = response.json().await.context("parse search response")?;
|
||||
Ok(resp.sessions)
|
||||
}
|
||||
|
||||
/// GET /v1/sessions/{id}/replicas
|
||||
pub async fn get_session(&self, session_id: &str) -> Result<SessionRecord> {
|
||||
let url = format!("{}/sessions/{}/replicas", self.base_url, session_id);
|
||||
let response = self.send_authed(self.get(&url), "session get").await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(self.check_response(response, "session get"));
|
||||
}
|
||||
response.json().await.context("parse session response")
|
||||
}
|
||||
|
||||
/// GET /v1/sessions/{id}/download — returns a signed GCS URL without downloading.
|
||||
pub async fn get_download_url(
|
||||
&self,
|
||||
session_id: &str,
|
||||
file: &str,
|
||||
turn: i32,
|
||||
) -> Result<String> {
|
||||
let url = format!("{}/sessions/{}/download", self.base_url, session_id);
|
||||
let builder = self
|
||||
.get(&url)
|
||||
.query(&[("file", file), ("turn", &turn.to_string())]);
|
||||
let response = self.send_authed(builder, "session download url").await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(self.check_response(response, "session download url"));
|
||||
}
|
||||
let resp: DownloadResponse = response.json().await.context("parse download response")?;
|
||||
Ok(resp.download_url)
|
||||
}
|
||||
|
||||
/// GET /v1/sessions/{id}/download — returns a signed URL, then streams to dest file.
|
||||
pub async fn download_file(
|
||||
&self,
|
||||
session_id: &str,
|
||||
file: &str,
|
||||
turn: i32,
|
||||
dest: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
let url = format!("{}/sessions/{}/download", self.base_url, session_id);
|
||||
let builder = self
|
||||
.get(&url)
|
||||
.query(&[("file", file), ("turn", &turn.to_string())]);
|
||||
let response = self.send_authed(builder, "session download").await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(self.check_response(response, "session download"));
|
||||
}
|
||||
let resp: DownloadResponse = response.json().await.context("parse download response")?;
|
||||
|
||||
// Stream from the signed GCS URL directly to disk (archives can be hundreds of MB)
|
||||
let mut gcs_response = self
|
||||
.raw_client
|
||||
.get(&resp.download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("download from GCS")?;
|
||||
if !gcs_response.status().is_success() {
|
||||
anyhow::bail!("GCS download failed: {}", gcs_response.status());
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let mut out = tokio::fs::File::create(dest)
|
||||
.await
|
||||
.context("create dest file")?;
|
||||
let chunk_timeout = std::time::Duration::from_secs(60);
|
||||
loop {
|
||||
match tokio::time::timeout(chunk_timeout, gcs_response.chunk()).await {
|
||||
Ok(Ok(Some(chunk))) => {
|
||||
tokio::io::AsyncWriteExt::write_all(&mut out, &chunk)
|
||||
.await
|
||||
.context("write chunk to disk")?;
|
||||
}
|
||||
Ok(Ok(None)) => break,
|
||||
Ok(Err(e)) => return Err(e).context("read GCS chunk"),
|
||||
Err(_) => anyhow::bail!(
|
||||
"GCS download stalled: no data received for {chunk_timeout:?} \
|
||||
while downloading {file}"
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── UpdateRequest wire shapes ────────────────────────────────────────────
|
||||
//
|
||||
// The writer split relies on two distinct update payloads being sent at
|
||||
// different times:
|
||||
//
|
||||
// 1. Immediate post-turn: `last_turn_number` + `repo_head_at_end`
|
||||
// 2. Artifact-ready: `restorable_turn_number` only
|
||||
//
|
||||
// These tests verify that `skip_serializing_if = "Option::is_none"` does the
|
||||
// right thing for each shape, so old servers silently ignore the new field and
|
||||
// clients don't accidentally overwrite unrelated fields with nulls.
|
||||
|
||||
#[test]
|
||||
fn immediate_turn_update_omits_restorable_field() {
|
||||
let req = UpdateRequest {
|
||||
summary: None,
|
||||
first_prompt: None,
|
||||
last_turn_number: Some(5),
|
||||
repo_head_at_end: Some("abc123".into()),
|
||||
restorable_turn_number: None,
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["lastTurnNumber"], 5);
|
||||
assert_eq!(json["repoHeadAtEnd"], "abc123");
|
||||
assert!(json.get("restorableTurnNumber").is_none());
|
||||
assert!(json.get("summary").is_none());
|
||||
assert!(json.get("firstPrompt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restorable_turn_update_omits_last_turn_and_head_fields() {
|
||||
let req = UpdateRequest {
|
||||
summary: None,
|
||||
first_prompt: None,
|
||||
last_turn_number: None,
|
||||
repo_head_at_end: None,
|
||||
restorable_turn_number: Some(5),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["restorableTurnNumber"], 5);
|
||||
assert!(json.get("lastTurnNumber").is_none());
|
||||
assert!(json.get("repoHeadAtEnd").is_none());
|
||||
assert!(json.get("summary").is_none());
|
||||
assert!(json.get("firstPrompt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_update_omits_all_turn_fields() {
|
||||
let req = UpdateRequest {
|
||||
summary: Some("My session summary".into()),
|
||||
first_prompt: None,
|
||||
last_turn_number: None,
|
||||
repo_head_at_end: None,
|
||||
restorable_turn_number: None,
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["summary"], "My session summary");
|
||||
assert!(json.get("lastTurnNumber").is_none());
|
||||
assert!(json.get("restorableTurnNumber").is_none());
|
||||
assert!(json.get("repoHeadAtEnd").is_none());
|
||||
}
|
||||
|
||||
// Wire-contract tests: server reads the camelCase `deviceId` key.
|
||||
|
||||
fn minimal_register_request(device_id: Option<String>) -> RegisterRequest {
|
||||
RegisterRequest {
|
||||
session_id: "s1".into(),
|
||||
cwd: "/x".into(),
|
||||
gcs_trace_prefix: "t".into(),
|
||||
model_id: None,
|
||||
repo_remote_url: None,
|
||||
repo_branch: None,
|
||||
repo_head_at_start: None,
|
||||
hostname: None,
|
||||
device_id,
|
||||
parent_session_id: None,
|
||||
session_kind: None,
|
||||
subagent_type: None,
|
||||
subagent_persona: None,
|
||||
subagent_role: None,
|
||||
fork_context_source: None,
|
||||
subagent_depth: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_request_serializes_device_id_as_camel_case() {
|
||||
let req = minimal_register_request(Some("machine-uuid-123".into()));
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["deviceId"], "machine-uuid-123");
|
||||
assert!(json.get("device_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_request_serializes_empty_device_id_as_present() {
|
||||
let req = minimal_register_request(Some(String::new()));
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["deviceId"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_request_omits_device_id_when_none() {
|
||||
let req = minimal_register_request(None);
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert!(json.get("deviceId").is_none());
|
||||
assert!(json.get("device_id").is_none());
|
||||
}
|
||||
|
||||
// ── SessionRecord backward compatibility ─────────────────────────────────
|
||||
//
|
||||
// Older servers do not include `restorable_turn_number` in their response.
|
||||
// The field is `#[serde(default)]` so it must deserialize as `None` when
|
||||
// absent, keeping new clients compatible with old servers.
|
||||
|
||||
#[test]
|
||||
fn session_record_without_restorable_turn_deserializes_as_none() {
|
||||
let json = serde_json::json!({
|
||||
"sessionId": "sess-abc",
|
||||
"summary": "hello",
|
||||
"firstPrompt": null,
|
||||
"modelId": null,
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"updatedAt": "2026-01-01T00:00:00Z",
|
||||
"lastTurnNumber": 3,
|
||||
"cwd": "/home/user/repo",
|
||||
"repoRemoteUrl": null,
|
||||
"hostname": null,
|
||||
"status": "active",
|
||||
"gcsTracePrefix": "sessions/sess-abc",
|
||||
"gcsBucket": "my-bucket"
|
||||
});
|
||||
let record: SessionRecord = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(record.last_turn_number, 3);
|
||||
assert_eq!(record.restorable_turn_number, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_record_with_restorable_turn_deserializes_correctly() {
|
||||
let json = serde_json::json!({
|
||||
"sessionId": "sess-xyz",
|
||||
"summary": "hello",
|
||||
"firstPrompt": null,
|
||||
"modelId": null,
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"updatedAt": "2026-01-01T00:00:00Z",
|
||||
"lastTurnNumber": 7,
|
||||
"restorableTurnNumber": 6,
|
||||
"cwd": "/home/user/repo",
|
||||
"repoRemoteUrl": null,
|
||||
"hostname": null,
|
||||
"status": "active",
|
||||
"gcsTracePrefix": "sessions/sess-xyz",
|
||||
"gcsBucket": "my-bucket"
|
||||
});
|
||||
let record: SessionRecord = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(record.last_turn_number, 7);
|
||||
assert_eq!(record.restorable_turn_number, Some(6));
|
||||
}
|
||||
|
||||
/// Verify per-request auth resolve picks up rotated tokens.
|
||||
#[tokio::test]
|
||||
async fn session_registry_client_uses_active_auth_for_each_request() {
|
||||
use crate::auth::{AuthManager, AuthMode, GrokAuth, GrokComConfig};
|
||||
use axum::{Router, response::IntoResponse, routing::post};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let captured = Arc::new(parking_lot::Mutex::new(None::<String>));
|
||||
let captured_for_handler = captured.clone();
|
||||
let router = Router::new().route(
|
||||
"/sessions/register",
|
||||
post(move |headers: axum::http::HeaderMap, _body: String| {
|
||||
let captured = captured_for_handler.clone();
|
||||
async move {
|
||||
if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) {
|
||||
*captured.lock() = Some(auth.to_str().unwrap_or("").to_owned());
|
||||
}
|
||||
(axum::http::StatusCode::OK, "").into_response()
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr: SocketAddr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let am = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
|
||||
am.hot_swap(GrokAuth {
|
||||
key: "fresh-from-auth-manager".into(),
|
||||
auth_mode: AuthMode::ApiKey,
|
||||
create_time: Utc::now(),
|
||||
user_id: "user-42".into(),
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
});
|
||||
|
||||
let client = SessionRegistryClient::new(format!("http://{addr}"), "STALE-build-time-token")
|
||||
.with_auth(am);
|
||||
let req = RegisterRequest {
|
||||
session_id: "s1".into(),
|
||||
cwd: "/x".into(),
|
||||
gcs_trace_prefix: "t".into(),
|
||||
model_id: None,
|
||||
repo_remote_url: None,
|
||||
repo_branch: None,
|
||||
repo_head_at_start: None,
|
||||
hostname: None,
|
||||
device_id: None,
|
||||
parent_session_id: None,
|
||||
session_kind: None,
|
||||
subagent_type: None,
|
||||
subagent_persona: None,
|
||||
subagent_role: None,
|
||||
fork_context_source: None,
|
||||
subagent_depth: None,
|
||||
};
|
||||
client.register(&req).await.unwrap();
|
||||
|
||||
let sent = captured.lock().clone().expect("server saw the request");
|
||||
assert_eq!(
|
||||
sent, "Bearer fresh-from-auth-manager",
|
||||
"outgoing bearer must come from AuthManager (not the build-time token)"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the split-pointer invariant: last_turn_number can be ahead of
|
||||
// restorable_turn_number (codebase best-effort means a turn may be "done"
|
||||
// but not yet restorable if session-state upload is still in flight).
|
||||
#[test]
|
||||
fn session_record_allows_last_turn_ahead_of_restorable() {
|
||||
let json = serde_json::json!({
|
||||
"sessionId": "sess-lag",
|
||||
"summary": "",
|
||||
"firstPrompt": null,
|
||||
"modelId": null,
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"updatedAt": "2026-01-01T00:00:00Z",
|
||||
"lastTurnNumber": 10,
|
||||
"restorableTurnNumber": 8,
|
||||
"cwd": "/repo",
|
||||
"repoRemoteUrl": null,
|
||||
"hostname": null,
|
||||
"status": "active",
|
||||
"gcsTracePrefix": "sessions/sess-lag",
|
||||
"gcsBucket": "bucket"
|
||||
});
|
||||
let record: SessionRecord = serde_json::from_value(json).unwrap();
|
||||
assert!(record.last_turn_number > record.restorable_turn_number.unwrap_or(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
#![allow(unused_imports)]
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use agent_client_protocol as acp;
|
||||
use tokio::sync::{Notify, mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use crate::extensions::notification::{SessionNotification, SessionUpdate};
|
||||
use crate::session::{
|
||||
self, SessionCommand, SessionHandle, SessionThread,
|
||||
commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult},
|
||||
fs_watch::FsWatchCapabilities, info::Info as SessionInfo,
|
||||
};
|
||||
use crate::terminal::AsyncTerminalRunner;
|
||||
use crate::tools::ToolContext;
|
||||
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
use kigi_tools::implementations::grok_build::task::types::*;
|
||||
use kigi_workspace::file_system::AsyncFileSystem;
|
||||
use kigi_hunk_tracker::HunkTrackerHandle;
|
||||
use super::*;
|
||||
impl SubagentCoordinator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: HashMap::new(),
|
||||
active: HashMap::new(),
|
||||
completed: HashMap::new(),
|
||||
completion_notify: Arc::new(Notify::new()),
|
||||
pending_completions: Vec::new(),
|
||||
is_turn_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
running_gauge: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
block_wait_slots: HashMap::new(),
|
||||
subagent_usage_not_applied_prompts: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
pub fn mark_subagent_usage_not_applied(&mut self, prompt_id: &str) {
|
||||
self.subagent_usage_not_applied_prompts.insert(prompt_id.to_string());
|
||||
}
|
||||
pub fn subagent_usage_not_applied(&self, prompt_id: &str) -> bool {
|
||||
self.subagent_usage_not_applied_prompts.contains(prompt_id)
|
||||
}
|
||||
pub fn clear_subagent_usage_not_applied(&mut self, prompt_id: &str) {
|
||||
self.subagent_usage_not_applied_prompts.remove(prompt_id);
|
||||
}
|
||||
pub fn parent_prompt_id_for(&self, subagent_id: &str) -> Option<String> {
|
||||
self.active
|
||||
.get(subagent_id)
|
||||
.and_then(|t| t.parent_prompt_id.clone())
|
||||
.or_else(|| {
|
||||
self.pending.get(subagent_id).and_then(|p| p.parent_prompt_id.clone())
|
||||
})
|
||||
}
|
||||
/// Rebind the running-subagent gauge, copying the current count so a
|
||||
/// late rebind cannot under-report.
|
||||
pub fn set_running_gauge(&mut self, gauge: Arc<std::sync::atomic::AtomicUsize>) {
|
||||
gauge
|
||||
.store(
|
||||
self.pending.len() + self.active.len(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
self.running_gauge = gauge;
|
||||
}
|
||||
/// Recompute the gauge from `pending` + `active` after every mutation of
|
||||
/// either map — recomputing (rather than incrementing) prevents drift.
|
||||
fn sync_running_gauge(&self) {
|
||||
self.running_gauge
|
||||
.store(
|
||||
self.pending.len() + self.active.len(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
/// Returns a handle to the completion [`Notify`].
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "used from tests only; remove expect when wired in production"
|
||||
)
|
||||
)]
|
||||
pub fn completion_notify(&self) -> Arc<Notify> {
|
||||
Arc::clone(&self.completion_notify)
|
||||
}
|
||||
/// Returns a shared handle to the turn-active flag.
|
||||
pub fn turn_active_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
|
||||
Arc::clone(&self.is_turn_active)
|
||||
}
|
||||
/// Whether the model's turn is currently active.
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "used from tests only; remove expect when wired in production"
|
||||
)
|
||||
)]
|
||||
pub fn is_turn_active(&self) -> bool {
|
||||
self.is_turn_active.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// Pending + active turn-blocking subagent IDs for `prompt_id`.
|
||||
/// Background children are excluded: they outlive the turn by design, so
|
||||
/// the freeze drain must not wait on them (their spend reaches the session
|
||||
/// ledger when they finish; the prompt report flags them via
|
||||
/// `background_live`).
|
||||
pub fn outstanding_for_prompt(&self, prompt_id: &str) -> Vec<String> {
|
||||
let mut ids: Vec<String> = self
|
||||
.pending
|
||||
.values()
|
||||
.filter(|p| {
|
||||
p.parent_prompt_id.as_deref() == Some(prompt_id) && !p.run_in_background
|
||||
})
|
||||
.map(|p| p.subagent_id.clone())
|
||||
.chain(
|
||||
self
|
||||
.active
|
||||
.values()
|
||||
.filter(|t| {
|
||||
t.parent_prompt_id.as_deref() == Some(prompt_id)
|
||||
&& !t.run_in_background
|
||||
})
|
||||
.map(|t| t.subagent_id.clone()),
|
||||
)
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
/// True while any background child of `prompt_id` is pending or active.
|
||||
/// Their spend is missing from the prompt report (it lands on the session
|
||||
/// ledger at completion), so the report is incomplete — without waiting.
|
||||
pub fn background_live_for_prompt(&self, prompt_id: &str) -> bool {
|
||||
self
|
||||
.pending
|
||||
.values()
|
||||
.any(|p| {
|
||||
p.parent_prompt_id.as_deref() == Some(prompt_id) && p.run_in_background
|
||||
})
|
||||
|| self
|
||||
.active
|
||||
.values()
|
||||
.any(|t| {
|
||||
t.parent_prompt_id.as_deref() == Some(prompt_id)
|
||||
&& t.run_in_background
|
||||
})
|
||||
}
|
||||
/// Record that a foreground child was auto-backgrounded (await budget
|
||||
/// expired): it no longer blocks the turn, so the freeze drain must stop
|
||||
/// waiting on it.
|
||||
pub fn mark_backgrounded(&mut self, subagent_id: &str) {
|
||||
if let Some(t) = self.active.values_mut().find(|t| t.subagent_id == subagent_id)
|
||||
{
|
||||
t.run_in_background = true;
|
||||
}
|
||||
if let Some(p) = self.pending.values_mut().find(|p| p.subagent_id == subagent_id)
|
||||
{
|
||||
p.run_in_background = true;
|
||||
}
|
||||
}
|
||||
pub fn outstanding_reply_for_prompt(
|
||||
&self,
|
||||
prompt_id: &str,
|
||||
) -> kigi_tools::implementations::grok_build::task::types::SubagentOutstandingReply {
|
||||
kigi_tools::implementations::grok_build::task::types::SubagentOutstandingReply {
|
||||
live_ids: self.outstanding_for_prompt(prompt_id),
|
||||
background_live: self.background_live_for_prompt(prompt_id),
|
||||
subagent_usage_not_applied: self.subagent_usage_not_applied(prompt_id),
|
||||
}
|
||||
}
|
||||
/// Drain all buffered completion summaries, returning them and clearing the buffer.
|
||||
pub fn drain_pending_completions(&mut self) -> Vec<SubagentCompletionSummary> {
|
||||
std::mem::take(&mut self.pending_completions)
|
||||
}
|
||||
/// Register a subagent as pending (initializing). Call this early,
|
||||
/// before any blocking work like worktree creation, so that
|
||||
/// `get_task_output` can report the subagent as initializing instead
|
||||
/// of "not found".
|
||||
pub fn insert_pending(&mut self, entry: PendingSubagent) {
|
||||
self.pending.insert(entry.subagent_id.clone(), entry);
|
||||
self.sync_running_gauge();
|
||||
}
|
||||
/// Remove a pending subagent without recording a failure.
|
||||
/// Used by cancel flows where the subagent was intentionally stopped.
|
||||
#[cfg(test)]
|
||||
pub fn remove_pending(&mut self, id: &str) {
|
||||
self.pending.remove(id);
|
||||
self.sync_running_gauge();
|
||||
}
|
||||
/// Move a pending subagent directly to `completed` so it stays queryable via
|
||||
/// `get_task_output`. `cancelled` stamps `"cancelled"` vs `"failed"`.
|
||||
fn move_pending_to_terminal(&mut self, id: &str, error: &str, cancelled: bool) {
|
||||
let Some(pending) = self.pending.remove(id) else {
|
||||
return;
|
||||
};
|
||||
self.record_failure_completion(FailureCompletion {
|
||||
subagent_id: pending.subagent_id,
|
||||
subagent_type: pending.subagent_type,
|
||||
description: pending.description,
|
||||
parent_prompt_id: pending.parent_prompt_id,
|
||||
parent_session_id: pending.parent_session_id,
|
||||
persona: pending.persona,
|
||||
started_at: pending.started_at,
|
||||
error,
|
||||
surface_completion: pending.surface_completion,
|
||||
cancelled,
|
||||
});
|
||||
}
|
||||
/// Move a pending subagent to `completed` as a failure so it stays queryable
|
||||
/// via `get_task_output`.
|
||||
pub fn move_pending_to_failed(&mut self, id: &str, error: &str) {
|
||||
self.move_pending_to_terminal(id, error, false);
|
||||
}
|
||||
/// Like [`Self::move_pending_to_failed`] but stamps `"cancelled"` — a pending
|
||||
/// subagent killed while initializing.
|
||||
pub fn move_pending_to_cancelled(&mut self, id: &str, error: &str) {
|
||||
self.move_pending_to_terminal(id, error, true);
|
||||
}
|
||||
/// Record a synthetic failure for a subagent that never reached `pending`.
|
||||
pub fn record_pre_spawn_failure(
|
||||
&mut self,
|
||||
subagent_id: String,
|
||||
subagent_type: String,
|
||||
description: String,
|
||||
parent_prompt_id: Option<String>,
|
||||
parent_session_id: String,
|
||||
error: &str,
|
||||
surface_completion: bool,
|
||||
) {
|
||||
self.record_failure_completion(FailureCompletion {
|
||||
subagent_id,
|
||||
subagent_type,
|
||||
description,
|
||||
parent_prompt_id,
|
||||
parent_session_id,
|
||||
persona: None,
|
||||
started_at: std::time::Instant::now(),
|
||||
error,
|
||||
surface_completion,
|
||||
cancelled: false,
|
||||
});
|
||||
}
|
||||
/// Insert a synthetic failed entry, push a completion summary, notify waiters.
|
||||
/// Clears any stale pending entry for the same id.
|
||||
fn record_failure_completion(&mut self, c: FailureCompletion<'_>) {
|
||||
self.pending.remove(&c.subagent_id);
|
||||
self.sync_running_gauge();
|
||||
let FailureCompletion {
|
||||
subagent_id,
|
||||
subagent_type,
|
||||
description,
|
||||
parent_prompt_id,
|
||||
parent_session_id,
|
||||
persona,
|
||||
started_at,
|
||||
error,
|
||||
surface_completion,
|
||||
cancelled,
|
||||
} = c;
|
||||
let result = SubagentResult {
|
||||
success: false,
|
||||
cancelled,
|
||||
error: Some(error.to_string()),
|
||||
subagent_id: subagent_id.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let summary_output = result.output.clone();
|
||||
self.completed
|
||||
.insert(
|
||||
subagent_id.clone(),
|
||||
CompletedSubagent {
|
||||
subagent_id: subagent_id.clone(),
|
||||
parent_session_id,
|
||||
parent_prompt_id,
|
||||
child_session_id: String::new(),
|
||||
description: description.clone(),
|
||||
subagent_type: subagent_type.clone(),
|
||||
persona,
|
||||
started_at,
|
||||
completed_at: std::time::Instant::now(),
|
||||
result,
|
||||
resumed_from: None,
|
||||
child_cwd: String::new(),
|
||||
worktree_path: None,
|
||||
snapshot_ref: None,
|
||||
effective_model_id: String::new(),
|
||||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
},
|
||||
);
|
||||
if surface_completion {
|
||||
self.pending_completions
|
||||
.push(SubagentCompletionSummary {
|
||||
subagent_id,
|
||||
subagent_type,
|
||||
description,
|
||||
success: false,
|
||||
duration_ms: 0,
|
||||
tool_calls: 0,
|
||||
turns: 0,
|
||||
output: summary_output,
|
||||
});
|
||||
}
|
||||
self.completion_notify.notify_waiters();
|
||||
}
|
||||
pub fn insert(&mut self, tracker: SubagentTracker) {
|
||||
self.pending.remove(&tracker.subagent_id);
|
||||
self.active.insert(tracker.subagent_id.clone(), tracker);
|
||||
self.sync_running_gauge();
|
||||
}
|
||||
/// Move a finished subagent from `active` to `completed`.
|
||||
/// Returns the tracker if it was active.
|
||||
pub fn move_to_completed(
|
||||
&mut self,
|
||||
id: &str,
|
||||
description: String,
|
||||
subagent_type: String,
|
||||
result: SubagentResult,
|
||||
) -> Option<SubagentTracker> {
|
||||
let tracker = self.active.remove(id);
|
||||
self.sync_running_gauge();
|
||||
let started_at = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.started_at)
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
let parent_session_id = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.parent_session_id.clone())
|
||||
.unwrap_or_default();
|
||||
let child_session_id = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.child_session_id.0.to_string())
|
||||
.unwrap_or_default();
|
||||
let parent_prompt_id = tracker.as_ref().and_then(|t| t.parent_prompt_id.clone());
|
||||
let persona = tracker.as_ref().and_then(|t| t.persona.clone());
|
||||
let child_cwd = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.child_cwd.clone())
|
||||
.unwrap_or_default();
|
||||
let worktree_path = tracker.as_ref().and_then(|t| t.worktree_path.clone());
|
||||
let resumed_from = tracker.as_ref().and_then(|t| t.resumed_from.clone());
|
||||
let effective_model_id = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.effective_model_id.clone())
|
||||
.unwrap_or_default();
|
||||
let block_waited = tracker.as_ref().is_some_and(|t| t.block_waited);
|
||||
let explicitly_killed = tracker.as_ref().is_some_and(|t| t.explicitly_killed);
|
||||
let surface_completion = tracker.as_ref().is_none_or(|t| t.surface_completion);
|
||||
self.completed
|
||||
.insert(
|
||||
id.to_string(),
|
||||
CompletedSubagent {
|
||||
subagent_id: id.to_string(),
|
||||
parent_session_id,
|
||||
parent_prompt_id,
|
||||
child_session_id,
|
||||
description,
|
||||
subagent_type,
|
||||
persona,
|
||||
started_at,
|
||||
completed_at: std::time::Instant::now(),
|
||||
result,
|
||||
resumed_from,
|
||||
child_cwd,
|
||||
worktree_path,
|
||||
snapshot_ref: None,
|
||||
effective_model_id,
|
||||
block_waited,
|
||||
explicitly_killed,
|
||||
},
|
||||
);
|
||||
let completed = self.completed.get(id).expect("just inserted");
|
||||
let success = completed.result.success && !completed.result.cancelled;
|
||||
{
|
||||
let preview = crate::util::truncate(&completed.result.output, 200);
|
||||
let level_fn = if success {
|
||||
kigi_log::unified_log::info
|
||||
} else {
|
||||
kigi_log::unified_log::error
|
||||
};
|
||||
level_fn(
|
||||
if success { "subagent completed" } else { "subagent failed" },
|
||||
None,
|
||||
Some(
|
||||
serde_json::json!(
|
||||
{ "subagent_id" : & completed.subagent_id, "subagent_type" : &
|
||||
completed.subagent_type, "effective_model" : & completed
|
||||
.effective_model_id, "success" : success, "cancelled" : completed
|
||||
.result.cancelled, "duration_ms" : completed.result.duration_ms,
|
||||
"turns" : completed.result.turns, "tool_calls" : completed.result
|
||||
.tool_calls, "output_preview" : preview, "error" : & completed
|
||||
.result.error, }
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if surface_completion {
|
||||
self.pending_completions
|
||||
.push(SubagentCompletionSummary {
|
||||
subagent_id: id.to_string(),
|
||||
subagent_type: completed.subagent_type.clone(),
|
||||
description: completed.description.clone(),
|
||||
success,
|
||||
duration_ms: completed.result.duration_ms,
|
||||
tool_calls: completed.result.tool_calls,
|
||||
turns: completed.result.turns,
|
||||
output: completed.result.output.clone(),
|
||||
});
|
||||
}
|
||||
self.completion_notify.notify_waiters();
|
||||
tracker
|
||||
}
|
||||
/// Record the durable worktree snapshot ref on a completed subagent so
|
||||
/// in-memory `resume_from` resolution can rehydrate the disposed worktree.
|
||||
/// No-op if the entry was already evicted (the on-disk meta.json still has it).
|
||||
pub fn set_completed_snapshot_ref(&mut self, id: &str, snapshot_ref: String) {
|
||||
if let Some(completed) = self.completed.get_mut(id) {
|
||||
completed.snapshot_ref = Some(snapshot_ref);
|
||||
}
|
||||
}
|
||||
/// Cancel all active subagents that were launched by a specific parent turn,
|
||||
/// including `run_in_background: true` subagents.
|
||||
pub fn cancel_by_parent_prompt_id(&mut self, parent_prompt_id: &str) {
|
||||
for tracker in self.active.values() {
|
||||
if tracker.parent_prompt_id.as_deref() == Some(parent_prompt_id) {
|
||||
Self::cancel_tracker(tracker);
|
||||
}
|
||||
}
|
||||
for pending in self.pending.values() {
|
||||
if pending.parent_prompt_id.as_deref() == Some(parent_prompt_id) {
|
||||
pending.cancel_token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Attempt to cancel a subagent. Returns a typed outcome covering all cases:
|
||||
/// - Active → cancel it, return Cancelled
|
||||
/// - Pending (initializing) → fire its spawn token, return Cancelled
|
||||
/// - Already finished → return AlreadyFinished with terminal status
|
||||
/// - Unknown ID → return NotFound
|
||||
pub fn cancel_with_outcome(&mut self, subagent_id: &str) -> SubagentCancelOutcome {
|
||||
if let Some(tracker) = self.active.get(subagent_id) {
|
||||
Self::cancel_tracker(tracker);
|
||||
return SubagentCancelOutcome::Cancelled;
|
||||
}
|
||||
if let Some(pending) = self.pending.get(subagent_id) {
|
||||
pending.cancel_token.cancel();
|
||||
return SubagentCancelOutcome::Cancelled;
|
||||
}
|
||||
if let Some(entry) = self.completed.get(subagent_id) {
|
||||
return SubagentCancelOutcome::AlreadyFinished {
|
||||
status: entry.result.status().to_string(),
|
||||
};
|
||||
}
|
||||
SubagentCancelOutcome::NotFound
|
||||
}
|
||||
/// Internal: send Cancel + Shutdown to a tracked subagent.
|
||||
fn cancel_tracker(tracker: &SubagentTracker) {
|
||||
tracker.cancel_token.cancel();
|
||||
let _ = tracker
|
||||
.child_handle
|
||||
.cmd_tx
|
||||
.send(SessionCommand::Cancel {
|
||||
cancel_subagents: true,
|
||||
kill_background_tasks: true,
|
||||
rewind_if_pristine: false,
|
||||
trigger: None,
|
||||
});
|
||||
let _ = tracker.child_handle.cmd_tx.send(SessionCommand::Shutdown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
#![allow(unused_imports)]
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use agent_client_protocol as acp;
|
||||
use tokio::sync::{Notify, mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use crate::extensions::notification::{SessionNotification, SessionUpdate};
|
||||
use crate::session::{
|
||||
self, SessionCommand, SessionHandle, SessionThread,
|
||||
commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult},
|
||||
fs_watch::FsWatchCapabilities, info::Info as SessionInfo,
|
||||
};
|
||||
use crate::terminal::AsyncTerminalRunner;
|
||||
use crate::tools::ToolContext;
|
||||
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
use kigi_tools::implementations::grok_build::task::types::*;
|
||||
use kigi_workspace::file_system::AsyncFileSystem;
|
||||
use kigi_hunk_tracker::HunkTrackerHandle;
|
||||
use super::*;
|
||||
impl SubagentCoordinator {
|
||||
/// Synchronous lookup of a subagent by ID.
|
||||
///
|
||||
/// Returns a three-way result so the caller can drop the `RefCell` borrow
|
||||
/// before awaiting the signals handle for running subagents.
|
||||
///
|
||||
/// - `Ready` — completed/failed/cancelled snapshot, no async work needed.
|
||||
/// - `NeedsSignals` — subagent is running; caller must await
|
||||
/// `resolve_snapshot()` after dropping the coordinator borrow.
|
||||
/// - `None` — ID not found in active, completed, or pending maps.
|
||||
pub(crate) fn lookup(&self, id: &str) -> Option<SnapshotLookup> {
|
||||
if let Some(tracker) = self.active.get(id) {
|
||||
return Some(
|
||||
SnapshotLookup::NeedsSignals(RunningSnapshotSeed {
|
||||
subagent_id: tracker.subagent_id.clone(),
|
||||
description: tracker.description.clone(),
|
||||
subagent_type: tracker.subagent_type.clone(),
|
||||
started_at_epoch_ms: instant_to_epoch_ms(tracker.started_at),
|
||||
duration_ms: tracker.started_at.elapsed().as_millis() as u64,
|
||||
persona: tracker.persona.clone(),
|
||||
signals_handle: tracker.child_handle.signals_handle.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(completed) = self.completed.get(id) {
|
||||
let status = if completed.result.cancelled {
|
||||
SubagentSnapshotStatus::Cancelled {
|
||||
reason: completed.result.error.clone(),
|
||||
}
|
||||
} else if completed.result.success {
|
||||
SubagentSnapshotStatus::Completed {
|
||||
output: completed.result.output.to_string(),
|
||||
tool_calls: completed.result.tool_calls,
|
||||
turns: completed.result.turns,
|
||||
worktree_path: completed.result.worktree_path.clone(),
|
||||
}
|
||||
} else {
|
||||
SubagentSnapshotStatus::Failed {
|
||||
error: completed
|
||||
.result
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown error".to_string()),
|
||||
}
|
||||
};
|
||||
return Some(
|
||||
SnapshotLookup::Ready(SubagentSnapshot {
|
||||
subagent_id: completed.subagent_id.clone(),
|
||||
description: completed.description.clone(),
|
||||
subagent_type: completed.subagent_type.clone(),
|
||||
status,
|
||||
started_at_epoch_ms: instant_to_epoch_ms(completed.started_at),
|
||||
duration_ms: completed.result.duration_ms,
|
||||
persona: completed.persona.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(pending) = self.pending.get(id) {
|
||||
return Some(
|
||||
SnapshotLookup::Ready(SubagentSnapshot {
|
||||
subagent_id: pending.subagent_id.clone(),
|
||||
description: pending.description.clone(),
|
||||
subagent_type: pending.subagent_type.clone(),
|
||||
status: SubagentSnapshotStatus::Initializing,
|
||||
started_at_epoch_ms: instant_to_epoch_ms(pending.started_at),
|
||||
duration_ms: pending.started_at.elapsed().as_millis() as u64,
|
||||
persona: pending.persona.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Return `(parent_session_id, child_session_id)` for a given subagent.
|
||||
///
|
||||
/// Checks active first, then completed. Returns `None` if not found.
|
||||
pub(crate) fn session_ids_for(&self, id: &str) -> Option<(String, String)> {
|
||||
if let Some(t) = self.active.get(id) {
|
||||
return Some((t.parent_session_id.clone(), t.child_session_id.0.to_string()));
|
||||
}
|
||||
if let Some(c) = self.completed.get(id) {
|
||||
return Some((c.parent_session_id.clone(), c.child_session_id.clone()));
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Mark a subagent as block-waited so auto-wake is suppressed on completion.
|
||||
pub(crate) fn mark_block_waited(&mut self, id: &str) {
|
||||
if let Some(t) = self.active.get_mut(id) {
|
||||
t.block_waited = true;
|
||||
} else if let Some(c) = self.completed.get_mut(id) {
|
||||
c.block_waited = true;
|
||||
}
|
||||
}
|
||||
/// Clear the block-waited flag after a block timed out without receiving
|
||||
/// the completion, so auto-wake can still fire when the subagent finishes.
|
||||
pub(crate) fn clear_block_waited(&mut self, id: &str) {
|
||||
if let Some(t) = self.active.get_mut(id) {
|
||||
t.block_waited = false;
|
||||
} else if let Some(c) = self.completed.get_mut(id) {
|
||||
c.block_waited = false;
|
||||
}
|
||||
}
|
||||
/// Whether a block-waiter already consumed this subagent's result.
|
||||
pub(crate) fn is_block_waited(&self, id: &str) -> bool {
|
||||
self.active.get(id).is_some_and(|t| t.block_waited)
|
||||
|| self.completed.get(id).is_some_and(|c| c.block_waited)
|
||||
}
|
||||
/// Register a live blocking-query reply slot and mark `block_waited`.
|
||||
///
|
||||
/// The slot lets `block_wait_delivered_or_live` verify at completion
|
||||
/// time that the waiter can still receive the result — the flag alone
|
||||
/// can be stale when the waiting turn was cancelled moments before the
|
||||
/// subagent finished.
|
||||
pub(crate) fn register_block_wait(&mut self, id: &str, slot: BlockWaitSlot) {
|
||||
self.mark_block_waited(id);
|
||||
self.block_wait_slots.entry(id.to_string()).or_default().push(slot);
|
||||
}
|
||||
/// Drop a previously registered reply slot (query poll loop exited).
|
||||
pub(crate) fn unregister_block_wait(&mut self, id: &str, slot: &BlockWaitSlot) {
|
||||
if let Some(slots) = self.block_wait_slots.get_mut(id) {
|
||||
slots.retain(|s| !std::rc::Rc::ptr_eq(s, slot));
|
||||
if slots.is_empty() {
|
||||
self.block_wait_slots.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Decision-time gate for the completion auto-wake: returns true when
|
||||
/// the result was already delivered to a blocking waiter, or a live
|
||||
/// waiter is still parked and will receive it. When every registered
|
||||
/// waiter is gone (receivers dropped by a cancelled turn), clears
|
||||
/// `block_waited` and returns false so the auto-wake fires.
|
||||
///
|
||||
/// This closes the race where the query poll loop clears the flag up to
|
||||
/// one poll interval *after* the caller cancelled — the completion
|
||||
/// handler could read the stale flag in that window and skip the wake.
|
||||
/// Consumes the id's slot registrations (completion is terminal).
|
||||
pub(crate) fn block_wait_delivered_or_live(&mut self, id: &str) -> bool {
|
||||
let slots = self.block_wait_slots.remove(id).unwrap_or_default();
|
||||
if !self.is_block_waited(id) {
|
||||
return false;
|
||||
}
|
||||
let delivered_or_live = slots.is_empty()
|
||||
|| slots
|
||||
.iter()
|
||||
.any(|s| s.borrow().as_ref().is_none_or(|tx| !tx.is_closed()));
|
||||
if !delivered_or_live {
|
||||
self.clear_block_waited(id);
|
||||
}
|
||||
delivered_or_live
|
||||
}
|
||||
/// Mark a subagent as explicitly killed so auto-wake is suppressed on completion.
|
||||
pub(crate) fn mark_explicitly_killed(&mut self, id: &str) {
|
||||
if let Some(t) = self.active.get_mut(id) {
|
||||
t.explicitly_killed = true;
|
||||
} else if let Some(c) = self.completed.get_mut(id) {
|
||||
c.explicitly_killed = true;
|
||||
}
|
||||
}
|
||||
/// Whether the model explicitly killed this subagent via the kill tool.
|
||||
pub(crate) fn is_explicitly_killed(&self, id: &str) -> bool {
|
||||
self.active.get(id).is_some_and(|t| t.explicitly_killed)
|
||||
|| self.completed.get(id).is_some_and(|c| c.explicitly_killed)
|
||||
}
|
||||
/// Return fork provenance for a given subagent.
|
||||
pub(crate) fn provenance_for(&self, id: &str) -> SubagentProvenance {
|
||||
if let Some(t) = self.active.get(id) {
|
||||
return SubagentProvenance {
|
||||
fork_parent_prompt_id: t.parent_prompt_id.clone(),
|
||||
resumed_from: t.resumed_from.clone(),
|
||||
};
|
||||
}
|
||||
if let Some(c) = self.completed.get(id) {
|
||||
return SubagentProvenance {
|
||||
fork_parent_prompt_id: c.parent_prompt_id.clone(),
|
||||
resumed_from: c.resumed_from.clone(),
|
||||
};
|
||||
}
|
||||
SubagentProvenance::default()
|
||||
}
|
||||
/// Resolve a completed subagent scoped to the requesting parent session.
|
||||
///
|
||||
/// Returns `None` if the subagent is not found, still active, or belongs
|
||||
/// to a different parent session (prevents cross-session context bleed).
|
||||
///
|
||||
/// Fast path: checks the in-memory `completed` map first. When that
|
||||
/// misses (e.g. after TTL eviction), falls back to on-disk metadata
|
||||
/// in `{parent_session_dir}/subagents/{id}/meta.json`.
|
||||
pub(crate) fn resumable_source_for(
|
||||
&self,
|
||||
id: &str,
|
||||
parent_session_id: &str,
|
||||
parent_cwd: &Path,
|
||||
) -> Option<ResumeSourceData> {
|
||||
if let Some(completed) = self.completed.get(id) {
|
||||
if completed.parent_session_id != parent_session_id {
|
||||
return None;
|
||||
}
|
||||
return Some(ResumeSourceData {
|
||||
subagent_id: completed.subagent_id.clone(),
|
||||
child_session_id: completed.child_session_id.clone(),
|
||||
child_cwd: completed.child_cwd.clone(),
|
||||
worktree_path: completed.worktree_path.clone(),
|
||||
snapshot_ref: completed.snapshot_ref.clone(),
|
||||
subagent_type: completed.subagent_type.clone(),
|
||||
persona: completed.persona.clone(),
|
||||
model_id: Some(completed.effective_model_id.clone()),
|
||||
});
|
||||
}
|
||||
let parent_info = SessionInfo {
|
||||
id: acp::SessionId::new(parent_session_id),
|
||||
cwd: parent_cwd.to_string_lossy().to_string(),
|
||||
};
|
||||
let meta_path = session::persistence::session_dir(&parent_info)
|
||||
.join("subagents")
|
||||
.join(id)
|
||||
.join("meta.json");
|
||||
let data = std::fs::read_to_string(&meta_path).ok()?;
|
||||
let meta: SubagentMeta = serde_json::from_str(&data).ok()?;
|
||||
if meta.parent_session_id != parent_session_id {
|
||||
return None;
|
||||
}
|
||||
match meta.status.as_str() {
|
||||
"completed" | "failed" | "cancelled" => {}
|
||||
_ => return None,
|
||||
}
|
||||
Some(ResumeSourceData {
|
||||
subagent_id: meta.subagent_id,
|
||||
child_session_id: meta.child_session_id,
|
||||
child_cwd: meta.child_cwd.unwrap_or_default(),
|
||||
worktree_path: meta.worktree_path.map(PathBuf::from),
|
||||
snapshot_ref: meta.snapshot_ref,
|
||||
subagent_type: meta.subagent_type,
|
||||
persona: meta.persona,
|
||||
model_id: meta.effective_model_id,
|
||||
})
|
||||
}
|
||||
/// Check whether an ID refers to a currently-active (running) subagent.
|
||||
pub(crate) fn is_active(&self, id: &str) -> bool {
|
||||
self.active.contains_key(id)
|
||||
}
|
||||
/// Whether the coordinator still has this id in flight (spawning or running).
|
||||
/// Orphan reconcile skips these — there is nothing stuck to heal.
|
||||
pub(crate) fn is_active_or_pending(&self, id: &str) -> bool {
|
||||
self.active.contains_key(id) || self.pending.contains_key(id)
|
||||
}
|
||||
/// The terminal `SubagentFinished` for an id the coordinator already holds in
|
||||
/// `completed`, else `None`. Lets orphan reconcile re-emit a subagent's real
|
||||
/// outcome when only its terminal meta write was lost (reconnect race: entry
|
||||
/// in `completed` but the on-disk meta is still `running`) instead of
|
||||
/// force-cancelling it and discarding the result.
|
||||
pub(crate) fn completed_finish(&self, id: &str) -> Option<SessionUpdate> {
|
||||
let c = self.completed.get(id)?;
|
||||
let duration_ms = c
|
||||
.completed_at
|
||||
.saturating_duration_since(c.started_at)
|
||||
.as_millis() as u64;
|
||||
Some(SessionUpdate::SubagentFinished {
|
||||
subagent_id: c.subagent_id.clone(),
|
||||
child_session_id: c.child_session_id.clone(),
|
||||
status: c.result.status().to_string(),
|
||||
error: c.result.error.clone(),
|
||||
tool_calls: c.result.tool_calls,
|
||||
turns: c.result.turns,
|
||||
duration_ms,
|
||||
tokens_used: 0,
|
||||
output: None,
|
||||
will_wake: false,
|
||||
})
|
||||
}
|
||||
/// TTL cleanup: remove completed entries older than 30 minutes.
|
||||
pub fn evict_stale_completed(&mut self) {
|
||||
let cutoff = std::time::Duration::from_secs(30 * 60);
|
||||
self.completed.retain(|_, entry| entry.completed_at.elapsed() < cutoff);
|
||||
}
|
||||
/// Snapshot all currently-running subagents for compaction state context.
|
||||
///
|
||||
/// Returns one `ActiveSubagentSummary` per entry in the `active` map.
|
||||
/// Completed/failed/cancelled subagents are NOT included — they live in
|
||||
/// the `completed` map and are irrelevant for post-compaction reminders
|
||||
/// (the model already saw their tool results before compaction).
|
||||
///
|
||||
/// The `elapsed_ms` field is computed from `started_at.elapsed()` at call
|
||||
/// time, so the values are a snapshot of "right now" — appropriate for
|
||||
/// compaction since it happens once and the reminder is static.
|
||||
#[cfg(test)]
|
||||
pub fn active_summaries(&self) -> Vec<ActiveSubagentSummary> {
|
||||
self.active.values().map(tracker_to_summary).collect()
|
||||
}
|
||||
pub fn active_summaries_for(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> Vec<ActiveSubagentSummary> {
|
||||
self.active
|
||||
.values()
|
||||
.filter(|t| t.parent_session_id == parent_session_id)
|
||||
.map(tracker_to_summary)
|
||||
.collect()
|
||||
}
|
||||
/// Return seeds for all running subagents belonging to `parent_session_id`.
|
||||
///
|
||||
/// Each seed carries copied identity metadata plus a cloned
|
||||
/// `SessionSignalsHandle` so the caller can resolve live progress
|
||||
/// asynchronously after dropping the coordinator borrow.
|
||||
///
|
||||
/// Returns an empty `Vec` if no active subagents match the given
|
||||
/// parent session ID. Callers (e.g. the `x.ai/subagent/list_running`
|
||||
/// ACP handler) should treat an empty result as a normal "no running
|
||||
/// subagents" response, not an error.
|
||||
pub(crate) fn list_running_for_parent(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> Vec<RunningSubagentListSeed> {
|
||||
self.active
|
||||
.values()
|
||||
.filter(|t| t.parent_session_id == parent_session_id)
|
||||
.map(|t| RunningSubagentListSeed {
|
||||
subagent_id: t.subagent_id.clone(),
|
||||
parent_session_id: t.parent_session_id.clone(),
|
||||
child_session_id: t.child_session_id.0.to_string(),
|
||||
subagent_type: t.subagent_type.clone(),
|
||||
description: t.description.clone(),
|
||||
started_at_epoch_ms: instant_to_epoch_ms(t.started_at),
|
||||
duration_ms: t.started_at.elapsed().as_millis() as u64,
|
||||
signals_handle: t.child_handle.signals_handle.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
//! Subscription check for paywall gate lift.
|
||||
//!
|
||||
//! Provides `single_check()` which queries `GET /user?include=subscription`
|
||||
//! for the live subscription tier from the backend, independent of the JWT.
|
||||
//! If a qualifying tier is detected, does a best-effort JWT refresh and
|
||||
//! settings re-fetch, then returns an `UnblockResult` so the agent can
|
||||
//! lift the gate.
|
||||
//!
|
||||
//! The pager drives the polling via `x.ai/auth/check_subscription`: the 5s
|
||||
//! paywall chain, the free-tier watch, the refocus check, and
|
||||
//! verify-before-paywall gate deferral (see the pager's `app::subscription`
|
||||
//! module).
|
||||
use crate::auth::AuthManager;
|
||||
use crate::auth::UserInfo;
|
||||
use crate::auth::manager::RefreshReason;
|
||||
use crate::auth::token_type::TokenType;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
/// Subscription tiers that qualify for Grok Build access.
|
||||
/// Any active subscription qualifies -- the access gate in remote settings
|
||||
/// controls which tiers are actually allowed.
|
||||
const QUALIFYING_TIERS: &[&str] = &[
|
||||
"SuperGrokPro",
|
||||
"GrokPro",
|
||||
"SuperGrokLite",
|
||||
"XPremiumPlus",
|
||||
"XPremium",
|
||||
"XBasic",
|
||||
];
|
||||
/// Successful subscription check result: confirmed qualifying tier +
|
||||
/// optionally refreshed settings.
|
||||
pub(crate) struct UnblockResult {
|
||||
pub(crate) new_tier: String,
|
||||
pub(crate) settings: Option<crate::util::config::RemoteSettings>,
|
||||
}
|
||||
/// Fetch `/user?include=subscription` and return the parsed `UserInfo`.
|
||||
async fn fetch_user_info(
|
||||
http_client: &reqwest::Client,
|
||||
url: &str,
|
||||
auth: &crate::auth::GrokAuth,
|
||||
auth_manager: &AuthManager,
|
||||
alpha_test_key: Option<&str>,
|
||||
) -> Result<UserInfo, &'static str> {
|
||||
let request = http_client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header(
|
||||
"X-XAI-Token-Auth",
|
||||
auth_manager.grok_com_config().token_header.as_str(),
|
||||
)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
crate::http::CLIENT_MODE_HEADER,
|
||||
crate::http::process_client_mode(),
|
||||
);
|
||||
let _ = alpha_test_key;
|
||||
match request.send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
resp.json::<UserInfo>().await.map_err(|_| "parse")
|
||||
}
|
||||
Ok(_resp) => Err("http_status"),
|
||||
Err(e) if e.is_timeout() => Err("timeout"),
|
||||
Err(_) => Err("transport"),
|
||||
}
|
||||
}
|
||||
/// Single-shot subscription check. Called by the pager every 5s while
|
||||
/// the paywall is shown (`x.ai/auth/check_subscription`).
|
||||
///
|
||||
/// Queries `/user?include=subscription` for the live tier. If a qualifying
|
||||
/// tier is found, does a best-effort JWT refresh + settings re-fetch and
|
||||
/// returns `Some(UnblockResult)`. Returns `None` if no qualifying
|
||||
/// subscription exists or the request fails.
|
||||
#[tracing::instrument(name = "paywall_check", skip_all, fields(user_id = %user_id))]
|
||||
pub(crate) async fn single_check(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
proxy_base_url: &str,
|
||||
alpha_test_key: Option<&str>,
|
||||
user_id: &str,
|
||||
) -> Option<UnblockResult> {
|
||||
let user_url = format!("{}/user?include=subscription", proxy_base_url);
|
||||
let http_client = crate::http::shared_client();
|
||||
let auth = auth_manager.current()?;
|
||||
let user_info = match fetch_user_info(
|
||||
&http_client,
|
||||
&user_url,
|
||||
&auth,
|
||||
&auth_manager,
|
||||
alpha_test_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ui) => ui,
|
||||
Err(kind) => {
|
||||
kigi_log::unified_log::warn(
|
||||
"paywall_check_error",
|
||||
None,
|
||||
Some(serde_json::json!({ "user_id" : user_id, "kind" : kind })),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
kigi_log::unified_log::info(
|
||||
"paywall_check_result",
|
||||
None,
|
||||
Some(serde_json::json!(
|
||||
{ "user_id" : user_id, "subscription_tier" : user_info.subscription_tier,
|
||||
}
|
||||
)),
|
||||
);
|
||||
let new_tier = match &user_info.subscription_tier {
|
||||
Some(tier) if !tier.is_empty() => tier.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
if !QUALIFYING_TIERS.contains(&new_tier.as_str()) {
|
||||
return None;
|
||||
}
|
||||
kigi_log::unified_log::info(
|
||||
"paywall_check_subscription_detected",
|
||||
None,
|
||||
Some(serde_json::json!({ "user_id" : user_id, "new_tier" : new_tier, })),
|
||||
);
|
||||
if let Err(e) = auth_manager
|
||||
.refresh_chain(TokenType::OidcSession, RefreshReason::ServerRejected)
|
||||
.await
|
||||
{
|
||||
kigi_log::unified_log::warn(
|
||||
"paywall_check_error",
|
||||
None,
|
||||
Some(serde_json::json!(
|
||||
{ "user_id" : user_id, "kind" : "refresh_failed", "detail" : e
|
||||
.to_string(), }
|
||||
)),
|
||||
);
|
||||
}
|
||||
let settings = if crate::util::config::resolve_remote_fetch_enabled() {
|
||||
let base_url = proxy_base_url.to_string();
|
||||
let auth_for_settings = auth_manager.current().unwrap_or(auth);
|
||||
let atk = alpha_test_key.map(str::to_string);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::remote::fetch_settings_blocking(&base_url, &auth_for_settings, atk.as_deref())
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
kigi_log::unified_log::info(
|
||||
"paywall_check_unblocked",
|
||||
None,
|
||||
Some(serde_json::json!({ "user_id" : user_id, "new_tier" : new_tier })),
|
||||
);
|
||||
Some(UnblockResult { new_tier, settings })
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn qualifying_tiers_includes_all_paid_tiers() {
|
||||
for tier in &[
|
||||
"SuperGrokPro",
|
||||
"GrokPro",
|
||||
"SuperGrokLite",
|
||||
"XPremiumPlus",
|
||||
"XPremium",
|
||||
"XBasic",
|
||||
] {
|
||||
assert!(
|
||||
QUALIFYING_TIERS.contains(tier),
|
||||
"{tier} must be in QUALIFYING_TIERS"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn free_tier_is_not_qualifying() {
|
||||
assert!(!QUALIFYING_TIERS.contains(&"Free"));
|
||||
}
|
||||
#[test]
|
||||
fn empty_tier_is_not_qualifying() {
|
||||
assert!(!QUALIFYING_TIERS.contains(&""));
|
||||
}
|
||||
/// The subscription check only returns `Some` when `/user` reports a
|
||||
/// qualifying tier. Verify the tier matching is exact (no prefix match).
|
||||
#[test]
|
||||
fn partial_tier_name_is_not_qualifying() {
|
||||
assert!(!QUALIFYING_TIERS.contains(&"Super"));
|
||||
assert!(!QUALIFYING_TIERS.contains(&"Grok"));
|
||||
assert!(!QUALIFYING_TIERS.contains(&"XPremium+"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,937 @@
|
||||
//! Shell-side 401-attribution helpers.
|
||||
//!
|
||||
//! Every 401 emit site in the shell joins the bearer the client
|
||||
//! actually sent on the wire (the `Authorization` value for OAI-compat
|
||||
//! backends, `x-api-key` for Anthropic Messages, the API proxy
|
||||
//! `Authorization` header for storage / feedback / registry /
|
||||
//! idle-resume) with the live
|
||||
//! [`AuthManager::current_api_key`] value. The two sinks are:
|
||||
//!
|
||||
//! 1. [`kigi_log::unified_log::warn`] for the local
|
||||
//! `~/.kigi/logs/unified.jsonl` file (best-effort; ships to GCS
|
||||
//! only on OIDC refresh failure via `auth/refresh.rs`).
|
||||
//! 2. A discrete `tracing::warn_span!("auth_401_attribution", ...)`
|
||||
//! captured by the OTel layer in `util/otel_layer.rs` and shipped
|
||||
//! via OTLP export to the configured telemetry backend
|
||||
//! (queryable by span name `auth_401_attribution`).
|
||||
//!
|
||||
//! # Schema (every emit)
|
||||
//!
|
||||
//! ```text
|
||||
//! {
|
||||
//! "sent_key_prefix": "<last 12 chars of bearer the client sent, or """>,
|
||||
//! "current_key_prefix": "<last 12 chars of AuthManager::current_api_key()>",
|
||||
//! "mint_age_seconds": <i64; current time minus auth.create_time, or -1>,
|
||||
//! "expires_at_seconds_from_now": <i64; auth.expires_at minus now,
|
||||
//! or 0 when no current token>,
|
||||
//! "consumer": "OaiCompatClient.<endpoint>" | "FeedbackClient.<op>"
|
||||
//! | "FeedbackClient.<op>" | "SessionRegistryClient.<op>"
|
||||
//! | "IdleResumeModelRefresh",
|
||||
//! "is_stale_snapshot": <bool; true iff sent_prefix differs from a *known* current_prefix>
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Cross-crate plumbing
|
||||
//!
|
||||
//! [`kigi_sampler`] is intentionally decoupled from this crate. It
|
||||
//! invokes the trait [`kigi_sampler::Auth401AttributionCallback`] at
|
||||
//! its six 401 arms; this module provides [`ShellAttribution`], the
|
||||
//! concrete impl that the shell wires into
|
||||
//! [`kigi_sampler::SamplerConfig::attribution_callback`] at every
|
||||
//! sampler-construction site. Non-sampler sites (storage / feedback /
|
||||
//! registry / idle-resume) call [`record_consumer_401`]
|
||||
//! directly with their `(consumer_kind, op)` pair.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kigi_sampler::{Auth401AttributionCallback, SamplingConsumer};
|
||||
use kigi_tools::{Auth401AttributionCallback as ToolAuth401AttributionCallback, ToolConsumer};
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::auth::{AuthManager, TOKEN_TTL, token_suffix};
|
||||
|
||||
/// `cfg(test)`-only process-global counter that bumps on every
|
||||
/// successful `record_auth_401` invocation.
|
||||
///
|
||||
/// Because the counter is process-global, every test that observes it
|
||||
/// MUST be annotated with `#[serial_test::serial(attribution_emit_count)]`.
|
||||
#[cfg(test)]
|
||||
static EMIT_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Read the test-only emit counter.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_emit_count() -> u64 {
|
||||
EMIT_COUNT.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Reset the test-only emit counter to zero. Tests that span multiple
|
||||
/// instrumented call sites should call this at setup so leftover bumps
|
||||
/// from earlier tests in the same process do not pollute the assertion.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_test_emit_count() {
|
||||
EMIT_COUNT.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Concrete implementation of [`Auth401AttributionCallback`] for the
|
||||
/// sampler crate's six 401 arms.
|
||||
///
|
||||
/// One instance is constructed per `SamplerConfig` and cloned cheaply
|
||||
/// (the struct holds an `Arc` and an `Option<String>`). The
|
||||
/// `session_id` is captured at construction time and used for the
|
||||
/// `unified_log::warn` `sid` field; non-session callers may pass
|
||||
/// `None`.
|
||||
pub(crate) struct ShellAttribution {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
// `AuthManager` does not implement `Debug` (it carries a `RwLock` over
|
||||
// auth state and would expose secrets if it did). Hand-roll a redacted
|
||||
// `Debug` impl so the `Auth401AttributionCallback` trait's
|
||||
// `Debug + Send + Sync` bound is satisfied without changing
|
||||
// `AuthManager`'s API surface.
|
||||
impl std::fmt::Debug for ShellAttribution {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ShellAttribution")
|
||||
.field("auth_manager", &"<redacted>")
|
||||
.field("session_id", &self.session_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellAttribution {
|
||||
/// Construct a shareable attribution callback wired to the given
|
||||
/// [`AuthManager`]. Returns `Arc<dyn Trait>` for the sampler
|
||||
/// trait so callers can drop the value directly into
|
||||
/// [`kigi_sampler::SamplerConfig::attribution_callback`].
|
||||
///
|
||||
/// (Returns `Arc<dyn Trait>` rather than `Self` because the
|
||||
/// `kigi_sampler::SamplerConfig` field expects exactly that;
|
||||
/// keeping the boundary in one place avoids `as Arc<dyn _>`
|
||||
/// coercions at every call site.)
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
session_id: Option<String>,
|
||||
) -> Arc<dyn Auth401AttributionCallback> {
|
||||
Arc::new(Self {
|
||||
auth_manager,
|
||||
session_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tool-side counterpart of [`Self::new`]: returns
|
||||
/// `Arc<dyn kigi_tools::Auth401AttributionCallback>` for the
|
||||
/// `with_attribution_callback(...)` builder on each tool HTTP
|
||||
/// client (`ImageGenClient`, `VideoGenClient`, `WebSearchClient`).
|
||||
/// The two callbacks share the same underlying impl and emit the
|
||||
/// same `auth_401_attribution` event format -- only the trait
|
||||
/// signature differs (`SamplingConsumer` vs. `ToolConsumer`).
|
||||
pub fn new_tool_callback(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
session_id: Option<String>,
|
||||
) -> Arc<dyn ToolAuth401AttributionCallback> {
|
||||
Arc::new(Self {
|
||||
auth_manager,
|
||||
session_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Auth401AttributionCallback for ShellAttribution {
|
||||
fn record_401(&self, consumer: SamplingConsumer, sent_bearer_prefix: Option<&str>) {
|
||||
// The sampler crate has already truncated `sent_bearer_prefix`
|
||||
// to `kigi_sampler::SENT_BEARER_PREFIX_LEN` characters
|
||||
// before this trait method fires (see
|
||||
// `SamplingClient::extract_sent_bearer`); the truncation
|
||||
// inside `compute_attribution_payload` (via `token_suffix`)
|
||||
// is therefore idempotent for this code path. The doubled
|
||||
// truncation is intentional belt-and-suspenders -- the
|
||||
// sampler-side scrub keeps the full bearer from ever leaving
|
||||
// that crate, and the shell-side scrub keeps the local-log
|
||||
// and OTel-span sinks aligned with the existing 12-char
|
||||
// convention used by every other auth log line.
|
||||
record_consumer_401(
|
||||
self.auth_manager.as_ref(),
|
||||
self.session_id.as_deref(),
|
||||
ConsumerKind::OaiCompatClient,
|
||||
consumer.as_endpoint(),
|
||||
sent_bearer_prefix,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool-side hook: each tool client (image_gen, video_gen, web_search)
|
||||
/// in `kigi-tools` emits a 401 attribution event through this
|
||||
/// trait when its HTTP request returns UNAUTHORIZED. Same shape as
|
||||
/// the sampler-side impl above; routes to the same pair of sinks.
|
||||
///
|
||||
/// `ToolConsumer::VideoGenStart` and `VideoGenPoll` collapse to the
|
||||
/// same [`ConsumerKind::VideoGen`] with different op strings so the
|
||||
/// gate query can break down video-gen 401s by phase.
|
||||
impl ToolAuth401AttributionCallback for ShellAttribution {
|
||||
fn record_401(&self, consumer: ToolConsumer, sent_bearer_prefix: Option<&str>) {
|
||||
let (kind, op) = match consumer {
|
||||
ToolConsumer::ImageGen => (ConsumerKind::ImageGen, ""),
|
||||
ToolConsumer::VideoGenStart => (ConsumerKind::VideoGen, "start"),
|
||||
ToolConsumer::VideoGenPoll => (ConsumerKind::VideoGen, "poll"),
|
||||
ToolConsumer::WebSearch => (ConsumerKind::WebSearch, ""),
|
||||
};
|
||||
record_consumer_401(
|
||||
self.auth_manager.as_ref(),
|
||||
self.session_id.as_deref(),
|
||||
kind,
|
||||
op,
|
||||
sent_bearer_prefix,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Categories of 401-attribution emit sites. Each variant maps to a
|
||||
/// fixed prefix in the rendered `consumer` field; the per-site `op`
|
||||
/// string is appended after a `.` separator (omitted for variants that
|
||||
/// have no per-operation discriminator, e.g.
|
||||
/// [`ConsumerKind::IdleResumeModelRefresh`]).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum ConsumerKind {
|
||||
/// Sampler-side OpenAI-compat / Anthropic Messages emit. The op
|
||||
/// string is the [`SamplingConsumer::as_endpoint`] return value.
|
||||
OaiCompatClient,
|
||||
/// Feedback collection sites in `agent/feedback_client.rs`.
|
||||
FeedbackClient,
|
||||
/// Session registry register/update sites in
|
||||
/// `agent/session_registry_client.rs`.
|
||||
SessionRegistryClient,
|
||||
/// Idle-resume model-metadata refresh in
|
||||
/// `session/acp_session.rs::maybe_refresh_model_metadata_on_resume`.
|
||||
/// No per-op discriminator -- the consumer string is just
|
||||
/// `"IdleResumeModelRefresh"`.
|
||||
IdleResumeModelRefresh,
|
||||
/// `kigi_tools::ToolConsumer::ImageGen` -- Imagine API
|
||||
/// (`POST /images/generations`). No per-op discriminator;
|
||||
/// consumer string is just `"ImageGen"`.
|
||||
ImageGen,
|
||||
/// `kigi_tools::ToolConsumer::VideoGenStart` and
|
||||
/// `VideoGenPoll` -- Video Generation API. The op string is
|
||||
/// `"start"` (`POST /videos/generations`) or `"poll"`
|
||||
/// (`GET /videos/{request_id}`).
|
||||
VideoGen,
|
||||
/// `kigi_tools::ToolConsumer::WebSearch` -- web search via
|
||||
/// `POST /responses` with a `WebSearch` tool. No per-op
|
||||
/// discriminator; consumer string is just `"WebSearch"`.
|
||||
WebSearch,
|
||||
}
|
||||
|
||||
impl ConsumerKind {
|
||||
/// Fixed prefix for the rendered `consumer` field.
|
||||
fn prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::OaiCompatClient => "OaiCompatClient",
|
||||
Self::FeedbackClient => "FeedbackClient",
|
||||
Self::SessionRegistryClient => "SessionRegistryClient",
|
||||
Self::IdleResumeModelRefresh => "IdleResumeModelRefresh",
|
||||
Self::ImageGen => "ImageGen",
|
||||
Self::VideoGen => "VideoGen",
|
||||
Self::WebSearch => "WebSearch",
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` for variants that take a per-operation discriminator
|
||||
/// appended as `<prefix>.<op>`. `false` for variants whose
|
||||
/// `consumer` string is just the prefix
|
||||
/// (`IdleResumeModelRefresh`, `ImageGen`, `WebSearch` -- each is
|
||||
/// a single endpoint with no sub-operation).
|
||||
fn takes_op(self) -> bool {
|
||||
!matches!(
|
||||
self,
|
||||
Self::IdleResumeModelRefresh | Self::ImageGen | Self::WebSearch
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a `(kind, op)` pair into the design-doc `consumer` string.
|
||||
fn format_consumer(kind: ConsumerKind, op: &str) -> String {
|
||||
if kind.takes_op() {
|
||||
format!("{}.{}", kind.prefix(), op)
|
||||
} else {
|
||||
kind.prefix().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a single `auth 401 attribution` event for a per-consumer 401.
|
||||
///
|
||||
/// Wraps [`record_auth_401`] with the design-doc `consumer` formatting
|
||||
/// (e.g., `"FeedbackClient.submit"`, `"VideoGen.start"`).
|
||||
/// All 401 emit sites in `kigi-shell` go through this helper -- the
|
||||
/// per-client `record_401_attribution` wrappers in
|
||||
/// `agent/feedback_client.rs` and `agent/session_registry_client.rs` each
|
||||
/// resolve their bearer and call this with the right `(kind, op)`.
|
||||
///
|
||||
/// `sent_bearer` may be either a full bearer (passed by the
|
||||
/// non-sampler call sites listed above, which read directly from the
|
||||
/// client's `user_token` / `deployment_key` snapshot) or a 12-char
|
||||
/// prefix (passed by the sampler-side
|
||||
/// [`Auth401AttributionCallback`] boundary; the sampler scrubs to a
|
||||
/// prefix before crossing the crate boundary). The truncation inside
|
||||
/// [`record_auth_401`] / `compute_attribution_payload` is idempotent
|
||||
/// for the prefix case.
|
||||
pub(crate) fn record_consumer_401(
|
||||
auth_manager: &AuthManager,
|
||||
session_id: Option<&str>,
|
||||
kind: ConsumerKind,
|
||||
op: &str,
|
||||
sent_bearer: Option<&str>,
|
||||
) {
|
||||
let consumer = format_consumer(kind, op);
|
||||
record_auth_401(auth_manager, session_id, &consumer, sent_bearer);
|
||||
}
|
||||
|
||||
/// Emit a single `auth 401 attribution` event to both sinks (local
|
||||
/// unified log file + OTel span for OTLP export).
|
||||
///
|
||||
/// Schema:
|
||||
/// `(sent_key_prefix, current_key_prefix, mint_age_seconds,
|
||||
/// expires_at_seconds_from_now, consumer, is_stale_snapshot)`.
|
||||
///
|
||||
/// `sent_bearer` is the bearer that was sent on the wire (the
|
||||
/// `Authorization` value with `"Bearer "` already stripped, or the
|
||||
/// `x-api-key` value for Anthropic Messages backends), OR a 12-char
|
||||
/// prefix of same -- the sampler boundary always passes a prefix
|
||||
/// here, the non-sampler shell sites pass full bearers and rely on
|
||||
/// the [`compute_attribution_payload`] truncation. `None` is fine;
|
||||
/// the prefix becomes the empty string.
|
||||
///
|
||||
/// `consumer` should be one of the canonical strings used by the
|
||||
/// per-client wrappers, e.g. `"OaiCompatClient.chat_completions_stream"`,
|
||||
/// `"FeedbackClient.submit"`, `"IdleResumeModelRefresh"`. Most call
|
||||
/// sites should go through [`record_consumer_401`] which formats the
|
||||
/// consumer string from a [`ConsumerKind`] for them.
|
||||
pub(crate) fn record_auth_401(
|
||||
auth_manager: &AuthManager,
|
||||
session_id: Option<&str>,
|
||||
consumer: &str,
|
||||
sent_bearer: Option<&str>,
|
||||
) {
|
||||
let payload = compute_attribution_payload(auth_manager, consumer, sent_bearer);
|
||||
|
||||
// Sink 1 -- local file (~/.kigi/logs/unified.jsonl) + scrubbed
|
||||
// tracing event. The local file is reliable but only ships to GCS
|
||||
// on OIDC refresh failure (auth/refresh.rs::spawn_diagnostic_upload),
|
||||
// so by itself it does not give visibility into the steady-state
|
||||
// 401 population. Sink 2 below provides that.
|
||||
kigi_log::unified_log::warn("auth 401 attribution", session_id, Some(payload.clone()));
|
||||
|
||||
// Sink 2 -- discrete OTel span exported via OTLP
|
||||
// (util/otel_layer.rs). Auth 401 attribution schema fields below
|
||||
// become OTel span attributes under `attributes.custom.<name>`
|
||||
// per the tracing-opentelemetry bridge; query by span name
|
||||
// `auth_401_attribution` in the configured telemetry backend.
|
||||
//
|
||||
// Wrapping in a `warn_span!` (vs. plain `tracing::warn!`) ensures
|
||||
// emission even when no parent span is active. The OTel layer
|
||||
// attaches plain events to the currently-entered span only, so a
|
||||
// `tracing::warn!` from a `spawn_blocking` closure (idle-resume
|
||||
// model refresh) or a background sync task is silently dropped.
|
||||
// A `warn_span!` itself is always emitted by the layer's
|
||||
// `on_new_span`/`on_close` hooks regardless of parent context.
|
||||
//
|
||||
// The span carries no body and is dropped immediately at the end
|
||||
// of this function, so its `duration` is a few microseconds and
|
||||
// it is logically a one-shot record (not a wrapping context for
|
||||
// any other work).
|
||||
let _attribution_span = tracing::warn_span!(
|
||||
"auth_401_attribution",
|
||||
// String fields. tracing flattens Option<&str> via Display, so
|
||||
// we pre-collapse `None` to "" for both prefix fields and for
|
||||
// session_id; downstream queries should treat "" as absent.
|
||||
sent_key_prefix = payload["sent_key_prefix"].as_str().unwrap_or(""),
|
||||
current_key_prefix = payload["current_key_prefix"].as_str().unwrap_or(""),
|
||||
consumer = consumer,
|
||||
session_id = session_id.unwrap_or(""),
|
||||
// Numeric fields. The sentinel values from
|
||||
// `compute_attribution_payload` (-1, 0) carry through
|
||||
// unchanged.
|
||||
mint_age_seconds = payload["mint_age_seconds"].as_i64().unwrap_or(-1),
|
||||
expires_at_seconds_from_now = payload["expires_at_seconds_from_now"].as_i64().unwrap_or(0),
|
||||
// Boolean -- the load-bearing field for stale-vs-live splits.
|
||||
is_stale_snapshot = payload["is_stale_snapshot"].as_bool().unwrap_or(false),
|
||||
)
|
||||
.entered();
|
||||
|
||||
#[cfg(test)]
|
||||
EMIT_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Pure (no I/O) computation of the attribution payload. Extracted
|
||||
/// from [`record_auth_401`] so unit tests can assert each field
|
||||
/// directly without reaching into `unified_log`'s file writer or the
|
||||
/// tracing layer.
|
||||
///
|
||||
/// This function performs **exactly one** read-side acquisition of
|
||||
/// [`AuthManager`]'s internal `RwLock` -- it calls
|
||||
/// [`AuthManager::current`] once and derives both `current_key_prefix`
|
||||
/// and the mint/expiry fields from the resulting `GrokAuth`.
|
||||
///
|
||||
/// `is_stale_snapshot` is `true` only when the live `current()` token
|
||||
/// differs from the bearer the client sent. When `current()` returns
|
||||
/// `None` (the manager has no active token), the result is `false`:
|
||||
/// absence of a live token is "no evidence of staleness," not stale.
|
||||
fn compute_attribution_payload(
|
||||
auth_manager: &AuthManager,
|
||||
consumer: &str,
|
||||
sent_bearer: Option<&str>,
|
||||
) -> JsonValue {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// Last-12-char suffix of the bearer the wire actually carried
|
||||
// (see [`token_suffix`]: JWT headers share a common base64 prefix).
|
||||
// `""` when the request had no bearer at all (distinct case from
|
||||
// "had a bearer that turned out to be stale" -- the gate-criteria
|
||||
// query can break down on this).
|
||||
let sent_prefix = sent_bearer.map(token_suffix).unwrap_or("");
|
||||
|
||||
// Single read-lock acquisition: pull the live `GrokAuth` (or
|
||||
// `None`) once and derive every other field from it.
|
||||
let current_auth = auth_manager.current();
|
||||
let current_prefix_owned: Option<String> = current_auth
|
||||
.as_ref()
|
||||
.map(|a| token_suffix(&a.key).to_string());
|
||||
|
||||
// None current means "no evidence of staleness," not stale --
|
||||
// the downstream stale-vs-live split should only count
|
||||
// true-positive staleness (sent bearer differs from a known live
|
||||
// bearer).
|
||||
let is_stale_snapshot = match current_prefix_owned.as_deref() {
|
||||
Some(c) => sent_prefix != c,
|
||||
None => false,
|
||||
};
|
||||
|
||||
// Mint-age + expiry come from the same `current_auth` we already
|
||||
// read; sentinels `-1 / 0` when the manager has no current token.
|
||||
//
|
||||
// TODO: mirror the full External-with-ttl branch from
|
||||
// `AuthManager::is_token_expired` (uses
|
||||
// `grok_com_config.auth_token_ttl` when `expires_at` is `None`
|
||||
// and `auth_mode == External`). The current 2-branch fallback
|
||||
// (`expires_at` if Some else `create_time + TOKEN_TTL`) is good
|
||||
// enough for diagnostic metadata; the External-ttl branch is
|
||||
// worth wiring once a real consumer needs it.
|
||||
let (mint_age_seconds, expires_at_seconds_from_now) = match current_auth {
|
||||
Some(auth) => {
|
||||
let mint_age = now.signed_duration_since(auth.create_time).num_seconds();
|
||||
let expiry = auth.expires_at.unwrap_or(auth.create_time + TOKEN_TTL);
|
||||
(mint_age, expiry.signed_duration_since(now).num_seconds())
|
||||
}
|
||||
None => (-1_i64, 0_i64),
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"sent_key_prefix": sent_prefix,
|
||||
"current_key_prefix": current_prefix_owned,
|
||||
"mint_age_seconds": mint_age_seconds,
|
||||
"expires_at_seconds_from_now": expires_at_seconds_from_now,
|
||||
"consumer": consumer,
|
||||
"is_stale_snapshot": is_stale_snapshot,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use crate::auth::{AuthManager, GrokAuth, GrokComConfig};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Test helper: build a fresh `AuthManager` rooted at a tempdir so
|
||||
/// nothing from a developer's actual `~/.kigi/auth.json` leaks in.
|
||||
fn empty_auth_manager() -> (tempfile::TempDir, AuthManager) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let cfg = GrokComConfig::default();
|
||||
let am = AuthManager::new(dir.path(), cfg);
|
||||
(dir, am)
|
||||
}
|
||||
|
||||
fn fresh_auth(key: &str) -> GrokAuth {
|
||||
GrokAuth {
|
||||
key: key.to_string(),
|
||||
create_time: Utc::now(),
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_field<'a>(payload: &'a JsonValue, key: &str) -> &'a JsonValue {
|
||||
payload
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("payload missing field {key:?}: {payload:?}"))
|
||||
}
|
||||
|
||||
/// Live token sent + 401 with matching `current()` ->
|
||||
/// `is_stale_snapshot` must be `false`. Also assert the auxiliary
|
||||
/// fields are set sensibly (prefix, mint age, expiry).
|
||||
#[test]
|
||||
fn live_token_sent_is_not_stale() {
|
||||
let (_dir, am) = empty_auth_manager();
|
||||
let sent = "live-token-1234567890abcdef";
|
||||
am.hot_swap(fresh_auth(sent));
|
||||
|
||||
let payload = compute_attribution_payload(&am, "Test.live", Some(sent));
|
||||
|
||||
assert_eq!(payload_field(&payload, "is_stale_snapshot"), false);
|
||||
assert_eq!(payload_field(&payload, "consumer"), "Test.live");
|
||||
// Last 12 chars (tail prefix for JWT-friendly diagnostics).
|
||||
assert_eq!(payload_field(&payload, "sent_key_prefix"), "567890abcdef");
|
||||
assert_eq!(
|
||||
payload_field(&payload, "current_key_prefix"),
|
||||
"567890abcdef"
|
||||
);
|
||||
// mint_age_seconds: should be small and non-negative for a
|
||||
// freshly-created auth.
|
||||
let mint = payload_field(&payload, "mint_age_seconds")
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
assert!(
|
||||
(0..5).contains(&mint),
|
||||
"mint_age_seconds should be 0-5 sec for a freshly-created auth, got {mint}"
|
||||
);
|
||||
// expires_at_seconds_from_now: should be just under 1 hour
|
||||
// (3600s), with a tolerance for elapsed time during the test.
|
||||
let expires = payload_field(&payload, "expires_at_seconds_from_now")
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
assert!(
|
||||
(3590..=3600).contains(&expires),
|
||||
"expires_at_seconds_from_now should be ~3600 for a 1h-expiry token, got {expires}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stale snapshot sent + 401 with a different (newer) `current()`
|
||||
/// -> `is_stale_snapshot` must be `true`.
|
||||
#[test]
|
||||
fn stale_snapshot_is_detected() {
|
||||
let (_dir, am) = empty_auth_manager();
|
||||
let stale = "stale-token-1234567890";
|
||||
let live = "live-token-different";
|
||||
am.hot_swap(fresh_auth(live));
|
||||
|
||||
let payload = compute_attribution_payload(&am, "Test.stale", Some(stale));
|
||||
|
||||
assert_eq!(payload_field(&payload, "is_stale_snapshot"), true);
|
||||
assert_eq!(payload_field(&payload, "sent_key_prefix"), "n-1234567890");
|
||||
assert_eq!(
|
||||
payload_field(&payload, "current_key_prefix"),
|
||||
"en-different"
|
||||
);
|
||||
assert_eq!(payload_field(&payload, "consumer"), "Test.stale");
|
||||
}
|
||||
|
||||
/// Live token sent + 401 with `current() == None` ->
|
||||
/// `is_stale_snapshot` must be `false` (no evidence of staleness).
|
||||
/// Sentinel `mint_age_seconds = -1`,
|
||||
/// `expires_at_seconds_from_now = 0`. `current_key_prefix` is JSON
|
||||
/// `null`.
|
||||
#[test]
|
||||
fn absent_current_is_not_stale() {
|
||||
let (_dir, am) = empty_auth_manager();
|
||||
// Do NOT inject anything -- manager has no current token.
|
||||
|
||||
let payload = compute_attribution_payload(&am, "Test.absent", Some("any-token"));
|
||||
|
||||
assert_eq!(payload_field(&payload, "is_stale_snapshot"), false);
|
||||
assert_eq!(payload_field(&payload, "sent_key_prefix"), "any-token");
|
||||
assert!(payload_field(&payload, "current_key_prefix").is_null());
|
||||
assert_eq!(payload_field(&payload, "mint_age_seconds"), -1);
|
||||
assert_eq!(payload_field(&payload, "expires_at_seconds_from_now"), 0);
|
||||
}
|
||||
|
||||
/// Two-branch fallback: legacy token (no `expires_at`) uses
|
||||
/// `create_time + TOKEN_TTL` as the expiry source. We assert the
|
||||
/// computed `expires_at_seconds_from_now` reflects that.
|
||||
#[test]
|
||||
fn legacy_token_uses_two_branch_fallback() {
|
||||
let (_dir, am) = empty_auth_manager();
|
||||
let auth = GrokAuth {
|
||||
key: "k".into(),
|
||||
create_time: Utc::now() - Duration::seconds(60),
|
||||
// No expires_at => falls through to create_time + TOKEN_TTL
|
||||
// (= 30 days).
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
am.hot_swap(auth);
|
||||
|
||||
let payload = compute_attribution_payload(&am, "Test.legacy", Some("k"));
|
||||
|
||||
// mint_age_seconds: ~60.
|
||||
let mint = payload_field(&payload, "mint_age_seconds")
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
assert!(
|
||||
(60..=70).contains(&mint),
|
||||
"mint_age_seconds should be ~60 for a 60s-old auth, got {mint}"
|
||||
);
|
||||
// expires_at_seconds_from_now: TOKEN_TTL minus 60s = roughly
|
||||
// 30 * 86400 - 60 = 2_591_940. Tolerate ~10s drift.
|
||||
let expires = payload_field(&payload, "expires_at_seconds_from_now")
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
let expected = TOKEN_TTL.num_seconds() - 60;
|
||||
assert!(
|
||||
(expected - 10..=expected + 10).contains(&expires),
|
||||
"expires_at_seconds_from_now should be ~{expected}, got {expires}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `format_consumer` matrix:
|
||||
/// - generic ops append "." + op (`OaiCompatClient.foo`)
|
||||
/// - IdleResumeModelRefresh and tool variants drop the op
|
||||
/// (their consumer string has no sub-op axis).
|
||||
#[test]
|
||||
fn format_consumer_matrix() {
|
||||
let cases: &[(ConsumerKind, &str, &str)] = &[
|
||||
(
|
||||
ConsumerKind::OaiCompatClient,
|
||||
"chat_completions_stream",
|
||||
"OaiCompatClient.chat_completions_stream",
|
||||
),
|
||||
(
|
||||
ConsumerKind::IdleResumeModelRefresh,
|
||||
"",
|
||||
"IdleResumeModelRefresh",
|
||||
),
|
||||
(
|
||||
ConsumerKind::IdleResumeModelRefresh,
|
||||
"ignored",
|
||||
"IdleResumeModelRefresh",
|
||||
),
|
||||
(ConsumerKind::ImageGen, "", "ImageGen"),
|
||||
(ConsumerKind::ImageGen, "ignored", "ImageGen"),
|
||||
(ConsumerKind::VideoGen, "start", "VideoGen.start"),
|
||||
(ConsumerKind::VideoGen, "poll", "VideoGen.poll"),
|
||||
(ConsumerKind::WebSearch, "", "WebSearch"),
|
||||
(ConsumerKind::WebSearch, "ignored", "WebSearch"),
|
||||
];
|
||||
for (kind, op, expected) in cases {
|
||||
assert_eq!(
|
||||
format_consumer(*kind, op),
|
||||
*expected,
|
||||
"kind={kind:?} op={op:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `format_consumer` formats `OaiCompatClient.<endpoint>`
|
||||
/// correctly and omits the `.` separator for
|
||||
/// `IdleResumeModelRefresh`.
|
||||
#[test]
|
||||
fn format_consumer_with_op_appends_dot() {
|
||||
assert_eq!(
|
||||
format_consumer(ConsumerKind::OaiCompatClient, "chat_completions_stream"),
|
||||
"OaiCompatClient.chat_completions_stream"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ShellAttribution` implements `kigi_tools::Auth401AttributionCallback`
|
||||
/// by routing each `ToolConsumer` variant to the right
|
||||
/// `(ConsumerKind, op)` pair, which formats to the expected
|
||||
/// `consumer` string in the emitted payload.
|
||||
#[test]
|
||||
#[serial_test::serial(attribution_emit_count)]
|
||||
fn shell_attribution_tool_impl_routes_to_correct_consumer_strings() {
|
||||
reset_test_emit_count();
|
||||
let (_dir, am) = empty_auth_manager();
|
||||
am.hot_swap(fresh_auth("bearer-1234567890"));
|
||||
let am_arc = Arc::new(am);
|
||||
let cb: Arc<dyn ToolAuth401AttributionCallback> =
|
||||
ShellAttribution::new_tool_callback(am_arc.clone(), Some("sid-tool".into()));
|
||||
|
||||
let cases = [
|
||||
(ToolConsumer::ImageGen, "ImageGen"),
|
||||
(ToolConsumer::VideoGenStart, "VideoGen.start"),
|
||||
(ToolConsumer::VideoGenPoll, "VideoGen.poll"),
|
||||
(ToolConsumer::WebSearch, "WebSearch"),
|
||||
];
|
||||
|
||||
for (consumer, expected_consumer_str) in cases {
|
||||
cb.record_401(consumer, Some("bearer-1234567890"));
|
||||
let payload = compute_attribution_payload(
|
||||
am_arc.as_ref(),
|
||||
expected_consumer_str,
|
||||
Some("bearer-1234567890"),
|
||||
);
|
||||
assert_eq!(
|
||||
payload_field(&payload, "consumer"),
|
||||
expected_consumer_str,
|
||||
"ToolConsumer::{consumer:?} should render as {expected_consumer_str:?}",
|
||||
);
|
||||
}
|
||||
|
||||
// Each variant bumped the global counter exactly once.
|
||||
assert_eq!(test_emit_count() as usize, cases.len());
|
||||
}
|
||||
|
||||
/// Capture `tracing::Span` `on_new_span` callbacks into a
|
||||
/// `Mutex<Vec<CapturedSpan>>` so tests can assert the
|
||||
/// `warn_span!("auth_401_attribution", ...)` emit fired with the
|
||||
/// expected name and field values.
|
||||
///
|
||||
/// We intentionally only need `on_new_span` (which the
|
||||
/// tracing-opentelemetry layer uses as its `OTel span_started`
|
||||
/// hook). `on_close` is not asserted because the test cares about
|
||||
/// "did the span exist with these attributes," not its duration.
|
||||
mod span_capture {
|
||||
use std::sync::Mutex;
|
||||
use tracing::Subscriber;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::span::Attributes;
|
||||
use tracing_subscriber::layer::{Context, Layer};
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct CapturedSpan {
|
||||
pub name: String,
|
||||
pub fields_str: std::collections::BTreeMap<String, String>,
|
||||
pub fields_i64: std::collections::BTreeMap<String, i64>,
|
||||
pub fields_bool: std::collections::BTreeMap<String, bool>,
|
||||
}
|
||||
|
||||
pub struct SpanCollector {
|
||||
pub spans: std::sync::Arc<Mutex<Vec<CapturedSpan>>>,
|
||||
}
|
||||
|
||||
impl SpanCollector {
|
||||
pub fn new() -> (Self, std::sync::Arc<Mutex<Vec<CapturedSpan>>>) {
|
||||
let buf = std::sync::Arc::new(Mutex::new(Vec::new()));
|
||||
(Self { spans: buf.clone() }, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for SpanCollector {
|
||||
fn on_new_span(&self, attrs: &Attributes<'_>, _id: &tracing::Id, _ctx: Context<'_, S>) {
|
||||
let mut captured = CapturedSpan {
|
||||
name: attrs.metadata().name().to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut visitor = FieldVisitor {
|
||||
captured: &mut captured,
|
||||
};
|
||||
attrs.record(&mut visitor);
|
||||
self.spans.lock().unwrap().push(captured);
|
||||
}
|
||||
}
|
||||
|
||||
struct FieldVisitor<'a> {
|
||||
captured: &'a mut CapturedSpan,
|
||||
}
|
||||
|
||||
impl<'a> Visit for FieldVisitor<'a> {
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.captured
|
||||
.fields_str
|
||||
.insert(field.name().to_string(), value.to_string());
|
||||
}
|
||||
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||
self.captured
|
||||
.fields_i64
|
||||
.insert(field.name().to_string(), value);
|
||||
}
|
||||
fn record_u64(&mut self, field: &Field, value: u64) {
|
||||
self.captured
|
||||
.fields_i64
|
||||
.insert(field.name().to_string(), value as i64);
|
||||
}
|
||||
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||
self.captured
|
||||
.fields_bool
|
||||
.insert(field.name().to_string(), value);
|
||||
}
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
self.captured
|
||||
.fields_str
|
||||
.insert(field.name().to_string(), format!("{value:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `record_auth_401` emits a discrete `warn_span!` with name
|
||||
/// `"auth_401_attribution"` and the attribution fields as span
|
||||
/// attributes. This is the span the tracing-opentelemetry bridge
|
||||
/// ships via OTLP export to the configured telemetry backend.
|
||||
/// Verifies field names, types, and values match the schema
|
||||
/// documented at the top of this module.
|
||||
#[test]
|
||||
#[serial_test::serial(attribution_emit_count)]
|
||||
fn record_auth_401_emits_otel_span_with_attribution_fields() {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
let (collector, captured) = span_capture::SpanCollector::new();
|
||||
let subscriber = tracing_subscriber::registry().with(collector);
|
||||
let _guard = subscriber.set_default();
|
||||
|
||||
reset_test_emit_count();
|
||||
let (_dir, am) = empty_auth_manager();
|
||||
am.hot_swap(fresh_auth("live-token-1234567890"));
|
||||
|
||||
record_auth_401(
|
||||
&am,
|
||||
Some("sid-otel-span"),
|
||||
"OaiCompatClient.chat_completions_stream",
|
||||
Some("stale-snapshot-aaaaaa"),
|
||||
);
|
||||
|
||||
let spans = captured.lock().unwrap();
|
||||
let attribution = spans
|
||||
.iter()
|
||||
.find(|s| s.name == "auth_401_attribution")
|
||||
.expect("expected one auth_401_attribution span; got: {spans:?}");
|
||||
|
||||
// String fields: prefixes truncated to 12 chars, consumer +
|
||||
// session_id passed verbatim.
|
||||
assert_eq!(
|
||||
attribution
|
||||
.fields_str
|
||||
.get("sent_key_prefix")
|
||||
.map(String::as_str),
|
||||
Some("pshot-aaaaaa"),
|
||||
"sent_key_prefix should be last 12 chars",
|
||||
);
|
||||
assert_eq!(
|
||||
attribution
|
||||
.fields_str
|
||||
.get("current_key_prefix")
|
||||
.map(String::as_str),
|
||||
Some("n-1234567890"),
|
||||
);
|
||||
assert_eq!(
|
||||
attribution.fields_str.get("consumer").map(String::as_str),
|
||||
Some("OaiCompatClient.chat_completions_stream"),
|
||||
);
|
||||
assert_eq!(
|
||||
attribution.fields_str.get("session_id").map(String::as_str),
|
||||
Some("sid-otel-span"),
|
||||
);
|
||||
|
||||
// Boolean: the load-bearing field for stale-vs-live splits.
|
||||
// `true` because `sent != current`.
|
||||
assert_eq!(
|
||||
attribution.fields_bool.get("is_stale_snapshot"),
|
||||
Some(&true),
|
||||
);
|
||||
|
||||
// Numeric: mint_age in [0, 5) for a freshly-injected auth;
|
||||
// expires_at ~3600s away.
|
||||
let mint = attribution
|
||||
.fields_i64
|
||||
.get("mint_age_seconds")
|
||||
.copied()
|
||||
.unwrap();
|
||||
assert!(
|
||||
(0..5).contains(&mint),
|
||||
"mint_age_seconds should be 0-5, got {mint}",
|
||||
);
|
||||
let expires = attribution
|
||||
.fields_i64
|
||||
.get("expires_at_seconds_from_now")
|
||||
.copied()
|
||||
.unwrap();
|
||||
assert!(
|
||||
(3590..=3600).contains(&expires),
|
||||
"expires_at_seconds_from_now should be ~3600, got {expires}",
|
||||
);
|
||||
}
|
||||
|
||||
/// `record_auth_401` (the I/O-bearing wrapper) bumps the
|
||||
/// `cfg(test)` counter so cross-module tests can observe how many
|
||||
/// times an attribution event was actually emitted.
|
||||
///
|
||||
/// `#[serial]` because `EMIT_COUNT` is process-global; concurrent
|
||||
/// tests that exercise the counter would race each other.
|
||||
#[test]
|
||||
#[serial_test::serial(attribution_emit_count)]
|
||||
fn record_auth_401_bumps_emit_counter() {
|
||||
reset_test_emit_count();
|
||||
let (_dir, am) = empty_auth_manager();
|
||||
am.hot_swap(fresh_auth("k"));
|
||||
record_auth_401(&am, None, "Test.counter", Some("k"));
|
||||
assert_eq!(test_emit_count(), 1);
|
||||
record_auth_401(&am, None, "Test.counter", Some("k"));
|
||||
assert_eq!(test_emit_count(), 2);
|
||||
}
|
||||
|
||||
/// The SubagentSpawnContext-borne callback flows through
|
||||
/// `read_parent_sampling_config` into the inherited
|
||||
/// `SamplerConfig.attribution_callback`. We can't drive the full
|
||||
/// subagent path here (requires SessionActor + chat-state
|
||||
/// scaffolding), but we can assert the structural property: the
|
||||
/// callback the parent constructs is the one any later
|
||||
/// `SamplerConfig` clone carries forward unchanged.
|
||||
#[test]
|
||||
#[serial_test::serial(attribution_emit_count)]
|
||||
fn parent_callback_flows_through_arc_clone() {
|
||||
reset_test_emit_count();
|
||||
let (_dir, am) = empty_auth_manager();
|
||||
let am_arc = Arc::new(am);
|
||||
let parent_cb = ShellAttribution::new(am_arc.clone(), Some("parent-sid".into()));
|
||||
|
||||
// Simulate the inheritance hand-off: the parent callback flows
|
||||
// through SessionHandle -> SubagentSpawnContext ->
|
||||
// SamplerConfig.attribution_callback as plain Arc clones.
|
||||
let inherited_cb = parent_cb.clone();
|
||||
|
||||
// Drive the inherited callback. The `record_401` should bump
|
||||
// the same global counter the parent callback would, proving
|
||||
// they refer to the same underlying impl.
|
||||
inherited_cb.record_401(SamplingConsumer::ChatCompletionsStream, Some("bearer"));
|
||||
assert_eq!(test_emit_count(), 1);
|
||||
|
||||
// Sanity: the parent_cb still works too (it's the same Arc).
|
||||
parent_cb.record_401(SamplingConsumer::Messages, Some("bearer"));
|
||||
assert_eq!(test_emit_count(), 2);
|
||||
}
|
||||
|
||||
/// End-to-end: the trait impl wraps `consumer.as_endpoint()` in
|
||||
/// `"OaiCompatClient.<endpoint>"` and delegates to
|
||||
/// `record_consumer_401` for every variant of `SamplingConsumer`.
|
||||
/// We assert one bump per variant via the test counter, plus the
|
||||
/// rendered `consumer` string for one variant via a payload
|
||||
/// recompute (the trait does not return the payload, so we
|
||||
/// recompute directly from the same inputs).
|
||||
#[test]
|
||||
#[serial_test::serial(attribution_emit_count)]
|
||||
fn shell_attribution_trait_impl_routes_through_helper() {
|
||||
reset_test_emit_count();
|
||||
let (_dir, am) = empty_auth_manager();
|
||||
let am_arc = Arc::new(am);
|
||||
let cb = ShellAttribution::new(am_arc.clone(), Some("sid-shell".into()));
|
||||
let variants = [
|
||||
SamplingConsumer::ChatCompletionsStream,
|
||||
SamplingConsumer::ChatCompletions,
|
||||
SamplingConsumer::ResponsesStream,
|
||||
SamplingConsumer::Responses,
|
||||
SamplingConsumer::MessagesStream,
|
||||
SamplingConsumer::Messages,
|
||||
];
|
||||
for consumer in variants {
|
||||
cb.record_401(consumer, Some("test-bearer"));
|
||||
}
|
||||
assert_eq!(test_emit_count() as usize, variants.len());
|
||||
|
||||
// Sanity-check the consumer-string formatting via direct
|
||||
// payload computation.
|
||||
let payload = compute_attribution_payload(
|
||||
am_arc.as_ref(),
|
||||
&format_consumer(
|
||||
ConsumerKind::OaiCompatClient,
|
||||
SamplingConsumer::MessagesStream.as_endpoint(),
|
||||
),
|
||||
Some("test-bearer"),
|
||||
);
|
||||
assert_eq!(
|
||||
payload_field(&payload, "consumer"),
|
||||
"OaiCompatClient.messages_stream"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
use super::model::TEAM_PRINCIPAL_TYPE;
|
||||
use serde::{Deserialize, Serialize};
|
||||
// Transitional: the M1 auth rewrite (Kimi device flow) replaces this origin.
|
||||
const AUTH_ORIGIN_DEFAULT: &str = "https://grok.com";
|
||||
fn default_oidc_scopes() -> Vec<String> {
|
||||
vec![
|
||||
"openid".into(),
|
||||
"profile".into(),
|
||||
"email".into(),
|
||||
"offline_access".into(),
|
||||
"api:access".into(),
|
||||
]
|
||||
}
|
||||
/// Default scopes for the xAI OAuth2 provider. Includes `grok-cli:access`
|
||||
/// which authorizes the token for API proxy requests.
|
||||
fn default_oauth2_scopes() -> Vec<String> {
|
||||
vec![
|
||||
"openid".into(),
|
||||
"profile".into(),
|
||||
"email".into(),
|
||||
"offline_access".into(),
|
||||
"grok-cli:access".into(),
|
||||
"api:access".into(),
|
||||
"conversations:read".into(),
|
||||
"conversations:write".into(),
|
||||
]
|
||||
}
|
||||
fn default_team_oauth2_scopes() -> Vec<String> {
|
||||
vec![
|
||||
"profile".into(),
|
||||
"offline_access".into(),
|
||||
"grok-cli:access".into(),
|
||||
"api:access".into(),
|
||||
"team:read".into(),
|
||||
"conversations:read".into(),
|
||||
"conversations:write".into(),
|
||||
]
|
||||
}
|
||||
/// Pin automatic auth to one method (`[auth] preferred_method` in config.toml).
|
||||
///
|
||||
/// When set, only that method is used for automatic selection; if it is
|
||||
/// unavailable, auth fails (no silent fallthrough to the other method).
|
||||
/// Unset keeps today's multi-method fallthrough (session preferred when both
|
||||
/// exist). Config-toml only — not remote settings, settings UI, or env.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PreferredAuthMethod {
|
||||
/// `XAI_API_KEY` / auth.json `xai::api_key` / per-model BYOK (`xai.api_key`).
|
||||
ApiKey,
|
||||
/// OIDC / OAuth2 session (`cached_token`, interactive `grok.com` / `oidc`,
|
||||
/// including devbox-minted OIDC).
|
||||
Oidc,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct GrokComConfig {
|
||||
/// Auth origin / login-host display (doubles as the legacy WS origin name).
|
||||
pub grok_ws_origin: String,
|
||||
pub token_header: String,
|
||||
/// OIDC config for customer-provided IdPs. See [`OidcAuthConfig`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oidc: Option<OidcAuthConfig>,
|
||||
/// OAuth2 provider config. When set, preferred over the legacy relay flow.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oauth2: Option<OAuth2ProviderConfig>,
|
||||
/// External auth provider command (stdout = token, stderr = user UX, exit 0 = success).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_provider_command: Option<String>,
|
||||
/// Login button label (env: `KIGI_AUTH_PROVIDER_LABEL`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_provider_label: Option<String>,
|
||||
/// Token TTL in seconds for external auth providers that output bare
|
||||
/// tokens without `expires_in`. Synthesizes `expires_at` so proactive
|
||||
/// refresh works. Env: `KIGI_AUTH_TOKEN_TTL`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_token_ttl: Option<u64>,
|
||||
/// Admin kill switch: when `Some(true)`, the `xai.api_key` auth method is
|
||||
/// neither advertised nor accepted, so `XAI_API_KEY`/per-model credentials
|
||||
/// can't bypass the deployment's IdP login. Env: `KIGI_DISABLE_API_KEY_AUTH`.
|
||||
/// Parity with common force-login-method admin knobs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub disable_api_key_auth: Option<bool>,
|
||||
/// Restrict login to a specific team — the login token's team principal must
|
||||
/// equal this. Put in `requirements.toml` to enforce as non-overridable policy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub force_login_team_uuid: Option<ForceLoginTeam>,
|
||||
/// Pin automatic auth to `api_key` or `oidc`. When set and the chosen
|
||||
/// method is unavailable, auth fails (no fallthrough). Unset keeps
|
||||
/// multi-method fallthrough. Config.toml only (`[auth] preferred_method`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preferred_method: Option<PreferredAuthMethod>,
|
||||
}
|
||||
/// Team login restriction. TOML string or array; an empty array fails closed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ForceLoginTeam {
|
||||
/// The only allowed team.
|
||||
Single(String),
|
||||
/// Allowed teams; empty = fail closed.
|
||||
AnyOf(Vec<String>),
|
||||
}
|
||||
/// Customer OIDC Identity Provider configuration (`[grok_com_config.oidc]`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OidcAuthConfig {
|
||||
pub issuer: String,
|
||||
pub client_id: String,
|
||||
#[serde(default = "default_oidc_scopes")]
|
||||
pub scopes: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub audience: Option<String>,
|
||||
}
|
||||
/// OAuth2 provider configuration (`KIGI_OAUTH2_ISSUER` / `KIGI_OAUTH2_CLIENT_ID`).
|
||||
///
|
||||
/// Uses the standard OAuth 2.1 Auth Code + PKCE flow via [`OidcAuthConfig`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuth2ProviderConfig {
|
||||
pub issuer: String,
|
||||
pub client_id: String,
|
||||
#[serde(default = "default_oauth2_scopes")]
|
||||
pub scopes: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub principal_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub principal_id: Option<String>,
|
||||
/// Client-supplied referrer for OAuth usage-attribution analytics.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub referrer: Option<String>,
|
||||
}
|
||||
pub const XAI_OAUTH2_ISSUER: &str = "https://auth.x.ai";
|
||||
/// Production accounts-app origin allowlist — the only origins builds without
|
||||
/// non-production builds accept. Lives in its own const, referenced by both
|
||||
/// profiles below, so the frozen-contract test (monorepo CI compiles with
|
||||
/// that feature enabled) still pins this production-origin const.
|
||||
const PROD_ACCOUNTS_APP_ORIGINS: &[&str] = &["https://accounts.x.ai"];
|
||||
/// See the opt-in non-production feature variant above — builds without
|
||||
/// the feature accept only the production accounts app.
|
||||
pub fn allowed_accounts_app_origins() -> Vec<String> {
|
||||
PROD_ACCOUNTS_APP_ORIGINS
|
||||
.iter()
|
||||
.map(|o| o.to_string())
|
||||
.collect()
|
||||
}
|
||||
/// Build a CORS layer that accepts requests from the accounts-app deployments
|
||||
/// listed in [`allowed_accounts_app_origins`] for the given HTTP method.
|
||||
///
|
||||
/// Callers can chain additional configuration (e.g. `.allow_headers(...)` or
|
||||
/// `.allow_private_network(true)`) onto the returned layer.
|
||||
pub fn accounts_app_cors_layer(method: axum::http::Method) -> tower_http::cors::CorsLayer {
|
||||
tower_http::cors::CorsLayer::new()
|
||||
.allow_origin(tower_http::cors::AllowOrigin::list(
|
||||
allowed_accounts_app_origins()
|
||||
.iter()
|
||||
.filter_map(|origin| match origin.parse() {
|
||||
Ok(value) => Some(value),
|
||||
Err(_) => {
|
||||
tracing::warn!(origin, "skipping malformed accounts-app CORS origin");
|
||||
None
|
||||
}
|
||||
}),
|
||||
))
|
||||
.allow_methods([method])
|
||||
}
|
||||
/// Local-dev OAuth2 issuer (accounts-app running on localhost).
|
||||
const XAI_OAUTH2_LOCAL_ISSUER: &str = "http://localhost:22255";
|
||||
const DEFAULT_OAUTH2_REFERRER: &str = "grok-build";
|
||||
/// Returns `true` when `KIGI_LOCAL_AUTH=1` is set,
|
||||
/// indicating the local accounts-app should be used as the OAuth2 issuer.
|
||||
pub fn use_local_auth() -> bool {
|
||||
std::env::var("KIGI_LOCAL_AUTH")
|
||||
.map(|v| !v.is_empty() && v != "0")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// Returns the active xAI OAuth2 issuer — the local-dev issuer when
|
||||
/// `KIGI_LOCAL_AUTH=1` is set, otherwise the production issuer.
|
||||
pub fn xai_oauth2_issuer() -> &'static str {
|
||||
if use_local_auth() {
|
||||
XAI_OAUTH2_LOCAL_ISSUER
|
||||
} else {
|
||||
XAI_OAUTH2_ISSUER
|
||||
}
|
||||
}
|
||||
/// Returns `true` if `issuer` is a recognised xAI OAuth2 issuer
|
||||
/// (production **or** local-dev). Use this instead of comparing against
|
||||
/// [`XAI_OAUTH2_ISSUER`] directly so that local-dev sessions are still
|
||||
/// treated as first-party xAI auth.
|
||||
pub fn is_xai_oauth2_issuer(issuer: &str) -> bool {
|
||||
issuer == XAI_OAUTH2_ISSUER || issuer == XAI_OAUTH2_LOCAL_ISSUER
|
||||
}
|
||||
/// auth.json scope key used by the pre-OIDC `grok login --legacy` flow.
|
||||
/// Matches the key format produced by the original `accounts.x.ai` relay auth.
|
||||
pub const LEGACY_AUTH_SCOPE: &str = "https://accounts.x.ai/sign-in";
|
||||
impl GrokComConfig {
|
||||
/// Whether `xai.api_key` auth is disabled. Pinning a team
|
||||
/// (`force_login_team_uuid`) implies this — team membership can't be verified
|
||||
/// from a bare API key, so it must go through IdP login. The
|
||||
/// `KIGI_DISABLE_API_KEY_AUTH` env lockdown is sticky: because the env value
|
||||
/// seeds `default()` (the merge base), a lower-trust user `config.toml` could
|
||||
/// otherwise set `disable_api_key_auth = false` and override it — so the env
|
||||
/// is OR-ed in here and cannot be turned back off by a user layer. Trusted
|
||||
/// `requirements.toml` already wins over `config.toml` via layer precedence.
|
||||
pub fn api_key_auth_disabled(&self) -> bool {
|
||||
self.disable_api_key_auth == Some(true)
|
||||
|| self.force_login_team_uuid.is_some()
|
||||
|| env_lockdown_forced()
|
||||
}
|
||||
/// When `preferred_method = api_key`, automatic OIDC paths (devbox mint,
|
||||
/// interactive browser login, external auth provider) must not run — the
|
||||
/// pin is fail-closed. Explicit `grok login --devbox` / `--api-key` bypass
|
||||
/// this by not consulting automatic flow helpers.
|
||||
pub fn blocks_automatic_oidc(&self) -> bool {
|
||||
matches!(self.preferred_method, Some(PreferredAuthMethod::ApiKey))
|
||||
}
|
||||
/// The auth.json scope key for this config.
|
||||
pub fn auth_scope(&self) -> String {
|
||||
if let Some(ref oidc) = self.oidc {
|
||||
format!("{}::{}", oidc.issuer.trim_end_matches('/'), oidc.client_id)
|
||||
} else if let Some(ref oauth2) = self.oauth2 {
|
||||
oauth2.auth_scope()
|
||||
} else {
|
||||
unreachable!("oauth2 config is always present (xAI default or env override)")
|
||||
}
|
||||
}
|
||||
}
|
||||
impl OAuth2ProviderConfig {
|
||||
pub fn is_team_principal(&self) -> bool {
|
||||
self.principal_type.as_deref() == Some(TEAM_PRINCIPAL_TYPE)
|
||||
}
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let issuer = std::env::var("KIGI_OAUTH2_ISSUER").ok()?;
|
||||
let client_id = std::env::var("KIGI_OAUTH2_CLIENT_ID").ok()?;
|
||||
let principal_type = std::env::var("KIGI_OAUTH2_PRINCIPAL_TYPE").ok();
|
||||
let principal_id = std::env::var("KIGI_OAUTH2_PRINCIPAL_ID").ok();
|
||||
let default_scopes = match principal_type.as_deref() {
|
||||
Some(TEAM_PRINCIPAL_TYPE) => default_team_oauth2_scopes(),
|
||||
_ => default_oauth2_scopes(),
|
||||
};
|
||||
Some(Self {
|
||||
issuer,
|
||||
client_id,
|
||||
scopes: std::env::var("KIGI_OAUTH2_SCOPES")
|
||||
.map(|s| s.split(',').map(|s| s.trim().to_owned()).collect())
|
||||
.unwrap_or(default_scopes),
|
||||
principal_type,
|
||||
principal_id,
|
||||
referrer: Some(
|
||||
std::env::var("KIGI_OAUTH2_REFERRER")
|
||||
.unwrap_or_else(|_| DEFAULT_OAUTH2_REFERRER.to_owned()),
|
||||
),
|
||||
})
|
||||
}
|
||||
/// Convert to [`OidcAuthConfig`] to reuse the OIDC login flow.
|
||||
pub fn as_oidc(&self) -> OidcAuthConfig {
|
||||
OidcAuthConfig {
|
||||
issuer: self.issuer.clone(),
|
||||
client_id: self.client_id.clone(),
|
||||
scopes: self.scopes.clone(),
|
||||
audience: None,
|
||||
}
|
||||
}
|
||||
pub fn base_auth_scope(&self) -> String {
|
||||
format!("{}::{}", self.issuer.trim_end_matches('/'), self.client_id)
|
||||
}
|
||||
pub fn auth_scope(&self) -> String {
|
||||
self.base_auth_scope()
|
||||
}
|
||||
}
|
||||
impl Default for GrokComConfig {
|
||||
fn default() -> Self {
|
||||
let oidc = OidcAuthConfig::from_env();
|
||||
let oauth2 = if oidc.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
OAuth2ProviderConfig::from_env().unwrap_or_else(|| OAuth2ProviderConfig {
|
||||
issuer: xai_oauth2_issuer().to_owned(),
|
||||
client_id: obfstr::obfstr!("b1a00492-073a-47ea-816f-4c329264a828").to_owned(),
|
||||
scopes: default_oauth2_scopes(),
|
||||
principal_type: None,
|
||||
principal_id: None,
|
||||
referrer: Some(DEFAULT_OAUTH2_REFERRER.to_owned()),
|
||||
}),
|
||||
)
|
||||
};
|
||||
Self {
|
||||
grok_ws_origin: std::env::var("KIGI_WS_ORIGIN")
|
||||
.unwrap_or_else(|_| AUTH_ORIGIN_DEFAULT.to_owned()),
|
||||
token_header: "xai-grok-cli".to_owned(),
|
||||
oidc,
|
||||
oauth2,
|
||||
auth_provider_command: std::env::var("KIGI_AUTH_PROVIDER_COMMAND").ok(),
|
||||
auth_provider_label: std::env::var("KIGI_AUTH_PROVIDER_LABEL").ok(),
|
||||
auth_token_ttl: std::env::var("KIGI_AUTH_TOKEN_TTL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok()),
|
||||
disable_api_key_auth: std::env::var("KIGI_DISABLE_API_KEY_AUTH")
|
||||
.ok()
|
||||
.map(|v| env_flag_enabled(&v)),
|
||||
force_login_team_uuid: None,
|
||||
preferred_method: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Parse a boolean env-var value for grok's on/off flags. A bare presence
|
||||
/// enables the flag, but the common falsy spellings (`0`, `false`, `off`,
|
||||
/// `no`, empty) count as disabled — so e.g. `KIGI_DISABLE_API_KEY_AUTH=false`
|
||||
/// does NOT turn the kill switch on.
|
||||
fn env_flag_enabled(value: &str) -> bool {
|
||||
!matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"" | "0" | "false" | "off" | "no"
|
||||
)
|
||||
}
|
||||
/// True when the admin has set `KIGI_DISABLE_API_KEY_AUTH` to a truthy value in
|
||||
/// the process environment. Read live (call-time) and OR-ed into
|
||||
/// `api_key_auth_disabled()` so the env lockdown is non-overridable by a
|
||||
/// user-layer `config.toml`.
|
||||
fn env_lockdown_forced() -> bool {
|
||||
std::env::var("KIGI_DISABLE_API_KEY_AUTH")
|
||||
.ok()
|
||||
.is_some_and(|v| env_flag_enabled(&v))
|
||||
}
|
||||
impl OidcAuthConfig {
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let issuer = std::env::var("KIGI_OIDC_ISSUER").ok()?;
|
||||
let client_id = std::env::var("KIGI_OIDC_CLIENT_ID").ok()?;
|
||||
Some(Self {
|
||||
issuer,
|
||||
client_id,
|
||||
scopes: std::env::var("KIGI_OIDC_SCOPES")
|
||||
.map(|s| s.split(',').map(|s| s.trim().to_owned()).collect())
|
||||
.unwrap_or_else(|_| default_oidc_scopes()),
|
||||
audience: std::env::var("KIGI_OIDC_AUDIENCE").ok(),
|
||||
})
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn team_auth_scope_is_base_scope() {
|
||||
let cfg = OAuth2ProviderConfig {
|
||||
issuer: "https://auth.x.ai".into(),
|
||||
client_id: "client-123".into(),
|
||||
scopes: default_team_oauth2_scopes(),
|
||||
principal_type: Some("Team".into()),
|
||||
principal_id: Some("team-abc".into()),
|
||||
referrer: Some("grok-build".into()),
|
||||
};
|
||||
assert_eq!(cfg.auth_scope(), "https://auth.x.ai::client-123");
|
||||
}
|
||||
#[test]
|
||||
fn env_flag_enabled_treats_falsy_spellings_as_off() {
|
||||
for off in ["", " ", "0", "false", "FALSE", "off", "No", " false "] {
|
||||
assert!(!env_flag_enabled(off), "{off:?} should be off");
|
||||
}
|
||||
for on in ["1", "true", "yes", "on", "enabled"] {
|
||||
assert!(env_flag_enabled(on), "{on:?} should be on");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn personal_auth_scope_is_base_scope() {
|
||||
let cfg = OAuth2ProviderConfig {
|
||||
issuer: "https://auth.x.ai".into(),
|
||||
client_id: "client-123".into(),
|
||||
scopes: default_oauth2_scopes(),
|
||||
principal_type: None,
|
||||
principal_id: None,
|
||||
referrer: Some("grok-build".into()),
|
||||
};
|
||||
assert_eq!(cfg.auth_scope(), "https://auth.x.ai::client-123");
|
||||
}
|
||||
/// FROZEN loopback contract: the accounts-app origins the CLI's loopback
|
||||
/// callback server accepts cross-origin requests from. The consent page
|
||||
/// (served from accounts.x.ai) delivers the code via `fetch(..., cors)`, so
|
||||
/// removing an origin breaks loopback delivery for already-installed CLIs.
|
||||
/// Keep in sync with the oauth2-provider / accounts-app deployments.
|
||||
/// Non-production / local-dev origins are opt-in only.
|
||||
#[test]
|
||||
fn allowed_accounts_app_origins_are_frozen() {
|
||||
assert_eq!(PROD_ACCOUNTS_APP_ORIGINS, &["https://accounts.x.ai"]);
|
||||
assert_eq!(allowed_accounts_app_origins(), PROD_ACCOUNTS_APP_ORIGINS);
|
||||
}
|
||||
/// FROZEN client contract: the 8 scopes the xAI OAuth2 client requests.
|
||||
/// The server must keep accepting all of them; existing tokens carry
|
||||
/// exactly this set. Frozen OAuth client scope contract.
|
||||
#[test]
|
||||
fn default_oauth2_scopes_are_frozen() {
|
||||
let scopes = default_oauth2_scopes();
|
||||
let scopes: Vec<&str> = scopes.iter().map(String::as_str).collect();
|
||||
assert_eq!(
|
||||
scopes,
|
||||
[
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access",
|
||||
"grok-cli:access",
|
||||
"api:access",
|
||||
"conversations:read",
|
||||
"conversations:write",
|
||||
]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn preferred_method_deserializes_from_toml() {
|
||||
let cfg: GrokComConfig = toml::from_str(
|
||||
r#"
|
||||
preferred_method = "api_key"
|
||||
"#,
|
||||
)
|
||||
.expect("parse");
|
||||
assert_eq!(cfg.preferred_method, Some(PreferredAuthMethod::ApiKey));
|
||||
let cfg: GrokComConfig = toml::from_str(
|
||||
r#"
|
||||
preferred_method = "oidc"
|
||||
"#,
|
||||
)
|
||||
.expect("parse");
|
||||
assert_eq!(cfg.preferred_method, Some(PreferredAuthMethod::Oidc));
|
||||
let cfg: GrokComConfig = toml::from_str("").expect("parse empty");
|
||||
assert_eq!(cfg.preferred_method, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
use crate::auth::AuthManager;
|
||||
use crate::util::grok_auth_credentials::GrokAuthCredentials;
|
||||
use kigi_auth::{
|
||||
AuthCredentialProvider, CredentialSnapshot, HttpAuth, StaticAuthCredentialProvider,
|
||||
};
|
||||
use reqwest::RequestBuilder;
|
||||
use std::sync::Arc;
|
||||
/// `api_key.id` for the active credential: hash the stable API key, never the
|
||||
/// OIDC bearer (which rotates). `None` for non-API-key auth.
|
||||
fn api_key_id_for(auth: Option<&crate::auth::GrokAuth>) -> Option<String> {
|
||||
auth.filter(|a| matches!(a.auth_mode, crate::auth::AuthMode::ApiKey))
|
||||
.map(|a| crate::agent::config::deployment_id_from_key(&a.key))
|
||||
}
|
||||
/// Production impl: wraps the live `AuthManager`. 401 recovery
|
||||
/// delegates to `AuthManager::unauthorized_recovery`.
|
||||
pub struct ShellAuthCredentialProvider {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
static_credentials: GrokAuthCredentials,
|
||||
}
|
||||
impl ShellAuthCredentialProvider {
|
||||
pub(crate) fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
deployment_key: Option<String>,
|
||||
alpha_test_key: Option<String>,
|
||||
) -> Self {
|
||||
let mut static_credentials = GrokAuthCredentials::new(None);
|
||||
static_credentials.deployment_key = deployment_key;
|
||||
static_credentials.alpha_test_key = alpha_test_key;
|
||||
Self {
|
||||
auth_manager,
|
||||
static_credentials,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for ShellAuthCredentialProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ShellAuthCredentialProvider")
|
||||
.field("auth_manager", &"<configured>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
impl HttpAuth for ShellAuthCredentialProvider {
|
||||
fn apply(&self, builder: RequestBuilder, base_url: &str) -> RequestBuilder {
|
||||
let mut creds = self.static_credentials.clone();
|
||||
if creds.deployment_key.is_none()
|
||||
&& let Some(auth) = self.auth_manager.current_or_expired()
|
||||
{
|
||||
creds.user_token = Some(auth.key);
|
||||
}
|
||||
creds.apply(builder, base_url)
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl AuthCredentialProvider for ShellAuthCredentialProvider {
|
||||
fn snapshot(&self) -> CredentialSnapshot {
|
||||
if let Some(ref dk) = self.static_credentials.deployment_key {
|
||||
return CredentialSnapshot {
|
||||
token: Some(dk.clone()),
|
||||
deployment_id: crate::managed_config::resolve_deployment_id(Some(dk)),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
let auth = self.auth_manager.current_or_expired();
|
||||
let user_id = auth.as_ref().map(|a| a.user_id.clone());
|
||||
let team_id = auth.as_ref().and_then(|a| a.team_id.clone());
|
||||
let organization_id = auth.as_ref().and_then(|a| a.organization_id.clone());
|
||||
let api_key_id = api_key_id_for(auth.as_ref());
|
||||
let token = auth.map(|a| a.key);
|
||||
CredentialSnapshot {
|
||||
token,
|
||||
user_id,
|
||||
team_id,
|
||||
deployment_id: None,
|
||||
api_key_id,
|
||||
organization_id,
|
||||
}
|
||||
}
|
||||
async fn refresh_after_unauthorized(&self) -> bool {
|
||||
if self.static_credentials.deployment_key.is_some() {
|
||||
return false;
|
||||
}
|
||||
self.auth_manager.try_recover_unauthorized().await
|
||||
}
|
||||
fn needs_token_auth_header(&self) -> bool {
|
||||
self.static_credentials.deployment_key.is_none()
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::GrokAuth;
|
||||
use crate::auth::GrokComConfig;
|
||||
use crate::auth::manager::AuthManager;
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use kigi_auth::AuthCredentialProvider;
|
||||
use std::sync::Mutex;
|
||||
/// Serializes tests that pin `KIGI_AUTH_EARLY_INVALIDATION_SECS`, since
|
||||
/// env vars are process-global and parallel tests would race.
|
||||
static EARLY_INVALIDATION_LOCK: Mutex<()> = Mutex::new(());
|
||||
/// RAII guard: pins `KIGI_AUTH_EARLY_INVALIDATION_SECS` to the production
|
||||
/// default (300s) while held, restoring the previous value on drop.
|
||||
/// Acquires `EARLY_INVALIDATION_LOCK` so concurrent test runners can't
|
||||
/// observe a half-mutated env.
|
||||
struct EarlyInvalidationGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
previous: Option<String>,
|
||||
}
|
||||
impl EarlyInvalidationGuard {
|
||||
fn pin_to_default() -> Self {
|
||||
let lock = EARLY_INVALIDATION_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let previous = std::env::var("KIGI_AUTH_EARLY_INVALIDATION_SECS").ok();
|
||||
unsafe { std::env::set_var("KIGI_AUTH_EARLY_INVALIDATION_SECS", "300") };
|
||||
Self {
|
||||
_lock: lock,
|
||||
previous,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for EarlyInvalidationGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match self.previous.take() {
|
||||
Some(prev) => std::env::set_var("KIGI_AUTH_EARLY_INVALIDATION_SECS", prev),
|
||||
None => std::env::remove_var("KIGI_AUTH_EARLY_INVALIDATION_SECS"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn make_auth(key: &str, expires_in: ChronoDuration) -> GrokAuth {
|
||||
GrokAuth {
|
||||
key: key.to_string(),
|
||||
user_id: "test-user".to_string(),
|
||||
create_time: Utc::now(),
|
||||
expires_at: Some(Utc::now() + expires_in),
|
||||
..GrokAuth::test_default()
|
||||
}
|
||||
}
|
||||
/// Build an `AuthManager` rooted at `dir`. Caller keeps `dir` alive for
|
||||
/// the duration of the test so the `TempDir` `Drop` actually cleans up.
|
||||
fn make_manager(dir: &tempfile::TempDir, initial: Option<GrokAuth>) -> Arc<AuthManager> {
|
||||
let mgr = AuthManager::new(dir.path(), GrokComConfig::default());
|
||||
if let Some(auth) = initial {
|
||||
mgr.hot_swap(auth);
|
||||
}
|
||||
Arc::new(mgr)
|
||||
}
|
||||
/// `apply()` and `snapshot()` agree (snapshot==wire invariant) when the
|
||||
/// in-memory token is fresh.
|
||||
#[test]
|
||||
fn apply_and_snapshot_agree_on_live_token() {
|
||||
let _guard = EarlyInvalidationGuard::pin_to_default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = make_manager(
|
||||
&dir,
|
||||
Some(make_auth("live-token", ChronoDuration::hours(1))),
|
||||
);
|
||||
let provider = ShellAuthCredentialProvider::new(mgr, None, None);
|
||||
let snap = provider.snapshot();
|
||||
assert_eq!(snap.token.as_deref(), Some("live-token"));
|
||||
assert_eq!(snap.user_id.as_deref(), Some("test-user"));
|
||||
}
|
||||
/// During the 5-minute pre-refresh buffer window, `auth_manager.current()`
|
||||
/// returns `None` (the token is treated as expired-soon for refresh
|
||||
/// scheduling), but the token is still valid at the proxy. The provider
|
||||
/// must fall back to `expired_auth()` so the in-memory token gets sent
|
||||
/// instead of nothing -- which is the fix for the bulk of the
|
||||
/// `POST /v1/storage` 401s observed in production.
|
||||
#[test]
|
||||
fn falls_back_to_expired_auth_during_buffer_window() {
|
||||
let _guard = EarlyInvalidationGuard::pin_to_default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = make_manager(
|
||||
&dir,
|
||||
Some(make_auth("buffer-token", ChronoDuration::minutes(4))),
|
||||
);
|
||||
assert!(mgr.current().is_none(), "buffer-window precondition");
|
||||
assert!(mgr.expired_auth().is_some(), "buffer-window precondition");
|
||||
let provider = ShellAuthCredentialProvider::new(mgr, None, None);
|
||||
let snap = provider.snapshot();
|
||||
assert_eq!(
|
||||
snap.token.as_deref(),
|
||||
Some("buffer-token"),
|
||||
"snapshot should fall back to expired_auth instead of None"
|
||||
);
|
||||
assert_eq!(snap.user_id.as_deref(), Some("test-user"));
|
||||
}
|
||||
/// When `auth_manager` has nothing at all (no in-memory auth, expired
|
||||
/// or otherwise), `snapshot()` returns `None` for the user-token branch.
|
||||
/// `apply()` would then send no Authorization header.
|
||||
#[test]
|
||||
fn no_token_when_auth_manager_is_empty() {
|
||||
let _guard = EarlyInvalidationGuard::pin_to_default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = make_manager(&dir, None);
|
||||
let provider = ShellAuthCredentialProvider::new(mgr, None, None);
|
||||
let snap = provider.snapshot();
|
||||
assert!(
|
||||
snap.token.is_none(),
|
||||
"snapshot should be None when manager has no auth"
|
||||
);
|
||||
assert!(snap.user_id.is_none());
|
||||
}
|
||||
/// 401 recovery routes through `unauthorized_recovery` (pre-fix
|
||||
/// it no-oped because the refresher arg was hardcoded `None`).
|
||||
#[tokio::test]
|
||||
async fn refresh_after_unauthorized_drives_recovery_state_machine() {
|
||||
let _guard = EarlyInvalidationGuard::pin_to_default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = Arc::new(AuthManager::new(
|
||||
dir.path(),
|
||||
crate::auth::GrokComConfig::default(),
|
||||
));
|
||||
mgr.hot_swap(GrokAuth {
|
||||
key: "stale".into(),
|
||||
auth_mode: crate::auth::AuthMode::Oidc,
|
||||
create_time: chrono::Utc::now() - ChronoDuration::hours(2),
|
||||
user_id: "u".into(),
|
||||
refresh_token: Some("rt-stale".into()),
|
||||
expires_at: Some(chrono::Utc::now() - ChronoDuration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
});
|
||||
struct OkRefresher {
|
||||
calls: Arc<std::sync::atomic::AtomicU32>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl crate::auth::refresh::TokenRefresher for OkRefresher {
|
||||
async fn refresh(
|
||||
&self,
|
||||
_r: crate::auth::manager::RefreshReason,
|
||||
) -> crate::auth::refresh::RefreshOutcome {
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
crate::auth::refresh::RefreshOutcome::Success(Box::new(GrokAuth {
|
||||
key: "fresh".into(),
|
||||
auth_mode: crate::auth::AuthMode::Oidc,
|
||||
create_time: chrono::Utc::now(),
|
||||
user_id: "u".into(),
|
||||
refresh_token: Some("rt-new".into()),
|
||||
expires_at: Some(chrono::Utc::now() + ChronoDuration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
}))
|
||||
}
|
||||
}
|
||||
let calls = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
mgr.set_refresher(Arc::new(OkRefresher {
|
||||
calls: calls.clone(),
|
||||
}));
|
||||
let provider = ShellAuthCredentialProvider::new(mgr.clone(), None, None);
|
||||
assert!(provider.refresh_after_unauthorized().await);
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
assert_eq!(mgr.current().unwrap().key, "fresh");
|
||||
assert_eq!(
|
||||
provider.snapshot().token.as_deref(),
|
||||
Some("fresh"),
|
||||
"snapshot must reflect refreshed token for subsequent apply() calls"
|
||||
);
|
||||
}
|
||||
/// Deployment-key path has no recovery (operator owns the bearer).
|
||||
#[tokio::test]
|
||||
async fn refresh_after_unauthorized_is_noop_for_deployment_key() {
|
||||
let _guard = EarlyInvalidationGuard::pin_to_default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = make_manager(&dir, None);
|
||||
let provider =
|
||||
ShellAuthCredentialProvider::new(mgr, Some("deployment-key".to_string()), None);
|
||||
assert!(!provider.refresh_after_unauthorized().await);
|
||||
}
|
||||
#[test]
|
||||
fn snapshot_populates_tenant_id_per_auth_mode() {
|
||||
use crate::agent::config::deployment_id_from_key;
|
||||
let _guard = EarlyInvalidationGuard::pin_to_default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dep = ShellAuthCredentialProvider::new(
|
||||
make_manager(&dir, None),
|
||||
Some("xai-token-EX".into()),
|
||||
None,
|
||||
)
|
||||
.snapshot();
|
||||
assert_eq!(
|
||||
dep.deployment_id.as_deref(),
|
||||
Some(deployment_id_from_key("xai-token-EX").as_str())
|
||||
);
|
||||
assert!(dep.api_key_id.is_none());
|
||||
let api_auth = GrokAuth {
|
||||
key: "sk-apikey-xyz".into(),
|
||||
auth_mode: crate::auth::AuthMode::ApiKey,
|
||||
expires_at: Some(Utc::now() + ChronoDuration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
let api = ShellAuthCredentialProvider::new(make_manager(&dir, Some(api_auth)), None, None)
|
||||
.snapshot();
|
||||
assert_eq!(
|
||||
api.api_key_id.as_deref(),
|
||||
Some(deployment_id_from_key("sk-apikey-xyz").as_str())
|
||||
);
|
||||
assert!(api.deployment_id.is_none());
|
||||
let oidc = ShellAuthCredentialProvider::new(
|
||||
make_manager(
|
||||
&dir,
|
||||
Some(make_auth("oidc-token", ChronoDuration::hours(1))),
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.snapshot();
|
||||
assert!(oidc.deployment_id.is_none() && oidc.api_key_id.is_none());
|
||||
}
|
||||
/// Bootstrap mode: `snapshot()` re-reads disk so sibling-rotated
|
||||
/// tokens are picked up without a live AuthManager.
|
||||
#[test]
|
||||
fn deployment_key_wins_over_resolved_user_token() {
|
||||
let _guard = EarlyInvalidationGuard::pin_to_default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = make_manager(
|
||||
&dir,
|
||||
Some(make_auth("user-token", ChronoDuration::hours(1))),
|
||||
);
|
||||
let provider =
|
||||
ShellAuthCredentialProvider::new(mgr, Some("deployment-key-12345".to_string()), None);
|
||||
let snap = provider.snapshot();
|
||||
assert_eq!(snap.token.as_deref(), Some("deployment-key-12345"));
|
||||
assert!(snap.user_id.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Stub for builds without the devbox auth feature.
|
||||
//!
|
||||
//! Compiled instead of `devbox_login.rs` when the devbox auth feature is
|
||||
//! off, so the remote devbox login helper is not reached. The API
|
||||
//! mirrors the real module: `is_devbox_environment()` is always `false`, which
|
||||
//! short-circuits every auto-recovery/migration call site, and the entry
|
||||
//! points that can still be reached directly (`grok login --devbox`) return a
|
||||
//! descriptive error.
|
||||
|
||||
use super::manager::AuthManager;
|
||||
use super::model::GrokAuth;
|
||||
|
||||
const UNAVAILABLE: &str =
|
||||
"devbox login is not available in this build (compiled without the `devbox-login` feature)";
|
||||
|
||||
/// Always `false` without the devbox auth feature; callers treat the
|
||||
/// process as running outside a devbox environment.
|
||||
pub(crate) fn is_devbox_environment() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Unreachable in practice (guarded by [`is_devbox_environment`]); errors
|
||||
/// defensively if called.
|
||||
pub(crate) async fn mint_devbox_auth(_auth_manager: &AuthManager) -> anyhow::Result<GrokAuth> {
|
||||
anyhow::bail!(UNAVAILABLE)
|
||||
}
|
||||
|
||||
/// Unreachable in practice (guarded by [`is_devbox_environment`]); errors
|
||||
/// defensively if called.
|
||||
pub(super) async fn mint_devbox_auth_raw() -> anyhow::Result<GrokAuth> {
|
||||
anyhow::bail!(UNAVAILABLE)
|
||||
}
|
||||
|
||||
/// `grok login --devbox` entry point: always errors in this build.
|
||||
pub async fn run_devbox_login(_config: &crate::agent::config::Config) -> anyhow::Result<GrokAuth> {
|
||||
anyhow::bail!(UNAVAILABLE)
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
//! RFC 8628 Device Authorization Grant -- CLI side.
|
||||
//!
|
||||
//! Two-phase API:
|
||||
//! 1. `request_device_code()` -- POST to server, get code + URL
|
||||
//! 2. `complete_device_code_login()` -- poll until approved, persist credentials
|
||||
//!
|
||||
//! Callers control what happens between the two phases (print to stderr,
|
||||
//! show in TUI, display in IDE sidebar, etc.).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::auth::oidc::with_alpha_test_key;
|
||||
use crate::auth::{AuthChannels, AuthManager, AuthMode, AuthUrlInfo, AuthUrlMode, GrokAuth};
|
||||
|
||||
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
const DEFAULT_DEVICE_POLL_INTERVAL_SECS: i32 = 5;
|
||||
const DEVICE_SLOW_DOWN_INCREMENT_SECS: u64 = 5;
|
||||
const MIN_DEVICE_CODE_EXPIRY_FALLBACK_SECS: i64 = 10 * 60;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DeviceCodeError {
|
||||
#[error(
|
||||
"Device-code login is not available for this deployment. \
|
||||
Try `grok login` or set XAI_API_KEY instead."
|
||||
)]
|
||||
NotEnabled,
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for DeviceCodeError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
Self::Other(e.into())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public types ---
|
||||
|
||||
/// Low-cardinality client-surface hint sent to the OAuth2 provider as the
|
||||
/// `x-grok-client-surface` header so device-flow metrics can separate logins a
|
||||
/// human can actually finish (`Ui`, `Cli`) from headless automation
|
||||
/// (`Headless`) that mints a device code but can never reach the browser
|
||||
/// consent page — the traffic that otherwise pollutes the device-flow
|
||||
/// conversion denominator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientSurface {
|
||||
/// An interactive front-end (TUI / IDE) renders the URL + code to a human.
|
||||
Ui,
|
||||
/// CLI attached to an interactive terminal (stderr is a TTY).
|
||||
Cli,
|
||||
/// No interactive surface (CI, container, script): no human can complete.
|
||||
Headless,
|
||||
}
|
||||
|
||||
impl ClientSurface {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ui => "ui",
|
||||
Self::Cli => "cli",
|
||||
Self::Headless => "headless",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify the CLI (non-TUI) surface: a TTY on stderr means a human is
|
||||
/// watching the printed URL + code; otherwise we're headless (CI/container/
|
||||
/// script) and no one will complete the flow.
|
||||
fn detect_cli_surface() -> ClientSurface {
|
||||
use std::io::IsTerminal as _;
|
||||
if std::io::stderr().is_terminal() {
|
||||
ClientSurface::Cli
|
||||
} else {
|
||||
ClientSurface::Headless
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of requesting a device code from the server.
|
||||
/// Callers display `verification_uri` + `user_code` to the user,
|
||||
/// then pass this struct to `complete_device_code_login`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceCode {
|
||||
pub verification_uri: String,
|
||||
pub verification_uri_complete: Option<String>,
|
||||
pub user_code: String,
|
||||
device_code: String,
|
||||
interval: i32,
|
||||
expires_in: i64,
|
||||
}
|
||||
|
||||
// --- Wire types (serde) ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeviceCodeResponse {
|
||||
device_code: String,
|
||||
user_code: String,
|
||||
verification_uri: String,
|
||||
verification_uri_complete: Option<String>,
|
||||
expires_in: i64,
|
||||
interval: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenOk {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expires_in: Option<i64>,
|
||||
#[expect(dead_code, reason = "field retained for protocol compatibility")]
|
||||
scope: Option<String>,
|
||||
id_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenErr {
|
||||
error: String,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IdTokenClaims {
|
||||
sub: Option<String>,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
// --- Phase 1: Request device code ---
|
||||
|
||||
/// Request a device code + user code from the OAuth2 provider.
|
||||
///
|
||||
/// This is a single HTTP POST. The caller is responsible for displaying
|
||||
/// `DeviceCode::verification_uri` and `DeviceCode::user_code` to the user
|
||||
/// before calling `complete_device_code_login`.
|
||||
pub async fn request_device_code(
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
scopes: &[String],
|
||||
surface: ClientSurface,
|
||||
) -> Result<DeviceCode, DeviceCodeError> {
|
||||
let client = crate::http::shared_client();
|
||||
let url = format!("{}/oauth2/device/code", issuer.trim_end_matches('/'));
|
||||
let scope_str = scopes.join(" ");
|
||||
|
||||
let resp = with_alpha_test_key(
|
||||
client
|
||||
.post(&url)
|
||||
// Lets oauth2-provider segment device-flow success by client version.
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
// Lets oauth2-provider separate human-completable logins from
|
||||
// headless automation in the device-flow funnel metrics.
|
||||
.header("x-grok-client-surface", surface.as_str())
|
||||
.form(&[
|
||||
("client_id", client_id),
|
||||
("scope", scope_str.as_str()),
|
||||
("referrer", "grok-build"),
|
||||
]),
|
||||
&url,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 404 {
|
||||
return Err(DeviceCodeError::NotEnabled);
|
||||
}
|
||||
return Err(anyhow::anyhow!("Device code request failed (HTTP {status}): {body}").into());
|
||||
}
|
||||
|
||||
let server_resp: DeviceCodeResponse = resp.json().await?;
|
||||
|
||||
// Defend against control characters from a malicious issuer.
|
||||
if !server_resp
|
||||
.user_code
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Server returned invalid user_code format (expected [A-Z0-9-])"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
validate_verification_uri(&server_resp.verification_uri)?;
|
||||
if let Some(ref verification_uri_complete) = server_resp.verification_uri_complete {
|
||||
validate_verification_uri(verification_uri_complete)?;
|
||||
}
|
||||
|
||||
Ok(DeviceCode {
|
||||
verification_uri: server_resp.verification_uri,
|
||||
verification_uri_complete: server_resp.verification_uri_complete,
|
||||
user_code: server_resp.user_code,
|
||||
device_code: server_resp.device_code,
|
||||
interval: server_resp
|
||||
.interval
|
||||
.unwrap_or(DEFAULT_DEVICE_POLL_INTERVAL_SECS),
|
||||
expires_in: server_resp.expires_in,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Phase 2: Poll until approved ---
|
||||
|
||||
/// Poll the token endpoint until the user approves (or denies / expires).
|
||||
///
|
||||
/// On success, persists credentials to `~/.kigi/auth.json` and returns
|
||||
/// the authenticated `GrokAuth`.
|
||||
///
|
||||
/// Callers should have already displayed `device_code.verification_uri`
|
||||
/// and `device_code.user_code` to the user before calling this.
|
||||
pub async fn complete_device_code_login(
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
device_code: DeviceCode,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
surface: ClientSurface,
|
||||
) -> anyhow::Result<(GrokAuth, bool)> {
|
||||
let client = crate::http::shared_client();
|
||||
let token_url = format!("{}/oauth2/token", issuer.trim_end_matches('/'));
|
||||
let mut poll_interval = std::time::Duration::from_secs(device_code.interval.max(1) as u64);
|
||||
let deadline = tokio::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(
|
||||
device_code
|
||||
.expires_in
|
||||
.max(MIN_DEVICE_CODE_EXPIRY_FALLBACK_SECS) as u64,
|
||||
);
|
||||
|
||||
loop {
|
||||
// Sleep first: an immediate poll on a fresh code only returns
|
||||
// authorization_pending (and risks slow_down).
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
anyhow::bail!("Device code expired. Run `grok login --device-auth` again.");
|
||||
}
|
||||
|
||||
let resp = with_alpha_test_key(
|
||||
client
|
||||
.post(&token_url)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header("x-grok-client-surface", surface.as_str())
|
||||
.form(&[
|
||||
("grant_type", DEVICE_GRANT_TYPE),
|
||||
("device_code", device_code.device_code.as_str()),
|
||||
("client_id", client_id),
|
||||
]),
|
||||
&token_url,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
let tokens: TokenOk = resp.json().await?;
|
||||
let auth = build_auth(&tokens, issuer, client_id, auth_manager).await?;
|
||||
return Ok((auth, true));
|
||||
}
|
||||
|
||||
let err: TokenErr = resp.json().await?;
|
||||
let detail = err.error_description.as_deref().unwrap_or(&err.error);
|
||||
match err.error.as_str() {
|
||||
"authorization_pending" => {
|
||||
// User hasn't acted yet -- keep polling.
|
||||
continue;
|
||||
}
|
||||
"slow_down" => {
|
||||
poll_interval += std::time::Duration::from_secs(DEVICE_SLOW_DOWN_INCREMENT_SECS);
|
||||
continue;
|
||||
}
|
||||
"access_denied" => {
|
||||
tracing::warn!(description = detail, "device auth authorization denied");
|
||||
anyhow::bail!("Authorization denied. The user rejected the request.");
|
||||
}
|
||||
"expired_token" => {
|
||||
tracing::warn!(description = detail, "device auth token expired");
|
||||
anyhow::bail!("Device code expired. Run `grok login --device-auth` again.");
|
||||
}
|
||||
other => {
|
||||
tracing::warn!(
|
||||
error = other,
|
||||
description = detail,
|
||||
"device auth token exchange failed"
|
||||
);
|
||||
anyhow::bail!("Token exchange error: {detail}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Device-code login shared by the TUI and CLI.
|
||||
///
|
||||
/// With `channels` (TUI) the verification URL goes to `url_tx` and the browser
|
||||
/// opens automatically; on failure the copyable URL is the fallback. Without
|
||||
/// `channels` (CLI) the URL + code are printed to stderr via `prompt_and_poll`.
|
||||
/// `code_rx` is unused here. The caller reports success (`✓ Signed in`).
|
||||
///
|
||||
/// Takes `channels` by `&mut`, consuming it only after the device code is
|
||||
/// obtained, so callers can reuse it for a loopback fallback on `NotEnabled`.
|
||||
pub async fn run_device_code_login_channels(
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
scopes: &[String],
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
channels: &mut Option<AuthChannels>,
|
||||
) -> anyhow::Result<(GrokAuth, bool)> {
|
||||
// A front-end (TUI/IDE) listening on `url_tx` renders the URL to a human, so
|
||||
// it's `Ui`. Without one we're on the CLI: a TTY means a human can act
|
||||
// (`Cli`), no TTY means headless automation (`Headless`) that will never
|
||||
// complete. Computed before `take()` so the `request_device_code` call
|
||||
// already carries the surface.
|
||||
let surface = if channels.is_some() {
|
||||
ClientSurface::Ui
|
||||
} else {
|
||||
detect_cli_surface()
|
||||
};
|
||||
|
||||
let device_code = request_device_code(issuer, client_id, scopes, surface).await?;
|
||||
|
||||
let Some(channels) = channels.take() else {
|
||||
// CLI: print the URL + code to stderr.
|
||||
return prompt_and_poll(issuer, client_id, device_code, auth_manager, surface).await;
|
||||
};
|
||||
|
||||
// TUI: push the URL through the channel BEFORE opening the browser, so
|
||||
// `x.ai/auth/get_url` isn't blocked on a slow/hanging browser launch
|
||||
// (e.g. SSH/headless). When the issuer omits `verification_uri_complete`,
|
||||
// embed the code so the welcome screen can still show it (anti-phishing).
|
||||
let display_uri = match device_code.verification_uri_complete.as_deref() {
|
||||
Some(uri) => uri.to_owned(),
|
||||
None => {
|
||||
let sep = if device_code.verification_uri.contains('?') {
|
||||
'&'
|
||||
} else {
|
||||
'?'
|
||||
};
|
||||
format!(
|
||||
"{}{}user_code={}",
|
||||
device_code.verification_uri, sep, device_code.user_code
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Some(tx) = channels.url_tx {
|
||||
let _ = tx.send(AuthUrlInfo {
|
||||
url: display_uri.clone(),
|
||||
mode: AuthUrlMode::Device,
|
||||
});
|
||||
}
|
||||
open_browser_detached(&display_uri).await;
|
||||
complete_device_code_login(issuer, client_id, device_code, auth_manager, surface).await
|
||||
}
|
||||
|
||||
/// Display the device code to stderr and poll until approved.
|
||||
async fn prompt_and_poll(
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
device_code: DeviceCode,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
surface: ClientSurface,
|
||||
) -> anyhow::Result<(GrokAuth, bool)> {
|
||||
let display_uri = device_code
|
||||
.verification_uri_complete
|
||||
.as_deref()
|
||||
.unwrap_or(&device_code.verification_uri);
|
||||
|
||||
eprintln!();
|
||||
eprintln!("To sign in, open this URL in your browser:");
|
||||
eprintln!();
|
||||
eprintln!(" {}", display_uri);
|
||||
eprintln!();
|
||||
|
||||
if !open_browser_detached(display_uri).await {
|
||||
eprintln!(" (Could not open browser automatically — open the URL above manually.)");
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
// Show the code to confirm it matches the browser (anti-phishing): a complete
|
||||
// URL pre-fills it (just confirm), otherwise the user types it.
|
||||
if device_code.verification_uri_complete.is_some() {
|
||||
eprintln!("Confirm this code in your browser:");
|
||||
} else {
|
||||
eprintln!("Then enter this code:");
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(" {}", device_code.user_code);
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"\x1b[90mOnly continue with a code you requested. \
|
||||
Don't share it with anyone.\x1b[0m"
|
||||
);
|
||||
eprintln!();
|
||||
eprintln!("Waiting for authorization...");
|
||||
|
||||
// The caller prints the `✓ Signed in` confirmation (it also owns the
|
||||
// external-provider / devbox early-return paths that never reach here).
|
||||
complete_device_code_login(issuer, client_id, device_code, auth_manager, surface).await
|
||||
}
|
||||
|
||||
/// Open `url` in the browser off-thread: `webbrowser::open` is synchronous and
|
||||
/// would stall the single-threaded TUI loop. Returns `true` on success so the
|
||||
/// caller can decide how to notify the user (eprintln on CLI, nothing on TUI
|
||||
/// where the URL is already rendered in the widget).
|
||||
async fn open_browser_detached(url: &str) -> bool {
|
||||
let url = url.to_owned();
|
||||
match tokio::task::spawn_blocking(move || webbrowser::open(&url)).await {
|
||||
Ok(Ok(())) => true,
|
||||
Ok(Err(e)) => {
|
||||
tracing::info!(error = %e, "device auth: could not open browser automatically");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!(error = %e, "device auth: browser-open task failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
/// No id_token signature verification -- token arrives over a direct HTTPS
|
||||
/// channel (no browser redirect), and is only used for display info (email).
|
||||
async fn build_auth(
|
||||
tokens: &TokenOk,
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
) -> anyhow::Result<GrokAuth> {
|
||||
let (user_id, email) = if let Some(ref id_token) = tokens.id_token {
|
||||
decode_jwt_claims(id_token)
|
||||
} else {
|
||||
(String::new(), None)
|
||||
};
|
||||
|
||||
let (principal_type, principal_id, token_team_id) =
|
||||
match crate::auth::oidc::peek_access_token_principal(&tokens.access_token) {
|
||||
Some((pt, pid, tid)) => (Some(pt), Some(pid), tid),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
// Device flow has no pre-selection; verify the token's principal here.
|
||||
// Match the principal id even if `principal_type` is absent.
|
||||
let principal_policy =
|
||||
crate::auth::oidc::login_principal_policy(auth_manager.grok_com_config());
|
||||
crate::auth::oidc::enforce_login_principal(
|
||||
principal_policy.as_ref(),
|
||||
crate::auth::oidc::peek_access_token_principal_id(&tokens.access_token).as_deref(),
|
||||
)?;
|
||||
|
||||
let (user_id, email, team_id, organization_id) =
|
||||
match (principal_type.as_deref(), principal_id.as_deref()) {
|
||||
(Some(pt), Some(principal_id)) if pt == crate::auth::model::TEAM_PRINCIPAL_TYPE => (
|
||||
principal_id.to_owned(),
|
||||
None,
|
||||
Some(principal_id.to_owned()),
|
||||
None,
|
||||
),
|
||||
(Some("Organization"), Some(principal_id)) => (
|
||||
principal_id.to_owned(),
|
||||
None,
|
||||
None,
|
||||
Some(principal_id.to_owned()),
|
||||
),
|
||||
_ => (user_id, email, token_team_id, None),
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let mut auth = GrokAuth {
|
||||
key: tokens.access_token.clone(),
|
||||
auth_mode: AuthMode::Oidc,
|
||||
create_time: now,
|
||||
user_id,
|
||||
email,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
profile_image_asset_id: None,
|
||||
principal_type,
|
||||
principal_id,
|
||||
organization_id,
|
||||
organization_name: None,
|
||||
organization_role: None,
|
||||
team_id,
|
||||
team_name: None,
|
||||
team_role: None,
|
||||
user_blocked_reason: None,
|
||||
team_blocked_reasons: vec![],
|
||||
coding_data_retention_opt_out: false,
|
||||
has_grok_code_access: None,
|
||||
refresh_token: tokens.refresh_token.clone(),
|
||||
expires_at: tokens.expires_in.map(|s| now + Duration::seconds(s)),
|
||||
oidc_issuer: Some(issuer.to_owned()),
|
||||
oidc_client_id: Some(client_id.to_owned()),
|
||||
};
|
||||
|
||||
auth_manager.enrich_auth_inline(&mut auth).await;
|
||||
|
||||
auth_manager
|
||||
.update(auth)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save credentials: {e}"))
|
||||
}
|
||||
|
||||
/// Decode JWT payload without signature verification.
|
||||
/// Returns (sub, Option<email>).
|
||||
fn decode_jwt_claims(jwt: &str) -> (String, Option<String>) {
|
||||
use base64::Engine;
|
||||
let parts: Vec<&str> = jwt.splitn(3, '.').collect();
|
||||
if parts.len() < 2 {
|
||||
return (String::new(), None);
|
||||
}
|
||||
let payload = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[1]) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return (String::new(), None),
|
||||
};
|
||||
let claims: IdTokenClaims = match serde_json::from_slice(&payload) {
|
||||
Ok(claims) => claims,
|
||||
Err(_) => return (String::new(), None),
|
||||
};
|
||||
(claims.sub.unwrap_or_default(), claims.email)
|
||||
}
|
||||
|
||||
fn validate_verification_uri(uri: &str) -> anyhow::Result<()> {
|
||||
if uri.chars().any(|c| c.is_ascii_control()) {
|
||||
anyhow::bail!("Server returned invalid verification URI");
|
||||
}
|
||||
|
||||
let parsed = url::Url::parse(uri)
|
||||
.map_err(|_| anyhow::anyhow!("Server returned invalid verification URI"))?;
|
||||
|
||||
match parsed.scheme() {
|
||||
"https" => Ok(()),
|
||||
"http" if matches!(parsed.host_str(), Some("localhost") | Some("127.0.0.1")) => Ok(()),
|
||||
_ => anyhow::bail!("Server returned unsupported verification URI scheme"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{AuthManager, build_auth, validate_verification_uri};
|
||||
use crate::auth::{AuthMode, GrokComConfig};
|
||||
|
||||
#[test]
|
||||
fn validate_verification_uri_rejects_unsupported_scheme() {
|
||||
let err = validate_verification_uri("javascript:alert(1)").unwrap_err();
|
||||
assert_eq!(
|
||||
"Server returned unsupported verification URI scheme",
|
||||
err.to_string()
|
||||
);
|
||||
}
|
||||
|
||||
fn auth_manager_with_kigi_home(
|
||||
kigi_home: &std::path::Path,
|
||||
proxy_base_url: &str,
|
||||
) -> Arc<AuthManager> {
|
||||
Arc::new(
|
||||
AuthManager::new(kigi_home, GrokComConfig::default())
|
||||
.with_proxy_base_url(proxy_base_url),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_auth_persists_credentials_without_proxy_fetch() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let kigi_home = temp_dir.path().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).unwrap();
|
||||
let auth_manager = auth_manager_with_kigi_home(&kigi_home, "http://127.0.0.1:9");
|
||||
let tokens = super::TokenOk {
|
||||
access_token: "access-token".to_string(),
|
||||
refresh_token: Some("refresh-token".to_string()),
|
||||
expires_in: Some(900),
|
||||
scope: Some("openid email offline_access grok-cli:access".to_string()),
|
||||
id_token: Some(
|
||||
"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyLTEyMyIsImVtYWlsIjoiZGV2aWNlLWF1dGhAbG9jYWwudGVzdCJ9.sig".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let auth = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(build_auth(
|
||||
&tokens,
|
||||
"http://localhost:22255",
|
||||
"client-id",
|
||||
&auth_manager,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!("access-token", auth.key);
|
||||
assert_eq!(AuthMode::Oidc, auth.auth_mode);
|
||||
assert_eq!("user-123", auth.user_id);
|
||||
assert_eq!(Some("device-auth@local.test".to_string()), auth.email);
|
||||
assert_eq!(Some("refresh-token".to_string()), auth.refresh_token);
|
||||
assert_eq!(Some("http://localhost:22255".to_string()), auth.oidc_issuer);
|
||||
assert_eq!(Some("client-id".to_string()), auth.oidc_client_id);
|
||||
assert!(auth_manager.current().is_some());
|
||||
}
|
||||
|
||||
/// jsonwebtoken needs a process-level CryptoProvider; tests that encode
|
||||
/// JWTs can't rely on another test having installed it first.
|
||||
fn ensure_crypto_provider() {
|
||||
let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_auth_seeds_team_metadata_from_access_token() {
|
||||
ensure_crypto_provider();
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let kigi_home = temp_dir.path().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).unwrap();
|
||||
let auth_manager = auth_manager_with_kigi_home(&kigi_home, "http://127.0.0.1:9");
|
||||
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
|
||||
let claims = serde_json::json!({
|
||||
"sub": "user-42",
|
||||
"iss": "https://auth.x.ai",
|
||||
"aud": "client-id",
|
||||
"exp": 9999999999u64,
|
||||
"iat": 1000000000u64,
|
||||
"scope": "offline_access grok-cli:access team:read",
|
||||
"principal_type": "Team",
|
||||
"principal_id": "team-123",
|
||||
"client_id": "client-id",
|
||||
"jti": "token-1",
|
||||
});
|
||||
let tokens = super::TokenOk {
|
||||
access_token: jsonwebtoken::encode(
|
||||
&header,
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_secret(b"test-secret"),
|
||||
)
|
||||
.unwrap(),
|
||||
refresh_token: Some("refresh-token".to_owned()),
|
||||
expires_in: Some(900),
|
||||
scope: Some("offline_access grok-cli:access team:read".to_owned()),
|
||||
id_token: None,
|
||||
};
|
||||
|
||||
let auth = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(build_auth(
|
||||
&tokens,
|
||||
"http://localhost:22255",
|
||||
"client-id",
|
||||
&auth_manager,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!("team-123", auth.user_id);
|
||||
assert_eq!(Some("Team".to_owned()), auth.principal_type);
|
||||
assert_eq!(Some("team-123".to_owned()), auth.principal_id);
|
||||
assert_eq!(Some("team-123".to_owned()), auth.team_id);
|
||||
assert_eq!(None, auth.organization_id);
|
||||
assert_eq!(None, auth.email);
|
||||
}
|
||||
|
||||
/// Team access token carrying `principal_id` (signature irrelevant — only
|
||||
/// the principal claims are peeked).
|
||||
fn team_access_token(principal_id: &str) -> super::TokenOk {
|
||||
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
|
||||
let claims = serde_json::json!({
|
||||
"sub": "user-42",
|
||||
"exp": 9999999999u64,
|
||||
"principal_type": "Team",
|
||||
"principal_id": principal_id,
|
||||
});
|
||||
super::TokenOk {
|
||||
access_token: jsonwebtoken::encode(
|
||||
&header,
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_secret(b"test-secret"),
|
||||
)
|
||||
.unwrap(),
|
||||
refresh_token: Some("refresh-token".to_owned()),
|
||||
expires_in: Some(900),
|
||||
scope: None,
|
||||
id_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `build_auth` with `token_principal` must fail with `expected_err` and
|
||||
/// persist nothing.
|
||||
fn assert_build_auth_rejected(cfg: GrokComConfig, token_principal: &str, expected_err: &str) {
|
||||
ensure_crypto_provider();
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let kigi_home = temp_dir.path().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).unwrap();
|
||||
let auth_manager =
|
||||
Arc::new(AuthManager::new(&kigi_home, cfg).with_proxy_base_url("http://127.0.0.1:9"));
|
||||
|
||||
let err = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(build_auth(
|
||||
&team_access_token(token_principal),
|
||||
"http://localhost:22255",
|
||||
"client-id",
|
||||
&auth_manager,
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.to_string(), expected_err);
|
||||
assert!(
|
||||
auth_manager.current().is_none(),
|
||||
"rejected login must not persist credentials",
|
||||
);
|
||||
assert!(
|
||||
!kigi_home.join("auth.json").exists(),
|
||||
"rejected login must not write auth.json",
|
||||
);
|
||||
}
|
||||
|
||||
/// The legacy `oauth2.principal_id` only pre-selects a team; it must not
|
||||
/// enforce a pin (only `force_login_team_uuid` does), so a different team's
|
||||
/// token is accepted.
|
||||
#[test]
|
||||
fn build_auth_does_not_enforce_legacy_oauth2_principal_id() {
|
||||
ensure_crypto_provider();
|
||||
let cfg = GrokComConfig {
|
||||
oauth2: Some(crate::auth::OAuth2ProviderConfig {
|
||||
issuer: "http://localhost:22255".into(),
|
||||
client_id: "client-id".into(),
|
||||
scopes: vec!["offline_access".into()],
|
||||
principal_type: Some("Team".into()),
|
||||
principal_id: Some("team-required".into()),
|
||||
referrer: None,
|
||||
}),
|
||||
..GrokComConfig::default()
|
||||
};
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let kigi_home = temp_dir.path().join(".kigi");
|
||||
std::fs::create_dir_all(&kigi_home).unwrap();
|
||||
let auth_manager =
|
||||
Arc::new(AuthManager::new(&kigi_home, cfg).with_proxy_base_url("http://127.0.0.1:9"));
|
||||
|
||||
let auth = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(build_auth(
|
||||
&team_access_token("team-other"),
|
||||
"http://localhost:22255",
|
||||
"client-id",
|
||||
&auth_manager,
|
||||
))
|
||||
.expect("legacy oauth2.principal_id must not enforce a pin");
|
||||
assert_eq!(
|
||||
auth.team_id.as_deref(),
|
||||
Some("team-other"),
|
||||
"the token's own team is used; the legacy pre-select id does not gate it",
|
||||
);
|
||||
}
|
||||
|
||||
/// Persistence-seam enforcement via a `force_login_team_uuid` list.
|
||||
#[test]
|
||||
fn build_auth_rejects_token_outside_force_login_team_list() {
|
||||
let cfg = GrokComConfig {
|
||||
force_login_team_uuid: Some(crate::auth::ForceLoginTeam::AnyOf(vec![
|
||||
"team-a".into(),
|
||||
"team-b".into(),
|
||||
])),
|
||||
..GrokComConfig::default()
|
||||
};
|
||||
assert_build_auth_rejected(
|
||||
cfg,
|
||||
"team-other",
|
||||
"This deployment requires logging into one of teams: team-a, team-b; \
|
||||
your login returned team-other",
|
||||
);
|
||||
}
|
||||
|
||||
// ── complete_device_code_login poll loop ────────────────────────────────
|
||||
|
||||
/// Spawn a mock `/oauth2/token` server that serves `responses` in order,
|
||||
/// repeating the last entry. Returns the issuer base URL.
|
||||
async fn spawn_token_server(
|
||||
responses: Vec<(u16, serde_json::Value)>,
|
||||
) -> (String, tokio::task::JoinHandle<()>) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let issuer = format!("http://{}", listener.local_addr().unwrap());
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let responses = Arc::new(responses);
|
||||
let app = axum::Router::new().route(
|
||||
"/oauth2/token",
|
||||
axum::routing::post(move || {
|
||||
let counter = counter.clone();
|
||||
let responses = responses.clone();
|
||||
async move {
|
||||
let idx = counter
|
||||
.fetch_add(1, Ordering::SeqCst)
|
||||
.min(responses.len() - 1);
|
||||
let (status, body) = &responses[idx];
|
||||
(
|
||||
axum::http::StatusCode::from_u16(*status).unwrap(),
|
||||
axum::Json(body.clone()),
|
||||
)
|
||||
}
|
||||
}),
|
||||
);
|
||||
let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
(issuer, handle)
|
||||
}
|
||||
|
||||
fn device_code_for_test(interval: i32, expires_in: i64) -> super::DeviceCode {
|
||||
super::DeviceCode {
|
||||
verification_uri: "https://example.test/device".into(),
|
||||
verification_uri_complete: Some(
|
||||
"https://example.test/device?user_code=ABCD-EFGH".into(),
|
||||
),
|
||||
user_code: "ABCD-EFGH".into(),
|
||||
device_code: "dev-code-123".into(),
|
||||
interval,
|
||||
expires_in,
|
||||
}
|
||||
}
|
||||
|
||||
// Real time (not `start_paused`: the shared client's 30s connect_timeout
|
||||
// fires under auto-advance). Deadline-expiry isn't tested — the deadline is
|
||||
// floored at 10 min (MIN_DEVICE_CODE_EXPIRY_FALLBACK_SECS).
|
||||
async fn run_poll(
|
||||
responses: Vec<(u16, serde_json::Value)>,
|
||||
) -> anyhow::Result<(super::GrokAuth, bool)> {
|
||||
let (issuer, server) = spawn_token_server(responses).await;
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let auth_manager = auth_manager_with_kigi_home(temp_dir.path(), "http://127.0.0.1:9");
|
||||
let device_code = device_code_for_test(1, 900);
|
||||
let result = super::complete_device_code_login(
|
||||
&issuer,
|
||||
"client-id",
|
||||
device_code,
|
||||
&auth_manager,
|
||||
super::ClientSurface::Cli,
|
||||
)
|
||||
.await;
|
||||
server.abort();
|
||||
result
|
||||
}
|
||||
|
||||
fn success_body() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 900,
|
||||
"scope": "openid",
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_succeeds_on_first_poll() {
|
||||
let (auth, is_new) = run_poll(vec![(200, success_body())])
|
||||
.await
|
||||
.expect("should resolve to a token");
|
||||
assert_eq!(auth.key, "mock-access-token");
|
||||
assert!(is_new);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_succeeds_after_pending() {
|
||||
let (auth, _) = run_poll(vec![
|
||||
(400, serde_json::json!({ "error": "authorization_pending" })),
|
||||
(200, success_body()),
|
||||
])
|
||||
.await
|
||||
.expect("should resolve to a token after pending");
|
||||
assert_eq!(auth.key, "mock-access-token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_handles_slow_down_then_succeeds() {
|
||||
// slow_down must be tolerated (interval bumped) without erroring.
|
||||
let (auth, _) = run_poll(vec![
|
||||
(400, serde_json::json!({ "error": "slow_down" })),
|
||||
(200, success_body()),
|
||||
])
|
||||
.await
|
||||
.expect("slow_down should be retried, not fatal");
|
||||
assert_eq!(auth.key, "mock-access-token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_maps_access_denied_to_error() {
|
||||
let err = run_poll(vec![(400, serde_json::json!({ "error": "access_denied" }))])
|
||||
.await
|
||||
.expect_err("access_denied must be an error");
|
||||
assert!(err.to_string().contains("denied"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_maps_expired_token_to_error() {
|
||||
let err = run_poll(vec![(400, serde_json::json!({ "error": "expired_token" }))])
|
||||
.await
|
||||
.expect_err("expired_token must be an error");
|
||||
assert!(err.to_string().contains("expired"), "got: {err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum AuthError {
|
||||
#[error("Not logged in. Run `grok login`.")]
|
||||
NotLoggedIn,
|
||||
|
||||
/// Token expired and no refresh authority available.
|
||||
#[error("Token expired. Run `grok login` to re-authenticate.")]
|
||||
TokenExpiredNoRefresh,
|
||||
|
||||
/// Server rejected the token (401) with no recovery path.
|
||||
#[error("Authentication rejected by server. Run `grok login` to re-authenticate.")]
|
||||
ServerRejectedNoRecovery,
|
||||
|
||||
/// All recovery strategies exhausted.
|
||||
#[error("Auth recovery exhausted; re-authentication required.")]
|
||||
RecoveryExhausted,
|
||||
|
||||
/// A session's team principal violates the `force_login_team_uuid` pin.
|
||||
/// `message` states which team is required vs. returned.
|
||||
#[error("{message} Run `grok login` to sign in with the required team.")]
|
||||
PinnedTeamMismatch { message: String },
|
||||
|
||||
/// Cached API-key session rejected because API-key auth is disabled.
|
||||
#[error("API-key auth is disabled by your administrator. Run `grok login` to authenticate.")]
|
||||
ApiKeyAuthDisabled,
|
||||
|
||||
/// Outcome of a refresh-authority attempt. Recoverability (and, for
|
||||
/// permanent failures, the reason) lives in [`RefreshTokenError`].
|
||||
#[error(transparent)]
|
||||
Refresh(#[from] RefreshTokenError),
|
||||
}
|
||||
|
||||
/// Recoverability axis of a token-refresh attempt. Deliberately total (no
|
||||
/// `#[non_exhaustive]`): "permanent vs transient" is a closed decision every
|
||||
/// caller must make, so a future third state should break consumers loudly.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RefreshTokenError {
|
||||
/// The credential is dead; the user must re-authenticate.
|
||||
#[error(transparent)]
|
||||
Permanent(#[from] RefreshTokenFailedError),
|
||||
/// Network / 5xx / unknown blip; safe to retry later. Carries the cause.
|
||||
#[error(transparent)]
|
||||
Transient(RefreshTransientError),
|
||||
}
|
||||
|
||||
/// A retryable refresh failure, wrapping its cause. No public `From`:
|
||||
/// construct only via [`AuthError::transient`] /
|
||||
/// [`AuthError::transient_source`], so a stray `?` on some error can't silently
|
||||
/// classify a permanent failure as retryable (mirrors the dedicated
|
||||
/// [`RefreshTokenFailedError`] on the permanent arm). Display frames the cause
|
||||
/// as an auth-refresh failure so internal messages (lock timeout, sleep defer)
|
||||
/// don't surface bare; the permanent arm derives its copy from
|
||||
/// [`RefreshTokenFailedReason::user_message`] and is not prefixed.
|
||||
#[derive(Debug, Error)]
|
||||
#[error("auth refresh failed: {0}")]
|
||||
pub struct RefreshTransientError(#[source] Box<dyn std::error::Error + Send + Sync>);
|
||||
|
||||
/// A terminal refresh failure. `reason` is machine-readable; the user-facing
|
||||
/// copy is derived from it via [`RefreshTokenFailedReason::user_message`], so
|
||||
/// the two can never drift.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
#[error("{}", .reason.user_message())]
|
||||
#[non_exhaustive]
|
||||
pub struct RefreshTokenFailedError {
|
||||
pub reason: RefreshTokenFailedReason,
|
||||
}
|
||||
|
||||
impl From<RefreshTokenFailedReason> for RefreshTokenFailedError {
|
||||
fn from(reason: RefreshTokenFailedReason) -> Self {
|
||||
Self { reason }
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a token refresh terminally failed, grounded in the OAuth2 error codes
|
||||
/// our IdP actually emits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum RefreshTokenFailedReason {
|
||||
/// `invalid_grant` — the refresh token is no longer valid (expired, reused,
|
||||
/// or revoked; the IdP does not distinguish these).
|
||||
RefreshTokenRejected,
|
||||
/// `invalid_client` — the client/app credential was rejected.
|
||||
ClientRejected,
|
||||
/// Escalation from repeated transient failures (OIDC) or a single
|
||||
/// external-binary failure. Never a raw IdP code: an unrecognized terminal
|
||||
/// code is classified transient, not `Other` (see `classify_terminal`).
|
||||
Other,
|
||||
}
|
||||
|
||||
impl RefreshTokenFailedReason {
|
||||
/// Sticky until the credential changes (never ages out): a revoked refresh
|
||||
/// token never self-heals, whereas client rotation / transient escalation
|
||||
/// recover, so those age out past the TTL.
|
||||
pub(crate) fn is_sticky(self) -> bool {
|
||||
match self {
|
||||
Self::RefreshTokenRejected => true,
|
||||
Self::ClientRejected | Self::Other => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing copy for a terminal refresh failure; the raw IdP code stays
|
||||
/// in logs.
|
||||
pub(crate) fn user_message(self) -> &'static str {
|
||||
match self {
|
||||
Self::RefreshTokenRejected => {
|
||||
"Your session has expired. Run `grok login` to sign in again."
|
||||
}
|
||||
Self::ClientRejected => {
|
||||
"Authentication is temporarily unavailable. Run `grok login` if this persists."
|
||||
}
|
||||
Self::Other => {
|
||||
"Authentication could not be refreshed. Run `grok login` to sign in again."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthError {
|
||||
/// A retryable refresh failure with a message-only cause, for the genuinely
|
||||
/// message-only sites (lock timeout, sleep/dark-wake defer, no refresher);
|
||||
/// use [`Self::transient_source`] when a real error is in hand.
|
||||
pub(crate) fn transient(message: impl Into<String>) -> Self {
|
||||
Self::transient_source(message.into())
|
||||
}
|
||||
|
||||
/// A retryable refresh failure that preserves `source` in the error chain
|
||||
/// (`Transient` carries the cause), so callers with a real error don't
|
||||
/// flatten it to a string.
|
||||
pub(crate) fn transient_source(
|
||||
source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
) -> Self {
|
||||
AuthError::Refresh(RefreshTokenError::Transient(RefreshTransientError(
|
||||
source.into(),
|
||||
)))
|
||||
}
|
||||
|
||||
/// A terminal refresh failure for an already-classified `reason`.
|
||||
pub(crate) fn permanent(reason: RefreshTokenFailedReason) -> Self {
|
||||
AuthError::Refresh(RefreshTokenError::Permanent(reason.into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
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.
|
||||
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)
|
||||
};
|
||||
|
||||
Ok(GrokAuth {
|
||||
key: token,
|
||||
auth_mode: AuthMode::External,
|
||||
create_time: chrono::Utc::now(),
|
||||
user_id: String::new(),
|
||||
email: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
profile_image_asset_id: None,
|
||||
principal_type: None,
|
||||
principal_id: None,
|
||||
team_id: None,
|
||||
team_name: None,
|
||||
team_role: None,
|
||||
organization_id: None,
|
||||
organization_name: None,
|
||||
organization_role: None,
|
||||
user_blocked_reason: None,
|
||||
team_blocked_reasons: vec![],
|
||||
coding_data_retention_opt_out: false,
|
||||
has_grok_code_access: None,
|
||||
refresh_token,
|
||||
expires_at,
|
||||
oidc_issuer: 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> {
|
||||
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)");
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.args(["-c", command])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
// Pipe stderr — inherit would corrupt the TUI alternate screen.
|
||||
.stderr(Stdio::piped());
|
||||
if is_refresh {
|
||||
cmd.env("KIGI_AUTH_EXPIRED", "1");
|
||||
}
|
||||
kigi_tools::util::detach_std_command(&mut cmd);
|
||||
cmd.envs(kigi_tools::util::pager_env());
|
||||
let mut child = cmd.spawn()
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, cmd = %command, "auth: failed to start external auth provider");
|
||||
e
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
let timeout = std::time::Duration::from_secs(timeout_secs);
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_status)) => break,
|
||||
Ok(None) => {
|
||||
if start.elapsed() > timeout {
|
||||
tracing::warn!(
|
||||
cmd = %command,
|
||||
timeout_secs,
|
||||
"auth: external auth provider timed out (likely needs interactive auth), killing"
|
||||
);
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "auth: error waiting for external auth provider");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, "auth: failed to read external auth provider output");
|
||||
e
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
match parse_output(&output) {
|
||||
Ok(auth) => {
|
||||
tracing::info!("auth: external auth provider returned fresh token");
|
||||
Some(auth)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "auth: external auth provider failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run external auth provider, carrying forward `/user`-derived fields from previous auth.
|
||||
pub(crate) fn refresh_with_command(command: &str, prev_auth: &GrokAuth) -> Option<GrokAuth> {
|
||||
let mut auth = run_external_auth_sync(command, true)?;
|
||||
auth.carry_user_profile_from(prev_auth);
|
||||
Some(auth)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_output_nonzero_exit_is_err() {
|
||||
let output = std::process::Output {
|
||||
status: std::process::Command::new("false").status().unwrap(),
|
||||
stdout: b"token".to_vec(),
|
||||
stderr: vec![],
|
||||
};
|
||||
assert!(parse_output(&output).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_output_empty_stdout_is_err() {
|
||||
let output = std::process::Output {
|
||||
status: std::process::Command::new("true").status().unwrap(),
|
||||
stdout: b" \n".to_vec(),
|
||||
stderr: vec![],
|
||||
};
|
||||
assert!(parse_output(&output).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_output_issuer_claim_enables_xai_auth() {
|
||||
let ok = |stdout: &str| std::process::Output {
|
||||
status: std::process::Command::new("true").status().unwrap(),
|
||||
stdout: stdout.as_bytes().to_vec(),
|
||||
stderr: vec![],
|
||||
};
|
||||
|
||||
// x.ai issuer claim → first-party session (relay-eligible).
|
||||
let auth = parse_output(&ok(
|
||||
r#"{"access_token":"t","expires_in":900,"issuer":"https://auth.x.ai"}"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(auth.oidc_issuer.as_deref(), Some("https://auth.x.ai"));
|
||||
assert!(auth.is_xai_auth());
|
||||
|
||||
// Non-x.ai issuer is stored but stays third-party.
|
||||
let auth = parse_output(&ok(
|
||||
r#"{"access_token":"t","issuer":"https://idp.acme.example"}"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
auth.oidc_issuer.as_deref(),
|
||||
Some("https://idp.acme.example")
|
||||
);
|
||||
assert!(!auth.is_xai_auth());
|
||||
|
||||
// Missing / empty / whitespace issuer → None.
|
||||
let auth = parse_output(&ok(r#"{"access_token":"t"}"#)).unwrap();
|
||||
assert_eq!(auth.oidc_issuer, None);
|
||||
assert!(!auth.is_xai_auth());
|
||||
let auth = parse_output(&ok(r#"{"access_token":"t","issuer":" "}"#)).unwrap();
|
||||
assert_eq!(auth.oidc_issuer, None);
|
||||
|
||||
// Bare-token output never carries an issuer.
|
||||
let auth = parse_output(&ok("bare-token")).unwrap();
|
||||
assert_eq!(auth.oidc_issuer, None);
|
||||
assert!(!auth.is_xai_auth());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_output_malformed_json_falls_back_to_bare() {
|
||||
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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_spawn_failure_returns_none() {
|
||||
assert!(run_external_auth_sync("/nonexistent/binary", false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_sets_grok_auth_expired_env_on_refresh() {
|
||||
let auth = run_external_auth_sync("echo $KIGI_AUTH_EXPIRED", true).unwrap();
|
||||
assert_eq!(auth.key, "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_carries_zdr_flags_forward() {
|
||||
let prev = GrokAuth {
|
||||
user_blocked_reason: Some("BLOCKED_REASON_OTHER".into()),
|
||||
team_blocked_reasons: vec!["BLOCKED_REASON_NO_LOGS".into()],
|
||||
coding_data_retention_opt_out: true,
|
||||
organization_id: Some("org-1".into()),
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
let auth = refresh_with_command("echo fresh-token", &prev).unwrap();
|
||||
assert_eq!(auth.key, "fresh-token");
|
||||
assert!(auth.is_zdr_team(), "ZDR flag must survive refresh");
|
||||
assert!(auth.coding_data_retention_opt_out);
|
||||
assert_eq!(
|
||||
auth.user_blocked_reason.as_deref(),
|
||||
Some("BLOCKED_REASON_OTHER")
|
||||
);
|
||||
assert_eq!(auth.user_id, "test-user", "profile must survive refresh");
|
||||
assert_eq!(auth.organization_id.as_deref(), Some("org-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_refresh_interactive_times_out() {
|
||||
// Binary writes link to stderr then blocks — 5s refresh timeout kills it.
|
||||
let cmd = r#"echo 'Visit http://example.com/auth' >&2; sleep 20; echo token"#;
|
||||
let start = std::time::Instant::now();
|
||||
let result = run_external_auth_sync(cmd, true);
|
||||
let elapsed = start.elapsed();
|
||||
assert!(result.is_none(), "should timeout and return None");
|
||||
assert!(
|
||||
elapsed.as_secs() < 10,
|
||||
"refresh should use 5s timeout, not 60s (took {}s)",
|
||||
elapsed.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
//! JWT expiration detection. Returns `None`/`false` for non-JWT tokens.
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Claims {
|
||||
exp: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn parse_jwt_expiration(token: &str) -> Option<DateTime<Utc>> {
|
||||
jsonwebtoken::dangerous::insecure_decode::<Claims>(token)
|
||||
.ok()
|
||||
.and_then(|data| data.claims.exp)
|
||||
.and_then(|ts| DateTime::from_timestamp(ts, 0))
|
||||
}
|
||||
|
||||
pub fn is_jwt_expired_or_near(token: &str, threshold: Duration) -> bool {
|
||||
parse_jwt_expiration(token)
|
||||
.map(|exp| exp <= Utc::now() + threshold)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Tokens with an `aud` claim must parse successfully.
|
||||
/// `jsonwebtoken::Validation::default()` enables audience validation which
|
||||
/// silently rejects these tokens unless `validate_aud = false` is set.
|
||||
#[test]
|
||||
fn parses_jwt_with_aud_claim() {
|
||||
let token = build_test_jwt(r#"{"aud":["some-audience"],"exp":1772575524}"#);
|
||||
let exp = parse_jwt_expiration(&token);
|
||||
assert_eq!(exp.unwrap().timestamp(), 1772575524);
|
||||
}
|
||||
|
||||
fn build_test_jwt(payload_json: &str) -> String {
|
||||
use base64::Engine;
|
||||
let enc = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
let header = enc.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
|
||||
let payload = enc.encode(payload_json);
|
||||
format!("{header}.{payload}.fake-signature")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
//! Background `/user` enrichment spawned by `AuthManager::update()`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use super::AuthManager;
|
||||
use super::lock::try_lock_auth_file_async;
|
||||
use crate::auth::manager::AUTH_LOCK_TIMEOUT;
|
||||
use crate::auth::model::{GrokAuth, UserInfo, lookup_auth};
|
||||
use crate::auth::storage::{read_auth_json, write_auth_json};
|
||||
|
||||
/// `/user` fetch budget, shared by the inline (login) and background paths.
|
||||
const USER_FETCH_TIMEOUT: StdDuration = StdDuration::from_secs(10);
|
||||
|
||||
/// Logs `auth update enrichment dropped` if the task is cancelled
|
||||
/// mid-flight. Disarmed on normal completion.
|
||||
pub(super) struct EnrichmentExitGuard {
|
||||
pub(super) started: std::time::Instant,
|
||||
pub(super) armed: bool,
|
||||
}
|
||||
|
||||
impl EnrichmentExitGuard {
|
||||
pub(super) fn disarm(&mut self) {
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnrichmentExitGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
kigi_log::unified_log::warn(
|
||||
"auth update enrichment dropped",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"elapsed_ms": self.started.elapsed().as_millis() as u64,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn spawn(manager: Arc<AuthManager>, auth: GrokAuth) {
|
||||
tokio::spawn(async move {
|
||||
let mut exit_guard = EnrichmentExitGuard {
|
||||
started: std::time::Instant::now(),
|
||||
armed: true,
|
||||
};
|
||||
run_user_info_enrichment(&manager, auth).await;
|
||||
exit_guard.disarm();
|
||||
});
|
||||
}
|
||||
|
||||
async fn fetch_user_info(manager: &AuthManager, key: &str, log_label: &str) -> Option<UserInfo> {
|
||||
let user_url = format!("{}/user", manager.proxy_base_url);
|
||||
let token_header = &manager.grok_com_config.token_header;
|
||||
let started = std::time::Instant::now();
|
||||
let http_client = crate::http::shared_client();
|
||||
let response = http_client
|
||||
.get(&user_url)
|
||||
.timeout(USER_FETCH_TIMEOUT)
|
||||
.header("Authorization", format!("Bearer {}", key))
|
||||
.header("X-XAI-Token-Auth", token_header.as_str())
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
crate::http::CLIENT_MODE_HEADER,
|
||||
crate::http::process_client_mode(),
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.status().is_success() => match resp.json::<UserInfo>().await {
|
||||
Ok(ui) if !ui.user_id.is_empty() => Some(ui),
|
||||
Ok(_) => {
|
||||
kigi_log::unified_log::warn(
|
||||
&format!("{log_label} skipped"),
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"reason": "empty_user_id",
|
||||
"elapsed_ms": started.elapsed().as_millis() as u64,
|
||||
})),
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
kigi_log::unified_log::warn(
|
||||
&format!("{log_label} failed"),
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"reason": "parse",
|
||||
"error": e.to_string(),
|
||||
"elapsed_ms": started.elapsed().as_millis() as u64,
|
||||
})),
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(resp) => {
|
||||
kigi_log::unified_log::warn(
|
||||
&format!("{log_label} failed"),
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"reason": "http_status",
|
||||
"http_status": resp.status().as_u16(),
|
||||
"elapsed_ms": started.elapsed().as_millis() as u64,
|
||||
})),
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
kigi_log::unified_log::warn(
|
||||
&format!("{log_label} failed"),
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"reason": if e.is_timeout() { "timeout" } else { "transport" },
|
||||
"error": e.to_string(),
|
||||
"elapsed_ms": started.elapsed().as_millis() as u64,
|
||||
})),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking login-time enrichment: merge `/user` fields before the first save.
|
||||
pub(super) async fn enrich_inline(manager: &AuthManager, auth: &mut GrokAuth) {
|
||||
let Some(ui) = fetch_user_info(manager, &auth.key, "auth login enrichment").await else {
|
||||
return;
|
||||
};
|
||||
apply_user_info_enrichment(auth, ui);
|
||||
}
|
||||
|
||||
async fn run_user_info_enrichment(manager: &AuthManager, auth: GrokAuth) {
|
||||
let started = std::time::Instant::now();
|
||||
let Some(user_info) = fetch_user_info(manager, &auth.key, "auth update enrichment").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let user_elapsed_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
// R-M-W file lock. On timeout, fall through to an unlocked write
|
||||
// rather than drop the enrichment.
|
||||
let lock_started = std::time::Instant::now();
|
||||
let lock_guard = try_lock_auth_file_async(&manager.path, AUTH_LOCK_TIMEOUT).await;
|
||||
let lock_wait_ms = lock_started.elapsed().as_millis() as u64;
|
||||
if lock_guard.is_none() {
|
||||
tracing::warn!("auth: enrichment proceeding without auth.json.lock");
|
||||
}
|
||||
|
||||
let Ok(mut map) = read_auth_json(&manager.path) else {
|
||||
kigi_log::unified_log::warn(
|
||||
"auth update enrichment skipped",
|
||||
None,
|
||||
Some(serde_json::json!({ "reason": "read_disk_failed" })),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(mut disk) = lookup_auth(&map, &manager.scope) else {
|
||||
kigi_log::unified_log::info(
|
||||
"auth update enrichment skipped",
|
||||
None,
|
||||
Some(serde_json::json!({ "reason": "no_disk_auth" })),
|
||||
);
|
||||
return;
|
||||
};
|
||||
// Sibling-stomp guard. If either the access token or refresh
|
||||
// token on disk differs from the one we wrote, a sibling process
|
||||
// rotated tokens since our update(). Skip enrichment to avoid
|
||||
// writing stale profile data over the sibling's fresher entry.
|
||||
//
|
||||
// OR logic (not AND): a single-field rotation (key changes, RT
|
||||
// stays) is the common case during concurrent refresh. The old
|
||||
// AND logic required ALL three fields to differ, letting
|
||||
// single-field rotations through.
|
||||
//
|
||||
// Team-login transitions (placeholder→real user_id) don't rotate
|
||||
// tokens, so OR correctly allows enrichment for that case.
|
||||
if disk.key != auth.key || disk.refresh_token != auth.refresh_token {
|
||||
kigi_log::unified_log::info(
|
||||
"auth update enrichment skipped",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"reason": "sibling_rotated",
|
||||
"written_key_prefix": crate::auth::token_suffix(&auth.key),
|
||||
"disk_key_prefix": crate::auth::token_suffix(&disk.key),
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
apply_user_info_enrichment(&mut disk, user_info);
|
||||
|
||||
map.insert(manager.scope.clone(), disk.clone());
|
||||
let write_started = std::time::Instant::now();
|
||||
if let Err(e) = write_auth_json(&manager.path, &map) {
|
||||
kigi_log::unified_log::error(
|
||||
"auth update enrichment write failed",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"error": e.to_string(),
|
||||
"user_ms": user_elapsed_ms,
|
||||
"lock_wait_ms": lock_wait_ms,
|
||||
"write_ms": write_started.elapsed().as_millis() as u64,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
manager.with_inner_write(|inner| *inner = Some(disk));
|
||||
kigi_log::unified_log::info(
|
||||
"auth update enrichment done",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"user_ms": user_elapsed_ms,
|
||||
"lock_wait_ms": lock_wait_ms,
|
||||
"write_ms": write_started.elapsed().as_millis() as u64,
|
||||
"total_ms": started.elapsed().as_millis() as u64,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/// Merge enrichment fields into disk auth. Does not touch token fields.
|
||||
pub(super) fn apply_user_info_enrichment(disk: &mut GrokAuth, user_info: UserInfo) {
|
||||
disk.user_id = user_info.user_id;
|
||||
disk.first_name = user_info.first_name.or(disk.first_name.take());
|
||||
disk.last_name = user_info.last_name.or(disk.last_name.take());
|
||||
disk.profile_image_asset_id = user_info
|
||||
.profile_image_asset_id
|
||||
.or(disk.profile_image_asset_id.take());
|
||||
disk.principal_type = user_info.principal_type.or(disk.principal_type.take());
|
||||
disk.principal_id = user_info.principal_id.or(disk.principal_id.take());
|
||||
disk.team_id = user_info.team_id.or(disk.team_id.take());
|
||||
disk.team_name = user_info.team_name.or(disk.team_name.take());
|
||||
disk.team_role = user_info.team_role.or(disk.team_role.take());
|
||||
disk.organization_id = user_info.organization_id.or(disk.organization_id.take());
|
||||
disk.organization_name = user_info
|
||||
.organization_name
|
||||
.or(disk.organization_name.take());
|
||||
disk.organization_role = user_info
|
||||
.organization_role
|
||||
.or(disk.organization_role.take());
|
||||
disk.user_blocked_reason = user_info
|
||||
.user_blocked_reason
|
||||
.or(disk.user_blocked_reason.take());
|
||||
if let Some(reasons) = user_info.team_blocked_reasons {
|
||||
disk.team_blocked_reasons = reasons;
|
||||
}
|
||||
if let Some(opt_out) = user_info.coding_data_retention_opt_out {
|
||||
disk.coding_data_retention_opt_out = opt_out;
|
||||
}
|
||||
if let Some(ref email) = user_info.email
|
||||
&& !email.is_empty()
|
||||
{
|
||||
disk.email = user_info.email;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
||||
//! System-sleep refresh-straddle mitigation for [`AuthManager`].
|
||||
//!
|
||||
//! A refresh that straddles a suspend can lose its rotated successor token,
|
||||
//! leaving a revoked refresh token on disk and forcing re-login. Two layers
|
||||
//! guard against that straddle:
|
||||
//!
|
||||
//! 1. The gate `refresh_chain` consults *defers* a not-yet-started refresh; an
|
||||
//! in-flight one is never aborted (dropping it could discard a rotated-token
|
||||
//! response — the very revocation we guard against). See
|
||||
//! [`AuthManager::refresh_chain`].
|
||||
//! 2. When sleep becomes imminent and a refresh *is* already in flight,
|
||||
//! [`AuthManager::set_system_sleep_imminent`] briefly **holds the OS sleep
|
||||
//! acknowledgment** (macOS delays `IOAllowPowerChange`; Linux holds its
|
||||
//! `delay` inhibitor — both via the blocking power-listener callback) until
|
||||
//! the refresh drains or [`SLEEP_ACK_MAX_WAIT`] elapses, so the in-flight
|
||||
//! exchange finishes *before* the machine suspends.
|
||||
//!
|
||||
//! Split out of `manager.rs` so the manager stays scannable: this is a
|
||||
//! self-contained unit (the [`SleepGate`] type, the [`InFlightGuard`], and a
|
||||
//! small `impl AuthManager` block driving them from OS power events).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration as StdDuration, Instant, SystemTime};
|
||||
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use super::AuthManager;
|
||||
|
||||
/// Max lifetime of the "system sleep imminent" gate. A wake event normally
|
||||
/// clears it; this is the safety bound so a *missed* wake event can never
|
||||
/// permanently block token refresh. Generous vs. the OS pre-sleep window
|
||||
/// (macOS ~30 s, Linux ~5 s) — it only needs to outlast the sleep transition.
|
||||
pub(super) const SLEEP_GATE_MAX: StdDuration = StdDuration::from_secs(120);
|
||||
|
||||
/// Max time a token refresh may stay deferred for **dark wake** before one is
|
||||
/// forced through, mirroring [`SLEEP_GATE_MAX`]. A normal dark wake lasts
|
||||
/// seconds and recurs interspersed with full wakes, so this rarely fires; it
|
||||
/// rescues a machine that reports a *continuous* dark wake — e.g. an
|
||||
/// interactive Mac with no display, whose system video capability is never set
|
||||
/// — which would otherwise defer every refresh forever and reach the same
|
||||
/// logged-out state this guard prevents. Bounded on two clocks (see
|
||||
/// [`GateRaise`]) so it also survives the machine sleeping between dark wakes.
|
||||
///
|
||||
/// The straddle risk of one forced refresh is far smaller than a guaranteed
|
||||
/// logout: requests only force through while the machine is busy enough to
|
||||
/// issue them (so it is unlikely to re-sleep mid-exchange), and the idle
|
||||
/// proactive loop reaches this at most once per [`BACKOFF_INTERVAL`].
|
||||
///
|
||||
/// [`BACKOFF_INTERVAL`]: super::BACKOFF_INTERVAL
|
||||
pub(super) const DARK_WAKE_DEFER_MAX: StdDuration = StdDuration::from_secs(120);
|
||||
|
||||
/// Upper bound on how long a `WillSleep` transition will hold the OS sleep
|
||||
/// acknowledgment waiting for in-flight IdP refreshes to drain (see
|
||||
/// [`AuthManager::set_system_sleep_imminent`]). Must stay inside the OS
|
||||
/// pre-sleep budgets — macOS allows ~30 s before `IOAllowPowerChange`; Linux
|
||||
/// logind's `InhibitDelayMaxSec` defaults to 5 s — so we pick a value
|
||||
/// comfortably under the smaller (Linux) budget; the inhibitor is released
|
||||
/// before logind force-sleeps regardless. A healthy refresh round-trip is
|
||||
/// ~1 s, so this is slack for a slow network, not the common path. Holding the
|
||||
/// machine awake a few extra seconds is a negligible cost next to the forced
|
||||
/// re-login a straddled refresh causes.
|
||||
pub(super) const SLEEP_ACK_MAX_WAIT: StdDuration = StdDuration::from_secs(3);
|
||||
|
||||
/// When a gate was raised, captured on *two* clocks so the [`SLEEP_GATE_MAX`]
|
||||
/// backstop survives a system sleep.
|
||||
///
|
||||
/// `Instant` is monotonic but, on macOS (`mach_absolute_time`) and Linux
|
||||
/// (`CLOCK_MONOTONIC`), *pauses while the machine is asleep*. A gate raised just
|
||||
/// before a long sleep would therefore never auto-expire on the monotonic clock
|
||||
/// alone — the exact bug that let an expired token reach the server and 401.
|
||||
/// The wall clock (`SystemTime`) keeps advancing through sleep, so we expire the
|
||||
/// gate once *either* clock passes the bound:
|
||||
/// - the monotonic clock bounds elapsed *awake* time (immune to wall-clock
|
||||
/// jumps from NTP / manual changes), and
|
||||
/// - the wall clock bounds elapsed *real* time (immune to the sleep pause).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct GateRaise {
|
||||
/// Monotonic; pauses during sleep. Bounds elapsed *awake* time.
|
||||
pub(super) mono: Instant,
|
||||
/// Wall clock; advances through sleep. Bounds elapsed *real* time.
|
||||
pub(super) wall: SystemTime,
|
||||
}
|
||||
|
||||
impl GateRaise {
|
||||
pub(super) fn now() -> Self {
|
||||
Self {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Elapsed on each clock as `(monotonic, wall)`. Wall-clock elapsed is
|
||||
/// clamped to zero if the clock ran backwards (NTP step / manual change) so
|
||||
/// a backward jump can never *extend* the gate — the monotonic clock still
|
||||
/// bounds it in that case.
|
||||
pub(super) fn elapsed(&self) -> (StdDuration, StdDuration) {
|
||||
(
|
||||
self.mono.elapsed(),
|
||||
self.wall.elapsed().unwrap_or(StdDuration::ZERO),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A gate `refresh_chain` consults to avoid *starting* an IdP refresh just
|
||||
/// before sleep. Only *defers* a not-yet-started refresh; an in-flight one is
|
||||
/// left to finish (see [`AuthManager::refresh_chain`]).
|
||||
#[derive(Default)]
|
||||
pub(super) struct SleepGate {
|
||||
pub(super) raised_at: RwLock<Option<GateRaise>>,
|
||||
}
|
||||
|
||||
impl SleepGate {
|
||||
pub(super) fn raise(&self) {
|
||||
*self.raised_at.write() = Some(GateRaise::now());
|
||||
kigi_log::unified_log::warn("auth.sleep.gate_set", None, None);
|
||||
}
|
||||
|
||||
pub(super) fn lower(&self, reason: &str) {
|
||||
let prev = self.raised_at.write().take();
|
||||
let (mono_ms, wall_ms) = prev
|
||||
.map(|r| {
|
||||
let (mono, wall) = r.elapsed();
|
||||
(mono.as_millis() as u64, wall.as_millis() as u64)
|
||||
})
|
||||
.unwrap_or((0, 0));
|
||||
kigi_log::unified_log::info(
|
||||
"auth.sleep.gate_cleared",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"reason": reason,
|
||||
"was_raised": prev.is_some(),
|
||||
"mono_elapsed_ms": mono_ms,
|
||||
"wall_elapsed_ms": wall_ms,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/// A stale gate (a missed/late wake event) is lazily lowered here so it can
|
||||
/// never permanently block refresh; this read can therefore have a side
|
||||
/// effect. The gate expires once *either* clock passes [`SLEEP_GATE_MAX`]
|
||||
/// (see [`GateRaise`]): without the wall-clock arm, a gate raised before a
|
||||
/// long sleep would never auto-expire, because the monotonic clock pauses
|
||||
/// while the machine is asleep.
|
||||
pub(super) fn is_gated(&self) -> bool {
|
||||
// Copy out so the read guard drops before the write lock below
|
||||
// (parking_lot is not reentrant).
|
||||
let raised_at = *self.raised_at.read();
|
||||
let Some(raise) = raised_at else {
|
||||
return false;
|
||||
};
|
||||
let (mono, wall) = raise.elapsed();
|
||||
if mono < SLEEP_GATE_MAX && wall < SLEEP_GATE_MAX {
|
||||
return true;
|
||||
}
|
||||
// Stale gate (missed/late wake). `sleep_straddle` = the monotonic clock
|
||||
// is still under the bound but real (wall-clock) time is not: the
|
||||
// machine slept through the gate without delivering a wake event. This
|
||||
// is precisely the case the wall-clock arm was added to catch, so
|
||||
// surface it explicitly to confirm the fix firing in the field.
|
||||
let sleep_straddle = mono < SLEEP_GATE_MAX;
|
||||
*self.raised_at.write() = None;
|
||||
kigi_log::unified_log::info(
|
||||
"auth.sleep.gate_cleared",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"reason": "auto_expiry",
|
||||
"sleep_straddle": sleep_straddle,
|
||||
"mono_elapsed_ms": mono.as_millis() as u64,
|
||||
"wall_elapsed_ms": wall.as_millis() as u64,
|
||||
})),
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII counter for in-flight IdP refreshes. Increments on construction and
|
||||
/// decrements on drop so the count stays balanced even if the refresh future is
|
||||
/// cancelled or panics. When the count returns to zero it wakes any
|
||||
/// sleep-imminent waiter parked in
|
||||
/// [`AuthManager::hold_sleep_ack_until_refresh_drains`].
|
||||
pub(super) struct InFlightGuard<'a>(&'a AuthManager);
|
||||
|
||||
impl<'a> InFlightGuard<'a> {
|
||||
pub(super) fn new(mgr: &'a AuthManager) -> Self {
|
||||
mgr.begin_refresh_in_flight();
|
||||
Self(mgr)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InFlightGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.end_refresh_in_flight();
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthManager {
|
||||
/// Report a system power transition (`true` = sleep imminent, `false` =
|
||||
/// woke). Safe to call from any thread.
|
||||
pub(crate) fn set_system_sleep_imminent(&self, imminent: bool) {
|
||||
if imminent {
|
||||
// Raise the gate first so a refresh that re-checks it right before
|
||||
// its IdP call (see `refresh_chain`) backs out instead of starting
|
||||
// into the suspend window. Then hold the OS sleep acknowledgment
|
||||
// until any refresh already in flight drains, so it can finish
|
||||
// before the machine suspends rather than straddling it.
|
||||
self.sleep_gate.raise();
|
||||
self.hold_sleep_ack_until_refresh_drains(SLEEP_ACK_MAX_WAIT);
|
||||
} else {
|
||||
self.sleep_gate.lower("wake");
|
||||
// End any in-progress dark-wake deferral run on a *genuine* full
|
||||
// wake so the next dark wake starts with a fresh budget — but only
|
||||
// if we are not still in a dark wake. macOS delivers
|
||||
// `SYSTEM_HAS_POWERED_ON` (→ `DidWake`) for dark wakes too;
|
||||
// unconditionally clearing here would reset the
|
||||
// `DARK_WAKE_DEFER_MAX` budget on every dark-wake cycle so it could
|
||||
// never exhaust, and the forced refresh would never run on a machine
|
||||
// stuck in continuous dark wake. (`should_defer_for_dark_wake` also
|
||||
// clears lazily under the same `!is_dark_wake()` condition.)
|
||||
if !self.is_dark_wake() {
|
||||
*self.dark_wake_defer_since.write() = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark an IdP refresh as starting. Paired with [`Self::end_refresh_in_flight`]
|
||||
/// via [`InFlightGuard`]; see [`Self::hold_sleep_ack_until_refresh_drains`].
|
||||
fn begin_refresh_in_flight(&self) {
|
||||
self.refresh_in_flight.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Mark an IdP refresh as finished. When the count returns to zero, wake any
|
||||
/// sleep-ack waiter under the same lock it parks on, so a held OS sleep ack
|
||||
/// is released the moment the exchange finishes rather than after the full
|
||||
/// timeout. `fetch_sub` returns the *previous* value, so `== 1` is the
|
||||
/// drop-to-zero edge. Notifying with no waiter parked is cheap and harmless.
|
||||
fn end_refresh_in_flight(&self) {
|
||||
if self.refresh_in_flight.fetch_sub(1, Ordering::SeqCst) == 1 {
|
||||
let _drain = self.refresh_drain_lock.lock();
|
||||
self.refresh_drain_cv.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Block the calling thread — the OS power-listener callback, so this
|
||||
/// delays the macOS `IOAllowPowerChange` ack / Linux `delay`-inhibitor
|
||||
/// release — until in-flight IdP refreshes drain or `max` elapses.
|
||||
///
|
||||
/// A refresh already on the wire when sleep is requested would otherwise
|
||||
/// straddle the suspend and, on a long sleep, lose its rotated successor
|
||||
/// token — revoking the refresh-token family and forcing re-login. We never
|
||||
/// abort the refresh; we briefly delay the suspend so it can finish first.
|
||||
///
|
||||
/// Bounded by `max` (see [`SLEEP_ACK_MAX_WAIT`]) so a hung refresh can't
|
||||
/// hold the machine awake past the OS pre-sleep budget: on timeout the
|
||||
/// suspend proceeds and the in-flight refresh is left to finish (a resulting
|
||||
/// straddle is surfaced by `auth.refresh.suspend_spanned`).
|
||||
fn hold_sleep_ack_until_refresh_drains(&self, max: StdDuration) {
|
||||
let in_flight = self.refresh_in_flight.load(Ordering::SeqCst);
|
||||
if in_flight == 0 {
|
||||
return;
|
||||
}
|
||||
kigi_log::unified_log::warn(
|
||||
"auth.sleep.refresh_in_flight_at_suspend",
|
||||
None,
|
||||
Some(serde_json::json!({ "in_flight": in_flight })),
|
||||
);
|
||||
let started = Instant::now();
|
||||
{
|
||||
let mut drain = self.refresh_drain_lock.lock();
|
||||
// Loop on the atomic (the authoritative predicate) under the lock so
|
||||
// a notify that races the park — or a spurious wake — can neither
|
||||
// lose the signal nor over-wait. `InFlightGuard::drop` notifies when
|
||||
// the count hits zero.
|
||||
while self.refresh_in_flight.load(Ordering::SeqCst) > 0 {
|
||||
let Some(remaining) = max.checked_sub(started.elapsed()) else {
|
||||
break;
|
||||
};
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
let _ = self.refresh_drain_cv.wait_for(&mut drain, remaining);
|
||||
}
|
||||
}
|
||||
let remaining = self.refresh_in_flight.load(Ordering::SeqCst);
|
||||
kigi_log::unified_log::info(
|
||||
"auth.sleep.refresh_drain",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"in_flight_at_start": in_flight,
|
||||
"in_flight_remaining": remaining,
|
||||
"drained": remaining == 0,
|
||||
"waited_ms": started.elapsed().as_millis() as u64,
|
||||
"max_wait_ms": max.as_millis() as u64,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn is_sleep_gated(&self) -> bool {
|
||||
self.sleep_gate.is_gated()
|
||||
}
|
||||
|
||||
/// Whether the system is currently in a **dark wake** (see
|
||||
/// [`kigi_system_power::PowerState`] for the canonical explanation of what a
|
||||
/// dark wake is and why an IdP refresh must avoid one). `refresh_chain`
|
||||
/// gates on [`Self::should_defer_for_dark_wake`], which wraps this with a
|
||||
/// deferral bound.
|
||||
///
|
||||
/// Scoped to processes that actively listen for power events (local /
|
||||
/// interactive): if the OS power listener was never started
|
||||
/// (headless / datacenter), we skip the query — both because dark wake is
|
||||
/// not a concern there and because a screenless Mac can read as a permanent
|
||||
/// dark wake (no video capability), which would otherwise wedge refresh.
|
||||
pub(crate) fn is_dark_wake(&self) -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(forced) = *self.dark_wake_override.lock() {
|
||||
return forced;
|
||||
}
|
||||
if !self.power_listener_started.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
kigi_system_power::current_power_state(),
|
||||
kigi_system_power::PowerState::DarkWake
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `refresh_chain` should defer this refresh because the system is
|
||||
/// in a dark wake — bounded so deferral can never be indefinite.
|
||||
///
|
||||
/// Tracks when the current unbroken run of dark-wake deferrals began (on two
|
||||
/// clocks; see [`GateRaise`]). While inside the [`DARK_WAKE_DEFER_MAX`]
|
||||
/// budget it returns `true` (defer). Once either clock passes the bound it
|
||||
/// forces one refresh through (`false`) and resets the clock, so a machine
|
||||
/// stuck reporting a continuous dark wake refreshes periodically instead of
|
||||
/// deferring forever and logging the user out. A full wake clears the run
|
||||
/// (here, or eagerly in [`Self::set_system_sleep_imminent`]).
|
||||
pub(crate) fn should_defer_for_dark_wake(&self) -> bool {
|
||||
if !self.is_dark_wake() {
|
||||
// Full wake (or no signal): end any deferral run in progress.
|
||||
if self.dark_wake_defer_since.read().is_some() {
|
||||
*self.dark_wake_defer_since.write() = None;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let Some(raise) = *self.dark_wake_defer_since.read() else {
|
||||
// First deferral of this dark-wake run: start the budget clock.
|
||||
*self.dark_wake_defer_since.write() = Some(GateRaise::now());
|
||||
return true;
|
||||
};
|
||||
let (mono, wall) = raise.elapsed();
|
||||
if mono < DARK_WAKE_DEFER_MAX && wall < DARK_WAKE_DEFER_MAX {
|
||||
return true;
|
||||
}
|
||||
// Budget exhausted: force this refresh through and reset the clock so a
|
||||
// still-continuous dark wake defers afresh (up to DARK_WAKE_DEFER_MAX)
|
||||
// before the next forced refresh, rather than abandoning deferral
|
||||
// entirely.
|
||||
*self.dark_wake_defer_since.write() = None;
|
||||
kigi_log::unified_log::warn(
|
||||
"auth.dark_wake.defer_budget_exhausted",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"mono_elapsed_ms": mono.as_millis() as u64,
|
||||
"wall_elapsed_ms": wall.as_millis() as u64,
|
||||
})),
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
/// Force the [`AuthManager::is_dark_wake`] result in tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_dark_wake_for_test(&self, dark: bool) {
|
||||
*self.dark_wake_override.lock() = Some(dark);
|
||||
}
|
||||
|
||||
/// Test hook: simulate an IdP refresh entering flight (mirrors
|
||||
/// [`InFlightGuard::new`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_enter_refresh_in_flight(&self) {
|
||||
self.begin_refresh_in_flight();
|
||||
}
|
||||
|
||||
/// Test hook: simulate an in-flight IdP refresh finishing (mirrors
|
||||
/// [`InFlightGuard`]'s drop), waking a sleep-ack waiter.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_exit_refresh_in_flight(&self) {
|
||||
self.end_refresh_in_flight();
|
||||
}
|
||||
|
||||
/// Test hook: run the bounded sleep-ack hold directly so tests can pass a
|
||||
/// short bound instead of [`SLEEP_ACK_MAX_WAIT`].
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_hold_sleep_ack(&self, max: StdDuration) {
|
||||
self.hold_sleep_ack_until_refresh_drains(max);
|
||||
}
|
||||
|
||||
/// Start the OS power listener so sleep/wake drives the gate. Idempotent and
|
||||
/// a no-op where the listener is unavailable. Call only from local /
|
||||
/// interactive entrypoints, never datacenter server/headless.
|
||||
pub fn start_system_power_listener(self: &Arc<Self>) {
|
||||
// Claim the one-time startup so concurrent/duplicate calls don't
|
||||
// double-register.
|
||||
if self
|
||||
.power_listener_started
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Weak ref to avoid a manager <-> listener Arc cycle.
|
||||
let weak = Arc::downgrade(self);
|
||||
let listener = kigi_system_power::SystemPowerListener::start(move |event| {
|
||||
if let Some(this) = weak.upgrade() {
|
||||
let imminent = matches!(event, kigi_system_power::PowerEvent::WillSleep);
|
||||
this.set_system_sleep_imminent(imminent);
|
||||
}
|
||||
});
|
||||
let available = listener.is_some();
|
||||
if available {
|
||||
*self.power_listener.lock() = listener;
|
||||
} else {
|
||||
// Unavailable (unsupported OS / no logind / registration failure):
|
||||
// release the guard so a later call can retry rather than being
|
||||
// permanently no-op'd for this manager.
|
||||
self.power_listener_started.store(false, Ordering::Release);
|
||||
}
|
||||
kigi_log::unified_log::info(
|
||||
"auth.sleep.power_listener_init",
|
||||
None,
|
||||
Some(serde_json::json!({ "available": available })),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Access gate from `grok_build_access_gate`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GateInfo {
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Typed auth metadata passed from the shell to the pager via ACP.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AuthMeta {
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub auth_mode: Option<String>,
|
||||
/// Team principal UUID when the session is a team login (`None` for personal).
|
||||
#[serde(default)]
|
||||
pub team_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub team_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_zdr: bool,
|
||||
#[serde(default)]
|
||||
pub team_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub coding_data_retention_opt_out: bool,
|
||||
#[serde(default)]
|
||||
pub show_resolved_model: Option<bool>,
|
||||
/// `Some` = user is blocked; `None` = user has access.
|
||||
#[serde(default)]
|
||||
pub gate: Option<GateInfo>,
|
||||
/// User-friendly display name for the current subscription tier
|
||||
/// (e.g. "SuperGrok Heavy", "X Premium", "Free"). From CCP `/settings`.
|
||||
#[serde(default)]
|
||||
pub subscription_tier: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
pub(crate) mod attribution;
|
||||
mod config;
|
||||
pub mod credential_provider;
|
||||
#[path = "devbox_login_stub.rs"]
|
||||
pub(crate) mod devbox_login;
|
||||
pub mod device_code;
|
||||
pub mod error;
|
||||
mod external_auth;
|
||||
mod flow;
|
||||
mod jwt;
|
||||
pub(crate) mod manager;
|
||||
mod model;
|
||||
pub mod oidc;
|
||||
pub(crate) mod recovery;
|
||||
pub(crate) mod refresh;
|
||||
mod storage;
|
||||
pub(crate) mod token_type;
|
||||
pub(crate) use config::LEGACY_AUTH_SCOPE;
|
||||
pub use config::{
|
||||
ForceLoginTeam, GrokComConfig, OAuth2ProviderConfig, OidcAuthConfig, PreferredAuthMethod,
|
||||
XAI_OAUTH2_ISSUER, is_xai_oauth2_issuer, xai_oauth2_issuer,
|
||||
};
|
||||
pub(crate) use external_auth::{parse_output, refresh_with_command};
|
||||
pub(crate) use flow::{
|
||||
AuthChannels, run_auth_flow, run_auth_flow_with_stderr_bridge,
|
||||
try_ensure_session_noninteractive,
|
||||
};
|
||||
pub use flow::{
|
||||
AuthUrlInfo, AuthUrlMode, LoginTransportOverride, LogoutResult, ensure_authenticated,
|
||||
ensure_authenticated_or_noninteractive, ensure_authenticated_with_override, perform_logout,
|
||||
run_cli_login, run_cli_logout, try_ensure_fresh_auth,
|
||||
};
|
||||
pub use jwt::{is_jwt_expired_or_near, parse_jwt_expiration};
|
||||
mod meta;
|
||||
pub use error::{AuthError, RefreshTokenError, RefreshTokenFailedReason};
|
||||
pub use manager::{AuthManager, shared_api_key_provider};
|
||||
pub use meta::{AuthMeta, GateInfo};
|
||||
pub use model::{AuthMode, GrokAuth, lookup_auth};
|
||||
pub(crate) use model::{TOKEN_TTL, UserInfo, is_expired, token_suffix};
|
||||
pub use storage::{
|
||||
clear_api_key, read_api_key, read_auth_json, read_token_by_scope, store_api_key,
|
||||
};
|
||||
@@ -0,0 +1,489 @@
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::is_xai_oauth2_issuer;
|
||||
|
||||
pub(crate) const TOKEN_TTL: Duration = Duration::days(30);
|
||||
const DEFAULT_EARLY_INVALIDATION_SECS: u64 = 300; // 5 minutes
|
||||
|
||||
/// Legacy auth.json scope key. Fallback for old devbox auth files.
|
||||
pub(super) const LEGACY_SCOPE: &str = "https://accounts.x.ai/sign-in";
|
||||
|
||||
/// auth.json scope key for plain API key auth (desktop login, `grok login --api-key`).
|
||||
pub const API_KEY_SCOPE: &str = "xai::api_key";
|
||||
|
||||
const BLOCKED_REASON_NO_LOGS: &str = "BLOCKED_REASON_NO_LOGS";
|
||||
const BLOCKED_REASON_NO_LOGS_MODERATED: &str = "BLOCKED_REASON_NO_LOGS_MODERATED";
|
||||
|
||||
/// Token provenance (debugging/auth.json only -- no code branches on this).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthMode {
|
||||
/// Deprecated. Kept for deserializing old auth.json files.
|
||||
#[serde(alias = "grok")]
|
||||
WebLogin,
|
||||
/// OIDC or OAuth2 interactive login via customer IdP
|
||||
#[serde(alias = "oidc")]
|
||||
Oidc,
|
||||
/// External auth provider binary
|
||||
External,
|
||||
/// Plain API key (e.g. from grok-desktop login or `grok login --api-key`)
|
||||
ApiKey,
|
||||
}
|
||||
|
||||
/// Wire value of `principal_type` for team OAuth principals (capitalized by
|
||||
/// the auth service). Single source for every comparison site.
|
||||
pub(crate) const TEAM_PRINCIPAL_TYPE: &str = "Team";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct GrokAuth {
|
||||
pub key: String,
|
||||
pub auth_mode: AuthMode,
|
||||
pub create_time: DateTime<Utc>,
|
||||
pub user_id: String,
|
||||
pub email: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub first_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_image_asset_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub principal_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub principal_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub team_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub team_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub team_role: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub organization_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub organization_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub organization_role: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user_blocked_reason: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub team_blocked_reasons: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub coding_data_retention_opt_out: bool,
|
||||
|
||||
/// Deprecated. Kept for deserializing existing auth.json files.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub has_grok_code_access: Option<bool>,
|
||||
|
||||
/// Refresh token (OIDC/OAuth2 or external provider).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
|
||||
/// Server-provided expiration (from OIDC `expires_in`).
|
||||
/// When present, takes precedence over the hardcoded `TOKEN_TTL`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
|
||||
/// Issuer URL that issued this token. For OIDC credentials it drives
|
||||
/// refresh via discovery; for external-provider credentials it is the
|
||||
/// provider's `issuer` claim. In both modes an x.ai issuer marks the
|
||||
/// credential first-party (`is_xai_auth`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oidc_issuer: Option<String>,
|
||||
|
||||
/// OIDC client_id used to obtain this token (needed for refresh).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oidc_client_id: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GrokAuth {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GrokAuth")
|
||||
.field("key", &token_suffix(&self.key))
|
||||
.field("auth_mode", &self.auth_mode)
|
||||
.field("user_id", &self.user_id)
|
||||
.field("expires_at", &self.expires_at)
|
||||
.field(
|
||||
"refresh_token",
|
||||
&self.refresh_token.as_deref().map(token_suffix),
|
||||
)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl GrokAuth {
|
||||
/// Seconds since this credential was minted. Negative when the local
|
||||
/// clock stepped back past `create_time` (NTP correction, VM restore, or
|
||||
/// a sibling machine's clock via an adopted auth.json) — `create_time`
|
||||
/// is always stamped from the minting machine's local clock.
|
||||
pub(crate) fn mint_age_seconds(&self) -> i64 {
|
||||
Utc::now()
|
||||
.signed_duration_since(self.create_time)
|
||||
.num_seconds()
|
||||
}
|
||||
|
||||
/// `true` when the token comes from a first-party xAI account —
|
||||
/// either an OIDC login against https://auth.x.ai (or the local-dev
|
||||
/// equivalent), or an external auth provider that declared an xAI
|
||||
/// issuer for its token.
|
||||
///
|
||||
/// The issuer is a client-side hint, not a trust assertion: everything
|
||||
/// it unlocks still authenticates the actual token server-side, and it
|
||||
/// never influences endpoints.
|
||||
pub fn is_xai_auth(&self) -> bool {
|
||||
match self.auth_mode {
|
||||
AuthMode::Oidc | AuthMode::External => self
|
||||
.oidc_issuer
|
||||
.as_deref()
|
||||
.is_some_and(is_xai_oauth2_issuer),
|
||||
AuthMode::ApiKey | AuthMode::WebLogin => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when this auth can access grok.com managed MCP connectors.
|
||||
pub fn is_managed_mcp_eligible(&self) -> bool {
|
||||
self.is_xai_auth() || self.auth_mode == AuthMode::WebLogin
|
||||
}
|
||||
|
||||
/// Whether this credential can access `supported_in_api: false` models.
|
||||
///
|
||||
/// Session logins (WebLogin, OIDC — including enterprise issuers) always
|
||||
/// qualify; external-provider credentials qualify only when first-party
|
||||
/// (`is_xai_auth`), matching the built-in devbox login they replace.
|
||||
/// Plain API keys never do.
|
||||
pub fn is_session_auth(&self) -> bool {
|
||||
match self.auth_mode {
|
||||
AuthMode::WebLogin | AuthMode::Oidc => true,
|
||||
AuthMode::External => self.is_xai_auth(),
|
||||
AuthMode::ApiKey => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_team_principal(&self) -> bool {
|
||||
self.principal_type.as_deref() == Some(TEAM_PRINCIPAL_TYPE) && self.team_id.is_some()
|
||||
}
|
||||
|
||||
/// `true` when the team has Zero Data Retention (ZDR) enabled.
|
||||
pub fn is_zdr_team(&self) -> bool {
|
||||
self.team_blocked_reasons
|
||||
.iter()
|
||||
.any(|r| r == BLOCKED_REASON_NO_LOGS || r == BLOCKED_REASON_NO_LOGS_MODERATED)
|
||||
}
|
||||
|
||||
/// `true` when the team has ZDR or the user opted out of coding data
|
||||
/// retention. Use this for trace-upload and research-data gates.
|
||||
/// Product analytics (`telemetry_enabled`) and user-facing sync
|
||||
/// features should use `is_zdr_team()` directly.
|
||||
pub fn is_data_collection_disabled(&self) -> bool {
|
||||
self.is_zdr_team() || self.coding_data_retention_opt_out
|
||||
}
|
||||
|
||||
/// Carry `/user`-derived fields from a previous auth so refresh rebuilds don't drop them.
|
||||
pub(crate) fn carry_user_profile_from(&mut self, prev: &GrokAuth) {
|
||||
self.user_id = prev.user_id.clone();
|
||||
self.email = prev.email.clone();
|
||||
self.principal_type = prev.principal_type.clone();
|
||||
self.principal_id = prev.principal_id.clone();
|
||||
self.team_id = prev.team_id.clone();
|
||||
self.team_name = prev.team_name.clone();
|
||||
self.team_role = prev.team_role.clone();
|
||||
self.organization_id = prev.organization_id.clone();
|
||||
self.organization_name = prev.organization_name.clone();
|
||||
self.organization_role = prev.organization_role.clone();
|
||||
self.user_blocked_reason = prev.user_blocked_reason.clone();
|
||||
self.team_blocked_reasons = prev.team_blocked_reasons.clone();
|
||||
self.coding_data_retention_opt_out = prev.coding_data_retention_opt_out;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GrokAuth {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
key: String::new(),
|
||||
auth_mode: AuthMode::Oidc,
|
||||
create_time: Utc::now(),
|
||||
user_id: String::new(),
|
||||
email: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
profile_image_asset_id: None,
|
||||
principal_type: None,
|
||||
principal_id: None,
|
||||
team_id: None,
|
||||
team_name: None,
|
||||
team_role: None,
|
||||
organization_id: None,
|
||||
organization_name: None,
|
||||
organization_role: None,
|
||||
user_blocked_reason: None,
|
||||
team_blocked_reasons: vec![],
|
||||
coding_data_retention_opt_out: false,
|
||||
has_grok_code_access: None,
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
oidc_issuer: None,
|
||||
oidc_client_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl GrokAuth {
|
||||
/// Returns a `GrokAuth` with sensible defaults for tests. Override fields
|
||||
/// with struct update syntax:
|
||||
/// ```ignore
|
||||
/// GrokAuth { key: "my-key".into(), ..GrokAuth::test_default() }
|
||||
/// ```
|
||||
pub fn test_default() -> Self {
|
||||
Self {
|
||||
key: "test-key".into(),
|
||||
user_id: "test-user".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type AuthStore = BTreeMap<String, GrokAuth>;
|
||||
|
||||
/// User information from the cli-chat-proxy `GET /v1/user` endpoint.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UserInfo {
|
||||
pub(crate) user_id: String,
|
||||
#[serde(default)]
|
||||
pub(super) email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) first_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) last_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) profile_image_asset_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) principal_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) principal_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) team_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) team_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) team_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) organization_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) organization_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) organization_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) user_blocked_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) team_blocked_reasons: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub(super) coding_data_retention_opt_out: Option<bool>,
|
||||
/// Live subscription tier from the backend (only present when
|
||||
/// `?include=subscription` is passed to `/user`).
|
||||
#[serde(default)]
|
||||
pub(crate) subscription_tier: Option<String>,
|
||||
}
|
||||
|
||||
/// Last 12 chars of a token string, safe for diagnostic logging.
|
||||
/// Uses the tail because JWT access tokens all share the same base64
|
||||
/// header prefix (`eyJ0eXAiOiJh…`); the tail (signature bytes) is
|
||||
/// unique per token and makes `key_changed` / `is_stale_snapshot`
|
||||
/// diagnostics meaningful.
|
||||
pub(crate) fn token_suffix(t: &str) -> &str {
|
||||
let len = t.len();
|
||||
if len > 12 { &t[len - 12..] } else { t }
|
||||
}
|
||||
|
||||
/// Look up auth from the store by scope key.
|
||||
///
|
||||
/// Legacy `WebLogin` tokens (from the pre-OIDC `grok login --legacy`
|
||||
/// flow) are skipped — they are validated via a per-request DB lookup
|
||||
/// server-side which fails at high volume. Skipping them here forces
|
||||
/// affected users to re-authenticate via OIDC on next launch.
|
||||
pub fn lookup_auth(map: &AuthStore, scope: &str) -> Option<GrokAuth> {
|
||||
let auth = map.get(scope).cloned().or_else(|| {
|
||||
if scope == LEGACY_SCOPE {
|
||||
None
|
||||
} else {
|
||||
map.get(LEGACY_SCOPE).cloned()
|
||||
}
|
||||
})?;
|
||||
if auth.auth_mode == AuthMode::WebLogin {
|
||||
tracing::info!("auth: ignoring legacy WebLogin token — re-authentication required");
|
||||
return None;
|
||||
}
|
||||
Some(auth)
|
||||
}
|
||||
|
||||
/// Early-invalidation buffer. Override with `KIGI_AUTH_EARLY_INVALIDATION_SECS`
|
||||
/// for testing (e.g. `=5` to shrink the buffer to 5 seconds).
|
||||
pub(super) fn early_invalidation() -> Duration {
|
||||
std::env::var("KIGI_AUTH_EARLY_INVALIDATION_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(|s| Duration::seconds(s as i64))
|
||||
.unwrap_or_else(|| Duration::seconds(DEFAULT_EARLY_INVALIDATION_SECS as i64))
|
||||
}
|
||||
|
||||
pub(crate) fn is_expired(auth: &GrokAuth) -> bool {
|
||||
is_expired_with_buffer(auth, early_invalidation())
|
||||
}
|
||||
|
||||
/// Like [`is_expired`] but with an explicit pre-expiry buffer. Pass
|
||||
/// `Duration::zero()` for actual (hard) expiry — the instant the token would
|
||||
/// really be rejected on the wire, with no early-invalidation margin.
|
||||
pub(crate) fn is_expired_with_buffer(auth: &GrokAuth, buffer: Duration) -> bool {
|
||||
if let Some(expires_at) = auth.expires_at {
|
||||
Utc::now() >= (expires_at - buffer)
|
||||
} else {
|
||||
let age = Utc::now().signed_duration_since(auth.create_time);
|
||||
age >= (TOKEN_TTL - buffer)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_auth(mode: AuthMode) -> GrokAuth {
|
||||
GrokAuth {
|
||||
key: "k".into(),
|
||||
auth_mode: mode,
|
||||
create_time: Utc::now(),
|
||||
user_id: "u".into(),
|
||||
email: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
profile_image_asset_id: None,
|
||||
principal_type: None,
|
||||
principal_id: None,
|
||||
team_id: None,
|
||||
team_name: None,
|
||||
team_role: None,
|
||||
organization_id: None,
|
||||
organization_name: None,
|
||||
organization_role: None,
|
||||
user_blocked_reason: None,
|
||||
team_blocked_reasons: vec![],
|
||||
coding_data_retention_opt_out: false,
|
||||
has_grok_code_access: None,
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
oidc_issuer: None,
|
||||
oidc_client_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_xai_auth_matrix() {
|
||||
use crate::auth::XAI_OAUTH2_ISSUER;
|
||||
let with_issuer = |mode: AuthMode, issuer: Option<&str>| GrokAuth {
|
||||
oidc_issuer: issuer.map(str::to_owned),
|
||||
..make_auth(mode)
|
||||
};
|
||||
|
||||
// Only Oidc/External qualify, and only with an x.ai issuer.
|
||||
assert!(with_issuer(AuthMode::Oidc, Some(XAI_OAUTH2_ISSUER)).is_xai_auth());
|
||||
assert!(with_issuer(AuthMode::External, Some(XAI_OAUTH2_ISSUER)).is_xai_auth());
|
||||
assert!(!with_issuer(AuthMode::Oidc, None).is_xai_auth());
|
||||
assert!(!with_issuer(AuthMode::External, None).is_xai_auth());
|
||||
assert!(!with_issuer(AuthMode::Oidc, Some("https://idp.acme.example")).is_xai_auth());
|
||||
assert!(!with_issuer(AuthMode::External, Some("https://idp.acme.example")).is_xai_auth());
|
||||
|
||||
// ApiKey / WebLogin stay false even with an x.ai issuer set.
|
||||
assert!(!with_issuer(AuthMode::ApiKey, Some(XAI_OAUTH2_ISSUER)).is_xai_auth());
|
||||
assert!(!with_issuer(AuthMode::WebLogin, Some(XAI_OAUTH2_ISSUER)).is_xai_auth());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_session_auth_requires_first_party_for_external() {
|
||||
use crate::auth::XAI_OAUTH2_ISSUER;
|
||||
let with_issuer = |mode: AuthMode, issuer: Option<&str>| GrokAuth {
|
||||
oidc_issuer: issuer.map(str::to_owned),
|
||||
..make_auth(mode)
|
||||
};
|
||||
|
||||
// Session logins qualify regardless of issuer (incl. enterprise OIDC).
|
||||
assert!(with_issuer(AuthMode::WebLogin, None).is_session_auth());
|
||||
assert!(with_issuer(AuthMode::Oidc, None).is_session_auth());
|
||||
assert!(with_issuer(AuthMode::Oidc, Some("https://idp.acme.example")).is_session_auth());
|
||||
|
||||
// External qualifies only when first-party (devbox-login parity).
|
||||
assert!(with_issuer(AuthMode::External, Some(XAI_OAUTH2_ISSUER)).is_session_auth());
|
||||
assert!(!with_issuer(AuthMode::External, None).is_session_auth());
|
||||
assert!(
|
||||
!with_issuer(AuthMode::External, Some("https://idp.acme.example")).is_session_auth()
|
||||
);
|
||||
|
||||
// Plain API keys never do.
|
||||
assert!(!with_issuer(AuthMode::ApiKey, Some(XAI_OAUTH2_ISSUER)).is_session_auth());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_auth_skips_weblogin_on_primary_scope() {
|
||||
let mut map = AuthStore::new();
|
||||
map.insert("scope".into(), make_auth(AuthMode::WebLogin));
|
||||
assert!(lookup_auth(&map, "scope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_auth_skips_weblogin_on_legacy_fallback() {
|
||||
let mut map = AuthStore::new();
|
||||
map.insert(LEGACY_SCOPE.into(), make_auth(AuthMode::WebLogin));
|
||||
assert!(lookup_auth(&map, "other-scope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_auth_returns_oidc_token() {
|
||||
let mut map = AuthStore::new();
|
||||
map.insert("scope".into(), make_auth(AuthMode::Oidc));
|
||||
assert!(lookup_auth(&map, "scope").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_auth_returns_api_key_token() {
|
||||
let mut map = AuthStore::new();
|
||||
map.insert("scope".into(), make_auth(AuthMode::ApiKey));
|
||||
assert!(lookup_auth(&map, "scope").is_some());
|
||||
}
|
||||
|
||||
/// subscriptionTier present → deserializes to Some.
|
||||
#[test]
|
||||
fn user_info_subscription_tier_present() {
|
||||
let json = r#"{
|
||||
"userId": "u1",
|
||||
"subscriptionTier": "SuperGrokPro"
|
||||
}"#;
|
||||
let info: UserInfo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info.subscription_tier.as_deref(), Some("SuperGrokPro"));
|
||||
}
|
||||
|
||||
/// subscriptionTier absent → deserializes to None (backwards compat).
|
||||
#[test]
|
||||
fn user_info_subscription_tier_absent() {
|
||||
let json = r#"{"userId": "u1"}"#;
|
||||
let info: UserInfo = serde_json::from_str(json).unwrap();
|
||||
assert!(info.subscription_tier.is_none());
|
||||
}
|
||||
|
||||
/// subscriptionTier null → deserializes to None.
|
||||
#[test]
|
||||
fn user_info_subscription_tier_null() {
|
||||
let json = r#"{"userId": "u1", "subscriptionTier": null}"#;
|
||||
let info: UserInfo = serde_json::from_str(json).unwrap();
|
||||
assert!(info.subscription_tier.is_none());
|
||||
}
|
||||
|
||||
/// subscriptionTier empty string → deserializes to Some("").
|
||||
/// The paywall poller treats this as "no subscription" (line 230:
|
||||
/// `Some(tier) if !tier.is_empty()`) and keeps polling.
|
||||
#[test]
|
||||
fn user_info_subscription_tier_empty_string() {
|
||||
let json = r#"{"userId": "u1", "subscriptionTier": ""}"#;
|
||||
let info: UserInfo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info.subscription_tier.as_deref(), Some(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
//! Interactive login orchestration: callback HTTP server, browser
|
||||
//! handoff, stdin paste fallback, race between the two.
|
||||
//!
|
||||
//! Cross-references [`super::protocol`] for OIDC mechanics and
|
||||
//! [`super::super::AuthManager`] for credential persistence.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Query, State},
|
||||
http::{Method, StatusCode},
|
||||
response::Html,
|
||||
routing::get,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use super::super::config::{GrokComConfig, OidcAuthConfig};
|
||||
use super::super::{AuthManager, GrokAuth};
|
||||
use super::protocol::{
|
||||
OidcError, build_authorize_url, build_grok_auth, discover, enforce_login_principal,
|
||||
exchange_code, extract_user_info, generate_pkce, login_principal_policy,
|
||||
peek_access_token_principal, peek_access_token_principal_id, validate_state,
|
||||
};
|
||||
|
||||
/// Maximum time to wait for the browser OAuth callback (or manual paste of the code).
|
||||
/// 10 minutes is long enough for users who step away briefly during login.
|
||||
const AUTH_CALLBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
/// Parse user-pasted input into `(code, state)`.
|
||||
///
|
||||
/// Accepts two formats:
|
||||
/// 1. Full callback URL: `http://127.0.0.1:PORT/callback?code=XXX&state=YYY`
|
||||
/// 2. Bare authorization code: `abc123`
|
||||
fn parse_pasted_input(input: &str) -> Result<Callback, OidcError> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Err(OidcError::InvalidPastedInput("empty input".into()));
|
||||
}
|
||||
|
||||
if let Ok(url) = url::Url::parse(input) {
|
||||
let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
|
||||
if let Some(code) = params.get("code") {
|
||||
let state = params.get("state").cloned().unwrap_or_default();
|
||||
return Ok(Callback {
|
||||
code: code.clone(),
|
||||
state,
|
||||
});
|
||||
}
|
||||
if let Some(error) = params.get("error") {
|
||||
let desc = params.get("error_description").cloned().unwrap_or_default();
|
||||
return Err(OidcError::CallbackAuthFailed(if desc.is_empty() {
|
||||
error.clone()
|
||||
} else {
|
||||
format!("{error}: {desc}")
|
||||
}));
|
||||
}
|
||||
return Err(OidcError::InvalidPastedInput(
|
||||
"URL has no 'code' query parameter".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Callback {
|
||||
code: input.to_owned(),
|
||||
state: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Render a styled callback page shown in the browser after the OAuth redirect.
|
||||
pub(crate) fn callback_page(title: &str, message: &str, is_success: bool) -> String {
|
||||
let icon = if is_success {
|
||||
// Grok logo
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 33 33"><path fill="currentColor" d="m13.237 21.04 11.082-8.19c.543-.4 1.32-.244 1.578.38 1.363 3.288.754 7.241-1.957 9.955-2.71 2.714-6.482 3.31-9.93 1.954l-3.765 1.745c5.401 3.697 11.96 2.782 16.059-1.324 3.251-3.255 4.258-7.692 3.317-11.693l.008.009c-1.365-5.878.336-8.227 3.82-13.031q.123-.17.247-.345l-4.585 4.59v-.014L13.234 21.044M10.95 23.031c-3.877-3.707-3.208-9.446.1-12.755 2.446-2.449 6.454-3.448 9.952-1.979L24.76 6.56c-.677-.49-1.545-1.017-2.54-1.387A12.465 12.465 0 0 0 8.675 7.901c-3.519 3.523-4.625 8.94-2.725 13.561 1.42 3.454-.907 5.898-3.251 8.364-.83.874-1.664 1.749-2.335 2.674l10.583-9.466"/></svg>"#
|
||||
} else {
|
||||
// X circle
|
||||
r#"<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:#ef4444"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>"#
|
||||
};
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<meta name="color-scheme" content="light dark"/>
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
display:flex;align-items:center;justify-content:center;min-height:100vh;
|
||||
background:#0a0a0a;color:#e5e5e5}}
|
||||
.card{{text-align:center;display:flex;flex-direction:column;align-items:center;gap:16px;padding:48px}}
|
||||
h1{{font-size:18px;font-weight:600}}
|
||||
p{{font-size:14px;color:#a3a3a3}}
|
||||
@media(prefers-color-scheme:light){{
|
||||
body{{background:#fafafa;color:#171717}}
|
||||
p{{color:#525252}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{icon}
|
||||
<h1>{title}</h1>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = title,
|
||||
icon = icon,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the axum router for the OIDC loopback callback server.
|
||||
fn build_callback_router(tx: tokio::sync::mpsc::Sender<CallbackResult>) -> Router {
|
||||
let cors =
|
||||
crate::auth::config::accounts_app_cors_layer(Method::GET).allow_private_network(true);
|
||||
|
||||
Router::new()
|
||||
.route("/callback", get(handle_callback))
|
||||
.layer(cors)
|
||||
.with_state(tx)
|
||||
}
|
||||
|
||||
async fn handle_callback(
|
||||
State(tx): State<tokio::sync::mpsc::Sender<CallbackResult>>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> (StatusCode, Html<String>) {
|
||||
let result = parse_callback_params(¶ms);
|
||||
let response = callback_response(&result);
|
||||
if let Err(e) = tx.try_send(result) {
|
||||
tracing::error!(?e, "OIDC: callback channel send failed; auth will time out");
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn parse_callback_params(params: &HashMap<String, String>) -> CallbackResult {
|
||||
if let Some(code) = params.get("code") {
|
||||
let state = params.get("state").cloned().unwrap_or_default();
|
||||
tracing::debug!(state = %state, "OIDC: received code via loopback callback");
|
||||
return Ok(Callback {
|
||||
code: code.clone(),
|
||||
state,
|
||||
});
|
||||
}
|
||||
let error = params.get("error").cloned().unwrap_or_default();
|
||||
let desc = params.get("error_description").cloned().unwrap_or_default();
|
||||
tracing::error!(error = %error, desc = %desc, "OIDC: IdP returned error");
|
||||
Err(if desc.is_empty() {
|
||||
error
|
||||
} else {
|
||||
format!("{error}: {desc}")
|
||||
})
|
||||
}
|
||||
|
||||
fn callback_response(result: &CallbackResult) -> (StatusCode, Html<String>) {
|
||||
let (title, message) = match result {
|
||||
Ok(_) => (
|
||||
"Signed in",
|
||||
"You can close this window and return to Grok Build.",
|
||||
),
|
||||
Err(_) => ("Access denied", "Close this window and try again."),
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Html(callback_page(title, message, result.is_ok())),
|
||||
)
|
||||
}
|
||||
|
||||
/// Wait until stdin has data or `tx` is closed. Returns `false` if closed.
|
||||
#[cfg(unix)]
|
||||
fn wait_for_stdin_or_closed(
|
||||
stdin: &std::io::Stdin,
|
||||
tx: &tokio::sync::mpsc::Sender<CallbackResult>,
|
||||
) -> bool {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = stdin.as_raw_fd();
|
||||
loop {
|
||||
if tx.is_closed() {
|
||||
return false;
|
||||
}
|
||||
let ready = unsafe {
|
||||
let mut fds = std::mem::zeroed::<libc::pollfd>();
|
||||
fds.fd = fd;
|
||||
fds.events = libc::POLLIN;
|
||||
libc::poll(&mut fds, 1, 200)
|
||||
};
|
||||
if ready > 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_stdin_reader(tx: tokio::sync::mpsc::Sender<CallbackResult>) {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
use std::io::BufRead;
|
||||
let stdin = std::io::stdin();
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
#[cfg(unix)]
|
||||
if !wait_for_stdin_or_closed(&stdin, &tx) {
|
||||
tracing::debug!("OIDC: stdin reader exiting, channel closed");
|
||||
return;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
if tx.is_closed() {
|
||||
tracing::debug!("OIDC: stdin reader exiting, channel closed");
|
||||
return;
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
let mut handle = stdin.lock();
|
||||
match handle.read_line(&mut buf) {
|
||||
Ok(0) => return,
|
||||
Ok(_) => {}
|
||||
Err(_) => return,
|
||||
}
|
||||
drop(handle);
|
||||
|
||||
let trimmed = buf.trim().to_owned();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match parse_pasted_input(&trimmed) {
|
||||
Ok(result) => {
|
||||
tracing::debug!("OIDC: received code via stdin paste");
|
||||
let _ = tx.blocking_send(Ok(result));
|
||||
return;
|
||||
}
|
||||
Err(OidcError::InvalidPastedInput(msg)) => {
|
||||
tracing::debug!(input = %msg, "OIDC: invalid stdin paste, retrying");
|
||||
eprintln!(" Invalid input: {msg}. Try again:");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "OIDC: stdin paste returned auth error");
|
||||
let _ = tx.blocking_send(Err(e.to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Race loopback callback against manual paste from `code_rx`.
|
||||
async fn race_callback_and_client_ui(
|
||||
listener: TcpListener,
|
||||
code_rx: &mut tokio::sync::mpsc::Receiver<String>,
|
||||
) -> anyhow::Result<Callback> {
|
||||
tracing::debug!("OIDC: waiting for auth code (loopback + client paste)");
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<CallbackResult>(1);
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
let app = build_callback_router(tx.clone());
|
||||
let server = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
// Bridge client paste input into the callback channel.
|
||||
let client_tx = tx.clone();
|
||||
let client_bridge = async {
|
||||
while let Some(code) = code_rx.recv().await {
|
||||
match parse_pasted_input(&code) {
|
||||
Ok(result) => {
|
||||
tracing::debug!("OIDC: received code via client paste");
|
||||
let _ = client_tx.send(Ok(result)).await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "OIDC: invalid client paste input");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drop(tx);
|
||||
|
||||
let result = tokio::select! {
|
||||
r = tokio::time::timeout(AUTH_CALLBACK_TIMEOUT, rx.recv()) => {
|
||||
r.map_err(|_| anyhow::Error::new(OidcError::CallbackTimeout))?
|
||||
.ok_or_else(|| anyhow::Error::new(OidcError::CallbackChannelClosed))?
|
||||
}
|
||||
_ = client_bridge => {
|
||||
rx.recv().await
|
||||
.ok_or_else(|| anyhow::Error::new(OidcError::CallbackChannelClosed))?
|
||||
}
|
||||
};
|
||||
|
||||
let _ = shutdown_tx.send(());
|
||||
let _ = server.await;
|
||||
|
||||
result.map_err(|e| anyhow::Error::new(OidcError::CallbackAuthFailed(e)))
|
||||
}
|
||||
|
||||
/// Race loopback callback against stdin paste.
|
||||
async fn race_callback_and_stdin(
|
||||
listener: TcpListener,
|
||||
enable_stdin: bool,
|
||||
) -> anyhow::Result<Callback> {
|
||||
tracing::debug!(
|
||||
enable_stdin = enable_stdin,
|
||||
"OIDC: waiting for auth code (loopback + stdin)"
|
||||
);
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<CallbackResult>(1);
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
let app = build_callback_router(tx.clone());
|
||||
let server = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
if enable_stdin {
|
||||
spawn_stdin_reader(tx.clone());
|
||||
}
|
||||
|
||||
drop(tx);
|
||||
|
||||
let result = tokio::time::timeout(AUTH_CALLBACK_TIMEOUT, rx.recv())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
// "10 minutes" must match AUTH_CALLBACK_TIMEOUT above
|
||||
tracing::error!("auth: timed out after 10 minutes waiting for auth code");
|
||||
anyhow::Error::new(OidcError::CallbackTimeout)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
tracing::error!(
|
||||
"OIDC: callback channel closed, no code received from loopback or stdin"
|
||||
);
|
||||
anyhow::Error::new(OidcError::CallbackChannelClosed)
|
||||
})?;
|
||||
|
||||
let _ = shutdown_tx.send(());
|
||||
let _ = server.await;
|
||||
|
||||
result.map_err(|e| anyhow::Error::new(OidcError::CallbackAuthFailed(e)))
|
||||
}
|
||||
|
||||
/// Run the full OIDC login flow: discovery → PKCE → browser → callback → token exchange → persist.
|
||||
pub async fn run_login_flow(
|
||||
config: &GrokComConfig,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
channels: Option<super::super::flow::AuthChannels>,
|
||||
) -> anyhow::Result<(GrokAuth, bool)> {
|
||||
let oidc = config
|
||||
.oidc
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::Error::new(OidcError::NotConfigured))?;
|
||||
run_login_flow_with_config(oidc, auth_manager, channels).await
|
||||
}
|
||||
|
||||
/// Run the OIDC login flow with an explicit [`OidcAuthConfig`].
|
||||
///
|
||||
/// Also used by the OAuth2 provider path via [`OAuth2ProviderConfig::as_oidc`].
|
||||
///
|
||||
/// The flow races two input paths:
|
||||
/// - **Path A**: A loopback HTTP server on `127.0.0.1` that receives the IdP redirect.
|
||||
/// - **Path B**: Stdin paste — the user manually pastes the callback URL or bare auth code.
|
||||
///
|
||||
/// Path B is essential for remote VMs where the browser runs on a different machine
|
||||
/// and the `127.0.0.1` redirect cannot reach the CLI process.
|
||||
/// * `channels` — `Some`: pushes the auth URL to the TUI and receives pasted codes.
|
||||
/// `None`: prints to stderr / reads stdin (CLI mode).
|
||||
pub async fn run_login_flow_with_config(
|
||||
oidc: &OidcAuthConfig,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
channels: Option<super::super::flow::AuthChannels>,
|
||||
) -> anyhow::Result<(GrokAuth, bool)> {
|
||||
tracing::info!(issuer = %oidc.issuer, client_id = %oidc.client_id, "OIDC: starting login flow");
|
||||
|
||||
// Ensure jsonwebtoken CryptoProvider is installed (required for JWT validation).
|
||||
jsonwebtoken::crypto::CryptoProvider::install_default(
|
||||
&jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER,
|
||||
)
|
||||
.ok();
|
||||
|
||||
let discovery = discover(&oidc.issuer).await?;
|
||||
let pkce = generate_pkce();
|
||||
let state = uuid::Uuid::now_v7().to_string();
|
||||
let nonce = uuid::Uuid::now_v7().to_string();
|
||||
|
||||
// In local-dev mode, use a fixed callback port so the redirect_uri is stable
|
||||
// and can be pre-registered with the local OAuth2 provider. In production the
|
||||
// OS picks a random available port.
|
||||
let callback_port: u16 = if super::super::config::use_local_auth() {
|
||||
56121
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let listener = TcpListener::bind(("127.0.0.1", callback_port))
|
||||
.await
|
||||
.map_err(|e| anyhow::Error::new(OidcError::BindLoopback(e.to_string())))?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let redirect_uri = format!("http://127.0.0.1:{}/callback", port);
|
||||
let oauth2 = auth_manager.grok_com_config().oauth2.as_ref();
|
||||
let auth_url = build_authorize_url(
|
||||
oidc,
|
||||
oauth2,
|
||||
&discovery,
|
||||
&redirect_uri,
|
||||
&pkce,
|
||||
&state,
|
||||
&nonce,
|
||||
);
|
||||
tracing::debug!(port = port, redirect_uri = %redirect_uri, "OIDC: callback server bound");
|
||||
|
||||
let (url_tx, code_rx) = match channels {
|
||||
Some(ch) => (ch.url_tx, Some(ch.code_rx)),
|
||||
None => (None, None),
|
||||
};
|
||||
let has_client_ui = code_rx.is_some();
|
||||
|
||||
if has_client_ui {
|
||||
// Client provides its own auth UI; just open the browser.
|
||||
if let Err(e) = webbrowser::open(&auth_url) {
|
||||
tracing::debug!(error = %e, "OIDC: failed to open browser");
|
||||
}
|
||||
} else {
|
||||
// No client UI — print to stderr.
|
||||
eprintln!();
|
||||
let provider_label = if oidc.issuer == super::super::config::XAI_OAUTH2_ISSUER {
|
||||
"Grok".to_owned()
|
||||
} else {
|
||||
oidc.issuer.clone()
|
||||
};
|
||||
eprintln!("Signing in with {}...", provider_label);
|
||||
eprintln!();
|
||||
if let Err(e) = webbrowser::open(&auth_url) {
|
||||
tracing::debug!(error = %e, "OIDC: failed to open browser");
|
||||
}
|
||||
eprintln!("Open this URL to sign in:");
|
||||
eprintln!(" {}", auth_url);
|
||||
}
|
||||
|
||||
let use_stdin = !has_client_ui && std::io::stdin().is_terminal();
|
||||
if use_stdin {
|
||||
eprintln!();
|
||||
eprintln!("Paste the URL here if it doesn't connect:");
|
||||
}
|
||||
|
||||
// Push auth URL to the TUI via oneshot.
|
||||
if let Some(tx) = url_tx {
|
||||
let _ = tx.send(super::super::flow::AuthUrlInfo {
|
||||
url: auth_url.clone(),
|
||||
mode: super::super::flow::AuthUrlMode::Loopback,
|
||||
});
|
||||
}
|
||||
|
||||
let Callback {
|
||||
code,
|
||||
state: received_state,
|
||||
} = if let Some(mut rx) = code_rx {
|
||||
// Client UI: race loopback against manual paste via code_rx.
|
||||
race_callback_and_client_ui(listener, &mut rx).await?
|
||||
} else {
|
||||
// No client UI: race loopback against stdin paste.
|
||||
race_callback_and_stdin(listener, use_stdin).await?
|
||||
};
|
||||
|
||||
// Validate state (skip for bare code paste where state is empty)
|
||||
if !received_state.is_empty() {
|
||||
validate_state(&state, &received_state)?;
|
||||
}
|
||||
|
||||
let tokens = exchange_code(
|
||||
&discovery.token_endpoint,
|
||||
&code,
|
||||
&redirect_uri,
|
||||
&oidc.client_id,
|
||||
&pkce.code_verifier,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
has_refresh = tokens.refresh_token.is_some(),
|
||||
expires_in = ?tokens.expires_in,
|
||||
"OIDC: token exchange complete"
|
||||
);
|
||||
|
||||
// Resolve the actual principal chosen on the consent screen.
|
||||
//
|
||||
// The shell's config may not have principal_type set (personal login),
|
||||
// but the user might pick "Team" on the consent screen. The server
|
||||
// encodes the chosen principal in the access token JWT. If the config
|
||||
// doesn't specify a principal, peek at the token to discover it.
|
||||
let token_principal = peek_access_token_principal(&tokens.access_token);
|
||||
|
||||
// The authorize URL only pre-selects; verify the token's principal here.
|
||||
// Match the principal id even if `principal_type` is absent.
|
||||
let principal_policy = login_principal_policy(auth_manager.grok_com_config());
|
||||
enforce_login_principal(
|
||||
principal_policy.as_ref(),
|
||||
peek_access_token_principal_id(&tokens.access_token).as_deref(),
|
||||
)?;
|
||||
|
||||
let (resolved_principal_type, resolved_principal_id, resolved_team_id) = {
|
||||
let cfg_pt = oauth2.and_then(|cfg| cfg.principal_type.clone());
|
||||
let cfg_pid = oauth2.and_then(|cfg| cfg.principal_id.clone());
|
||||
if cfg_pt.is_some() {
|
||||
(cfg_pt, cfg_pid, None)
|
||||
} else if let Some((pt, pid, tid)) = token_principal {
|
||||
tracing::info!(
|
||||
principal_type = %pt,
|
||||
principal_id = %pid,
|
||||
team_id = ?tid,
|
||||
"OIDC: resolved principal from access token"
|
||||
);
|
||||
(Some(pt), Some(pid), tid)
|
||||
} else {
|
||||
(cfg_pt, cfg_pid, None)
|
||||
}
|
||||
};
|
||||
|
||||
let user_info = extract_user_info(
|
||||
tokens.id_token.as_deref(),
|
||||
&discovery,
|
||||
&oidc.issuer,
|
||||
&oidc.client_id,
|
||||
&nonce,
|
||||
resolved_principal_type.as_deref(),
|
||||
resolved_principal_id.as_deref(),
|
||||
resolved_team_id,
|
||||
)
|
||||
.await?;
|
||||
tracing::debug!(user_id = %user_info.user_id, "OIDC: extracted user info");
|
||||
|
||||
let mut auth = build_grok_auth(tokens, user_info, &oidc.issuer, &oidc.client_id);
|
||||
auth_manager.enrich_auth_inline(&mut auth).await;
|
||||
let auth = auth_manager
|
||||
.update(auth)
|
||||
.await
|
||||
.map_err(|e| anyhow::Error::new(OidcError::SaveAuth(e.to_string())))?;
|
||||
tracing::info!(user_id = %auth.user_id, "OIDC: login complete, credentials saved");
|
||||
|
||||
Ok((auth, true))
|
||||
}
|
||||
|
||||
/// Successful OIDC callback payload.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Callback {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
/// Result from the OIDC callback: either a [`Callback`] or an IdP error message.
|
||||
type CallbackResult = Result<Callback, String>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_helpers::*;
|
||||
use super::*;
|
||||
|
||||
/// End-to-end test: mock IdP + full login flow with code arriving via loopback.
|
||||
/// Exercises discovery → PKCE → race_callback_and_stdin → token exchange → user info → persist.
|
||||
#[tokio::test]
|
||||
async fn full_login_flow_via_race() {
|
||||
ensure_crypto_provider();
|
||||
let (issuer, idp_server) = start_mock_idp().await;
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
// Dead proxy port: inline `/user` enrichment fails fast in tests.
|
||||
let dead_proxy = {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
format!("http://127.0.0.1:{}", l.local_addr().unwrap().port())
|
||||
};
|
||||
let auth_manager = Arc::new(
|
||||
AuthManager::new(temp_dir.path(), GrokComConfig::default())
|
||||
.with_proxy_base_url(&dead_proxy),
|
||||
);
|
||||
|
||||
let oidc_cfg = OidcAuthConfig {
|
||||
issuer: issuer.clone(),
|
||||
client_id: TEST_CLIENT_ID.into(),
|
||||
scopes: vec!["openid".into(), "email".into()],
|
||||
audience: None,
|
||||
};
|
||||
let discovery = discover(&oidc_cfg.issuer).await.unwrap();
|
||||
let pkce = generate_pkce();
|
||||
let state = "test-state".to_string();
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let redirect_uri = format!("http://127.0.0.1:{port}/callback");
|
||||
let _auth_url = build_authorize_url(
|
||||
&oidc_cfg,
|
||||
None,
|
||||
&discovery,
|
||||
&redirect_uri,
|
||||
&pkce,
|
||||
&state,
|
||||
TEST_NONCE,
|
||||
);
|
||||
|
||||
// Simulate browser callback via race_callback_and_stdin
|
||||
let Callback {
|
||||
code,
|
||||
state: received_state,
|
||||
} = tokio::join!(race_callback_and_stdin(listener, false), async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
reqwest::get(format!(
|
||||
"http://127.0.0.1:{port}/callback?code=mock-auth-code&state={state}"
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
.0
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(code, "mock-auth-code");
|
||||
assert_eq!(received_state, state);
|
||||
|
||||
let tokens = exchange_code(
|
||||
&discovery.token_endpoint,
|
||||
&code,
|
||||
&redirect_uri,
|
||||
&oidc_cfg.client_id,
|
||||
&pkce.code_verifier,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tokens.access_token, "mock-access-token");
|
||||
|
||||
let user_info = extract_user_info(
|
||||
tokens.id_token.as_deref(),
|
||||
&discovery,
|
||||
&oidc_cfg.issuer,
|
||||
&oidc_cfg.client_id,
|
||||
TEST_NONCE,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let auth = build_grok_auth(tokens, user_info, &oidc_cfg.issuer, &oidc_cfg.client_id);
|
||||
let auth = auth_manager.update(auth).await.unwrap();
|
||||
|
||||
assert_eq!(auth.key, "mock-access-token");
|
||||
assert_eq!(auth.refresh_token.as_deref(), Some("mock-refresh-token"));
|
||||
assert_eq!(auth.user_id, "user-42");
|
||||
assert_eq!(auth.email.as_deref(), Some("test@corp.com"));
|
||||
assert!(auth.principal_type.is_none());
|
||||
assert!(auth.principal_id.is_none());
|
||||
assert!(auth.expires_at.is_some());
|
||||
assert_eq!(auth.oidc_issuer.as_deref(), Some(issuer.as_str()));
|
||||
|
||||
let auth_json = std::fs::read_to_string(temp_dir.path().join("auth.json")).unwrap();
|
||||
assert!(auth_json.contains("mock-access-token"));
|
||||
assert!(auth_json.contains("user-42"));
|
||||
|
||||
idp_server.abort();
|
||||
}
|
||||
/// Parser matrix: full callback URL, bare code, error URL, empty.
|
||||
/// Each case is one bug class:
|
||||
/// - full URL: regression in URL extraction
|
||||
/// - bare code: paste-friendly fallback
|
||||
/// - error URL: surfaces IdP error to user
|
||||
/// - empty: input validation
|
||||
#[test]
|
||||
fn parse_pasted_input_matrix() {
|
||||
// (input, expected: Ok((code, state)) | Err substring)
|
||||
let ok_cases: &[(&str, &str, &str)] = &[
|
||||
(
|
||||
"http://127.0.0.1:54321/callback?code=abc123&state=xyz789",
|
||||
"abc123",
|
||||
"xyz789",
|
||||
),
|
||||
("abc123def456", "abc123def456", ""),
|
||||
];
|
||||
for (input, code, state) in ok_cases {
|
||||
let cb =
|
||||
parse_pasted_input(input).unwrap_or_else(|e| panic!("parse {input:?} failed: {e}"));
|
||||
assert_eq!(cb.code, *code, "code for {input:?}");
|
||||
assert_eq!(cb.state, *state, "state for {input:?}");
|
||||
}
|
||||
|
||||
let err_cases: &[(&str, &str)] = &[
|
||||
(
|
||||
"http://127.0.0.1:54321/callback?error=access_denied&error_description=User+denied",
|
||||
"access_denied",
|
||||
),
|
||||
("", ""),
|
||||
(" ", ""),
|
||||
];
|
||||
for (input, expected_substr) in err_cases {
|
||||
let err = parse_pasted_input(input).unwrap_err();
|
||||
if !expected_substr.is_empty() {
|
||||
assert!(
|
||||
err.to_string().contains(expected_substr),
|
||||
"input {input:?} -> unexpected err: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! OIDC authentication: protocol, login, and refresh submodules.
|
||||
|
||||
mod login;
|
||||
pub(crate) mod protocol;
|
||||
pub(crate) mod refresh;
|
||||
#[cfg(test)]
|
||||
mod test_helpers;
|
||||
|
||||
pub use login::{run_login_flow, run_login_flow_with_config};
|
||||
pub(crate) use protocol::{
|
||||
enforce_login_principal, is_configured, login_principal_policy, peek_access_token_principal,
|
||||
peek_access_token_principal_id, with_alpha_test_key,
|
||||
};
|
||||
pub(crate) use refresh::{OidcRefreshResult, oidc_token_exchange};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
//! Pure-data OIDC refresh. Talks to the IdP and returns
|
||||
//! [`OidcRefreshResult`] without touching [`AuthManager`].
|
||||
|
||||
use super::super::GrokAuth;
|
||||
use super::protocol::{OidcError, OidcUserInfo, build_grok_auth, discover, refresh_tokens};
|
||||
use crate::auth::error::RefreshTokenFailedReason;
|
||||
|
||||
/// Outcome of a pure OIDC token refresh (no AuthManager mutations).
|
||||
pub(crate) enum OidcRefreshResult {
|
||||
/// Fresh token obtained. Caller must persist.
|
||||
Success(Box<GrokAuth>),
|
||||
/// Terminal error from the IdP, already classified into a reason.
|
||||
TerminalError { reason: RefreshTokenFailedReason },
|
||||
/// Non-terminal failure (discovery failed, network error, etc.)
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Classify an OAuth2 `error` code as a terminal refresh failure. `None` means
|
||||
/// non-terminal (retryable). Single source of truth for which codes are fatal;
|
||||
/// the retry gate (`protocol::is_transient_refresh_error`) defers to this too.
|
||||
pub(super) fn classify_terminal(error_code: &str) -> Option<RefreshTokenFailedReason> {
|
||||
match error_code {
|
||||
"invalid_grant" => Some(RefreshTokenFailedReason::RefreshTokenRejected),
|
||||
"invalid_client" => Some(RefreshTokenFailedReason::ClientRejected),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `oauth2-provider` refresh-token rotation-grace window (ms). Only a clock
|
||||
/// divergence past this bound is flagged as a suspected suspend-straddle, since
|
||||
/// a longer suspend can turn a lost refresh response into a revoked RT.
|
||||
const ROTATION_GRACE_MS: u64 = 60_000;
|
||||
|
||||
/// Exchange a refresh_token for fresh tokens at the IdP. Pure data return, no
|
||||
/// `AuthManager` mutations; the caller (`OidcRefresher`) routes the result
|
||||
/// through `refresh_chain`.
|
||||
pub(crate) async fn oidc_token_exchange(auth: &GrokAuth) -> OidcRefreshResult {
|
||||
let has_rt = auth.refresh_token.is_some();
|
||||
let has_issuer = auth.oidc_issuer.is_some();
|
||||
let has_client_id = auth.oidc_client_id.is_some();
|
||||
tracing::debug!(
|
||||
has_rt,
|
||||
has_issuer,
|
||||
has_client_id,
|
||||
"oidc try_refresh_pure enter"
|
||||
);
|
||||
if !has_rt || !has_issuer || !has_client_id {
|
||||
kigi_log::unified_log::warn(
|
||||
"oidc try_refresh skipped: missing fields",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"has_refresh_token": has_rt,
|
||||
"has_issuer": has_issuer,
|
||||
"has_client_id": has_client_id,
|
||||
"auth_mode": format!("{:?}", auth.auth_mode),
|
||||
})),
|
||||
);
|
||||
}
|
||||
let Some(refresh_tok) = auth.refresh_token.as_ref() else {
|
||||
return OidcRefreshResult::Failed;
|
||||
};
|
||||
let Some(issuer) = auth.oidc_issuer.as_ref() else {
|
||||
return OidcRefreshResult::Failed;
|
||||
};
|
||||
let Some(client_id) = auth.oidc_client_id.as_ref() else {
|
||||
return OidcRefreshResult::Failed;
|
||||
};
|
||||
|
||||
crate::unified_log::info(
|
||||
"oidc try_refresh_pure enter",
|
||||
None,
|
||||
Some(serde_json::json!({ "issuer": issuer, "client_id": client_id })),
|
||||
);
|
||||
|
||||
// Suspend probe: the monotonic clock pauses while the machine is asleep
|
||||
// but the wall clock does not, so a large divergence around the IdP call
|
||||
// means the process was suspended mid-refresh — the exact condition that
|
||||
// can revoke the refresh token (response lost across sleep).
|
||||
let started_mono = std::time::Instant::now();
|
||||
let started_wall = chrono::Utc::now();
|
||||
let timing = || {
|
||||
let mono_ms = started_mono.elapsed().as_millis() as u64;
|
||||
let wall_ms = (chrono::Utc::now() - started_wall)
|
||||
.num_milliseconds()
|
||||
.max(0) as u64;
|
||||
let suspended_ms = wall_ms.saturating_sub(mono_ms);
|
||||
(
|
||||
mono_ms,
|
||||
wall_ms,
|
||||
suspended_ms,
|
||||
suspended_ms > ROTATION_GRACE_MS,
|
||||
)
|
||||
};
|
||||
|
||||
let discovery = match discover(issuer).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
let (mono_ms, wall_ms, suspended_ms, suspected_suspend) = timing();
|
||||
crate::unified_log::error(
|
||||
"oidc try_refresh_pure discovery failed",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"error": format!("{e:#}"),
|
||||
"mono_ms": mono_ms,
|
||||
"wall_ms": wall_ms,
|
||||
"suspended_ms": suspended_ms,
|
||||
"suspected_suspend": suspected_suspend,
|
||||
})),
|
||||
);
|
||||
if suspected_suspend {
|
||||
emit_suspend_spanned("discovery_failed", suspended_ms);
|
||||
}
|
||||
return OidcRefreshResult::Failed;
|
||||
}
|
||||
};
|
||||
let tokens = match refresh_tokens(
|
||||
&discovery.token_endpoint,
|
||||
refresh_tok,
|
||||
client_id,
|
||||
auth.principal_type.as_deref(),
|
||||
auth.principal_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
if let Some(OidcError::TokenRefreshHttp { body, .. }) = e.downcast_ref::<OidcError>()
|
||||
&& let Some(error_code) = serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error")?.as_str().map(str::to_owned))
|
||||
&& let Some(reason) = classify_terminal(&error_code)
|
||||
{
|
||||
let (mono_ms, wall_ms, suspended_ms, suspected_suspend) = timing();
|
||||
let cred_age_secs = auth.mint_age_seconds();
|
||||
crate::unified_log::error(
|
||||
"oidc try_refresh_pure terminal error",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"error_code": error_code,
|
||||
"client_id": client_id,
|
||||
"tried_rt_prefix": auth.refresh_token.as_deref().map(crate::auth::token_suffix),
|
||||
"error_description": serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error_description").cloned()),
|
||||
"mono_ms": mono_ms,
|
||||
"wall_ms": wall_ms,
|
||||
"suspended_ms": suspended_ms,
|
||||
"suspected_suspend": suspected_suspend,
|
||||
"cred_age_secs": cred_age_secs,
|
||||
})),
|
||||
);
|
||||
if suspected_suspend {
|
||||
emit_suspend_spanned(&error_code, suspended_ms);
|
||||
}
|
||||
return OidcRefreshResult::TerminalError { reason };
|
||||
}
|
||||
let http_status = e.downcast_ref::<OidcError>().and_then(|oe| match oe {
|
||||
OidcError::TokenRefreshHttp { status, .. } => Some(*status),
|
||||
_ => None,
|
||||
});
|
||||
let (mono_ms, wall_ms, suspended_ms, suspected_suspend) = timing();
|
||||
crate::unified_log::error(
|
||||
"oidc try_refresh_pure token exchange failed",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"error": e.to_string(),
|
||||
"client_id": client_id,
|
||||
"http_status": http_status,
|
||||
"mono_ms": mono_ms,
|
||||
"wall_ms": wall_ms,
|
||||
"suspended_ms": suspended_ms,
|
||||
"suspected_suspend": suspected_suspend,
|
||||
})),
|
||||
);
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
http_status = ?http_status,
|
||||
client_id = %client_id,
|
||||
issuer = %issuer,
|
||||
"OIDC: token refresh failed"
|
||||
);
|
||||
if suspected_suspend {
|
||||
emit_suspend_spanned("transient_failed", suspended_ms);
|
||||
}
|
||||
return OidcRefreshResult::Failed;
|
||||
}
|
||||
};
|
||||
|
||||
// Reuse identity from original login; new id_token from refresh is intentionally skipped.
|
||||
let user_info = OidcUserInfo {
|
||||
user_id: auth.user_id.clone(),
|
||||
email: auth.email.clone(),
|
||||
first_name: auth.first_name.clone(),
|
||||
last_name: auth.last_name.clone(),
|
||||
profile_image_asset_id: auth.profile_image_asset_id.clone(),
|
||||
principal_type: auth.principal_type.clone(),
|
||||
principal_id: auth.principal_id.clone(),
|
||||
team_id: auth.team_id.clone(),
|
||||
team_name: auth.team_name.clone(),
|
||||
team_role: auth.team_role.clone(),
|
||||
organization_id: auth.organization_id.clone(),
|
||||
organization_name: auth.organization_name.clone(),
|
||||
organization_role: auth.organization_role.clone(),
|
||||
user_blocked_reason: auth.user_blocked_reason.clone(),
|
||||
team_blocked_reasons: auth.team_blocked_reasons.clone(),
|
||||
coding_data_retention_opt_out: auth.coding_data_retention_opt_out,
|
||||
};
|
||||
let mut new_auth = build_grok_auth(tokens, user_info, issuer, client_id);
|
||||
let idp_rotated = new_auth.refresh_token.is_some();
|
||||
// Keep old refresh token if IdP didn't rotate it
|
||||
if new_auth.refresh_token.is_none() {
|
||||
new_auth.refresh_token = auth.refresh_token.clone();
|
||||
}
|
||||
tracing::debug!(
|
||||
idp_rotated,
|
||||
key_prefix = crate::auth::token_suffix(&new_auth.key),
|
||||
"oidc try_refresh_pure token obtained"
|
||||
);
|
||||
let (mono_ms, wall_ms, suspended_ms, suspected_suspend) = timing();
|
||||
crate::unified_log::info(
|
||||
"oidc try_refresh_pure succeeded",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"expires_at": new_auth.expires_at.map(|e| e.to_rfc3339()),
|
||||
"mono_ms": mono_ms,
|
||||
"wall_ms": wall_ms,
|
||||
"suspended_ms": suspended_ms,
|
||||
"suspected_suspend": suspected_suspend,
|
||||
})),
|
||||
);
|
||||
if suspected_suspend {
|
||||
emit_suspend_spanned("ok", suspended_ms);
|
||||
}
|
||||
OidcRefreshResult::Success(Box::new(new_auth))
|
||||
}
|
||||
|
||||
/// Alertable event: an OIDC refresh's network call spanned a suspend (wall
|
||||
/// clock ran far ahead of the monotonic clock) — the precondition for a
|
||||
/// lost-response refresh-token revocation.
|
||||
fn emit_suspend_spanned(outcome: &str, suspended_ms: u64) {
|
||||
crate::unified_log::warn(
|
||||
"auth.refresh.suspend_spanned",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"outcome": outcome,
|
||||
"suspended_ms": suspended_ms,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Shared test helpers for `oidc::protocol::tests` and `oidc::login::tests`.
|
||||
//! Both test modules need a mock IdP server (`start_mock_idp`), JWT
|
||||
//! signing primitives (`generate_test_rsa_key`, `mock_idp_token`), and
|
||||
//! the same constants. Extracted here so neither test mod has to
|
||||
//! re-implement them.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
|
||||
use super::protocol::{Discovery, discover};
|
||||
|
||||
pub(super) const TEST_KID: &str = "test-kid";
|
||||
pub(super) const TEST_NONCE: &str = "test-nonce-value";
|
||||
pub(super) const TEST_CLIENT_ID: &str = "test-client-id";
|
||||
pub(super) fn ensure_crypto_provider() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default();
|
||||
}
|
||||
pub(super) fn generate_test_rsa_key() -> (String, String, String) {
|
||||
use rsa::pkcs8::EncodePrivateKey;
|
||||
use rsa::traits::PublicKeyParts;
|
||||
let private_key = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 2048).unwrap();
|
||||
let pem = private_key
|
||||
.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let jwk_n = URL_SAFE_NO_PAD.encode(private_key.n().to_bytes_be());
|
||||
let jwk_e = URL_SAFE_NO_PAD.encode(private_key.e().to_bytes_be());
|
||||
(pem, jwk_n, jwk_e)
|
||||
}
|
||||
pub(super) async fn mock_idp_token() -> (String, String, Discovery, tokio::task::JoinHandle<()>) {
|
||||
let (issuer, handle) = start_mock_idp().await;
|
||||
let discovery = discover(&issuer).await.unwrap();
|
||||
let resp: serde_json::Value = crate::http::shared_client()
|
||||
.post(&discovery.token_endpoint)
|
||||
.form(&[("grant_type", "authorization_code")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let id_token = resp["id_token"]
|
||||
.as_str()
|
||||
.expect("mock missing id_token")
|
||||
.to_string();
|
||||
(issuer, id_token, discovery, handle)
|
||||
}
|
||||
pub(super) async fn start_mock_idp() -> (String, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let issuer = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
|
||||
let issuer_for_discovery = issuer.clone();
|
||||
let (rsa_pem, jwk_n, jwk_e) = generate_test_rsa_key();
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Claims {
|
||||
sub: &'static str,
|
||||
email: &'static str,
|
||||
iss: String,
|
||||
aud: &'static str,
|
||||
nonce: &'static str,
|
||||
exp: usize,
|
||||
}
|
||||
|
||||
let id_token = {
|
||||
let mut hdr = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
|
||||
hdr.kid = Some(TEST_KID.to_owned());
|
||||
jsonwebtoken::encode(
|
||||
&hdr,
|
||||
&Claims {
|
||||
sub: "user-42",
|
||||
email: "test@corp.com",
|
||||
iss: issuer.clone(),
|
||||
aud: TEST_CLIENT_ID,
|
||||
nonce: TEST_NONCE,
|
||||
exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() as usize,
|
||||
},
|
||||
&jsonwebtoken::EncodingKey::from_rsa_pem(rsa_pem.as_bytes()).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/.well-known/openid-configuration",
|
||||
axum::routing::get(move || {
|
||||
let iss = issuer_for_discovery.clone();
|
||||
async move {
|
||||
axum::Json(serde_json::json!({
|
||||
"authorization_endpoint": format!("{iss}/authorize"),
|
||||
"token_endpoint": format!("{iss}/token"),
|
||||
"jwks_uri": format!("{iss}/jwks"),
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/jwks",
|
||||
axum::routing::get(move || {
|
||||
let n = jwk_n.clone();
|
||||
let e = jwk_e.clone();
|
||||
async move {
|
||||
axum::Json(serde_json::json!({
|
||||
"keys": [{
|
||||
"kty": "RSA", "alg": "RS256", "kid": TEST_KID,
|
||||
"n": n, "e": e,
|
||||
}]
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/token",
|
||||
axum::routing::post(move || {
|
||||
let tok = id_token.clone();
|
||||
async move {
|
||||
axum::Json(serde_json::json!({
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"id_token": tok,
|
||||
"expires_in": 3600,
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
(issuer, handle)
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
//! Unauthorized (401) recovery state machine.
|
||||
//!
|
||||
//! When the server rejects a token, `UnauthorizedRecovery` walks through
|
||||
//! a sequence of recovery steps before giving up:
|
||||
//!
|
||||
//! 1. **ReloadFromDisk** — re-read `auth.json` under a file lock; if the
|
||||
//! on-disk token differs from the rejected one, accept it (another
|
||||
//! process may have refreshed).
|
||||
//! 2. **RefreshFromAuthority** — run the appropriate refresh chain
|
||||
//! (OIDC token refresh, external binary, etc.) based on `TokenType`,
|
||||
//! unless the live token was minted moments ago (fresh-mint guard).
|
||||
//! 3. **DevboxRecovery** — on devboxes, purge `auth.json` and mint fresh
|
||||
//! OIDC credentials.
|
||||
//! 4. **Done** — all recovery strategies exhausted.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::error::{AuthError, RefreshTokenError, RefreshTokenFailedReason};
|
||||
use crate::auth::manager::AuthManager;
|
||||
use crate::auth::model::GrokAuth;
|
||||
use crate::auth::token_type::TokenType;
|
||||
|
||||
/// Whether a terminal `AuthError` forces a manual re-login (`None` cases
|
||||
/// self-heal or are transient). Lives here (not on `AuthError`) so the error
|
||||
/// model stays free of recovery policy.
|
||||
pub(crate) fn forces_manual_reauth(err: &AuthError) -> bool {
|
||||
match err {
|
||||
AuthError::Refresh(RefreshTokenError::Permanent(e)) => match e.reason {
|
||||
RefreshTokenFailedReason::RefreshTokenRejected => true,
|
||||
// Self-healing via the TTL, not a manual re-auth.
|
||||
RefreshTokenFailedReason::ClientRejected | RefreshTokenFailedReason::Other => false,
|
||||
},
|
||||
AuthError::ServerRejectedNoRecovery
|
||||
| AuthError::RecoveryExhausted
|
||||
| AuthError::TokenExpiredNoRefresh
|
||||
| AuthError::PinnedTeamMismatch { .. } => true,
|
||||
// API-key lockouts are out of scope: an admin disabling API-key auth
|
||||
// means rotate the key, not `/login`.
|
||||
AuthError::ApiKeyAuthDisabled
|
||||
| AuthError::Refresh(RefreshTokenError::Transient(_))
|
||||
| AuthError::NotLoggedIn => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the relay should stop reconnecting on this recovery error. Its own
|
||||
/// predicate rather than reusing `forces_manual_reauth`: the relay must give up
|
||||
/// on any terminal auth failure, including `ApiKeyAuthDisabled` (a kill-switched
|
||||
/// API key), which deliberately doesn't force a manual re-login.
|
||||
pub(crate) fn relay_should_cancel(err: &AuthError) -> bool {
|
||||
forces_manual_reauth(err) || matches!(err, AuthError::ApiKeyAuthDisabled)
|
||||
}
|
||||
|
||||
/// Fresh-mint guard window (±) for `ServerRejected` refreshes
|
||||
/// ([`UnauthorizedRecovery::fresh_mint_guard`]). 120s outlasts in-flight
|
||||
/// requests sent with a previous key plus validation lag (observed stale
|
||||
/// 401s land ~20s after mint), while `current()`'s 300s early-invalidation
|
||||
/// buffer keeps any guard-returned token wire-valid. A genuinely-dead fresh
|
||||
/// token waits at most this long to re-mint; the symmetric bound caps that
|
||||
/// delay when the clock stepped back.
|
||||
const FRESH_MINT_GUARD_SECS: i64 = 120;
|
||||
|
||||
/// Which recovery step to attempt next.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RecoveryStep {
|
||||
/// Re-read auth.json from disk (file-locked).
|
||||
ReloadFromDisk,
|
||||
/// Refresh via the authority (OIDC, external binary, etc.).
|
||||
RefreshFromAuthority,
|
||||
/// On devboxes: purge auth.json and mint fresh OIDC credentials.
|
||||
DevboxRecovery,
|
||||
/// All strategies exhausted.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// State machine that walks through recovery strategies after a 401.
|
||||
pub struct UnauthorizedRecovery {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
/// The token that was rejected by the server.
|
||||
rejected_token: String,
|
||||
/// Current step in the recovery sequence.
|
||||
step: RecoveryStep,
|
||||
/// Error from `RefreshFromAuthority`, propagated as fallback when
|
||||
/// devbox recovery doesn't apply.
|
||||
authority_error: Option<AuthError>,
|
||||
/// Whether the last authority failure was transient. Kept past the
|
||||
/// `authority_error` handoff so exhaustion preserves the
|
||||
/// transient/permanent axis (see the `Done` arm).
|
||||
authority_was_transient: bool,
|
||||
}
|
||||
|
||||
impl UnauthorizedRecovery {
|
||||
/// `rejected` is the credential the server rejected: its key drives recovery.
|
||||
pub(crate) fn new(auth_manager: Arc<AuthManager>, rejected: Option<GrokAuth>) -> Self {
|
||||
let rejected_token = rejected.as_ref().map(|a| a.key.clone()).unwrap_or_default();
|
||||
Self {
|
||||
auth_manager,
|
||||
rejected_token,
|
||||
step: RecoveryStep::ReloadFromDisk,
|
||||
authority_error: None,
|
||||
authority_was_transient: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt the next recovery step. Walks
|
||||
/// `ReloadFromDisk -> RefreshFromAuthority -> DevboxRecovery -> Done`.
|
||||
/// `token_type` span field is recorded lazily via
|
||||
/// `Span::is_disabled()` to avoid the lock when tracing is off.
|
||||
#[tracing::instrument(
|
||||
skip(self),
|
||||
fields(step = ?self.step, token_type = tracing::field::Empty),
|
||||
)]
|
||||
pub async fn next(&mut self) -> Result<GrokAuth, AuthError> {
|
||||
let span = tracing::Span::current();
|
||||
if !span.is_disabled() {
|
||||
// Only acquire the inner-lock when tracing actually
|
||||
// collects the span. `token_type()` -> `inner.read()` is
|
||||
// ~free but it's still a lock, and recovery is on the
|
||||
// 401-recovery path; making the cost zero when tracing is
|
||||
// off matches the no-trace-no-cost contract.
|
||||
span.record(
|
||||
"token_type",
|
||||
tracing::field::debug(self.auth_manager.token_type()),
|
||||
);
|
||||
}
|
||||
self.resolve_next().await
|
||||
}
|
||||
|
||||
/// Walk the recovery steps and apply the team-pin policy gate.
|
||||
async fn resolve_next(&mut self) -> Result<GrokAuth, AuthError> {
|
||||
// Team-pin gate: 401 recovery must not resurrect a wrong-team session
|
||||
// (disk adoption / refresh / devbox mint) for the relay to reconnect
|
||||
// with. Clear + reject on mismatch.
|
||||
let auth = self.next_step_loop().await?;
|
||||
if let Some(e) = self.auth_manager.cached_token_policy_error(&auth) {
|
||||
self.auth_manager.reject_and_clear(&e);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
async fn next_step_loop(&mut self) -> Result<GrokAuth, AuthError> {
|
||||
loop {
|
||||
match self.step {
|
||||
RecoveryStep::ReloadFromDisk => {
|
||||
self.step = RecoveryStep::RefreshFromAuthority;
|
||||
if let Some(auth) = self.try_reload_from_disk().await {
|
||||
return Ok(auth);
|
||||
}
|
||||
}
|
||||
RecoveryStep::RefreshFromAuthority => {
|
||||
self.step = RecoveryStep::DevboxRecovery;
|
||||
match self.try_refresh_from_authority().await {
|
||||
Ok(auth) => return Ok(auth),
|
||||
Err(e) => {
|
||||
self.authority_was_transient =
|
||||
matches!(e, AuthError::Refresh(RefreshTokenError::Transient(_)));
|
||||
self.authority_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
RecoveryStep::DevboxRecovery => {
|
||||
self.step = RecoveryStep::Done;
|
||||
// preferred_method=api_key forbids automatic OIDC mint.
|
||||
if !self.auth_manager.grok_com_config().blocks_automatic_oidc()
|
||||
&& self.auth_manager.is_devbox_environment()
|
||||
&& let Ok(auth) = self.auth_manager.try_devbox_recovery().await
|
||||
{
|
||||
return Ok(auth);
|
||||
}
|
||||
return Err(self
|
||||
.authority_error
|
||||
.take()
|
||||
.unwrap_or(AuthError::RecoveryExhausted));
|
||||
}
|
||||
RecoveryStep::Done => {
|
||||
// Exhaustion after a *transient* authority failure stays
|
||||
// transient: `RecoveryExhausted` here would count a network
|
||||
// blip as a forced re-login and cancel the relay instead of
|
||||
// letting it reconnect.
|
||||
return Err(if self.authority_was_transient {
|
||||
AuthError::transient("recovery exhausted after transient refresh failure")
|
||||
} else {
|
||||
AuthError::RecoveryExhausted
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read `auth.json` from disk. Accept the token only if it differs
|
||||
/// from the one that was rejected.
|
||||
async fn try_reload_from_disk(&self) -> Option<GrokAuth> {
|
||||
let _lock = self
|
||||
.auth_manager
|
||||
.try_lock_auth_file_async(crate::auth::manager::AUTH_LOCK_TIMEOUT)
|
||||
.await;
|
||||
if _lock.is_none() {
|
||||
tracing::warn!("auth recovery: proceeding without file lock");
|
||||
}
|
||||
|
||||
let Some(disk_auth) = self.auth_manager.read_disk_auth() else {
|
||||
// Every ReloadFromDisk outcome must log (adopted / expired /
|
||||
// same-as-rejected / no entry): a silent arm hides which path
|
||||
// a recovery loop is taking. Debug level — the disk-state
|
||||
// *transition* is logged once by `read_disk_auth` itself.
|
||||
kigi_log::unified_log::debug("auth recovery: no disk entry", None, None);
|
||||
return None;
|
||||
};
|
||||
if crate::auth::is_expired(&disk_auth) {
|
||||
tracing::debug!("auth recovery: disk token is expired, skipping");
|
||||
kigi_log::unified_log::debug(
|
||||
"auth recovery: disk token expired",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"disk_key_prefix": crate::auth::token_suffix(&disk_auth.key),
|
||||
"expires_at": disk_auth.expires_at.map(|e| e.to_rfc3339()),
|
||||
})),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if self.is_different_token(&disk_auth) {
|
||||
tracing::info!("auth recovery: disk has a different token, accepting");
|
||||
kigi_log::unified_log::info(
|
||||
"auth recovery: adopted disk token",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"adopted_key_prefix": crate::auth::token_suffix(&disk_auth.key),
|
||||
"expires_at": disk_auth.expires_at.map(|e| e.to_rfc3339()),
|
||||
})),
|
||||
);
|
||||
self.auth_manager.hot_swap(disk_auth.clone());
|
||||
Some(disk_auth)
|
||||
} else {
|
||||
tracing::debug!("auth recovery: disk token is same as rejected, skipping");
|
||||
kigi_log::unified_log::debug("auth recovery: disk token same as rejected", None, None);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the live token instead of refreshing when its mint age is
|
||||
/// within ±[`FRESH_MINT_GUARD_SECS`]; anything outside (including a
|
||||
/// clock that stepped far back) falls through to a normal refresh.
|
||||
///
|
||||
/// A 401 moments after a successful mint is a stale rejection (sent with
|
||||
/// the previous key and mis-attributed — see `is_stale_snapshot`) or
|
||||
/// validation lag on the new key — re-minting fixes neither, and a crash
|
||||
/// between the IdP grant and persisting the response orphans the
|
||||
/// replacement RT (forced re-login). Consumers retry with the returned
|
||||
/// token; a genuinely-bad one refreshes once the window passes. Lives
|
||||
/// here, not in `refresh_chain`, so paywall claims re-mints that call
|
||||
/// `refresh_chain(ServerRejected)` directly are unaffected.
|
||||
fn fresh_mint_guard(&self) -> Option<GrokAuth> {
|
||||
let auth = self.auth_manager.current()?;
|
||||
let mint_age_seconds = auth.mint_age_seconds();
|
||||
if !(-FRESH_MINT_GUARD_SECS..FRESH_MINT_GUARD_SECS).contains(&mint_age_seconds) {
|
||||
return None;
|
||||
}
|
||||
tracing::info!(
|
||||
mint_age_seconds,
|
||||
"auth recovery: current token freshly minted, skipping refresh"
|
||||
);
|
||||
kigi_log::unified_log::info(
|
||||
"auth recovery: fresh mint, refresh skipped",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"key_prefix": crate::auth::token_suffix(&auth.key),
|
||||
"mint_age_seconds": mint_age_seconds,
|
||||
"guard_seconds": FRESH_MINT_GUARD_SECS,
|
||||
"expires_at": auth.expires_at.map(|e| e.to_rfc3339()),
|
||||
})),
|
||||
);
|
||||
Some(auth)
|
||||
}
|
||||
|
||||
/// Dispatch to the correct refresh chain based on the current `TokenType`.
|
||||
///
|
||||
/// Per-variant outcome:
|
||||
///
|
||||
/// - **OidcSession / ExternalBinary**: full refresh chain via the
|
||||
/// authority, unless the live token is inside the fresh-mint guard
|
||||
/// window ([`Self::fresh_mint_guard`]).
|
||||
/// - **LegacySession / ApiKey**: no refresh authority for these
|
||||
/// types. We've already tried `ReloadFromDisk` (the previous
|
||||
/// recovery step), so the server's 401 stands. Surface
|
||||
/// [`AuthError::ServerRejectedNoRecovery`] -- *not*
|
||||
/// `TokenExpiredNoRefresh`, because the trigger here is the
|
||||
/// server rejecting the token (it may not have aged past any
|
||||
/// local TTL; ApiKey in particular has no expiry). Consumers
|
||||
/// reading the variant can distinguish "ran past local TTL" from
|
||||
/// "server actively rejected".
|
||||
/// - **None**: no credentials at all.
|
||||
async fn try_refresh_from_authority(&self) -> Result<GrokAuth, AuthError> {
|
||||
let tt = self.auth_manager.token_type();
|
||||
match tt {
|
||||
TokenType::OidcSession | TokenType::ExternalBinary => {
|
||||
if let Some(auth) = self.fresh_mint_guard() {
|
||||
return Ok(auth);
|
||||
}
|
||||
let result = self
|
||||
.auth_manager
|
||||
.refresh_chain(tt, crate::auth::manager::RefreshReason::ServerRejected)
|
||||
.await;
|
||||
match &result {
|
||||
Ok(auth) => {
|
||||
kigi_log::unified_log::info(
|
||||
"auth recovery: refreshed from authority",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"token_type": format!("{tt:?}"),
|
||||
"new_key_prefix": crate::auth::token_suffix(&auth.key),
|
||||
"expires_at": auth.expires_at.map(|e| e.to_rfc3339()),
|
||||
})),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
kigi_log::unified_log::warn(
|
||||
"auth recovery: refresh from authority failed",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"token_type": format!("{tt:?}"),
|
||||
"error": format!("{e}"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
TokenType::LegacySession | TokenType::ApiKey => {
|
||||
kigi_log::unified_log::warn(
|
||||
"auth recovery: no refresh authority for token type",
|
||||
None,
|
||||
Some(serde_json::json!({ "token_type": format!("{tt:?}") })),
|
||||
);
|
||||
Err(AuthError::ServerRejectedNoRecovery)
|
||||
}
|
||||
TokenType::None => Err(AuthError::NotLoggedIn),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a candidate token is different from the rejected one.
|
||||
fn is_different_token(&self, candidate: &GrokAuth) -> bool {
|
||||
candidate.key != self.rejected_token
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! State-machine matrix tests for `UnauthorizedRecovery`.
|
||||
//!
|
||||
//! Coverage targets:
|
||||
//! - All 5 `TokenType` variants x dispatch in `try_refresh_from_authority`.
|
||||
//! - `try_reload_from_disk`: same/different/no token on disk.
|
||||
//! - `next()` exhaustion (Done -> RecoveryExhausted).
|
||||
//! - Fresh-mint guard: ±window bounds, ExternalBinary, verdict grace,
|
||||
//! policy-hidden fall-through (fail closed).
|
||||
//!
|
||||
//! These tests use the same in-process `AuthManager` that production
|
||||
//! does and inject a counting refresher so we can observe whether the
|
||||
//! authority was consulted.
|
||||
use super::*;
|
||||
use crate::auth::config::GrokComConfig;
|
||||
use crate::auth::error::{RefreshTokenError, RefreshTokenFailedReason};
|
||||
use crate::auth::model::{AuthMode, GrokAuth};
|
||||
use crate::auth::refresh::{RefreshOutcome, TokenRefresher};
|
||||
use crate::auth::storage::{read_auth_json, write_auth_json};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// The rejected wire bearer these tests seed into the manager.
|
||||
fn rejected_cred() -> Option<GrokAuth> {
|
||||
Some(GrokAuth {
|
||||
key: "rejected-tok".into(),
|
||||
..GrokAuth::test_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresher fake: returns Success with a fresh token on every call.
|
||||
struct OkRefresher {
|
||||
calls: Arc<AtomicU32>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl TokenRefresher for OkRefresher {
|
||||
async fn refresh(&self, _reason: crate::auth::manager::RefreshReason) -> RefreshOutcome {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
RefreshOutcome::Success(Box::new(GrokAuth {
|
||||
key: "fresh-from-authority".into(),
|
||||
auth_mode: AuthMode::Oidc,
|
||||
refresh_token: Some("rt-new".into()),
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresher fake: returns PermanentFailure (invalid_grant).
|
||||
struct FailRefresher {
|
||||
calls: Arc<AtomicU32>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl TokenRefresher for FailRefresher {
|
||||
async fn refresh(&self, _reason: crate::auth::manager::RefreshReason) -> RefreshOutcome {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
RefreshOutcome::permanent(RefreshTokenFailedReason::RefreshTokenRejected, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn mgr() -> (tempfile::TempDir, Arc<AuthManager>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let m = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
|
||||
(dir, m)
|
||||
}
|
||||
|
||||
fn seed(mgr: &AuthManager, mode: AuthMode, refresh_token: Option<&str>) {
|
||||
let auth = GrokAuth {
|
||||
key: "rejected-tok".into(),
|
||||
auth_mode: mode,
|
||||
refresh_token: refresh_token.map(str::to_string),
|
||||
// Past expiry so `current()` returns None and the refresh
|
||||
// chain actually has to do work.
|
||||
expires_at: Some(Utc::now() - Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
mgr.hot_swap(auth);
|
||||
}
|
||||
|
||||
// -- TokenType dispatch matrix ----------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_oidc_session_uses_refresh_chain() {
|
||||
let (_d, m) = mgr();
|
||||
seed(&m, AuthMode::Oidc, Some("rt"));
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
m.set_refresher(Arc::new(OkRefresher {
|
||||
calls: calls.clone(),
|
||||
}));
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
// ReloadFromDisk fails (no disk auth), then RefreshFromAuthority succeeds.
|
||||
let auth = rec.next().await.expect("recovery should succeed");
|
||||
assert_eq!(auth.key, "fresh-from-authority");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_external_binary_uses_refresh_chain() {
|
||||
let (_d, m) = mgr();
|
||||
seed(&m, AuthMode::External, None);
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
m.set_refresher(Arc::new(OkRefresher {
|
||||
calls: calls.clone(),
|
||||
}));
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let auth = rec.next().await.expect("external-binary recovery succeeds");
|
||||
assert_eq!(auth.key, "fresh-from-authority");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// -- Fresh-mint guard --------------------------------------------------
|
||||
|
||||
/// Seed a *valid* (unexpired) in-memory token whose `create_time` lies
|
||||
/// `mint_age` in the past (negative = clock stepped back since mint).
|
||||
fn seed_valid(mgr: &AuthManager, mode: AuthMode, mint_age: Duration) {
|
||||
mgr.hot_swap(GrokAuth {
|
||||
key: "rejected-tok".into(),
|
||||
auth_mode: mode,
|
||||
refresh_token: Some("rt".into()),
|
||||
create_time: Utc::now() - mint_age,
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
});
|
||||
}
|
||||
|
||||
/// Run one recovery against a counting refresher; return the outcome and
|
||||
/// how many times the authority was consulted.
|
||||
async fn recover_with_ok_refresher(m: &Arc<AuthManager>) -> (Result<GrokAuth, AuthError>, u32) {
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
m.set_refresher(Arc::new(OkRefresher {
|
||||
calls: calls.clone(),
|
||||
}));
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let result = rec.next().await;
|
||||
(result, calls.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_mint_guard_skips_idp_for_freshly_minted_token() {
|
||||
let (_d, m) = mgr();
|
||||
seed_valid(&m, AuthMode::Oidc, Duration::seconds(10));
|
||||
let (result, calls) = recover_with_ok_refresher(&m).await;
|
||||
assert_eq!(
|
||||
result.expect("guard returns the live token").key,
|
||||
"rejected-tok"
|
||||
);
|
||||
assert_eq!(calls, 0, "a 10s-old token must not be re-minted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_mint_guard_applies_to_external_binary_tokens() {
|
||||
let (_d, m) = mgr();
|
||||
seed_valid(&m, AuthMode::External, Duration::seconds(10));
|
||||
let (result, calls) = recover_with_ok_refresher(&m).await;
|
||||
assert_eq!(
|
||||
result.expect("guard returns the live token").key,
|
||||
"rejected-tok"
|
||||
);
|
||||
assert_eq!(calls, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_mint_guard_treats_small_negative_age_as_fresh() {
|
||||
// Clock stepped back slightly since mint (NTP nudge).
|
||||
let (_d, m) = mgr();
|
||||
seed_valid(&m, AuthMode::Oidc, Duration::seconds(-60));
|
||||
let (result, calls) = recover_with_ok_refresher(&m).await;
|
||||
assert_eq!(
|
||||
result.expect("guard returns the live token").key,
|
||||
"rejected-tok"
|
||||
);
|
||||
assert_eq!(calls, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_mint_guard_refreshes_when_clock_stepped_far_back() {
|
||||
// A large backwards clock step must not wedge recovery for the whole
|
||||
// step: outside the ±window the guard stands down.
|
||||
let (_d, m) = mgr();
|
||||
seed_valid(&m, AuthMode::Oidc, Duration::hours(-1));
|
||||
let (result, calls) = recover_with_ok_refresher(&m).await;
|
||||
assert_eq!(
|
||||
result.expect("recovery should succeed").key,
|
||||
"fresh-from-authority"
|
||||
);
|
||||
assert_eq!(calls, 1, "far-negative mint age must reach the IdP");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_mint_guard_lets_old_token_refresh() {
|
||||
let (_d, m) = mgr();
|
||||
seed_valid(&m, AuthMode::Oidc, Duration::minutes(10));
|
||||
let (result, calls) = recover_with_ok_refresher(&m).await;
|
||||
assert_eq!(
|
||||
result.expect("recovery should succeed").key,
|
||||
"fresh-from-authority"
|
||||
);
|
||||
assert_eq!(
|
||||
calls, 1,
|
||||
"outside the guard window ServerRejected must reach the IdP"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_mint_guard_wins_over_cached_permanent_failure() {
|
||||
// A fresh *valid* token is served even when a permanent-failure
|
||||
// verdict is cached for it — mirrors `auth()`'s wire-valid grace arm;
|
||||
// the verdict re-applies once the guard window passes.
|
||||
let (_d, m) = mgr();
|
||||
seed_valid(&m, AuthMode::Oidc, Duration::seconds(10));
|
||||
m.record_permanent_failure(
|
||||
"rejected-tok".into(),
|
||||
RefreshTokenFailedReason::RefreshTokenRejected.into(),
|
||||
);
|
||||
let (result, calls) = recover_with_ok_refresher(&m).await;
|
||||
assert_eq!(
|
||||
result
|
||||
.expect("guard precedes the verdict short-circuit")
|
||||
.key,
|
||||
"rejected-tok"
|
||||
);
|
||||
assert_eq!(calls, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_mint_guard_never_returns_policy_hidden_token() {
|
||||
// Wrong-team fresh token: `current()` hides it (vet_cached), so the
|
||||
// guard must fall through to a normal refresh — fail closed.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = GrokComConfig {
|
||||
force_login_team_uuid: Some(crate::auth::config::ForceLoginTeam::Single(
|
||||
"team-good".into(),
|
||||
)),
|
||||
..GrokComConfig::default()
|
||||
};
|
||||
let m = Arc::new(AuthManager::new(dir.path(), cfg));
|
||||
m.hot_swap(GrokAuth {
|
||||
key: team_jwt("team-wrong"),
|
||||
auth_mode: AuthMode::Oidc,
|
||||
refresh_token: Some("rt".into()),
|
||||
create_time: Utc::now(),
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
});
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
m.set_refresher(Arc::new(OkRefresher {
|
||||
calls: calls.clone(),
|
||||
}));
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let result = rec.next().await;
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"hidden token must not satisfy the guard"
|
||||
);
|
||||
if let Ok(auth) = result {
|
||||
assert_ne!(
|
||||
auth.key,
|
||||
team_jwt("team-wrong"),
|
||||
"wrong-team token must never be returned"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_legacy_session_returns_server_rejected_no_recovery() {
|
||||
let (_d, m) = mgr();
|
||||
// WebLogin (no refresh_token) -> LegacySession.
|
||||
seed(&m, AuthMode::WebLogin, None);
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let err = rec.next().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AuthError::ServerRejectedNoRecovery),
|
||||
"LegacySession recovery should surface ServerRejectedNoRecovery, got {err:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_oidc_without_refresh_token_returns_server_rejected_no_recovery() {
|
||||
// Oidc without refresh_token classifies as LegacySession.
|
||||
let (_d, m) = mgr();
|
||||
seed(&m, AuthMode::Oidc, None);
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let err = rec.next().await.unwrap_err();
|
||||
assert!(matches!(err, AuthError::ServerRejectedNoRecovery));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_api_key_returns_server_rejected_no_recovery() {
|
||||
let (_d, m) = mgr();
|
||||
seed(&m, AuthMode::ApiKey, None);
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let err = rec.next().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AuthError::ServerRejectedNoRecovery),
|
||||
"ApiKey recovery should surface ServerRejectedNoRecovery (not \
|
||||
TokenExpiredNoRefresh), got {err:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_none_returns_not_logged_in() {
|
||||
let (_d, m) = mgr();
|
||||
// No seed — inner stays None → TokenType::None.
|
||||
// Single next() falls through ReloadFromDisk → RefreshFromAuthority.
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let err = rec.next().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AuthError::NotLoggedIn),
|
||||
"None token type should surface NotLoggedIn, got {err:?}",
|
||||
);
|
||||
}
|
||||
|
||||
// -- ReloadFromDisk matrix --------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_from_disk_picks_up_different_token() {
|
||||
let (dir, m) = mgr();
|
||||
seed(&m, AuthMode::Oidc, Some("rt"));
|
||||
|
||||
// Sibling process wrote a different valid token to disk.
|
||||
let scope = m.grok_com_config().auth_scope();
|
||||
let fresh = GrokAuth {
|
||||
key: "fresh-from-disk".into(),
|
||||
auth_mode: AuthMode::Oidc,
|
||||
refresh_token: Some("rt-new".into()),
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
let mut store = read_auth_json(&dir.path().join("auth.json")).unwrap_or_default();
|
||||
store.insert(scope, fresh);
|
||||
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let auth = rec
|
||||
.next()
|
||||
.await
|
||||
.expect("recovery should pick up the disk token");
|
||||
assert_eq!(auth.key, "fresh-from-disk");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_from_disk_skips_same_token_then_proceeds_to_authority() {
|
||||
let (dir, m) = mgr();
|
||||
seed(&m, AuthMode::Oidc, Some("rt"));
|
||||
|
||||
// Disk has the SAME token that was rejected -- skip, fall through.
|
||||
let scope = m.grok_com_config().auth_scope();
|
||||
let same = GrokAuth {
|
||||
key: "rejected-tok".into(),
|
||||
auth_mode: AuthMode::Oidc,
|
||||
refresh_token: Some("rt".into()),
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
let mut store = read_auth_json(&dir.path().join("auth.json")).unwrap_or_default();
|
||||
store.insert(scope, same);
|
||||
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
|
||||
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
m.set_refresher(Arc::new(OkRefresher {
|
||||
calls: calls.clone(),
|
||||
}));
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let auth = rec.next().await.expect("authority refresh succeeds");
|
||||
assert_eq!(auth.key, "fresh-from-authority");
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"fall-through to authority must invoke the refresher exactly once",
|
||||
);
|
||||
}
|
||||
|
||||
// -- Done state -------------------------------------------------------
|
||||
|
||||
/// With no stored authority error (the first `next()` succeeded), driving
|
||||
/// past `Done` surfaces `RecoveryExhausted`. The transient-failure case is
|
||||
/// pinned by `exhaustion_after_transient_failure_stays_transient`.
|
||||
#[tokio::test]
|
||||
async fn next_after_done_returns_recovery_exhausted() {
|
||||
let (_d, m) = mgr();
|
||||
seed(&m, AuthMode::Oidc, Some("rt"));
|
||||
m.set_refresher(Arc::new(OkRefresher {
|
||||
calls: Arc::new(AtomicU32::new(0)),
|
||||
}));
|
||||
|
||||
// Pin non-devbox so DevboxRecovery can't adopt the seeded token (CI runs
|
||||
// in K8s pods where is_devbox_environment() is true).
|
||||
m.set_devbox_env_for_test(false);
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let _ = rec.next().await.unwrap();
|
||||
let err = loop {
|
||||
if let Err(e) = rec.next().await {
|
||||
break e;
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
matches!(err, AuthError::RecoveryExhausted),
|
||||
"Done state must surface RecoveryExhausted, got {err:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Exhaustion after a *transient* authority failure preserves the
|
||||
/// transient axis: surfacing `RecoveryExhausted` would count a network
|
||||
/// blip as a forced re-login (`manual_auth`) and make the relay cancel
|
||||
/// instead of reconnect.
|
||||
#[tokio::test]
|
||||
async fn exhaustion_after_transient_failure_stays_transient() {
|
||||
/// Refresher fake: transient failure on every call.
|
||||
struct TransientFailRefresher;
|
||||
#[async_trait::async_trait]
|
||||
impl TokenRefresher for TransientFailRefresher {
|
||||
async fn refresh(
|
||||
&self,
|
||||
_reason: crate::auth::manager::RefreshReason,
|
||||
) -> RefreshOutcome {
|
||||
RefreshOutcome::transient("network blip")
|
||||
}
|
||||
}
|
||||
|
||||
let (_d, m) = mgr();
|
||||
seed(&m, AuthMode::Oidc, Some("rt"));
|
||||
m.set_refresher(Arc::new(TransientFailRefresher));
|
||||
m.set_devbox_env_for_test(false);
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
// First next(): the authority's transient error propagates as-is.
|
||||
let first = rec.next().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(first, AuthError::Refresh(RefreshTokenError::Transient(_))),
|
||||
"authority transient must propagate, got {first:?}",
|
||||
);
|
||||
|
||||
// Driving past exhaustion must stay transient too.
|
||||
let err = loop {
|
||||
if let Err(e) = rec.next().await {
|
||||
break e;
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
matches!(err, AuthError::Refresh(RefreshTokenError::Transient(_))),
|
||||
"exhaustion after a transient failure must stay transient, got {err:?}",
|
||||
);
|
||||
assert!(
|
||||
!forces_manual_reauth(&err),
|
||||
"a transient exhaustion must not force a manual re-login",
|
||||
);
|
||||
assert!(
|
||||
!relay_should_cancel(&err),
|
||||
"the relay must reconnect (not cancel) on a transient exhaustion",
|
||||
);
|
||||
}
|
||||
|
||||
// -- Permanent failure short-circuit (cross-check) ------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_authority_short_circuits_on_cached_permanent_failure() {
|
||||
let (_d, m) = mgr();
|
||||
seed(&m, AuthMode::Oidc, Some("rt"));
|
||||
// Pre-record a permanent failure scoped to the seeded credential.
|
||||
m.record_permanent_failure(
|
||||
"rejected-tok".into(),
|
||||
RefreshTokenFailedReason::RefreshTokenRejected.into(),
|
||||
);
|
||||
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
m.set_refresher(Arc::new(FailRefresher {
|
||||
calls: calls.clone(),
|
||||
}));
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let err = rec.next().await.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
AuthError::Refresh(RefreshTokenError::Permanent(_))
|
||||
));
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
0,
|
||||
"refresher must not be invoked when permanent_failure is cached",
|
||||
);
|
||||
}
|
||||
|
||||
// -- ReloadFromDisk rejects expired disk tokens -------------------------
|
||||
|
||||
/// Regression: disk holds a different but expired token. Recovery
|
||||
/// must skip it and fall through to RefreshFromAuthority, not
|
||||
/// return it for the caller to send on the wire (instant 401).
|
||||
#[tokio::test]
|
||||
async fn reload_from_disk_rejects_expired_different_token() {
|
||||
let (dir, m) = mgr();
|
||||
seed(&m, AuthMode::Oidc, Some("rt"));
|
||||
|
||||
let scope = m.grok_com_config().auth_scope();
|
||||
let expired_different = GrokAuth {
|
||||
key: "different-but-expired".into(),
|
||||
auth_mode: AuthMode::Oidc,
|
||||
refresh_token: Some("rt-new".into()),
|
||||
expires_at: Some(Utc::now() - Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
let mut store = read_auth_json(&dir.path().join("auth.json")).unwrap_or_default();
|
||||
store.insert(scope, expired_different);
|
||||
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
|
||||
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
m.set_refresher(Arc::new(OkRefresher {
|
||||
calls: calls.clone(),
|
||||
}));
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let auth = rec.next().await.expect("should fall through to authority");
|
||||
assert_eq!(
|
||||
auth.key, "fresh-from-authority",
|
||||
"must skip the expired disk token and use the refresher"
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// -- force_login_team_uuid pin enforced on the 401-recovery path -------
|
||||
|
||||
fn ensure_crypto_provider() {
|
||||
let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default();
|
||||
}
|
||||
|
||||
fn team_jwt(principal_id: &str) -> String {
|
||||
ensure_crypto_provider();
|
||||
jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
|
||||
&serde_json::json!({
|
||||
"sub": "user-1",
|
||||
"principal_type": "Team",
|
||||
"principal_id": principal_id,
|
||||
"exp": 9999999999u64,
|
||||
}),
|
||||
&jsonwebtoken::EncodingKey::from_secret(b"test-secret"),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A sibling writes a wrong-team token to disk; 401 recovery (relay path)
|
||||
/// must reject + clear it at `next()`, not hand it back as a bearer.
|
||||
#[tokio::test]
|
||||
async fn recovery_rejects_wrong_team_adopted_disk_token() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = GrokComConfig {
|
||||
force_login_team_uuid: Some(crate::auth::config::ForceLoginTeam::Single(
|
||||
"team-good".into(),
|
||||
)),
|
||||
..GrokComConfig::default()
|
||||
};
|
||||
let scope = cfg.auth_scope();
|
||||
let m = Arc::new(AuthManager::new(dir.path(), cfg));
|
||||
|
||||
// In-memory: the rejected (expired) session that triggered recovery.
|
||||
seed(&m, AuthMode::Oidc, Some("rt"));
|
||||
|
||||
// Disk: a different, non-expired, *wrong-team* token a sibling wrote.
|
||||
let mut store = read_auth_json(&dir.path().join("auth.json")).unwrap_or_default();
|
||||
store.insert(
|
||||
scope,
|
||||
GrokAuth {
|
||||
key: team_jwt("team-wrong"),
|
||||
auth_mode: AuthMode::Oidc,
|
||||
refresh_token: Some("rt-sibling".into()),
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
},
|
||||
);
|
||||
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
|
||||
|
||||
let mut rec = m.unauthorized_recovery(rejected_cred());
|
||||
let err = rec.next().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AuthError::PinnedTeamMismatch { .. }),
|
||||
"recovery must reject a wrong-team disk token, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! End-to-end auth-backend contract tests: a mock IdP whose `/token` response
|
||||
//! is forced per case, asserting the refresh outcome, the storm cap, and the
|
||||
//! terminal-error classification on the live recovery path.
|
||||
|
||||
use super::*;
|
||||
use crate::auth::error::RefreshTokenFailedReason;
|
||||
use crate::auth::{GrokAuth, GrokComConfig};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// Mock IdP: OIDC discovery + a `/token` endpoint returning a fixed
|
||||
/// `(status, body)` and counting every hit, plus the `/user` endpoint
|
||||
/// `AuthManager::update` calls after a successful refresh. `delay_ms` widens
|
||||
/// the in-lock window so concurrent callers queue on `refresh_lock`.
|
||||
async fn start_idp(
|
||||
token_status: u16,
|
||||
token_body: String,
|
||||
hits: Arc<AtomicU32>,
|
||||
delay_ms: u64,
|
||||
) -> (String, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
|
||||
let disco = base.clone();
|
||||
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/.well-known/openid-configuration",
|
||||
axum::routing::get(move || {
|
||||
let b = disco.clone();
|
||||
async move {
|
||||
axum::Json(serde_json::json!({
|
||||
"authorization_endpoint": format!("{b}/authorize"),
|
||||
"token_endpoint": format!("{b}/token"),
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/token",
|
||||
axum::routing::post(move || {
|
||||
let hits = hits.clone();
|
||||
let body = token_body.clone();
|
||||
async move {
|
||||
hits.fetch_add(1, Ordering::SeqCst);
|
||||
if delay_ms > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
(
|
||||
axum::http::StatusCode::from_u16(token_status).unwrap(),
|
||||
body,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/user",
|
||||
axum::routing::get(|| async {
|
||||
axum::Json(serde_json::json!({ "userId": "user-42", "email": "u@corp.com" }))
|
||||
}),
|
||||
);
|
||||
|
||||
let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
(base, handle)
|
||||
}
|
||||
|
||||
fn expired_oidc(base_url: &str) -> GrokAuth {
|
||||
GrokAuth {
|
||||
key: "expired-at".into(),
|
||||
create_time: Utc::now() - Duration::hours(2),
|
||||
user_id: "user-42".into(),
|
||||
auth_mode: crate::auth::model::AuthMode::Oidc,
|
||||
refresh_token: Some("rt-under-test".into()),
|
||||
expires_at: Some(Utc::now() - Duration::hours(1)),
|
||||
oidc_issuer: Some(base_url.to_owned()),
|
||||
oidc_client_id: Some("client-under-test".into()),
|
||||
..GrokAuth::test_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Expect {
|
||||
Success,
|
||||
Permanent(RefreshTokenFailedReason),
|
||||
Transient,
|
||||
}
|
||||
|
||||
/// The IdP token-endpoint contract: each response shape maps to one outcome.
|
||||
/// `invalid_grant`/`invalid_client` are the only permanent verdicts; status
|
||||
/// blips and unrecognized codes stay transient (never permanent-lock).
|
||||
#[tokio::test]
|
||||
async fn auth_backend_contract_token_responses_map_to_outcomes() {
|
||||
use RefreshTokenFailedReason::{ClientRejected, RefreshTokenRejected};
|
||||
let cases: &[(&str, u16, &str, Expect)] = &[
|
||||
(
|
||||
"success",
|
||||
200,
|
||||
r#"{"access_token":"fresh","refresh_token":"fresh-rt","expires_in":3600}"#,
|
||||
Expect::Success,
|
||||
),
|
||||
(
|
||||
"invalid_grant",
|
||||
400,
|
||||
r#"{"error":"invalid_grant"}"#,
|
||||
Expect::Permanent(RefreshTokenRejected),
|
||||
),
|
||||
(
|
||||
"invalid_client",
|
||||
401,
|
||||
r#"{"error":"invalid_client"}"#,
|
||||
Expect::Permanent(ClientRejected),
|
||||
),
|
||||
("server_error_5xx", 503, "{}", Expect::Transient),
|
||||
("rate_limited_429", 429, "{}", Expect::Transient),
|
||||
(
|
||||
"temporarily_unavailable",
|
||||
400,
|
||||
r#"{"error":"temporarily_unavailable"}"#,
|
||||
Expect::Transient,
|
||||
),
|
||||
("bare_4xx_no_body", 400, "", Expect::Transient),
|
||||
("malformed_body", 400, "not json", Expect::Transient),
|
||||
// Proxy/WAF-mangled bodies must degrade to retry, never a false permanent
|
||||
// lock: a nested error object or a non-string `error` is not a recognized
|
||||
// top-level code, so it stays transient.
|
||||
(
|
||||
"nested_error_object",
|
||||
400,
|
||||
r#"{"error":{"code":"invalid_grant"}}"#,
|
||||
Expect::Transient,
|
||||
),
|
||||
(
|
||||
"non_string_error",
|
||||
400,
|
||||
r#"{"error":123}"#,
|
||||
Expect::Transient,
|
||||
),
|
||||
];
|
||||
|
||||
for (name, status, body, expect) in cases {
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
let (base_url, server) = start_idp(*status, body.to_string(), hits.clone(), 0).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth_manager = Arc::new(
|
||||
AuthManager::new(dir.path(), GrokComConfig::default()).with_proxy_base_url(&base_url),
|
||||
);
|
||||
auth_manager.hot_swap(expired_oidc(&base_url));
|
||||
|
||||
let refresher = OidcRefresher::new(auth_manager.clone());
|
||||
let result = refresher.refresh(RefreshReason::ServerRejected).await;
|
||||
|
||||
match (expect, &result) {
|
||||
(Expect::Success, RefreshOutcome::Success(_)) => {}
|
||||
(Expect::Permanent(want), RefreshOutcome::PermanentFailure { error, .. }) => {
|
||||
assert_eq!(error.reason, *want, "{name}: wrong permanent reason");
|
||||
}
|
||||
(Expect::Transient, RefreshOutcome::TransientFailure { .. }) => {}
|
||||
(exp, got) => panic!("{name}: expected {exp:?}, got {got:?}"),
|
||||
}
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// A burst of concurrent 401s on the same revoked refresh token must hit the
|
||||
/// IdP exactly once. The callers serialize on `refresh_lock`; the leader records
|
||||
/// the verdict before releasing, so the in-lock re-check (`refresh_chain` step
|
||||
/// 1b) short-circuits every follower. Delete step 1b and the count climbs to N.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn auth_backend_contract_concurrent_401s_hit_idp_once() {
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
// 100ms /token delay so every caller passes the pre-lock check and queues
|
||||
// on refresh_lock before the leader records the verdict, exercising step 1b.
|
||||
let (base_url, server) = start_idp(
|
||||
400,
|
||||
r#"{"error":"invalid_grant"}"#.to_string(),
|
||||
hits.clone(),
|
||||
100,
|
||||
)
|
||||
.await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth_manager = Arc::new(
|
||||
AuthManager::new(dir.path(), GrokComConfig::default()).with_proxy_base_url(&base_url),
|
||||
);
|
||||
auth_manager.hot_swap(expired_oidc(&base_url));
|
||||
auth_manager.set_refresher(Arc::new(OidcRefresher::new(auth_manager.clone())));
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..6 {
|
||||
let auth_manager = auth_manager.clone();
|
||||
tasks.push(tokio::spawn(async move { auth_manager.auth().await }));
|
||||
}
|
||||
for t in tasks {
|
||||
let outcome = t.await.unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
outcome,
|
||||
Err(crate::auth::AuthError::Refresh(
|
||||
crate::auth::RefreshTokenError::Permanent(_)
|
||||
))
|
||||
),
|
||||
"every concurrent caller must fail permanently on a revoked refresh token, got {outcome:?}",
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"concurrent 401s on one dead credential must hit the IdP exactly once",
|
||||
);
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
/// The classification loop through the live recovery state machine: a dead
|
||||
/// refresh token terminates recovery with an error that forces a manual
|
||||
/// re-login; a refreshable token auto-refreshes.
|
||||
#[tokio::test]
|
||||
async fn auth_backend_contract_dead_token_forces_manual_reauth() {
|
||||
// A dead refresh token terminates recovery with a forced-relogin error.
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
let (url, server) = start_idp(400, r#"{"error":"invalid_grant"}"#.to_string(), hits, 0).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth_manager =
|
||||
Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()).with_proxy_base_url(&url));
|
||||
auth_manager.hot_swap(expired_oidc(&url));
|
||||
auth_manager.set_refresher(Arc::new(OidcRefresher::new(auth_manager.clone())));
|
||||
|
||||
let err = auth_manager
|
||||
.unauthorized_recovery(auth_manager.current_or_expired())
|
||||
.next()
|
||||
.await
|
||||
.expect_err("a dead refresh token must fail recovery");
|
||||
assert!(
|
||||
crate::auth::recovery::forces_manual_reauth(&err),
|
||||
"a dead refresh token must be a forced-relogin error, got {err:?}",
|
||||
);
|
||||
server.abort();
|
||||
|
||||
// Refreshable token: recovery auto-refreshes.
|
||||
let ok_hits = Arc::new(AtomicU32::new(0));
|
||||
let (ok_url, ok_server) = start_idp(
|
||||
200,
|
||||
r#"{"access_token":"fresh","refresh_token":"fresh-rt","expires_in":3600}"#.to_string(),
|
||||
ok_hits,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
let ok_dir = tempfile::tempdir().unwrap();
|
||||
let ok_manager = Arc::new(
|
||||
AuthManager::new(ok_dir.path(), GrokComConfig::default()).with_proxy_base_url(&ok_url),
|
||||
);
|
||||
ok_manager.hot_swap(expired_oidc(&ok_url));
|
||||
ok_manager.set_refresher(Arc::new(OidcRefresher::new(ok_manager.clone())));
|
||||
|
||||
let refreshed = ok_manager
|
||||
.unauthorized_recovery(ok_manager.current_or_expired())
|
||||
.next()
|
||||
.await
|
||||
.expect("a refreshable token must auto-refresh");
|
||||
assert_eq!(
|
||||
refreshed.key, "fresh",
|
||||
"recovery must return the fresh token"
|
||||
);
|
||||
ok_server.abort();
|
||||
}
|
||||
|
||||
/// Consecutive transient failures self-heal up to a bound, then escalate to a
|
||||
/// non-sticky `Other` permanent failure (which ages out via the TTL). A
|
||||
/// regression here would turn recoverable blips into a permanent `/login`.
|
||||
#[tokio::test]
|
||||
async fn auth_backend_contract_transient_failures_escalate_to_non_sticky_permanent() {
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
// Persistent 503: every refresh attempt is transient.
|
||||
let (base_url, server) = start_idp(503, "{}".to_string(), hits, 0).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth_manager = Arc::new(
|
||||
AuthManager::new(dir.path(), GrokComConfig::default()).with_proxy_base_url(&base_url),
|
||||
);
|
||||
auth_manager.hot_swap(expired_oidc(&base_url));
|
||||
|
||||
// One refresher instance: it owns the consecutive-failure counter.
|
||||
let refresher = OidcRefresher::new(auth_manager.clone());
|
||||
let mut outcomes = Vec::new();
|
||||
for _ in 0..3 {
|
||||
outcomes.push(refresher.refresh(RefreshReason::ServerRejected).await);
|
||||
}
|
||||
|
||||
assert!(
|
||||
matches!(outcomes[0], RefreshOutcome::TransientFailure { .. }),
|
||||
"first blip is transient, not a lockout: {:?}",
|
||||
outcomes[0],
|
||||
);
|
||||
match &outcomes[2] {
|
||||
RefreshOutcome::PermanentFailure { error, .. } => {
|
||||
assert_eq!(
|
||||
error.reason,
|
||||
RefreshTokenFailedReason::Other,
|
||||
"escalation must use the generic Other reason",
|
||||
);
|
||||
assert!(
|
||||
!error.reason.is_sticky(),
|
||||
"an escalated transient must age out, not strand the user forever",
|
||||
);
|
||||
}
|
||||
other => panic!("repeated transients must escalate to a permanent Other, got {other:?}"),
|
||||
}
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
/// Two `AuthManager`s sharing one auth.json stand in for two CLI processes: the
|
||||
/// auth.json flock must serialize their refreshes so the shared refresh token is
|
||||
/// spent at the IdP exactly once. The loser adopts the rotated token from disk
|
||||
/// instead of racing a second exchange (which the IdP could revoke as reuse).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn auth_backend_contract_two_instances_share_one_idp_call() {
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
let (url, server) = start_idp(
|
||||
200,
|
||||
r#"{"access_token":"fresh","refresh_token":"fresh-rt","expires_in":3600}"#.to_string(),
|
||||
hits.clone(),
|
||||
100,
|
||||
)
|
||||
.await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Distinct managers, same on-disk auth.json (separate flock OFDs => they
|
||||
// genuinely contend, like two processes).
|
||||
let new_instance = || {
|
||||
let m = Arc::new(
|
||||
AuthManager::new(dir.path(), GrokComConfig::default()).with_proxy_base_url(&url),
|
||||
);
|
||||
m.hot_swap(expired_oidc(&url));
|
||||
m.set_refresher(Arc::new(OidcRefresher::new(m.clone())));
|
||||
m
|
||||
};
|
||||
let a = new_instance();
|
||||
let b = new_instance();
|
||||
|
||||
let (ra, rb) = tokio::join!(a.auth(), b.auth());
|
||||
|
||||
assert_eq!(ra.expect("instance A must obtain a token").key, "fresh");
|
||||
assert_eq!(rb.expect("instance B must obtain a token").key, "fresh");
|
||||
assert_eq!(
|
||||
hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"two instances sharing auth.json must spend the refresh token at the IdP only once",
|
||||
);
|
||||
|
||||
server.abort();
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::error::RefreshTokenFailedReason;
|
||||
use crate::auth::manager::RefreshReason;
|
||||
|
||||
use super::{ExternalCommandRunner, RefreshOutcome, TokenRefresher};
|
||||
|
||||
/// Refreshes by re-running the operator's external auth binary via
|
||||
/// `spawn_blocking`. Pure data return -- mutation lives in
|
||||
/// `refresh_chain` (honors the [`TokenRefresher`] no-mutation contract).
|
||||
pub(crate) struct ExternalBinaryRefresher {
|
||||
runner: Arc<dyn ExternalCommandRunner>,
|
||||
command: String,
|
||||
timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl ExternalBinaryRefresher {
|
||||
pub(crate) fn new(runner: Arc<dyn ExternalCommandRunner>, command: String) -> Self {
|
||||
Self {
|
||||
runner,
|
||||
command,
|
||||
timeout: EXTERNAL_REFRESH_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the binary timeout (tests use a short one to exercise the
|
||||
/// timeout arm without a real 30s wait).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// A failed binary run is a single-strike `Other` permanent failure; the
|
||||
/// `PERMANENT_FAILURE_TTL` lets a flaky binary self-heal without `/login`.
|
||||
/// No consecutive-blip tolerance like OIDC: a local binary failure is a
|
||||
/// stronger signal than a network refresh blip.
|
||||
fn record_failure(&self, message: String) -> RefreshOutcome {
|
||||
tracing::warn!(%message, "auth: external binary refresh failed -> permanent");
|
||||
// No token key in the binary flow; the caller scopes the verdict.
|
||||
RefreshOutcome::permanent(RefreshTokenFailedReason::Other, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout for the external auth binary. If the binary hangs, the
|
||||
/// `spawn_blocking` thread is leaked (it cannot be interrupted), but this is
|
||||
/// acceptable: the thread holds no locks and mutates no shared state.
|
||||
const EXTERNAL_REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TokenRefresher for ExternalBinaryRefresher {
|
||||
async fn refresh(&self, reason: RefreshReason) -> RefreshOutcome {
|
||||
tracing::debug!(?reason, "auth: external binary refresh starting");
|
||||
let runner = self.runner.clone();
|
||||
let cmd = self.command.clone();
|
||||
let timeout_ms = self.timeout.as_millis() as u64;
|
||||
match tokio::time::timeout(
|
||||
self.timeout,
|
||||
tokio::task::spawn_blocking(move || runner.run_external_command(&cmd)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(_elapsed) => {
|
||||
tracing::warn!(
|
||||
timeout_ms,
|
||||
"auth: external binary refresh timed out (thread leaked)"
|
||||
);
|
||||
crate::unified_log::warn(
|
||||
"auth.refresh.external_timeout",
|
||||
None,
|
||||
Some(serde_json::json!({ "timeout_ms": timeout_ms })),
|
||||
);
|
||||
self.record_failure(format!("external binary timed out after {timeout_ms}ms"))
|
||||
}
|
||||
Ok(Ok(Some(auth))) => {
|
||||
crate::unified_log::info("auth: external binary refresh succeeded", None, None);
|
||||
RefreshOutcome::success(auth)
|
||||
}
|
||||
Ok(Ok(None)) => {
|
||||
crate::unified_log::warn(
|
||||
"auth: external binary refresh returned no token",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
self.record_failure("external binary returned no token".into())
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(error = %e, "auth: external binary refresh task failed");
|
||||
self.record_failure(format!("external binary task failed: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::GrokAuth;
|
||||
|
||||
/// Minimal runner whose external command returns a fixed result.
|
||||
struct FakeRunner {
|
||||
external_result: Option<GrokAuth>,
|
||||
}
|
||||
impl ExternalCommandRunner for FakeRunner {
|
||||
fn run_external_command(&self, _command: &str) -> Option<GrokAuth> {
|
||||
self.external_result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed binary run is a single-strike `Other` permanent failure that is
|
||||
/// NON-sticky: it must age out via the TTL, never lock an external-binary
|
||||
/// user out forever. (Flipping this to a sticky reason would be a silent
|
||||
/// lockout regression.)
|
||||
#[tokio::test]
|
||||
async fn external_binary_failure_is_single_strike_non_sticky_permanent() {
|
||||
let refresher = ExternalBinaryRefresher::new(
|
||||
Arc::new(FakeRunner {
|
||||
external_result: None,
|
||||
}),
|
||||
"auth-binary".into(),
|
||||
);
|
||||
match refresher.refresh(RefreshReason::ServerRejected).await {
|
||||
RefreshOutcome::PermanentFailure { error, .. } => {
|
||||
assert_eq!(error.reason, RefreshTokenFailedReason::Other);
|
||||
assert!(
|
||||
!error.reason.is_sticky(),
|
||||
"external-binary failure must age out, not strand the user forever",
|
||||
);
|
||||
}
|
||||
other => panic!("a failed binary run must be a permanent Other failure, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A binary that outlives the (test-shortened) timeout hits the `Elapsed`
|
||||
/// arm and maps to the same non-sticky `Other` permanent failure.
|
||||
#[tokio::test]
|
||||
async fn external_binary_timeout_is_non_sticky_permanent() {
|
||||
struct SlowRunner;
|
||||
impl ExternalCommandRunner for SlowRunner {
|
||||
fn run_external_command(&self, _command: &str) -> Option<GrokAuth> {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
Some(GrokAuth::test_default())
|
||||
}
|
||||
}
|
||||
let refresher = ExternalBinaryRefresher::new(Arc::new(SlowRunner), "auth-binary".into())
|
||||
.with_timeout(std::time::Duration::from_millis(5));
|
||||
match refresher.refresh(RefreshReason::ServerRejected).await {
|
||||
RefreshOutcome::PermanentFailure { error, .. } => {
|
||||
assert_eq!(error.reason, RefreshTokenFailedReason::Other);
|
||||
assert!(
|
||||
!error.reason.is_sticky(),
|
||||
"timeout must age out, not strand"
|
||||
);
|
||||
}
|
||||
other => panic!("a timed-out binary must be a permanent Other failure, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_binary_success_returns_fresh_token() {
|
||||
let token = GrokAuth {
|
||||
key: "ext-fresh".into(),
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
let refresher = ExternalBinaryRefresher::new(
|
||||
Arc::new(FakeRunner {
|
||||
external_result: Some(token),
|
||||
}),
|
||||
"auth-binary".into(),
|
||||
);
|
||||
match refresher.refresh(RefreshReason::ServerRejected).await {
|
||||
RefreshOutcome::Success(auth) => assert_eq!(auth.key, "ext-fresh"),
|
||||
other => panic!("a successful binary run must return Success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
mod external_refresher;
|
||||
mod oidc_refresher;
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::manager::AuthManager;
|
||||
pub(crate) use crate::auth::manager::RefreshReason;
|
||||
use crate::auth::model::GrokAuth;
|
||||
|
||||
use external_refresher::ExternalBinaryRefresher;
|
||||
pub(crate) use oidc_refresher::OidcRefresher;
|
||||
|
||||
/// Read-only view of `AuthManager` for refreshers. Enforces the
|
||||
/// no-mutation contract on *credential* state at the type level: refreshers
|
||||
/// hold `Arc<dyn AuthSnapshot>` and physically cannot call `update()`,
|
||||
/// `clear()`, `hot_swap()`, or `refresh_chain()`.
|
||||
pub(crate) trait AuthSnapshot: Send + Sync {
|
||||
/// Read the current in-memory bearer outside the early-invalidation buffer.
|
||||
fn current(&self) -> Option<GrokAuth>;
|
||||
/// Read the expired in-memory bearer (for its `refresh_token`).
|
||||
fn expired_auth(&self) -> Option<GrokAuth>;
|
||||
/// Re-read auth.json from disk for the configured scope. Read-only w.r.t.
|
||||
/// credentials, but may advance disk-observation state and emit transition
|
||||
/// telemetry (not credential mutation).
|
||||
fn read_disk_auth(&self) -> Option<GrokAuth>;
|
||||
/// Whether the in-memory bearer is expired.
|
||||
fn is_expired(&self) -> bool;
|
||||
}
|
||||
|
||||
impl AuthSnapshot for AuthManager {
|
||||
fn current(&self) -> Option<GrokAuth> {
|
||||
self.current()
|
||||
}
|
||||
fn expired_auth(&self) -> Option<GrokAuth> {
|
||||
self.expired_auth()
|
||||
}
|
||||
fn read_disk_auth(&self) -> Option<GrokAuth> {
|
||||
self.read_disk_auth()
|
||||
}
|
||||
fn is_expired(&self) -> bool {
|
||||
self.is_expired()
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability to run the operator's external auth binary. Split out of
|
||||
/// [`AuthSnapshot`] so OIDC refreshers (read-only) physically cannot reach it
|
||||
/// (interface segregation); only [`ExternalBinaryRefresher`] depends on it.
|
||||
pub(crate) trait ExternalCommandRunner: Send + Sync {
|
||||
/// Run the external auth binary and return the parsed output.
|
||||
fn run_external_command(&self, command: &str) -> Option<GrokAuth>;
|
||||
}
|
||||
|
||||
impl ExternalCommandRunner for AuthManager {
|
||||
fn run_external_command(&self, command: &str) -> Option<GrokAuth> {
|
||||
self.run_external_refresh_command(command)
|
||||
}
|
||||
}
|
||||
|
||||
/// The credential a refresh would send to the IdP: disk refresh-token first,
|
||||
/// then the expired in-mem bearer, then current (only on `ServerRejected`).
|
||||
/// Single source of truth shared by [`OidcRefresher::refresh`] (the attempt) and
|
||||
/// `AuthManager::attempted_verdict_key` (the verdict scope), so the two can't
|
||||
/// drift. The caller supplies the disk read: the verdict path passes a
|
||||
/// side-effect-free read, the refresher the observing one.
|
||||
pub(crate) fn resolve_refresh_credential(
|
||||
snap: &dyn AuthSnapshot,
|
||||
disk_auth: Option<GrokAuth>,
|
||||
reason: RefreshReason,
|
||||
) -> Option<GrokAuth> {
|
||||
disk_auth
|
||||
.filter(|a| a.refresh_token.is_some())
|
||||
.or_else(|| snap.expired_auth())
|
||||
.or_else(|| {
|
||||
(reason == RefreshReason::ServerRejected)
|
||||
.then(|| snap.current())
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// Outcome of a refresh attempt. Data only -- `refresh_chain` handles mutations.
|
||||
#[derive(Debug)]
|
||||
#[must_use = "RefreshOutcome encodes a state transition; route it through refresh_chain"]
|
||||
pub(crate) enum RefreshOutcome {
|
||||
/// Authority returned a fresh token. Caller persists via `update()`.
|
||||
Success(Box<GrokAuth>),
|
||||
/// Terminal failure (e.g. invalid_grant), or a transient escalated to
|
||||
/// `Other` after repeated blips. Caller records a verdict scoped to the
|
||||
/// rejected credential and retains it (`RefreshTokenRejected` is sticky,
|
||||
/// the rest age out past the TTL).
|
||||
PermanentFailure {
|
||||
error: crate::auth::error::RefreshTokenFailedError,
|
||||
/// Key of the credential the refresher actually sent to the IdP, so
|
||||
/// `refresh_chain` scopes the verdict to it. `None` when the authority
|
||||
/// has no token key (external binary flow); the caller falls back to
|
||||
/// its own resolution.
|
||||
tried_key: Option<String>,
|
||||
},
|
||||
/// Transient / unknown failure. Caller may retry later. Message-only: the
|
||||
/// underlying cause is logged structurally at the refresher, then flattened
|
||||
/// here (the retry decision needs recoverability, not the source chain).
|
||||
TransientFailure { message: String },
|
||||
}
|
||||
|
||||
impl RefreshOutcome {
|
||||
/// A fresh credential from the authority (hides the `Box`).
|
||||
pub(crate) fn success(auth: GrokAuth) -> Self {
|
||||
Self::Success(Box::new(auth))
|
||||
}
|
||||
|
||||
/// Terminal failure for an already-classified reason against the credential
|
||||
/// `tried_key` (the one actually sent to the IdP).
|
||||
pub(crate) fn permanent(
|
||||
reason: crate::auth::error::RefreshTokenFailedReason,
|
||||
tried_key: Option<String>,
|
||||
) -> Self {
|
||||
Self::PermanentFailure {
|
||||
error: reason.into(),
|
||||
tried_key,
|
||||
}
|
||||
}
|
||||
|
||||
/// A retryable failure carrying a diagnostic message.
|
||||
pub(crate) fn transient(message: impl Into<String>) -> Self {
|
||||
Self::TransientFailure {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait TokenRefresher: Send + Sync {
|
||||
/// Attempt to obtain a fresh token from the authority.
|
||||
///
|
||||
/// Implementations MUST NOT call auth_manager.update(), clear(),
|
||||
/// hot_swap(), or any other state-mutating method. Return the
|
||||
/// result and let refresh_chain handle all mutations.
|
||||
async fn refresh(&self, reason: RefreshReason) -> RefreshOutcome;
|
||||
}
|
||||
|
||||
pub(crate) fn build_refresher(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
auth_provider_command: Option<String>,
|
||||
) -> Arc<dyn TokenRefresher> {
|
||||
match auth_provider_command {
|
||||
Some(cmd) => {
|
||||
let runner: Arc<dyn ExternalCommandRunner> = auth_manager;
|
||||
Arc::new(ExternalBinaryRefresher::new(runner, cmd))
|
||||
}
|
||||
None => {
|
||||
let snapshot: Arc<dyn AuthSnapshot> = auth_manager;
|
||||
Arc::new(OidcRefresher::new(snapshot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::{AuthMode, GrokAuth, GrokComConfig};
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
/// auth_token_ttl makes is_token_expired use create_time + ttl for
|
||||
/// External tokens without expires_at, instead of the 30-day fallback.
|
||||
#[test]
|
||||
fn token_ttl_expires_external_token_by_create_time() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = GrokComConfig {
|
||||
auth_token_ttl: Some(3600), // 1 hour
|
||||
..GrokComConfig::default()
|
||||
};
|
||||
let mgr = AuthManager::new(dir.path(), cfg);
|
||||
|
||||
// Token created 2 hours ago, no expires_at. With auth_token_ttl=3600,
|
||||
// is_token_expired should return true (age 2h > ttl 1h).
|
||||
let old_token = GrokAuth {
|
||||
key: "old-external-token".into(),
|
||||
auth_mode: AuthMode::External,
|
||||
create_time: Utc::now() - Duration::hours(2),
|
||||
expires_at: None,
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
mgr.hot_swap(old_token);
|
||||
assert!(
|
||||
mgr.current().is_none(),
|
||||
"expired external token via auth_token_ttl"
|
||||
);
|
||||
assert!(mgr.is_expired());
|
||||
|
||||
// Fresh token created just now — should be valid.
|
||||
let new_token = GrokAuth {
|
||||
key: "new-external-token".into(),
|
||||
auth_mode: AuthMode::External,
|
||||
create_time: Utc::now(),
|
||||
expires_at: None,
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
mgr.hot_swap(new_token);
|
||||
assert!(
|
||||
mgr.current().is_some(),
|
||||
"fresh external token should be valid"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::auth::error::RefreshTokenFailedReason;
|
||||
use crate::auth::manager::RefreshReason;
|
||||
use crate::auth::oidc::OidcRefreshResult;
|
||||
|
||||
use super::{AuthSnapshot, RefreshOutcome, TokenRefresher};
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::auth::manager::AuthManager;
|
||||
|
||||
/// Escalate to `PermanentFailure` after this many consecutive transient
|
||||
/// failures (then `PERMANENT_FAILURE_TTL` allows recovery). OIDC tolerates more
|
||||
/// blips than `ExternalBinaryRefresher` (1) since network refreshes flake more
|
||||
/// than a local binary.
|
||||
const MAX_CONSECUTIVE_TRANSIENT_FAILURES: u32 = 3;
|
||||
|
||||
/// Consecutive transient-failure budget, scoped to the credential it accrued
|
||||
/// against. Held under one lock so the credential check, reset, and increment
|
||||
/// are a single atomic step.
|
||||
#[derive(Default)]
|
||||
struct TransientBudget {
|
||||
/// Credential the count belongs to. A different credential (e.g. after
|
||||
/// re-login on this long-lived refresher) re-arms the budget so a fresh,
|
||||
/// valid token never inherits a dead one's escalation.
|
||||
key: Option<String>,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
pub(crate) struct OidcRefresher {
|
||||
auth: Arc<dyn AuthSnapshot>,
|
||||
transient_budget: parking_lot::Mutex<TransientBudget>,
|
||||
}
|
||||
|
||||
impl OidcRefresher {
|
||||
pub(crate) fn new(auth: Arc<dyn AuthSnapshot>) -> Self {
|
||||
Self {
|
||||
auth,
|
||||
transient_budget: parking_lot::Mutex::new(TransientBudget::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the transient-blip budget on refresh progress (a fresh token or an
|
||||
/// adopted sibling token), so later blips start from a full budget.
|
||||
fn note_refresh_progress(&self) {
|
||||
*self.transient_budget.lock() = TransientBudget::default();
|
||||
}
|
||||
|
||||
fn record_transient_failure(
|
||||
&self,
|
||||
message: String,
|
||||
tried_key: Option<String>,
|
||||
) -> RefreshOutcome {
|
||||
let escalate = {
|
||||
let mut budget = self.transient_budget.lock();
|
||||
// Re-arm when the credential changes so a fresh token never inherits
|
||||
// a prior credential's accrued blips.
|
||||
if budget.key != tried_key {
|
||||
budget.key = tried_key.clone();
|
||||
budget.count = 0;
|
||||
}
|
||||
budget.count += 1;
|
||||
let escalate = budget.count >= MAX_CONSECUTIVE_TRANSIENT_FAILURES;
|
||||
// On escalation reset the count so the next TTL window gets the full
|
||||
// budget (the verdict gates refresh() meanwhile). The key is left in
|
||||
// place; a same-key retry resumes from zero, a new key re-arms.
|
||||
if escalate {
|
||||
budget.count = 0;
|
||||
}
|
||||
escalate
|
||||
};
|
||||
if escalate {
|
||||
tracing::warn!(%message, "auth: escalating consecutive transient failures to permanent");
|
||||
RefreshOutcome::permanent(RefreshTokenFailedReason::Other, tried_key)
|
||||
} else {
|
||||
RefreshOutcome::transient(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot retry with disk's RT after `invalid_grant`.
|
||||
///
|
||||
/// If disk already has a valid (unexpired) AT with a different key,
|
||||
/// adopt it directly, without consuming the disk's RT in another IdP
|
||||
/// call. This prevents cascading `invalid_grant` when a sibling
|
||||
/// already refreshed and wrote a valid token.
|
||||
async fn retry_with_fresh_disk_token(
|
||||
&self,
|
||||
tried: &crate::auth::GrokAuth,
|
||||
) -> Option<RefreshOutcome> {
|
||||
let disk_now = self.auth.read_disk_auth()?;
|
||||
|
||||
// If disk has a valid AT that differs from what we tried,
|
||||
// a sibling already refreshed. Adopt directly — no IdP call.
|
||||
if !crate::auth::is_expired(&disk_now) && disk_now.key != tried.key {
|
||||
crate::unified_log::info(
|
||||
"oidc refresh: disk has valid AT, adopting instead of consuming RT",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"disk_key_prefix": crate::auth::token_suffix(&disk_now.key),
|
||||
"tried_key_prefix": crate::auth::token_suffix(&tried.key),
|
||||
})),
|
||||
);
|
||||
self.note_refresh_progress();
|
||||
return Some(RefreshOutcome::success(disk_now));
|
||||
}
|
||||
|
||||
if disk_now.refresh_token.is_none()
|
||||
|| disk_now.refresh_token.as_deref() == tried.refresh_token.as_deref()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
crate::unified_log::info(
|
||||
"oidc refresh retrying with disk token",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"tried_rt_prefix": tried
|
||||
.refresh_token
|
||||
.as_deref()
|
||||
.map(crate::auth::token_suffix),
|
||||
"disk_rt_prefix": disk_now
|
||||
.refresh_token
|
||||
.as_deref()
|
||||
.map(crate::auth::token_suffix),
|
||||
})),
|
||||
);
|
||||
|
||||
match crate::auth::oidc::oidc_token_exchange(&disk_now).await {
|
||||
OidcRefreshResult::Success(new_auth) => {
|
||||
self.note_refresh_progress();
|
||||
Some(RefreshOutcome::Success(new_auth))
|
||||
}
|
||||
OidcRefreshResult::TerminalError { reason } => {
|
||||
crate::unified_log::warn(
|
||||
"oidc refresh disk retry exhausted",
|
||||
None,
|
||||
Some(serde_json::json!({ "reason": format!("{reason:?}") })),
|
||||
);
|
||||
Some(RefreshOutcome::permanent(
|
||||
reason,
|
||||
Some(disk_now.key.clone()),
|
||||
))
|
||||
}
|
||||
OidcRefreshResult::Failed => {
|
||||
Some(RefreshOutcome::transient("OIDC disk-retry refresh failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TokenRefresher for OidcRefresher {
|
||||
async fn refresh(&self, reason: RefreshReason) -> RefreshOutcome {
|
||||
crate::unified_log::debug(
|
||||
"oidc refresh enter",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"reason": format!("{reason:?}"),
|
||||
"has_current": self.auth.current().is_some(),
|
||||
"is_expired": self.auth.is_expired(),
|
||||
})),
|
||||
);
|
||||
|
||||
let disk_auth = self.auth.read_disk_auth();
|
||||
|
||||
// Short-circuit: if disk has a valid unexpired AT that differs
|
||||
// from in-memory, a sibling refreshed between refresh_chain
|
||||
// step 2 (disk check under lock) and here. Adopt it directly,
|
||||
// no IdP call needed.
|
||||
if let Some(ref d) = disk_auth
|
||||
&& !crate::auth::is_expired(d)
|
||||
&& self.auth.current().map(|a| a.key).as_deref() != Some(&d.key)
|
||||
{
|
||||
crate::unified_log::info(
|
||||
"oidc refresh: sibling refreshed, adopting valid disk AT",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"disk_key_prefix": crate::auth::token_suffix(&d.key),
|
||||
})),
|
||||
);
|
||||
self.note_refresh_progress();
|
||||
return RefreshOutcome::success(d.clone());
|
||||
}
|
||||
|
||||
let auth = super::resolve_refresh_credential(self.auth.as_ref(), disk_auth, reason);
|
||||
|
||||
let Some(auth) = auth else {
|
||||
crate::unified_log::warn(
|
||||
"oidc refresh no token available",
|
||||
None,
|
||||
Some(serde_json::json!({ "reason": format!("{reason:?}") })),
|
||||
);
|
||||
return RefreshOutcome::transient("no token with refresh_token available");
|
||||
};
|
||||
|
||||
crate::unified_log::info(
|
||||
"oidc refresh attempting idp",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"has_rt": auth.refresh_token.is_some(),
|
||||
"issuer": auth.oidc_issuer,
|
||||
"client_id": auth.oidc_client_id,
|
||||
"expires_at": auth.expires_at.map(|e| e.to_rfc3339()),
|
||||
})),
|
||||
);
|
||||
|
||||
match crate::auth::oidc::oidc_token_exchange(&auth).await {
|
||||
OidcRefreshResult::Success(new_auth) => {
|
||||
self.note_refresh_progress();
|
||||
RefreshOutcome::Success(new_auth)
|
||||
}
|
||||
OidcRefreshResult::TerminalError { reason } => {
|
||||
// Sibling-rotation race: disk may hold a
|
||||
// fresher RT than the one we tried. One-shot retry.
|
||||
if reason == RefreshTokenFailedReason::RefreshTokenRejected
|
||||
&& let Some(retry_outcome) = self.retry_with_fresh_disk_token(&auth).await
|
||||
{
|
||||
return retry_outcome;
|
||||
}
|
||||
|
||||
RefreshOutcome::permanent(reason, Some(auth.key.clone()))
|
||||
}
|
||||
OidcRefreshResult::Failed => {
|
||||
tracing::warn!(
|
||||
refresh_reason = ?reason,
|
||||
user_id = %auth.user_id,
|
||||
has_refresh_token = auth.refresh_token.is_some(),
|
||||
issuer = ?auth.oidc_issuer,
|
||||
client_id = ?auth.oidc_client_id,
|
||||
expires_at = ?auth.expires_at,
|
||||
"auth: OIDC token refresh failed"
|
||||
);
|
||||
crate::unified_log::error(
|
||||
"oidc refresh failed",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"has_refresh_token": auth.refresh_token.is_some(),
|
||||
"auth_mode": format!("{:?}", auth.auth_mode),
|
||||
"issuer": auth.oidc_issuer,
|
||||
"client_id": auth.oidc_client_id,
|
||||
"expires_at": auth.expires_at.map(|e| e.to_rfc3339()),
|
||||
})),
|
||||
);
|
||||
self.record_transient_failure(
|
||||
"OIDC token refresh failed".into(),
|
||||
Some(auth.key.clone()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "oidc_refresher_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "auth_backend_contract_tests.rs"]
|
||||
mod auth_backend_contract_tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,512 @@
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::model::{API_KEY_SCOPE, AuthMode, AuthStore, GrokAuth, lookup_auth};
|
||||
|
||||
/// RAII guard for an exclusive advisory lock on `auth.json.lock`.
|
||||
/// The lock is released when the inner `File` is dropped (closing the FD).
|
||||
pub(crate) struct AuthFileLock {
|
||||
pub(super) _file: File,
|
||||
}
|
||||
|
||||
impl AuthFileLock {
|
||||
/// Returns `true` while this guard still refers to the **live**
|
||||
/// `auth.json.lock` inode.
|
||||
///
|
||||
/// A waiter that finds a holder stuck past the stale-lock timeout breaks
|
||||
/// the lock by `unlink`ing the file and recreating it on a fresh inode
|
||||
/// (see [`crate::auth::manager::lock`]). The usual cause of a "stuck"
|
||||
/// holder is a process **suspended across system sleep** while holding the
|
||||
/// lock: it stays alive (so the kernel never releases its flock) yet makes
|
||||
/// no progress, so siblings break it. When such a holder resumes, its
|
||||
/// flock lives on the now-deleted inode — it no longer holds the live lock
|
||||
/// even though this `AuthFileLock` still exists.
|
||||
///
|
||||
/// Callers about to perform an irreversible, lock-protected action
|
||||
/// (sending a refresh token to the IdP, writing `auth.json`) MUST
|
||||
/// re-validate first; otherwise two processes can spend the same refresh
|
||||
/// token and trip token-family revocation.
|
||||
///
|
||||
/// Non-Unix has no inode concept, so this conservatively returns `true`.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn still_live(&self, auth_json_path: &Path) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let lock_path = auth_json_path.with_file_name("auth.json.lock");
|
||||
let (Ok(fd_meta), Ok(path_meta)) = (self._file.metadata(), std::fs::metadata(&lock_path))
|
||||
else {
|
||||
// Lock file gone or unreadable → we no longer hold the live lock.
|
||||
return false;
|
||||
};
|
||||
fd_meta.ino() == path_meta.ino() && fd_meta.dev() == path_meta.dev()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn still_live(&self, _auth_json_path: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_auth_json(auth_file: &Path) -> std::io::Result<AuthStore> {
|
||||
let mut file = File::open(auth_file)?;
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
|
||||
// Empty files are valid (recover from prior crash/partial write).
|
||||
let trimmed = contents.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(AuthStore::new());
|
||||
}
|
||||
|
||||
let map = serde_json::from_str(trimmed)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Read auth.json, returning an empty map if the file does not exist.
|
||||
///
|
||||
/// Non-empty corrupt JSON, permission errors, etc. are returned as errors
|
||||
/// so the caller can decide whether to skip the write (to avoid clobbering
|
||||
/// sibling scopes).
|
||||
///
|
||||
/// Kept for the test-only `persist_and_swap` and as a strict reader.
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "used from tests only; remove expect when wired in production"
|
||||
)
|
||||
)]
|
||||
pub(crate) fn read_auth_json_or_empty(auth_file: &Path) -> std::io::Result<AuthStore> {
|
||||
match read_auth_json(auth_file) {
|
||||
Ok(map) => Ok(map),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort backup of a corrupt (unparseable) auth.json.
|
||||
///
|
||||
/// If the file exists and `read_auth_json` fails with `InvalidData`,
|
||||
/// it is renamed to `auth.json.corrupt.<millis>` (sibling in the same
|
||||
/// directory) and the backup path is returned. Used before recovery
|
||||
/// writes so the original bytes are never silently lost.
|
||||
pub(crate) fn backup_corrupt_auth_file(path: &Path) -> Option<PathBuf> {
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
if read_auth_json(path).is_ok() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "auth.json".to_string());
|
||||
|
||||
let backup_name = format!("{}.corrupt.{}", file_name, ts);
|
||||
let backup = path.with_file_name(backup_name);
|
||||
|
||||
match std::fs::rename(path, &backup) {
|
||||
Ok(()) => {
|
||||
tracing::warn!(
|
||||
original = %path.display(),
|
||||
backup = %backup.display(),
|
||||
"auth: backed up corrupt auth.json before recovery write"
|
||||
);
|
||||
// Must reach unified.jsonl: the tracing line above is invisible
|
||||
// in production captures, and this is the only record of both
|
||||
// the corruption and where the original bytes went.
|
||||
kigi_log::unified_log::error(
|
||||
"auth: corrupt auth.json backed up",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"original": path.display().to_string(),
|
||||
"backup": backup.display().to_string(),
|
||||
})),
|
||||
);
|
||||
Some(backup)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "auth: failed to rename corrupt auth.json for backup");
|
||||
kigi_log::unified_log::error(
|
||||
"auth: corrupt auth.json backup failed",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"original": path.display().to_string(),
|
||||
"error": e.to_string(),
|
||||
})),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read auth.json for an upcoming write, with recovery for corrupt files.
|
||||
///
|
||||
/// - Missing/empty → empty map (safe to write fresh)
|
||||
/// - Valid JSON → parsed map
|
||||
/// - Non-empty corrupt JSON → backs up to `auth.json.corrupt.<millis>`,
|
||||
/// then returns empty map so the caller can write the new credential.
|
||||
///
|
||||
/// Other I/O errors (PermissionDenied, etc.) are still returned as errors.
|
||||
pub(crate) fn read_auth_json_or_empty_recovering_corrupt(
|
||||
auth_file: &Path,
|
||||
) -> std::io::Result<AuthStore> {
|
||||
match read_auth_json(auth_file) {
|
||||
Ok(map) => Ok(map),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
|
||||
let _ = backup_corrupt_auth_file(auth_file);
|
||||
Ok(AuthStore::new())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist `auth.json`, preferring a crash-safe atomic write but falling
|
||||
/// back to a non-atomic in-place write when the disk is full.
|
||||
///
|
||||
/// The atomic path (temp + rename) needs free space >= the file size,
|
||||
/// because the old file and a full temp copy coexist until the rename. On a
|
||||
/// nearly-full disk that temp copy can fail with `StorageFull` (ENOSPC)
|
||||
/// even though the credentials themselves are tiny. When that happens we
|
||||
/// retry with an in-place truncate+write, which only needs the freed blocks
|
||||
/// of the old file — far less than the temp-copy approach.
|
||||
///
|
||||
/// The in-place path is non-atomic, with two accepted trade-offs:
|
||||
/// - If the in-place write itself fails (e.g. a concurrent process grabs the
|
||||
/// just-freed blocks, or a crash mid-write), the prior bytes are restored
|
||||
/// best-effort so a torn/empty file never *replaces* the previous on-disk
|
||||
/// credential — on-disk state ends up no worse than before the attempt.
|
||||
/// - Unlocked concurrent readers can still observe a torn (partial) file
|
||||
/// during the brief write window; a partial file is healed on the next
|
||||
/// read via [`read_auth_json_or_empty_recovering_corrupt`] (backup +
|
||||
/// relogin). This window is inherent to any sub-1×-free single-file
|
||||
/// replace and is preferable to persisting nothing at all, which would
|
||||
/// leave every concurrent process with a stale, already-revoked token.
|
||||
pub(super) fn write_auth_json(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
|
||||
write_auth_json_with(auth_file, auth_store, write_auth_json_atomic)
|
||||
}
|
||||
|
||||
/// Dispatch helper: run `atomic`, and on `StorageFull` fall back to an
|
||||
/// in-place write. Split out (with `atomic` injectable) so the disk-full
|
||||
/// fallback is unit-testable without an actually-full filesystem.
|
||||
fn write_auth_json_with(
|
||||
auth_file: &Path,
|
||||
auth_store: &AuthStore,
|
||||
atomic: fn(&Path, &AuthStore) -> std::io::Result<()>,
|
||||
) -> std::io::Result<()> {
|
||||
match atomic(auth_file, auth_store) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::StorageFull => {
|
||||
tracing::warn!(
|
||||
path = %auth_file.display(),
|
||||
"auth: disk full during atomic write, falling back to in-place write"
|
||||
);
|
||||
// Must reach unified.jsonl: a silent in-memory-only credential
|
||||
// (the prior behavior) leaves sibling processes with a stale
|
||||
// refresh token and no record of why. Surface it loudly.
|
||||
kigi_log::unified_log::warn(
|
||||
"auth: disk full, falling back to non-atomic in-place write",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"path": auth_file.display().to_string(),
|
||||
})),
|
||||
);
|
||||
write_auth_json_in_place(auth_file, auth_store)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize `auth_store` to `path` (truncate + rewrite), owner-only (0o600)
|
||||
/// and `fsync`'d. Shared core of the atomic path (which targets the temp
|
||||
/// file) and the in-place fallback (which targets `auth.json` directly).
|
||||
///
|
||||
/// Uses streaming `to_writer_pretty` through a `BufWriter` to avoid
|
||||
/// allocating the entire JSON string in memory — eliminates OOM risk under
|
||||
/// severe memory pressure.
|
||||
fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
|
||||
use crate::util::secure_file::open_secure_file;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = open_secure_file(path)?;
|
||||
let mut writer = std::io::BufWriter::new(file);
|
||||
serde_json::to_writer_pretty(&mut writer, auth_store)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
writer.flush()?;
|
||||
writer
|
||||
.into_inner()
|
||||
.map_err(|e| e.into_error())?
|
||||
.sync_all()?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::util::secure_file::set_windows_secure_permissions(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomic write: tmp + rename. Unix `rename(2)` replaces atomically;
|
||||
/// Windows `rename` requires removing the target first.
|
||||
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
|
||||
let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
|
||||
write_store_to(&tmp, auth_store)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::fs::remove_file(auth_file);
|
||||
}
|
||||
std::fs::rename(&tmp, auth_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Non-atomic fallback: truncate and rewrite `auth.json` in place.
|
||||
///
|
||||
/// Used only when [`write_auth_json_atomic`] fails with `StorageFull`.
|
||||
/// Opening with truncation first frees the old content's blocks before the
|
||||
/// new bytes are written, so this needs only the file size in free space
|
||||
/// rather than the temp-copy approach's file-size-of-headroom.
|
||||
///
|
||||
/// Truncation is destructive, so the prior bytes are snapshotted first and
|
||||
/// restored best-effort if the rewrite fails partway — a failed fallback
|
||||
/// must not leave an empty/torn file where a parseable (if stale) credential
|
||||
/// used to be. A partial file that survives (because even the restore failed)
|
||||
/// is healed on the next read via [`read_auth_json_or_empty_recovering_corrupt`].
|
||||
fn write_auth_json_in_place(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
|
||||
write_auth_json_in_place_with(auth_file, auth_store, write_store_to)
|
||||
}
|
||||
|
||||
/// Inner of [`write_auth_json_in_place`] with `write` injectable so the
|
||||
/// rollback-on-failure path is unit-testable without an actually-full disk.
|
||||
fn write_auth_json_in_place_with(
|
||||
auth_file: &Path,
|
||||
auth_store: &AuthStore,
|
||||
write: fn(&Path, &AuthStore) -> std::io::Result<()>,
|
||||
) -> std::io::Result<()> {
|
||||
// Snapshot the prior bytes so a torn/empty write can be rolled back to
|
||||
// the previous on-disk credential. `None` when the file is absent.
|
||||
let prior = std::fs::read(auth_file).ok();
|
||||
match write(auth_file, auth_store) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
if let Some(prior) = prior
|
||||
&& let Err(restore_err) = restore_prior_bytes(auth_file, &prior)
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %restore_err,
|
||||
"auth: failed to restore prior auth.json after in-place write failure"
|
||||
);
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort rollback: rewrite `bytes` (owner-only, `fsync`'d) after a
|
||||
/// failed in-place write so a torn/empty file does not replace the prior
|
||||
/// credential.
|
||||
fn restore_prior_bytes(auth_file: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use crate::util::secure_file::open_secure_file;
|
||||
|
||||
let mut file = open_secure_file(auth_file)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::util::secure_file::set_windows_secure_permissions(auth_file)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a single auth token from `auth.json` by scope key.
|
||||
/// Falls back to the legacy `https://accounts.x.ai/sign-in` scope key
|
||||
/// when the requested scope is not found (devbox auth.json migration).
|
||||
pub fn read_token_by_scope(kigi_home: &Path, scope: &str) -> anyhow::Result<String> {
|
||||
let path = kigi_home.join("auth.json");
|
||||
let store =
|
||||
read_auth_json(&path).map_err(|_| anyhow::anyhow!("Not logged in. Run `grok login`."))?;
|
||||
lookup_auth(&store, scope).map(|a| a.key).ok_or_else(|| {
|
||||
anyhow::anyhow!("Your auth token is invalid. Run `grok login` to re-authenticate.")
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the API key from the `xai::api_key` scope in auth.json.
|
||||
pub fn read_api_key(kigi_home: &Path) -> Option<String> {
|
||||
let path = kigi_home.join("auth.json");
|
||||
let map = read_auth_json(&path).ok()?;
|
||||
map.get(API_KEY_SCOPE).map(|a| a.key.clone())
|
||||
}
|
||||
|
||||
/// Store a plain API key in auth.json under the `xai::api_key` scope.
|
||||
///
|
||||
/// Uses the corrupt-recovery reader so a malformed auth.json (e.g. from a
|
||||
/// previous crash) can be healed when the user sets an API key.
|
||||
pub fn store_api_key(kigi_home: &Path, api_key: &str) -> std::io::Result<()> {
|
||||
let path = kigi_home.join("auth.json");
|
||||
let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?;
|
||||
map.insert(
|
||||
API_KEY_SCOPE.to_owned(),
|
||||
GrokAuth {
|
||||
key: api_key.to_owned(),
|
||||
auth_mode: AuthMode::ApiKey,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
write_auth_json(&path, &map)
|
||||
}
|
||||
|
||||
/// Remove the `xai::api_key` scope from auth.json.
|
||||
pub fn clear_api_key(kigi_home: &Path) -> std::io::Result<()> {
|
||||
let path = kigi_home.join("auth.json");
|
||||
if let Ok(mut map) = read_auth_json(&path) {
|
||||
map.remove(API_KEY_SCOPE);
|
||||
if map.is_empty() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
} else {
|
||||
write_auth_json(&path, &map)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod write_fallback_tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_store() -> AuthStore {
|
||||
let mut map = AuthStore::new();
|
||||
map.insert(
|
||||
API_KEY_SCOPE.to_owned(),
|
||||
GrokAuth {
|
||||
key: "secret-key".to_owned(),
|
||||
auth_mode: AuthMode::ApiKey,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
map
|
||||
}
|
||||
|
||||
fn read_key(path: &Path) -> Option<String> {
|
||||
read_auth_json(path)
|
||||
.ok()
|
||||
.and_then(|m| m.get(API_KEY_SCOPE).map(|a| a.key.clone()))
|
||||
}
|
||||
|
||||
fn fake_storage_full(_: &Path, _: &AuthStore) -> std::io::Result<()> {
|
||||
Err(std::io::Error::from(std::io::ErrorKind::StorageFull))
|
||||
}
|
||||
|
||||
fn fake_permission_denied(_: &Path, _: &AuthStore) -> std::io::Result<()> {
|
||||
Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
|
||||
}
|
||||
|
||||
/// Simulates an in-place write that truncates the file (destroying the
|
||||
/// old content, as `open_secure_file` does) and then fails partway — the
|
||||
/// torn-write case the rollback must recover from.
|
||||
fn fake_truncate_then_fail(path: &Path, _: &AuthStore) -> std::io::Result<()> {
|
||||
crate::util::secure_file::open_secure_file(path)?; // truncates to 0 bytes
|
||||
Err(std::io::Error::from(std::io::ErrorKind::StorageFull))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_place_write_roundtrips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
write_auth_json_in_place(&path, &sample_store()).unwrap();
|
||||
assert_eq!(read_key(&path).as_deref(), Some("secret-key"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn in_place_write_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
write_auth_json_in_place(&path, &sample_store()).unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "in-place write must stay 0o600");
|
||||
}
|
||||
|
||||
/// A `StorageFull` (ENOSPC) failure on the atomic path must fall back to
|
||||
/// the in-place write so the credential still lands on disk.
|
||||
#[test]
|
||||
fn falls_back_to_in_place_on_storage_full() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
write_auth_json_with(&path, &sample_store(), fake_storage_full).unwrap();
|
||||
assert_eq!(
|
||||
read_key(&path).as_deref(),
|
||||
Some("secret-key"),
|
||||
"disk-full atomic write must fall back to a successful in-place write"
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-ENOSPC errors must propagate unchanged and must NOT trigger the
|
||||
/// in-place fallback (e.g. a permission error should not write the file).
|
||||
#[test]
|
||||
fn propagates_non_storage_full_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
let err = write_auth_json_with(&path, &sample_store(), fake_permission_denied).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
assert!(!path.exists(), "non-ENOSPC failure must not write the file");
|
||||
}
|
||||
|
||||
/// The normal (real atomic) path still works end to end.
|
||||
#[test]
|
||||
fn atomic_write_roundtrips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
write_auth_json(&path, &sample_store()).unwrap();
|
||||
assert_eq!(read_key(&path).as_deref(), Some("secret-key"));
|
||||
}
|
||||
|
||||
/// A fallback write that truncates then fails must roll back to the prior
|
||||
/// bytes instead of leaving an empty/torn file — otherwise a second
|
||||
/// disk-full failure would destroy a previously-valid credential.
|
||||
#[test]
|
||||
fn in_place_restores_prior_bytes_on_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
// Seed a valid prior credential.
|
||||
write_auth_json_in_place(&path, &sample_store()).unwrap();
|
||||
assert_eq!(read_key(&path).as_deref(), Some("secret-key"));
|
||||
|
||||
let mut replacement = AuthStore::new();
|
||||
replacement.insert(
|
||||
API_KEY_SCOPE.to_owned(),
|
||||
GrokAuth {
|
||||
key: "replacement-key".to_owned(),
|
||||
auth_mode: AuthMode::ApiKey,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let err = write_auth_json_in_place_with(&path, &replacement, fake_truncate_then_fail)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::StorageFull);
|
||||
assert_eq!(
|
||||
read_key(&path).as_deref(),
|
||||
Some("secret-key"),
|
||||
"a failed in-place write must restore the prior credential, not leave an empty file"
|
||||
);
|
||||
}
|
||||
|
||||
/// Rollback after a failed write must keep the file owner-only (0o600).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn in_place_restore_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
write_auth_json_in_place(&path, &sample_store()).unwrap();
|
||||
let _ = write_auth_json_in_place_with(&path, &sample_store(), fake_truncate_then_fail);
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "restored file must stay 0o600");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use crate::auth::model::{AuthMode, GrokAuth};
|
||||
|
||||
/// What kind of bearer is loaded right now. Dispatch key for
|
||||
/// `auth()`, `unauthorized_recovery()`, and proactive refresh.
|
||||
///
|
||||
/// Not a session classifier — use `is_session_based_method` for that.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum TokenType {
|
||||
/// OIDC/OAuth2 session with a refresh_token available.
|
||||
OidcSession,
|
||||
/// Legacy web-login session or OIDC without a refresh_token.
|
||||
LegacySession,
|
||||
/// External auth binary provides tokens.
|
||||
ExternalBinary,
|
||||
/// Plain API key (no refresh possible).
|
||||
ApiKey,
|
||||
/// No credentials loaded.
|
||||
None,
|
||||
}
|
||||
|
||||
impl TokenType {
|
||||
/// Classify the loaded credential (pure; no manager state).
|
||||
pub(crate) fn from_auth(auth: Option<&GrokAuth>) -> Self {
|
||||
match auth {
|
||||
None => Self::None,
|
||||
// Oidc without a refresh_token degrades to the unrefreshable LegacySession shape.
|
||||
Some(a) => match a.auth_mode {
|
||||
AuthMode::Oidc if a.refresh_token.is_some() => Self::OidcSession,
|
||||
AuthMode::Oidc | AuthMode::WebLogin => Self::LegacySession,
|
||||
AuthMode::External => Self::ExternalBinary,
|
||||
AuthMode::ApiKey => Self::ApiKey,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` for types that can be silently refreshed (OIDC, external binary).
|
||||
pub(crate) fn is_refreshable(self) -> bool {
|
||||
matches!(self, Self::OidcSession | Self::ExternalBinary)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Per-variant matrix for `is_refreshable`.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_refreshable_matrix() {
|
||||
assert!(TokenType::OidcSession.is_refreshable());
|
||||
assert!(TokenType::ExternalBinary.is_refreshable());
|
||||
assert!(!TokenType::LegacySession.is_refreshable());
|
||||
assert!(!TokenType::ApiKey.is_refreshable());
|
||||
assert!(!TokenType::None.is_refreshable());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! normalize chat_history.jsonl, convert any v1 (ConversationItem) to v0 (ChatRequestMessage) format.
|
||||
//! Used for data processing pipeline.
|
||||
//!
|
||||
//! Usage:
|
||||
//! chat-history-downgrade <INPUT> <OUTPUT>
|
||||
//!
|
||||
//! ## Reasoning-shape compatibility
|
||||
//!
|
||||
//! Two on-disk v1 shapes carry reasoning, both must be downgraded into
|
||||
//! the v0 `reasoning_content: Option<String>` field:
|
||||
//!
|
||||
//! 1. **Legacy shape** -- reasoning lived as a field on the
|
||||
//! assistant item itself: `{"type":"assistant","reasoning":{"text":...},...}`.
|
||||
//! Post-refactor `AssistantItem` no longer has that field, so serde
|
||||
//! silently drops it on deserialize. We pre-extract it from the raw
|
||||
//! JSON before parsing as `ConversationItem`.
|
||||
//!
|
||||
//! 2. **Current shape** -- reasoning is a sibling
|
||||
//! `ConversationItem::Reasoning(rs::ReasoningItem)` item that precedes
|
||||
//! the assistant in the JSONL stream. The downgrade buffers these
|
||||
//! sibling lines and folds their text into the next assistant's
|
||||
//! `reasoning_content`, matching what `conversation_to_chat_messages`
|
||||
//! does at the in-process layer. Intervening user / tool messages
|
||||
//! clear the buffer.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use kigi_shell::sampling::conversation::{ConversationItem, conversation_item_to_chat_message};
|
||||
use kigi_shell::sampling::types::{ChatRequestMessage, Role};
|
||||
|
||||
/// normalize chat_history.jsonl, convert any v1 (ConversationItem) to v0 (ChatRequestMessage) format.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "chat-history-downgrade")]
|
||||
struct Args {
|
||||
/// Input v1 chat_history.jsonl file
|
||||
input: PathBuf,
|
||||
/// Output v0 chat_history.jsonl file
|
||||
output: PathBuf,
|
||||
}
|
||||
|
||||
/// Convert one v1 JSONL line to a v0 `ChatRequestMessage`, threading
|
||||
/// `pending_reasoning` across calls so sibling `Reasoning` items are
|
||||
/// folded into the following assistant.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(Some(msg))` -- emit this v0 message.
|
||||
/// - `Ok(None)` -- line was a sibling `Reasoning` item; its text has been
|
||||
/// buffered into `pending_reasoning` for the next assistant. Skip emit.
|
||||
/// - `Err(_)` -- line was neither v1 nor v0 parseable.
|
||||
fn convert_line(
|
||||
trimmed: &str,
|
||||
pending_reasoning: &mut Vec<String>,
|
||||
) -> anyhow::Result<Option<ChatRequestMessage>> {
|
||||
// Inspect the raw JSON first so we can:
|
||||
// (a) extract a legacy `assistant.reasoning.text` field before
|
||||
// strongly-typed parsing drops it, and
|
||||
// (b) buffer sibling `Reasoning` lines.
|
||||
let raw: serde_json::Value = serde_json::from_str(trimmed)
|
||||
.map_err(|e| anyhow::anyhow!("line is not valid JSON: {e}"))?;
|
||||
let item_type = raw.get("type").and_then(|t| t.as_str());
|
||||
|
||||
// (b) Sibling Reasoning: extract text and buffer for the next assistant.
|
||||
if item_type == Some("reasoning") {
|
||||
if let Some(text) = extract_reasoning_text(&raw) {
|
||||
pending_reasoning.push(text);
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// (a) Legacy reasoning field on the assistant item.
|
||||
// Tries `reasoning.text` first (chat-completions-style) then
|
||||
// `reasoning.encrypted` (responses-API-style); the latter is opaque
|
||||
// bytes so we surface it as a placeholder rather than dropping it
|
||||
// silently. Real text wins if both are present.
|
||||
let legacy_reasoning: Option<String> = if item_type == Some("assistant") {
|
||||
raw.get("reasoning").and_then(|r| {
|
||||
r.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| {
|
||||
r.get("encrypted")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|_| "[encrypted reasoning]".to_string())
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Parse as v1, fall back to v0 passthrough.
|
||||
let mut chat_msg: ChatRequestMessage =
|
||||
match serde_json::from_value::<ConversationItem>(raw.clone()) {
|
||||
Ok(item) => conversation_item_to_chat_message(item),
|
||||
Err(v1_err) => serde_json::from_value::<ChatRequestMessage>(raw)
|
||||
.map_err(|_| anyhow::anyhow!("failed to parse as v1 or v0: {v1_err}"))?,
|
||||
};
|
||||
|
||||
// Attach reasoning to assistant messages, with the legacy field
|
||||
// taking precedence when both sources exist.
|
||||
if let Some(text) = legacy_reasoning {
|
||||
chat_msg.reasoning_content = Some(text);
|
||||
pending_reasoning.clear();
|
||||
} else if matches!(chat_msg.role, Role::Assistant) && !pending_reasoning.is_empty() {
|
||||
chat_msg.reasoning_content = Some(pending_reasoning.join("\n"));
|
||||
pending_reasoning.clear();
|
||||
} else if !matches!(chat_msg.role, Role::Assistant) {
|
||||
// Intervening user / tool message clears pending reasoning --
|
||||
// matches `conversation_to_chat_messages` (attaches to the
|
||||
// immediately-following assistant only).
|
||||
pending_reasoning.clear();
|
||||
}
|
||||
|
||||
Ok(Some(chat_msg))
|
||||
}
|
||||
|
||||
/// Extract joined reasoning text from a sibling `Reasoning` JSON value.
|
||||
/// Joins `summary[].text` and `content[].text` blocks.
|
||||
fn extract_reasoning_text(raw: &serde_json::Value) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(summary) = raw.get("summary").and_then(|s| s.as_array()) {
|
||||
for sp in summary {
|
||||
if let Some(t) = sp.get("text").and_then(|t| t.as_str())
|
||||
&& !t.is_empty()
|
||||
{
|
||||
parts.push(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(content) = raw.get("content").and_then(|c| c.as_array()) {
|
||||
for cp in content {
|
||||
if let Some(t) = cp.get("text").and_then(|t| t.as_str())
|
||||
&& !t.is_empty()
|
||||
{
|
||||
parts.push(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let reader = BufReader::new(File::open(&args.input)?);
|
||||
let mut writer = BufWriter::new(File::create(&args.output)?);
|
||||
|
||||
let mut converted = 0usize;
|
||||
let mut pending_reasoning: Vec<String> = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(chat_msg) = convert_line(trimmed, &mut pending_reasoning)? else {
|
||||
// Sibling Reasoning item: buffered, no v0 line to emit.
|
||||
continue;
|
||||
};
|
||||
|
||||
serde_json::to_writer(&mut writer, &chat_msg)?;
|
||||
writer.write_all(b"\n")?;
|
||||
converted += 1;
|
||||
}
|
||||
|
||||
writer.flush()?;
|
||||
eprintln!("Done: {converted} messages converted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests — hardcoded JSON fixtures so schema changes in either
|
||||
// ConversationItem (v1) or ChatRequestMessage (v0) will break these.
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Parse a single v1 JSON line, convert to v0, re-serialize, and
|
||||
/// re-parse as v0. Returns the v0 JSON string for further assertions.
|
||||
/// Uses a fresh empty `pending_reasoning` buffer so single-line tests
|
||||
/// stay self-contained.
|
||||
fn convert_line_for_test(v1_json: &str) -> String {
|
||||
let mut pending = Vec::new();
|
||||
let v0 = convert_line(v1_json, &mut pending)
|
||||
.expect("convert_line should succeed")
|
||||
.expect("v1 line should produce a v0 message (not a buffered Reasoning)");
|
||||
let out = serde_json::to_string(&v0).expect("v0 should serialize");
|
||||
// Verify the output is valid v0
|
||||
let _: ChatRequestMessage =
|
||||
serde_json::from_str(&out).expect("v0 output should round-trip");
|
||||
out
|
||||
}
|
||||
|
||||
fn v0_value(json: &str) -> serde_json::Value {
|
||||
serde_json::from_str(json).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_message() {
|
||||
let v1 = r#"{"type":"system","content":"You are a helpful assistant."}"#;
|
||||
let out = convert_line_for_test(v1);
|
||||
let v = v0_value(&out);
|
||||
assert_eq!(v["role"], "system");
|
||||
assert_eq!(v["content"], "You are a helpful assistant.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_text_message() {
|
||||
let v1 = r#"{"type":"user","content":[{"type":"text","text":"Hello!"}]}"#;
|
||||
let out = convert_line_for_test(v1);
|
||||
let v = v0_value(&out);
|
||||
assert_eq!(v["role"], "user");
|
||||
// v0 content must be a plain string, not an array of blocks
|
||||
assert_eq!(v["content"], "Hello!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_with_image() {
|
||||
let v1 = r#"{"type":"user","content":[{"type":"text","text":"Look at this"},{"type":"image","url":"https://example.com/img.png"}]}"#;
|
||||
let out = convert_line_for_test(v1);
|
||||
let v = v0_value(&out);
|
||||
assert_eq!(v["role"], "user");
|
||||
let blocks = v["content"].as_array().expect("content should be array");
|
||||
assert_eq!(blocks.len(), 2);
|
||||
assert_eq!(blocks[1]["image_url"]["url"], "https://example.com/img.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_simple() {
|
||||
let v1 = r#"{"type":"assistant","content":"Hi there!","tool_calls":[]}"#;
|
||||
let out = convert_line_for_test(v1);
|
||||
let v = v0_value(&out);
|
||||
assert_eq!(v["role"], "assistant");
|
||||
assert_eq!(v["content"], "Hi there!");
|
||||
}
|
||||
|
||||
/// Legacy shape: `reasoning` was a field on the
|
||||
/// assistant item. Post-refactor the field doesn't exist on
|
||||
/// `AssistantItem`, so serde would silently drop it. We pre-extract
|
||||
/// it from the raw JSON to preserve downstream data fidelity.
|
||||
#[test]
|
||||
fn test_assistant_with_reasoning() {
|
||||
let v1 = r#"{"type":"assistant","content":"The answer is 42.","reasoning":{"text":"Let me think..."},"tool_calls":[],"model_id":"grok-3"}"#;
|
||||
let out = convert_line_for_test(v1);
|
||||
let v = v0_value(&out);
|
||||
assert_eq!(v["role"], "assistant");
|
||||
assert_eq!(v["content"], "The answer is 42.");
|
||||
assert_eq!(v["reasoning_content"], "Let me think...");
|
||||
assert_eq!(v["model_id"], "grok-3");
|
||||
}
|
||||
|
||||
/// Current shape: reasoning is a sibling line
|
||||
/// before the assistant. The downgrade buffers it and folds the
|
||||
/// text into the next assistant's `reasoning_content`.
|
||||
#[test]
|
||||
fn test_sibling_reasoning_folds_into_following_assistant() {
|
||||
let mut pending = Vec::new();
|
||||
|
||||
let r_line = r#"{"type":"reasoning","id":"rs_abc","summary":[{"type":"summary_text","text":"Let me think..."}]}"#;
|
||||
let r = convert_line(r_line, &mut pending).unwrap();
|
||||
assert!(
|
||||
r.is_none(),
|
||||
"sibling Reasoning line must be buffered, not emitted"
|
||||
);
|
||||
assert_eq!(pending.len(), 1);
|
||||
|
||||
let a_line = r#"{"type":"assistant","content":"The answer is 42.","tool_calls":[],"model_id":"grok-3"}"#;
|
||||
let a = convert_line(a_line, &mut pending)
|
||||
.unwrap()
|
||||
.expect("assistant line produces a v0 message");
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
|
||||
assert_eq!(v["role"], "assistant");
|
||||
assert_eq!(v["content"], "The answer is 42.");
|
||||
assert_eq!(v["reasoning_content"], "Let me think...");
|
||||
assert!(pending.is_empty(), "buffer must be flushed after attaching");
|
||||
}
|
||||
|
||||
/// Multiple sibling Reasoning lines before one assistant get joined
|
||||
/// with newlines (matches `conversation_to_chat_messages`).
|
||||
#[test]
|
||||
fn test_multiple_sibling_reasoning_joined_into_one_assistant() {
|
||||
let mut pending = Vec::new();
|
||||
|
||||
for (id, text) in &[("rs_1", "first"), ("rs_2", "second"), ("rs_3", "third")] {
|
||||
let r_line = format!(
|
||||
r#"{{"type":"reasoning","id":"{id}","summary":[{{"type":"summary_text","text":"{text}"}}]}}"#
|
||||
);
|
||||
let r = convert_line(&r_line, &mut pending).unwrap();
|
||||
assert!(r.is_none());
|
||||
}
|
||||
assert_eq!(pending.len(), 3);
|
||||
|
||||
let a_line = r#"{"type":"assistant","content":"ok","tool_calls":[]}"#;
|
||||
let a = convert_line(a_line, &mut pending).unwrap().unwrap();
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
|
||||
assert_eq!(v["reasoning_content"], "first\nsecond\nthird");
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
/// An intervening user / tool message clears pending reasoning --
|
||||
/// matches the `conversation_to_chat_messages` semantic where
|
||||
/// reasoning attaches only to the immediately-following assistant.
|
||||
#[test]
|
||||
fn test_intervening_user_clears_pending_reasoning() {
|
||||
let mut pending = Vec::new();
|
||||
|
||||
let r_line = r#"{"type":"reasoning","id":"rs_orphan","summary":[{"type":"summary_text","text":"orphan"}]}"#;
|
||||
convert_line(r_line, &mut pending).unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
|
||||
let u_line = r#"{"type":"user","content":[{"type":"text","text":"new turn"}]}"#;
|
||||
convert_line(u_line, &mut pending).unwrap();
|
||||
assert!(
|
||||
pending.is_empty(),
|
||||
"user message must clear pending reasoning"
|
||||
);
|
||||
|
||||
let a_line = r#"{"type":"assistant","content":"ok","tool_calls":[]}"#;
|
||||
let a = convert_line(a_line, &mut pending).unwrap().unwrap();
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
|
||||
assert!(
|
||||
v.get("reasoning_content").is_none() || v["reasoning_content"].is_null(),
|
||||
"orphan reasoning must not attach to assistant across a user turn"
|
||||
);
|
||||
}
|
||||
|
||||
/// Legacy assistant.reasoning field wins over buffered sibling
|
||||
/// reasoning -- the explicit per-assistant field is the more
|
||||
/// specific signal.
|
||||
#[test]
|
||||
fn test_legacy_field_overrides_buffered_sibling() {
|
||||
let mut pending = Vec::new();
|
||||
|
||||
let r_line = r#"{"type":"reasoning","id":"rs_sib","summary":[{"type":"summary_text","text":"from sibling"}]}"#;
|
||||
convert_line(r_line, &mut pending).unwrap();
|
||||
|
||||
let a_line = r#"{"type":"assistant","content":"ok","reasoning":{"text":"from legacy field"},"tool_calls":[]}"#;
|
||||
let a = convert_line(a_line, &mut pending).unwrap().unwrap();
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
|
||||
assert_eq!(v["reasoning_content"], "from legacy field");
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_with_tool_calls() {
|
||||
let v1 = r#"{"type":"assistant","content":"","tool_calls":[{"id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}]}"#;
|
||||
let out = convert_line_for_test(v1);
|
||||
let v = v0_value(&out);
|
||||
assert_eq!(v["role"], "assistant");
|
||||
let tc = &v["tool_calls"][0];
|
||||
assert_eq!(tc["id"], "call_1");
|
||||
assert_eq!(tc["function"]["name"], "bash");
|
||||
assert_eq!(tc["function"]["arguments"], r#"{"command":"ls"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_result() {
|
||||
let v1 =
|
||||
r#"{"type":"tool_result","tool_call_id":"call_1","content":"file1.txt\nfile2.txt"}"#;
|
||||
let out = convert_line_for_test(v1);
|
||||
let v = v0_value(&out);
|
||||
assert_eq!(v["role"], "tool");
|
||||
assert_eq!(v["tool_call_id"], "call_1");
|
||||
assert_eq!(v["content"], "file1.txt\nfile2.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_v0_passthrough() {
|
||||
// Already v0 — should pass through without error
|
||||
let v0_input = r#"{"role":"system","content":"Hello"}"#;
|
||||
let parsed: ChatRequestMessage =
|
||||
serde_json::from_str(v0_input).expect("v0 fixture should parse as ChatRequestMessage");
|
||||
let out = serde_json::to_string(&parsed).unwrap();
|
||||
let v = v0_value(&out);
|
||||
assert_eq!(v["role"], "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_json_fails() {
|
||||
let bad = r#"{"type":"unknown_variant","foo":"bar"}"#;
|
||||
let v1_result = serde_json::from_str::<ConversationItem>(bad);
|
||||
let v0_result = serde_json::from_str::<ChatRequestMessage>(bad);
|
||||
assert!(v1_result.is_err());
|
||||
assert!(v0_result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_file_roundtrip() {
|
||||
let v1_lines = [
|
||||
r#"{"type":"system","content":"System prompt"}"#,
|
||||
r#"{"type":"user","content":[{"type":"text","text":"Hello"}]}"#,
|
||||
r#"{"type":"assistant","content":"Hi!","tool_calls":[]}"#,
|
||||
r#"{"type":"assistant","content":"","tool_calls":[{"id":"c1","name":"bash","arguments":"{}"}]}"#,
|
||||
r#"{"type":"tool_result","tool_call_id":"c1","content":"ok"}"#,
|
||||
];
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input_path = dir.path().join("input.jsonl");
|
||||
let output_path = dir.path().join("output.jsonl");
|
||||
|
||||
// Write v1 input
|
||||
std::fs::write(&input_path, v1_lines.join("\n") + "\n").unwrap();
|
||||
|
||||
// Run the conversion logic using the same `convert_line` the
|
||||
// binary's main loop uses, so the test exercises the real path.
|
||||
let reader = BufReader::new(File::open(&input_path).unwrap());
|
||||
let mut writer = BufWriter::new(File::create(&output_path).unwrap());
|
||||
let mut count = 0;
|
||||
let mut pending = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let line = line.unwrap();
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(v0) = convert_line(trimmed, &mut pending).unwrap() else {
|
||||
continue;
|
||||
};
|
||||
serde_json::to_writer(&mut writer, &v0).unwrap();
|
||||
writer.write_all(b"\n").unwrap();
|
||||
count += 1;
|
||||
}
|
||||
writer.flush().unwrap();
|
||||
assert_eq!(count, 5);
|
||||
|
||||
// Verify every output line parses as v0
|
||||
let output = std::fs::read_to_string(&output_path).unwrap();
|
||||
for line in output.lines() {
|
||||
let _: ChatRequestMessage = serde_json::from_str(line)
|
||||
.expect("each output line should be valid v0 ChatRequestMessage");
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-file round trip with the new-shape sibling Reasoning lines
|
||||
/// interleaved between turns. Verifies the binary's buffered
|
||||
/// extraction folds correctly across the whole stream.
|
||||
#[test]
|
||||
fn test_full_file_roundtrip_with_sibling_reasoning() {
|
||||
let v1_lines = [
|
||||
r#"{"type":"system","content":"sys"}"#,
|
||||
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
|
||||
r#"{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"think 1"}]}"#,
|
||||
r#"{"type":"assistant","content":"a1","tool_calls":[]}"#,
|
||||
r#"{"type":"user","content":[{"type":"text","text":"q2"}]}"#,
|
||||
r#"{"type":"reasoning","id":"rs_2","summary":[{"type":"summary_text","text":"think 2"}]}"#,
|
||||
r#"{"type":"assistant","content":"a2","tool_calls":[]}"#,
|
||||
];
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let input_path = dir.path().join("input.jsonl");
|
||||
let output_path = dir.path().join("output.jsonl");
|
||||
std::fs::write(&input_path, v1_lines.join("\n") + "\n").unwrap();
|
||||
|
||||
let reader = BufReader::new(File::open(&input_path).unwrap());
|
||||
let mut writer = BufWriter::new(File::create(&output_path).unwrap());
|
||||
let mut pending = Vec::new();
|
||||
let mut count = 0;
|
||||
for line in reader.lines() {
|
||||
let line = line.unwrap();
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(v0) = convert_line(trimmed, &mut pending).unwrap() {
|
||||
serde_json::to_writer(&mut writer, &v0).unwrap();
|
||||
writer.write_all(b"\n").unwrap();
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
writer.flush().unwrap();
|
||||
|
||||
// 5 emitted lines: sys, q1, a1, q2, a2 (two reasoning lines folded in).
|
||||
assert_eq!(count, 5);
|
||||
|
||||
let output = std::fs::read_to_string(&output_path).unwrap();
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
assert_eq!(lines.len(), 5);
|
||||
|
||||
let a1: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
|
||||
assert_eq!(a1["role"], "assistant");
|
||||
assert_eq!(a1["content"], "a1");
|
||||
assert_eq!(a1["reasoning_content"], "think 1");
|
||||
|
||||
let a2: serde_json::Value = serde_json::from_str(lines[4]).unwrap();
|
||||
assert_eq!(a2["role"], "assistant");
|
||||
assert_eq!(a2["content"], "a2");
|
||||
assert_eq!(a2["reasoning_content"], "think 2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use anyhow::Result;
|
||||
|
||||
/// Usage: cargo run -p kigi-shell --bin test-sampling-server
|
||||
#[derive(Debug, clap::Parser)]
|
||||
pub struct Cli {
|
||||
#[arg(long, default_value = "127.0.0.1:55345")]
|
||||
pub bind_ip_port: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
use clap::Parser;
|
||||
let cli = Cli::parse();
|
||||
let app = axum::Router::new().route("/chat/completions", axum::routing::post(handler));
|
||||
let listener = tokio::net::TcpListener::bind(&cli.bind_ip_port).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handler(_: axum::http::Request<axum::body::Body>) -> impl axum::response::IntoResponse {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Result<axum::response::sse::Event, String>>(10);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let chunk1 = kigi_shell::sampling::ChatCompletionChunk {
|
||||
id: "chat-123".to_string(),
|
||||
object: "chat.completion.chunk".to_string(),
|
||||
created: 1,
|
||||
model: "demo-model".to_string(),
|
||||
choices: vec![],
|
||||
usage: None,
|
||||
system_fingerprint: None,
|
||||
};
|
||||
match serde_json::to_string(&chunk1) {
|
||||
Ok(json1) => {
|
||||
let _ = tx
|
||||
.send(Ok(axum::response::sse::Event::default().data(json1)))
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(e.to_string())).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
// End stream
|
||||
let _ = tx
|
||||
.send(Ok(axum::response::sse::Event::default().data("[DONE]")))
|
||||
.await;
|
||||
});
|
||||
|
||||
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
axum::response::Sse::new(stream).keep_alive(
|
||||
axum::response::sse::KeepAlive::new()
|
||||
.interval(std::time::Duration::from_secs(15))
|
||||
.text("keep-alive"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Replay an offline session trace against the Layer-2 TodoGate and
|
||||
//! Layer-3 LazinessDetector classifier, emitting one JSONL line per
|
||||
//! turn.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --bin trace_classify -- \
|
||||
//! --trace /path/to/trace-<id>-all-turns.json \
|
||||
//! [--output out.jsonl] \
|
||||
//! [--model grok-4.5] \
|
||||
//! [--api-base-url https://api.x.ai/v1] \
|
||||
//! [--api-key <key> | $XAI_API_KEY | <kigi-home>/auth.json] \
|
||||
//! [--min-confidence 0.7] \
|
||||
//! [--include-reasoning true] \
|
||||
//! [--kigi-home <path>]
|
||||
//!
|
||||
//! The binary name is `trace_classify` (underscore) — that's the file
|
||||
//! name in `src/bin/`, which cargo's auto-discovery uses verbatim.
|
||||
//! The task brief calls it `trace-classify` (hyphen) in prose; the
|
||||
//! canonical CLI invocation is the underscore form.
|
||||
//!
|
||||
//! Each JSONL line carries the per-turn gate decision, the parsed
|
||||
//! classifier verdict (or the abort/parse error if the call failed),
|
||||
//! and the inputs that drove them.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use kigi_shell::trace_classifier::{RunArgs, run, validate_min_confidence};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "trace_classify",
|
||||
about = "Replay a session trace against the TodoGate + Laziness classifier"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to the offline trace JSON (a top-level array of turn records).
|
||||
#[arg(long)]
|
||||
trace: PathBuf,
|
||||
|
||||
/// Write JSONL output here (one line per turn). Defaults to stdout
|
||||
/// when omitted.
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Model the classifier sampler calls. Must be a model the API key
|
||||
/// has access to.
|
||||
#[arg(long, default_value = "grok-4.5")]
|
||||
model: String,
|
||||
|
||||
/// Sampler base URL.
|
||||
#[arg(long, default_value = "https://api.x.ai/v1")]
|
||||
api_base_url: String,
|
||||
|
||||
/// API key. Overrides `$XAI_API_KEY` when set; falls back to
|
||||
/// `$XAI_API_KEY`, then `<kigi-home>/auth.json` (`xai::api_key`
|
||||
/// scope) when absent or empty.
|
||||
#[arg(long)]
|
||||
api_key: Option<String>,
|
||||
|
||||
/// Override the LazinessDetector min-confidence threshold (default
|
||||
/// matches production's `LAZINESS_DEFAULT_MIN_CONFIDENCE`). Must
|
||||
/// be a finite float in `[0.0, 1.0]`. Use this to mirror a
|
||||
/// per-model override from the production models catalog. (F6/N5)
|
||||
#[arg(long, value_parser = validate_min_confidence)]
|
||||
min_confidence: Option<f32>,
|
||||
|
||||
/// Override the harness `[assistant reasoning]` emission flag.
|
||||
/// When absent (the default), the binary uses the harness default
|
||||
/// `LAZINESS_INCLUDE_REASONING`. Accepts `true` / `false`. The
|
||||
/// offline replay tool has no per-model config to consult, so
|
||||
/// this is the only override surface here — production resolves
|
||||
/// `LazinessDetectorPerModelConfig::include_reasoning` separately.
|
||||
#[arg(long)]
|
||||
include_reasoning: Option<bool>,
|
||||
|
||||
/// Override the directory containing `auth.json` for the
|
||||
/// third-tier API-key fallback. Defaults to the same path the
|
||||
/// shell uses (`$KIGI_SHARE_DIR` or `~/.kigi`). Exposed primarily for
|
||||
/// tests / sandboxed invocations.
|
||||
#[arg(long)]
|
||||
kigi_home: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// `current_thread` flavour: the replay is strictly sequential
|
||||
/// (one turn at a time), and a multi-threaded runtime would force
|
||||
/// every writer (including `StdoutLock`) to be `Send` — which it
|
||||
/// isn't. The sequential nature also means we never schedule work in
|
||||
/// parallel, so `current_thread` is the right cost shape too.
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let args = RunArgs {
|
||||
trace: cli.trace,
|
||||
output: cli.output,
|
||||
model_id: cli.model,
|
||||
api_base_url: cli.api_base_url,
|
||||
api_key: cli.api_key,
|
||||
min_confidence: cli.min_confidence,
|
||||
include_reasoning: cli.include_reasoning,
|
||||
kigi_home: cli.kigi_home,
|
||||
};
|
||||
let summary = run(args).await?;
|
||||
eprintln!("{}", summary.render());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
|
||||
#[test]
|
||||
fn cli_parses_minimal_args() {
|
||||
let cli = Cli::try_parse_from(["trace_classify", "--trace", "foo.json", "--model", "bar"])
|
||||
.expect("parse");
|
||||
assert_eq!(cli.trace, PathBuf::from("foo.json"));
|
||||
assert_eq!(cli.model, "bar");
|
||||
assert_eq!(cli.api_base_url, "https://api.x.ai/v1");
|
||||
assert!(cli.output.is_none());
|
||||
assert!(cli.api_key.is_none());
|
||||
assert!(cli.min_confidence.is_none());
|
||||
assert!(cli.include_reasoning.is_none());
|
||||
assert!(cli.kigi_home.is_none());
|
||||
}
|
||||
|
||||
/// Per-model knob (mirrored as a CLI override on the offline tool):
|
||||
/// `--include-reasoning true` and `--include-reasoning false` both
|
||||
/// parse; absent → `None` so the harness default applies.
|
||||
#[test]
|
||||
fn cli_include_reasoning_override_parses() {
|
||||
let cli_true = Cli::try_parse_from([
|
||||
"trace_classify",
|
||||
"--trace",
|
||||
"foo.json",
|
||||
"--include-reasoning",
|
||||
"true",
|
||||
])
|
||||
.expect("parse true");
|
||||
assert_eq!(cli_true.include_reasoning, Some(true));
|
||||
|
||||
let cli_false = Cli::try_parse_from([
|
||||
"trace_classify",
|
||||
"--trace",
|
||||
"foo.json",
|
||||
"--include-reasoning",
|
||||
"false",
|
||||
])
|
||||
.expect("parse false");
|
||||
assert_eq!(cli_false.include_reasoning, Some(false));
|
||||
|
||||
let cli_absent =
|
||||
Cli::try_parse_from(["trace_classify", "--trace", "foo.json"]).expect("parse absent");
|
||||
assert!(cli_absent.include_reasoning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_kigi_home_override_parses() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"trace_classify",
|
||||
"--trace",
|
||||
"foo.json",
|
||||
"--kigi-home",
|
||||
"/tmp/scratch-kigi",
|
||||
])
|
||||
.expect("parse");
|
||||
assert_eq!(cli.kigi_home, Some(PathBuf::from("/tmp/scratch-kigi")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_requires_trace() {
|
||||
let err = Cli::try_parse_from(["trace_classify"]).expect_err("missing --trace");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("--trace"), "error mentions --trace: {msg}");
|
||||
}
|
||||
|
||||
/// F18 — assert the documented defaults actually take effect.
|
||||
#[test]
|
||||
fn cli_defaults_match_documented_values() {
|
||||
let cmd = Cli::command();
|
||||
let by_id = |id: &str| {
|
||||
cmd.get_arguments()
|
||||
.find(|a| a.get_id().as_str() == id)
|
||||
.unwrap_or_else(|| panic!("arg {id} missing"))
|
||||
.get_default_values()
|
||||
.iter()
|
||||
.map(|v| v.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(by_id("model"), vec!["grok-4.5"]);
|
||||
assert_eq!(by_id("api_base_url"), vec!["https://api.x.ai/v1"]);
|
||||
assert!(by_id("min_confidence").is_empty(), "no default");
|
||||
assert!(by_id("include_reasoning").is_empty(), "no default");
|
||||
}
|
||||
|
||||
/// F6 — `--min-confidence 0.5` parses and lands in `RunArgs`.
|
||||
#[test]
|
||||
fn cli_min_confidence_override_parses() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"trace_classify",
|
||||
"--trace",
|
||||
"foo.json",
|
||||
"--min-confidence",
|
||||
"0.42",
|
||||
])
|
||||
.expect("parse");
|
||||
assert_eq!(cli.min_confidence, Some(0.42));
|
||||
}
|
||||
|
||||
/// N5 — clap `value_parser` rejects out-of-range / non-finite
|
||||
/// floats at parse time, before they reach `RunArgs`. Bad values
|
||||
/// are passed via `--min-confidence=VALUE` syntax so negative
|
||||
/// literals aren't mis-parsed as short flags.
|
||||
#[test]
|
||||
fn cli_min_confidence_rejects_bad_values() {
|
||||
for bad in ["1.5", "-0.1", "nan", "inf", "not-a-float"] {
|
||||
let arg = format!("--min-confidence={bad}");
|
||||
let err = Cli::try_parse_from(["trace_classify", "--trace", "foo.json", arg.as_str()])
|
||||
.expect_err(bad);
|
||||
// Parsing failed — that's all we need. Exact error text
|
||||
// is clap-version-dependent.
|
||||
let _ = err.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Built-in files extracted to `~/.kigi/` on startup.
|
||||
|
||||
const BUNDLED_FILES: &[(&str, &str)] = &[("README.md", include_str!("../README.md"))];
|
||||
|
||||
const HELP_SKILL_MD: &str = include_str!("../skills/help/SKILL.md");
|
||||
const CREATE_SKILL_MD: &str = include_str!("../skills/create-skill/SKILL.md");
|
||||
const CODE_REVIEW_SKILL_MD: &str = include_str!("../skills/code-review/SKILL.md");
|
||||
const IMAGINE_SKILL_MD: &str = include_str!("../skills/imagine/SKILL.md");
|
||||
/// Compiled-in SKILL.md content for `/check-work` (available to headless mode).
|
||||
pub const CHECK_SKILL_MD: &str = include_str!("../skills/check-work/SKILL.md");
|
||||
/// Compiled-in SKILL.md content for headless `--best-of-n` (not extracted as
|
||||
/// a bundled skill).
|
||||
pub const BEST_OF_N_SKILL_MD: &str = include_str!("../skills/best-of-n/SKILL.md");
|
||||
|
||||
/// Legacy bundled skill names (renamed or removed).
|
||||
///
|
||||
/// These directories under `~/.kigi/skills/` will be deleted on startup
|
||||
/// (during bundled file extraction). This ensures that when a bundled
|
||||
/// skill is renamed (e.g. `check` → `check-work`), the old slash command
|
||||
/// does not linger on users' machines after an upgrade.
|
||||
///
|
||||
/// Important behavior:
|
||||
/// - Deletion happens **early** in `extract_bundled_files`, before we write
|
||||
/// any current bundled skills.
|
||||
/// - We **never** delete a name that is currently present in `BUNDLED_SKILLS`
|
||||
/// (see `remove_legacy_bundled_skills`).
|
||||
///
|
||||
/// This means:
|
||||
/// - If you later re-introduce a skill with a name that is still in this
|
||||
/// legacy list (e.g. you ship a new "check" skill years later), the legacy
|
||||
/// cleanup will **skip** it and the new skill will be created normally.
|
||||
/// - The legacy list is a "delete old user copies of names we no longer ship",
|
||||
/// not a permanent blacklist.
|
||||
///
|
||||
/// Lifecycle / maintenance:
|
||||
/// - Add an old name here when you rename/remove a bundled skill.
|
||||
/// - Once the directory is gone on a user's machine, further checks are
|
||||
/// cheap no-ops.
|
||||
/// - You do **not** have to remove entries immediately. It is safe to leave
|
||||
/// them for many releases.
|
||||
/// - After the rename has had time to propagate, you **may** clean old
|
||||
/// strings out of this list for hygiene.
|
||||
const LEGACY_BUNDLED_SKILL_NAMES: &[&str] = &["check", "best-of-n", "docx", "pptx", "xlsx"];
|
||||
|
||||
/// All bundled skill SKILL.md files. Single source of truth used by both
|
||||
/// the full extraction path (version bump) and the missing-file fast path
|
||||
/// (same version). Adding a new skill here is all that's needed.
|
||||
///
|
||||
/// When renaming a bundled skill (e.g. "check" → "check-work"), also add the
|
||||
/// old name to `LEGACY_BUNDLED_SKILL_NAMES` so `remove_legacy_bundled_skills`
|
||||
/// will clean up the old directory on user machines on the next upgrade.
|
||||
///
|
||||
/// See the docs on `LEGACY_BUNDLED_SKILL_NAMES` for the full lifecycle
|
||||
/// (including when it is safe/optional to remove old entries later).
|
||||
const BUNDLED_SKILLS: &[(&str, &str)] = &[
|
||||
("help", HELP_SKILL_MD),
|
||||
("create-skill", CREATE_SKILL_MD),
|
||||
("code-review", CODE_REVIEW_SKILL_MD),
|
||||
("imagine", IMAGINE_SKILL_MD),
|
||||
("check-work", CHECK_SKILL_MD),
|
||||
];
|
||||
|
||||
/// True when a discovered skill is the copy `extract_bundled_files` wrote to
|
||||
/// `<kigi_home>/skills/<name>/SKILL.md`. Exact-path (not prefix) so a
|
||||
/// user-authored skill that reuses a bundled name — even elsewhere under
|
||||
/// `<kigi_home>/skills/` — is never labeled bundled. Lives beside the
|
||||
/// extraction code so the target layout and this predicate move together.
|
||||
/// Used by inspect, which otherwise sees extracted copies as user skills.
|
||||
pub(crate) fn is_extracted_bundled_skill(
|
||||
name: &str,
|
||||
path: &std::path::Path,
|
||||
kigi_home: &std::path::Path,
|
||||
) -> bool {
|
||||
BUNDLED_SKILLS.iter().any(|&(n, _)| n == name)
|
||||
&& path == kigi_home.join("skills").join(name).join("SKILL.md")
|
||||
}
|
||||
|
||||
/// Resolve the content for a skill, applying any name-specific transforms.
|
||||
fn resolve_skill_content(name: &str, raw: &str, kigi_home: &std::path::Path) -> String {
|
||||
match name {
|
||||
// Help skill needs path substitution so absolute paths work.
|
||||
"help" => {
|
||||
let kigi_home_str = format!("{}/", kigi_home.to_string_lossy());
|
||||
raw.replace("~/.kigi/", &kigi_home_str)
|
||||
}
|
||||
_ => raw.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract bundled files to `~/.kigi/` on startup.
|
||||
///
|
||||
/// Full extraction runs on every version bump. On same-version startups,
|
||||
/// a lightweight check ensures all expected skill files exist on disk —
|
||||
/// any missing files are extracted individually.
|
||||
///
|
||||
/// Legacy/renamed bundled skills (see `LEGACY_BUNDLED_SKILL_NAMES`) are
|
||||
/// always cleaned up first so that old slash commands disappear after
|
||||
/// a rename (e.g. the previous `/check` after the move to `/check-work`).
|
||||
pub fn extract_bundled_files(kigi_home: &std::path::Path) {
|
||||
// Always remove legacy/renamed bundled skills first (e.g. the old
|
||||
// `check` directory after the rename to `check-work`). This runs on
|
||||
// every startup so users get cleaned up even without hitting a
|
||||
// version-bump marker change.
|
||||
remove_legacy_bundled_skills(kigi_home);
|
||||
|
||||
let version = kigi_version::VERSION;
|
||||
let marker = kigi_home.join(".metadata_version");
|
||||
|
||||
if let Ok(existing) = std::fs::read_to_string(&marker)
|
||||
&& existing.trim() == version
|
||||
{
|
||||
// Same version — only extract skill files that are missing on disk.
|
||||
// This handles skills added between version bumps.
|
||||
extract_missing_skills(kigi_home);
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = std::fs::create_dir_all(kigi_home);
|
||||
|
||||
// Clean up cached changelog files from previous version so
|
||||
// /release-notes fetches fresh content for the new version.
|
||||
for stale in &["CHANGELOG.json", "CHANGELOG.md"] {
|
||||
let _ = std::fs::remove_file(kigi_home.join(stale));
|
||||
}
|
||||
|
||||
for &(filename, content) in BUNDLED_FILES {
|
||||
if let Err(e) = std::fs::write(kigi_home.join(filename), content) {
|
||||
tracing::debug!(error = %e, filename, "Failed to extract bundled file");
|
||||
}
|
||||
}
|
||||
|
||||
// Skill SKILL.md files.
|
||||
for &(name, raw) in BUNDLED_SKILLS {
|
||||
let skill_dir = kigi_home.join("skills").join(name);
|
||||
let _ = std::fs::create_dir_all(&skill_dir);
|
||||
let content = resolve_skill_content(name, raw, kigi_home);
|
||||
if let Err(e) = std::fs::write(skill_dir.join("SKILL.md"), content) {
|
||||
tracing::debug!(error = %e, name, "Failed to write skill");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::write(&marker, version);
|
||||
tracing::debug!(version, "Extracted bundled files");
|
||||
}
|
||||
|
||||
/// Extract only missing skill SKILL.md files (same-version fast path).
|
||||
/// Iterates `BUNDLED_SKILLS` so adding a new skill there is sufficient.
|
||||
fn extract_missing_skills(kigi_home: &std::path::Path) {
|
||||
for &(name, raw) in BUNDLED_SKILLS {
|
||||
let skill_md = kigi_home.join("skills").join(name).join("SKILL.md");
|
||||
if skill_md.exists() {
|
||||
continue;
|
||||
}
|
||||
let _ = std::fs::create_dir_all(skill_md.parent().unwrap());
|
||||
let content = resolve_skill_content(name, raw, kigi_home);
|
||||
let _ = std::fs::write(&skill_md, content);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove directories for legacy/renamed bundled skills (e.g. old `check`
|
||||
/// after it was renamed to `check-work`).
|
||||
///
|
||||
/// Called on every startup from `extract_bundled_files`. Safe and idempotent.
|
||||
///
|
||||
/// Key guarantees (see `LEGACY_BUNDLED_SKILL_NAMES` docs for details):
|
||||
/// - If a name is still present in `BUNDLED_SKILLS`, we deliberately skip
|
||||
/// deletion. This allows safe re-use of a skill name in the future.
|
||||
/// - If the target directory no longer exists, this is a trivial no-op.
|
||||
fn remove_legacy_bundled_skills(kigi_home: &std::path::Path) {
|
||||
remove_legacy_skills(kigi_home, LEGACY_BUNDLED_SKILL_NAMES, BUNDLED_SKILLS);
|
||||
}
|
||||
|
||||
/// Core implementation, extracted for testability.
|
||||
fn remove_legacy_skills(
|
||||
kigi_home: &std::path::Path,
|
||||
legacy_names: &[&str],
|
||||
bundled_skills: &[(&str, &str)],
|
||||
) {
|
||||
for name in legacy_names {
|
||||
// Safety: Never delete a name that we are currently shipping.
|
||||
// This protects against re-introducing a skill name that still has
|
||||
// an entry in the legacy list.
|
||||
if bundled_skills.iter().any(|(n, _)| *n == *name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dir = kigi_home.join("skills").join(name);
|
||||
if dir.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&dir) {
|
||||
tracing::debug!(error = %e, name, "Failed to remove legacy bundled skill");
|
||||
} else {
|
||||
tracing::debug!(name, "Removed legacy bundled skill directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_bump_re_extracts_all_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path();
|
||||
|
||||
extract_bundled_files(home);
|
||||
|
||||
for &(filename, _) in BUNDLED_FILES {
|
||||
std::fs::write(home.join(filename), "old").unwrap();
|
||||
}
|
||||
std::fs::write(home.join("skills/help/SKILL.md"), "old").unwrap();
|
||||
for name in ["check-work", "imagine", "code-review"] {
|
||||
std::fs::write(home.join(format!("skills/{name}/SKILL.md")), "old").unwrap();
|
||||
}
|
||||
std::fs::write(home.join(".metadata_version"), "0.0.0-stale").unwrap();
|
||||
|
||||
// Simulate legacy skills that should be cleaned up.
|
||||
for name in ["check", "best-of-n", "docx", "pptx", "xlsx"] {
|
||||
std::fs::create_dir_all(home.join(format!("skills/{name}"))).unwrap();
|
||||
std::fs::write(
|
||||
home.join(format!("skills/{name}/SKILL.md")),
|
||||
"old legacy skill",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
extract_bundled_files(home);
|
||||
|
||||
for &(filename, _) in BUNDLED_FILES {
|
||||
assert_ne!(
|
||||
std::fs::read_to_string(home.join(filename)).unwrap(),
|
||||
"old",
|
||||
"{filename} was not re-extracted after version bump"
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
std::fs::read_to_string(home.join("skills/help/SKILL.md")).unwrap(),
|
||||
"old"
|
||||
);
|
||||
for name in ["check-work", "imagine", "code-review"] {
|
||||
assert_ne!(
|
||||
std::fs::read_to_string(home.join(format!("skills/{name}/SKILL.md"))).unwrap(),
|
||||
"old",
|
||||
"{name} skill was not re-extracted after version bump"
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy skill directories must have been removed (the key part of
|
||||
// supporting renames like check → check-work without leaving orphans).
|
||||
for name in ["check", "best-of-n", "docx", "pptx", "xlsx"] {
|
||||
assert!(
|
||||
!home.join(format!("skills/{name}")).exists(),
|
||||
"legacy '{name}' skill directory should have been deleted during version bump"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn office_skills_not_bundled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path();
|
||||
|
||||
extract_bundled_files(home);
|
||||
|
||||
// Former office document skills must NOT be extracted as bundled.
|
||||
for name in ["docx", "pptx", "xlsx"] {
|
||||
assert!(
|
||||
!home.join(format!("skills/{name}")).exists(),
|
||||
"{name} should not be a bundled skill"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn help_skill_discovered_by_skill_pipeline() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path();
|
||||
|
||||
extract_bundled_files(home);
|
||||
|
||||
let workspace = tmp.path().join("workspace");
|
||||
std::fs::create_dir_all(workspace.join(".kigi").join("skills").join("help")).unwrap();
|
||||
std::fs::copy(
|
||||
home.join("skills/help/SKILL.md"),
|
||||
workspace.join(".kigi/skills/help/SKILL.md"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = kigi_agent::prompt::skills::list_skills(
|
||||
Some(workspace.to_str().unwrap()),
|
||||
&Default::default(),
|
||||
kigi_agent::prompt::skills::CompatConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let help = skills.iter().find(|s| s.name == "help");
|
||||
assert!(
|
||||
help.is_some(),
|
||||
"help skill not found. skills: {:?}",
|
||||
skills.iter().map(|s| &s.name).collect::<Vec<_>>()
|
||||
);
|
||||
let help = help.unwrap();
|
||||
assert!(help.description.contains("configuration"));
|
||||
assert!(help.user_invocable);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tests for legacy bundled skill removal (the rename migration system)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn remove_legacy_deletes_old_skill_when_not_currently_shipped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path();
|
||||
|
||||
// Simulate an old legacy "check" directory from before a rename.
|
||||
let legacy_dir = home.join("skills/check");
|
||||
std::fs::create_dir_all(&legacy_dir).unwrap();
|
||||
std::fs::write(legacy_dir.join("SKILL.md"), "old check").unwrap();
|
||||
|
||||
// "check" is in legacy list but NOT in current BUNDLED_SKILLS
|
||||
remove_legacy_skills(home, &["check"], BUNDLED_SKILLS);
|
||||
|
||||
assert!(
|
||||
!legacy_dir.exists(),
|
||||
"legacy skill directory should have been deleted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_legacy_does_not_delete_when_name_is_reused_in_current_bundled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path();
|
||||
|
||||
// User still has an old "check" directory.
|
||||
let legacy_dir = home.join("skills/check");
|
||||
std::fs::create_dir_all(&legacy_dir).unwrap();
|
||||
std::fs::write(legacy_dir.join("SKILL.md"), "user had old check").unwrap();
|
||||
|
||||
// Simulate the situation where we later re-ship a skill named "check".
|
||||
// In this case the legacy entry should be ignored.
|
||||
let fake_bundled: &[(&str, &str)] = &[("check", "fake content"), ("help", "help")];
|
||||
|
||||
remove_legacy_skills(home, &["check"], fake_bundled);
|
||||
|
||||
// The directory must still exist (we did not nuke the user's copy
|
||||
// or a skill we're about to (re)create).
|
||||
assert!(
|
||||
legacy_dir.exists(),
|
||||
"should not delete a name that is currently being shipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_legacy_handles_multiple_names_some_current_some_legacy() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path();
|
||||
|
||||
std::fs::create_dir_all(home.join("skills/old-renamed")).unwrap();
|
||||
std::fs::write(home.join("skills/old-renamed/SKILL.md"), "old").unwrap();
|
||||
|
||||
std::fs::create_dir_all(home.join("skills/another-legacy")).unwrap();
|
||||
std::fs::write(home.join("skills/another-legacy/SKILL.md"), "old2").unwrap();
|
||||
|
||||
// Current bundled skills include one name that used to be legacy
|
||||
let current: &[(&str, &str)] = &[("another-legacy", "now shipping again")];
|
||||
|
||||
// Legacy list contains both the truly removed one and the reintroduced one
|
||||
remove_legacy_skills(home, &["old-renamed", "another-legacy"], current);
|
||||
|
||||
assert!(
|
||||
!home.join("skills/old-renamed").exists(),
|
||||
"truly legacy name should be removed"
|
||||
);
|
||||
assert!(
|
||||
home.join("skills/another-legacy").exists(),
|
||||
"reintroduced name must not be deleted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_legacy_is_noop_when_directory_does_not_exist() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path();
|
||||
|
||||
// No directory exists for the legacy name
|
||||
remove_legacy_skills(home, &["check"], BUNDLED_SKILLS);
|
||||
|
||||
// Should not panic or create anything
|
||||
assert!(!home.join("skills/check").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_cleanup_runs_even_on_same_version_fast_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path();
|
||||
|
||||
// First run: extract current state
|
||||
extract_bundled_files(home);
|
||||
|
||||
// Simulate user still having an old legacy directory
|
||||
let legacy_dir = home.join("skills/check");
|
||||
std::fs::create_dir_all(&legacy_dir).unwrap();
|
||||
std::fs::write(legacy_dir.join("SKILL.md"), "stale").unwrap();
|
||||
|
||||
// Force the "same version" fast path by writing the current version marker
|
||||
let version = kigi_version::VERSION;
|
||||
std::fs::write(home.join(".metadata_version"), version).unwrap();
|
||||
|
||||
// This should still run legacy cleanup even though we're in fast path
|
||||
extract_bundled_files(home);
|
||||
|
||||
assert!(
|
||||
!legacy_dir.exists(),
|
||||
"legacy cleanup must run even on same-version fast path"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
// claude_import_state.rs
|
||||
// Tracks what Claude settings have been imported/dismissed so we don't re-prompt.
|
||||
//
|
||||
// State is persisted to `~/.kigi/claude_import_state.json`.
|
||||
// Hash is SHA-256 over sorted, concatenated contents of all Claude settings
|
||||
// files at a given scope (global or project).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use kigi_workspace::permission::claude_settings::find_claude_settings_paths;
|
||||
|
||||
// Types
|
||||
|
||||
/// Persistent import state, loaded from / saved to `~/.kigi/claude_import_state.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportState {
|
||||
/// Schema version for forward compatibility.
|
||||
pub version: u32,
|
||||
/// Hash of global Claude settings (`~/.claude/settings*.json`, `~/.claude.json`).
|
||||
#[serde(default)]
|
||||
pub global: Option<ScopeState>,
|
||||
/// Per-project hashes, keyed by canonical project root path.
|
||||
#[serde(default)]
|
||||
pub projects: HashMap<String, ScopeState>,
|
||||
}
|
||||
|
||||
/// Import state for a single scope (global or one project).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScopeState {
|
||||
/// SHA-256 hex digest of the concatenated Claude settings file contents.
|
||||
pub last_hash: String,
|
||||
/// RFC 3339 timestamp of when the hash was last recorded.
|
||||
pub last_checked: String,
|
||||
}
|
||||
|
||||
impl Default for ImportState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
global: None,
|
||||
projects: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persistence
|
||||
|
||||
/// Path to the import state file.
|
||||
fn state_path() -> PathBuf {
|
||||
crate::util::kigi_home::kigi_home().join("claude_import_state.json")
|
||||
}
|
||||
|
||||
/// Load the import state from disk. Returns default if missing or unreadable.
|
||||
pub fn load_import_state() -> ImportState {
|
||||
let path = state_path();
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"Failed to parse claude_import_state.json, using default"
|
||||
);
|
||||
ImportState::default()
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => ImportState::default(),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"Failed to read claude_import_state.json, using default"
|
||||
);
|
||||
ImportState::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the import state to disk (atomic write via tmp + rename).
|
||||
pub fn save_import_state(state: &ImportState) -> std::io::Result<()> {
|
||||
let path = state_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(state).map_err(std::io::Error::other)?;
|
||||
// `.with_extension("json.tmp")` replaces `.json` → produces
|
||||
// `claude_import_state.json.tmp` (the last extension is replaced).
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &json)?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Hash Computation
|
||||
|
||||
/// Compute a SHA-256 hash over the contents of all Claude settings files for a
|
||||
/// given set of paths. Files that don't exist or can't be read are skipped.
|
||||
///
|
||||
/// Paths are sorted before hashing so the result is deterministic regardless of
|
||||
/// discovery order.
|
||||
fn compute_settings_hash(paths: &[PathBuf]) -> String {
|
||||
let mut existing: Vec<(&PathBuf, Vec<u8>)> = paths
|
||||
.iter()
|
||||
.filter_map(|p| std::fs::read(p).ok().map(|content| (p, content)))
|
||||
.collect();
|
||||
|
||||
// Sort by path for determinism.
|
||||
existing.sort_by(|a, b| a.0.cmp(b.0));
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
for (path, content) in &existing {
|
||||
// Include path in the hash so renaming a file changes the hash.
|
||||
hasher.update(path.to_string_lossy().as_bytes());
|
||||
hasher.update(b"\x00");
|
||||
hasher.update(content);
|
||||
hasher.update(b"\x00");
|
||||
}
|
||||
|
||||
format!("sha256:{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Compute hash for global Claude settings (`~/.claude/settings*.json`, `~/.claude.json`).
|
||||
///
|
||||
/// Uses `dirs::home_dir()` to match the home directory resolution used by
|
||||
/// `load_claude_json_mcp_servers_as_configs()` in `util/config.rs`.
|
||||
fn compute_global_hash() -> (String, Vec<PathBuf>) {
|
||||
let mut paths = Vec::new();
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
paths.push(home.join(".claude").join("settings.json"));
|
||||
paths.push(home.join(".claude").join("settings.local.json"));
|
||||
paths.push(home.join(".claude.json"));
|
||||
}
|
||||
let hash = compute_settings_hash(&paths);
|
||||
(hash, paths)
|
||||
}
|
||||
|
||||
/// Compute hash for project-level Claude settings.
|
||||
///
|
||||
/// Uses `dirs::home_dir()` to match the home directory resolution used by
|
||||
/// the scanner in `claude_import.rs`.
|
||||
fn compute_project_hash(cwd: &Path) -> (String, Vec<PathBuf>) {
|
||||
// Use find_claude_settings_paths but filter to only project-level paths
|
||||
// (exclude global ~/.claude/ paths).
|
||||
let all_paths = find_claude_settings_paths(cwd);
|
||||
let home = dirs::home_dir();
|
||||
|
||||
let project_paths: Vec<PathBuf> = all_paths
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
// Exclude global paths (under ~/.claude/).
|
||||
if let Some(ref h) = home {
|
||||
!p.starts_with(h.join(".claude"))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Also include .mcp.json candidate paths from cwd up to repo root.
|
||||
// Non-existent files are skipped by compute_settings_hash(), so we
|
||||
// unconditionally add candidates (avoids TOCTOU race vs .exists()).
|
||||
let mut all = project_paths;
|
||||
let mut current = cwd.to_path_buf();
|
||||
loop {
|
||||
all.push(current.join(".mcp.json"));
|
||||
if current.join(".git").exists() {
|
||||
break;
|
||||
}
|
||||
match current.parent() {
|
||||
Some(parent) => current = parent.to_path_buf(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
let hash = compute_settings_hash(&all);
|
||||
(hash, all)
|
||||
}
|
||||
|
||||
// Change Detection
|
||||
|
||||
/// Check if any Claude settings files have changed since the last import/dismiss.
|
||||
///
|
||||
/// Returns `true` if:
|
||||
/// - Global settings exist and have a different hash than last recorded
|
||||
/// - Project settings exist and have a different hash than last recorded
|
||||
/// - No import state exists yet but Claude settings files are present
|
||||
pub fn has_new_changes(cwd: &Path) -> bool {
|
||||
let state = load_import_state();
|
||||
|
||||
// Check global scope.
|
||||
let (global_hash, global_paths) = compute_global_hash();
|
||||
let global_files_exist = global_paths.iter().any(|p| p.exists());
|
||||
if global_files_exist {
|
||||
match &state.global {
|
||||
None => {
|
||||
debug!("Claude import: global settings found, no previous import state");
|
||||
return true;
|
||||
}
|
||||
Some(s) if s.last_hash != global_hash => {
|
||||
debug!(
|
||||
old = %s.last_hash,
|
||||
new = %global_hash,
|
||||
"Claude import: global settings changed since last import"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Check project scope.
|
||||
let (project_hash, project_paths) = compute_project_hash(cwd);
|
||||
let project_files_exist = project_paths.iter().any(|p| p.exists());
|
||||
if project_files_exist {
|
||||
let cwd_key = cwd.to_string_lossy().to_string();
|
||||
match state.projects.get(&cwd_key) {
|
||||
None => {
|
||||
debug!("Claude import: project settings found, no previous import state");
|
||||
return true;
|
||||
}
|
||||
Some(s) if s.last_hash != project_hash => {
|
||||
debug!(
|
||||
old = %s.last_hash,
|
||||
new = %project_hash,
|
||||
"Claude import: project settings changed since last import"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// State Updates
|
||||
|
||||
fn now_rfc3339() -> String {
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
/// Record the current hash for both global and project scopes.
|
||||
///
|
||||
/// Called after a successful import or explicit dismiss.
|
||||
pub fn mark_imported(cwd: &Path) {
|
||||
let mut state = load_import_state();
|
||||
|
||||
let (global_hash, _) = compute_global_hash();
|
||||
state.global = Some(ScopeState {
|
||||
last_hash: global_hash,
|
||||
last_checked: now_rfc3339(),
|
||||
});
|
||||
|
||||
let (project_hash, _) = compute_project_hash(cwd);
|
||||
let cwd_key = cwd.to_string_lossy().to_string();
|
||||
state.projects.insert(
|
||||
cwd_key,
|
||||
ScopeState {
|
||||
last_hash: project_hash,
|
||||
last_checked: now_rfc3339(),
|
||||
},
|
||||
);
|
||||
|
||||
if let Err(e) = save_import_state(&state) {
|
||||
warn!(error = %e, "Failed to save claude_import_state.json");
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias for `mark_imported` — dismissing records the same hash so we don't
|
||||
/// re-prompt until the files actually change.
|
||||
pub fn mark_dismissed(cwd: &Path) {
|
||||
mark_imported(cwd);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compute_settings_hash_deterministic() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f1 = dir.path().join("a.json");
|
||||
let f2 = dir.path().join("b.json");
|
||||
std::fs::write(&f1, r#"{"allow": ["Bash"]}"#).unwrap();
|
||||
std::fs::write(&f2, r#"{"env": {"FOO": "bar"}}"#).unwrap();
|
||||
|
||||
// Same order.
|
||||
let h1 = compute_settings_hash(&[f1.clone(), f2.clone()]);
|
||||
let h2 = compute_settings_hash(&[f1.clone(), f2.clone()]);
|
||||
assert_eq!(h1, h2, "same order should produce same hash");
|
||||
|
||||
// Reversed order should also produce the same hash (sorted internally).
|
||||
let h3 = compute_settings_hash(&[f2.clone(), f1.clone()]);
|
||||
assert_eq!(
|
||||
h1, h3,
|
||||
"reversed order should produce same hash due to sorting"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_settings_hash_skips_missing_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let existing = dir.path().join("exists.json");
|
||||
let missing = dir.path().join("does_not_exist.json");
|
||||
std::fs::write(&existing, "content").unwrap();
|
||||
|
||||
let h1 = compute_settings_hash(std::slice::from_ref(&existing));
|
||||
let h2 = compute_settings_hash(&[existing.clone(), missing]);
|
||||
assert_eq!(h1, h2, "missing files should be skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_settings_hash_changes_on_content_change() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f = dir.path().join("settings.json");
|
||||
|
||||
std::fs::write(&f, "version1").unwrap();
|
||||
let h1 = compute_settings_hash(std::slice::from_ref(&f));
|
||||
|
||||
std::fs::write(&f, "version2").unwrap();
|
||||
let h2 = compute_settings_hash(std::slice::from_ref(&f));
|
||||
|
||||
assert_ne!(h1, h2, "different content should produce different hash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_settings_hash_empty_input() {
|
||||
// No paths at all should produce a deterministic hash.
|
||||
let h1 = compute_settings_hash(&[]);
|
||||
let h2 = compute_settings_hash(&[]);
|
||||
assert_eq!(h1, h2, "empty input should produce same hash");
|
||||
assert!(h1.starts_with("sha256:"), "hash should have sha256: prefix");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Data APIs for `grok models`. Clients own display.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::Result;
|
||||
use kigi_acp_lib::{AcpAgentTx, acp_send};
|
||||
|
||||
use crate::agent::config::Config as AgentConfig;
|
||||
|
||||
/// Status for the `grok models` banner (display order ≠ sampling priority; see [`AuthStatus::resolve`]).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AuthStatus {
|
||||
ApiKey,
|
||||
/// Auth host from `grok_ws_origin` (scheme stripped).
|
||||
LoggedIn(String),
|
||||
/// Catalog key of the first model with own `api_key`/`env_key`.
|
||||
ModelCredentials(String),
|
||||
DeploymentKey,
|
||||
NotAuthenticated,
|
||||
}
|
||||
|
||||
impl AuthStatus {
|
||||
/// Banner status: env key → session → BYOK → deployment → none.
|
||||
///
|
||||
/// Differs from sampling (`resolve_credentials`: BYOK → session → env) so a
|
||||
/// logged-in user sees the login host. BYOK uses
|
||||
/// [`crate::agent::auth_method::should_advertise_xai_api_key`] so
|
||||
/// `disable_api_key_auth` is honored.
|
||||
pub fn resolve(agent_config: &AgentConfig) -> Self {
|
||||
if crate::agent::auth_method::has_xai_api_key_env() {
|
||||
return Self::ApiKey;
|
||||
}
|
||||
if agent_config.create_auth_manager().current().is_some() {
|
||||
let origin = &agent_config.grok_com_config.grok_ws_origin;
|
||||
let host = origin
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| origin.strip_prefix("http://"))
|
||||
.unwrap_or(origin);
|
||||
return Self::LoggedIn(host.to_owned());
|
||||
}
|
||||
let models = crate::agent::config::resolve_model_list(agent_config, None);
|
||||
if crate::agent::auth_method::should_advertise_xai_api_key(
|
||||
agent_config.grok_com_config.api_key_auth_disabled(),
|
||||
models.values(),
|
||||
) && let Some(name) = models
|
||||
.iter()
|
||||
.find_map(|(name, entry)| entry.has_own_credentials().then(|| name.clone()))
|
||||
{
|
||||
return Self::ModelCredentials(name);
|
||||
}
|
||||
if agent_config.endpoints.deployment_key.is_some() {
|
||||
return Self::DeploymentKey;
|
||||
}
|
||||
Self::NotAuthenticated
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch model state (available models + default) over an ACP channel.
|
||||
pub async fn list_models(
|
||||
acp_tx: &AcpAgentTx,
|
||||
client_type: &str,
|
||||
client_version: &str,
|
||||
) -> Result<acp::SessionModelState> {
|
||||
let init_resp: acp::InitializeResponse = acp_send(
|
||||
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
|
||||
.client_capabilities(
|
||||
acp::ClientCapabilities::new()
|
||||
.fs(acp::FileSystemCapabilities::new())
|
||||
.terminal(false),
|
||||
)
|
||||
.meta(
|
||||
serde_json::json!({
|
||||
"clientType": client_type,
|
||||
"clientVersion": client_version,
|
||||
})
|
||||
.as_object()
|
||||
.cloned(),
|
||||
),
|
||||
acp_tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let model_state = init_resp
|
||||
.meta
|
||||
.and_then(|m| m.get("modelState").cloned())
|
||||
.ok_or_else(|| anyhow::anyhow!("InitializeResponse missing modelState"))?;
|
||||
let state: acp::SessionModelState = serde_json::from_value(model_state)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse modelState: {}", e))?;
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::auth_method::{LEGACY_XAI_API_KEY_ENV_VAR, XAI_API_KEY_ENV_VAR};
|
||||
use crate::agent::config::Config;
|
||||
use crate::auth::{AuthMode, GrokAuth};
|
||||
use kigi_test_support::EnvGuard;
|
||||
use serial_test::serial;
|
||||
|
||||
/// Isolate process-global auth sources that `AuthStatus::resolve` consults.
|
||||
///
|
||||
/// Uses `KIGI_AUTH_PATH` (not `KIGI_SHARE_DIR`) so a OnceLock-cached real home
|
||||
/// with `auth.json` cannot leak into these tests.
|
||||
fn isolate_auth_sources() -> (tempfile::TempDir, [EnvGuard; 7]) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth_path = dir.path().join("no-auth.json");
|
||||
let guards = [
|
||||
EnvGuard::unset(XAI_API_KEY_ENV_VAR),
|
||||
EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR),
|
||||
EnvGuard::unset("KIGI_AUTH"),
|
||||
EnvGuard::set("KIGI_AUTH_PATH", auth_path.to_str().unwrap()),
|
||||
EnvGuard::unset("KIGI_DEPLOYMENT_KEY"),
|
||||
EnvGuard::unset("KIGI_WS_ORIGIN"),
|
||||
EnvGuard::unset("KIGI_DISABLE_API_KEY_AUTH"),
|
||||
];
|
||||
(dir, guards)
|
||||
}
|
||||
|
||||
fn byok_and_deployment_toml(model_id: &str) -> String {
|
||||
format!(
|
||||
r#"
|
||||
[endpoints]
|
||||
deployment_key = "deploy-key"
|
||||
|
||||
[model."{model_id}"]
|
||||
model = "{model_id}"
|
||||
api_key = "sk-byok"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn config_from_toml(toml_src: &str) -> Config {
|
||||
let toml: toml::Value = toml::from_str(toml_src).unwrap();
|
||||
Config::new_from_toml_cfg(&toml).expect("config should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_api_key_env() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let _key = EnvGuard::set(XAI_API_KEY_ENV_VAR, "xai-test-key");
|
||||
assert_eq!(AuthStatus::resolve(&Config::default()), AuthStatus::ApiKey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_legacy_api_key_env() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let _key = EnvGuard::set(LEGACY_XAI_API_KEY_ENV_VAR, "legacy-key");
|
||||
assert_eq!(AuthStatus::resolve(&Config::default()), AuthStatus::ApiKey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_oauth_session() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let token = GrokAuth {
|
||||
key: "session-token".into(),
|
||||
auth_mode: AuthMode::WebLogin,
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
let json = serde_json::to_string(&token).unwrap();
|
||||
let _auth = EnvGuard::set("KIGI_AUTH", &json);
|
||||
|
||||
assert_eq!(
|
||||
AuthStatus::resolve(&Config::default()),
|
||||
AuthStatus::LoggedIn("grok.com".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_model_api_key_byok() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let dm = crate::models::default_model();
|
||||
let cfg = config_from_toml(&format!(
|
||||
r#"
|
||||
[model."{dm}"]
|
||||
model = "{dm}"
|
||||
api_key = "sk-byok-inline"
|
||||
"#
|
||||
));
|
||||
assert_eq!(
|
||||
AuthStatus::resolve(&cfg),
|
||||
AuthStatus::ModelCredentials(dm.to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_model_env_key_byok() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
const TEST_ENV: &str = "TEST_AUTH_STATUS_BYOK_ENV_KEY";
|
||||
let dm = crate::models::default_model();
|
||||
let cfg = config_from_toml(&format!(
|
||||
r#"
|
||||
[model."{dm}"]
|
||||
model = "{dm}"
|
||||
env_key = "{TEST_ENV}"
|
||||
"#
|
||||
));
|
||||
|
||||
{
|
||||
let _unset = EnvGuard::unset(TEST_ENV);
|
||||
assert_eq!(AuthStatus::resolve(&cfg), AuthStatus::NotAuthenticated);
|
||||
}
|
||||
{
|
||||
let _set = EnvGuard::set(TEST_ENV, "secret-token");
|
||||
assert_eq!(
|
||||
AuthStatus::resolve(&cfg),
|
||||
AuthStatus::ModelCredentials(dm.to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_deployment_key() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let mut cfg = Config::default();
|
||||
cfg.endpoints.deployment_key = Some("deploy-key".into());
|
||||
assert_eq!(AuthStatus::resolve(&cfg), AuthStatus::DeploymentKey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_not_authenticated() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
assert_eq!(
|
||||
AuthStatus::resolve(&Config::default()),
|
||||
AuthStatus::NotAuthenticated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_priority_api_key_over_byok_and_deployment() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let _key = EnvGuard::set(XAI_API_KEY_ENV_VAR, "xai-test-key");
|
||||
let dm = crate::models::default_model();
|
||||
let cfg = config_from_toml(&byok_and_deployment_toml(dm));
|
||||
assert_eq!(AuthStatus::resolve(&cfg), AuthStatus::ApiKey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_priority_session_over_byok_and_deployment() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let token = GrokAuth {
|
||||
key: "session-token".into(),
|
||||
auth_mode: AuthMode::WebLogin,
|
||||
..GrokAuth::test_default()
|
||||
};
|
||||
let json = serde_json::to_string(&token).unwrap();
|
||||
let _auth = EnvGuard::set("KIGI_AUTH", &json);
|
||||
|
||||
let dm = crate::models::default_model();
|
||||
let cfg = config_from_toml(&byok_and_deployment_toml(dm));
|
||||
assert_eq!(
|
||||
AuthStatus::resolve(&cfg),
|
||||
AuthStatus::LoggedIn("grok.com".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_priority_byok_over_deployment() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let dm = crate::models::default_model();
|
||||
let cfg = config_from_toml(&byok_and_deployment_toml(dm));
|
||||
assert_eq!(
|
||||
AuthStatus::resolve(&cfg),
|
||||
AuthStatus::ModelCredentials(dm.to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_disable_api_key_auth_suppresses_byok_banner() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let dm = crate::models::default_model();
|
||||
let cfg = config_from_toml(&format!(
|
||||
r#"
|
||||
[grok_com_config]
|
||||
disable_api_key_auth = true
|
||||
|
||||
[model."{dm}"]
|
||||
model = "{dm}"
|
||||
api_key = "sk-byok"
|
||||
"#
|
||||
));
|
||||
assert_eq!(AuthStatus::resolve(&cfg), AuthStatus::NotAuthenticated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_disable_api_key_auth_falls_through_to_deployment() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let dm = crate::models::default_model();
|
||||
let cfg = config_from_toml(&format!(
|
||||
r#"
|
||||
[grok_com_config]
|
||||
disable_api_key_auth = true
|
||||
|
||||
[endpoints]
|
||||
deployment_key = "deploy-key"
|
||||
|
||||
[model."{dm}"]
|
||||
model = "{dm}"
|
||||
api_key = "sk-byok"
|
||||
"#
|
||||
));
|
||||
assert_eq!(AuthStatus::resolve(&cfg), AuthStatus::DeploymentKey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_model_credentials_uses_first_catalog_key() {
|
||||
let (_dir, _g) = isolate_auth_sources();
|
||||
let cfg = config_from_toml(
|
||||
r#"
|
||||
[model."my-openai"]
|
||||
model = "gpt-4o"
|
||||
api_key = "sk-first"
|
||||
|
||||
[model."my-anthropic"]
|
||||
model = "claude"
|
||||
api_key = "sk-second"
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
AuthStatus::resolve(&cfg),
|
||||
AuthStatus::ModelCredentials("my-openai".to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
//! `x.ai/auth/*` and legacy `x.ai/{get,set}ApiKey` extension handlers.
|
||||
//!
|
||||
//! These methods let the client read/write the API key via the agent and
|
||||
//! drive the OAuth login flow. The agent is the single source of truth for
|
||||
//! `auth.json`.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ExtResult, parse_params, to_raw_response};
|
||||
use crate::agent::MvpAgent;
|
||||
use crate::session::ExtMethodResult;
|
||||
|
||||
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/auth/getBearerToken" => handle_get_bearer_token(agent).await,
|
||||
"x.ai/getApiKey" => handle_get_api_key(),
|
||||
"x.ai/setApiKey" => handle_set_api_key(args),
|
||||
"x.ai/auth/submit_code" => handle_submit_code(agent, args),
|
||||
"x.ai/auth/get_url" => handle_get_url(agent).await,
|
||||
"x.ai/auth/logout" => handle_logout(agent, args).await,
|
||||
"x.ai/auth/info" => handle_info(agent),
|
||||
"x.ai/auth/check_subscription" => handle_check_subscription(agent).await,
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_get_bearer_token(agent: &MvpAgent) -> ExtResult {
|
||||
let token = match agent.auth_manager.get_valid_token().await {
|
||||
Ok(token) => Some(token),
|
||||
Err(_) => agent
|
||||
.sampling_config
|
||||
.borrow()
|
||||
.api_key
|
||||
.clone()
|
||||
.or_else(|| agent.auth_manager.current().map(|a| a.key)),
|
||||
};
|
||||
ExtMethodResult::success(serde_json::json!({ "token": token }))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
fn handle_get_api_key() -> ExtResult {
|
||||
let key = crate::agent::auth_method::read_xai_api_key_env().ok();
|
||||
ExtMethodResult::success(serde_json::json!({ "key": key }))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
fn handle_set_api_key(args: &acp::ExtRequest) -> ExtResult {
|
||||
let params: serde_json::Value = parse_params(args)?;
|
||||
let key = params.get("key").and_then(|v| v.as_str());
|
||||
let kigi_home = crate::util::kigi_home::kigi_home();
|
||||
if let Some(k) = key {
|
||||
if k.is_empty() {
|
||||
crate::auth::clear_api_key(&kigi_home)
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
// SAFETY: ext_method is single-threaded per agent
|
||||
unsafe { std::env::remove_var("XAI_API_KEY") };
|
||||
} else {
|
||||
crate::auth::store_api_key(&kigi_home, k)
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
// SAFETY: ext_method is single-threaded per agent
|
||||
unsafe { std::env::set_var("XAI_API_KEY", k) };
|
||||
}
|
||||
} else {
|
||||
crate::auth::clear_api_key(&kigi_home)
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
// SAFETY: ext_method is single-threaded per agent
|
||||
unsafe { std::env::remove_var("XAI_API_KEY") };
|
||||
}
|
||||
ExtMethodResult::success(serde_json::json!({ "ok": true }))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
|
||||
/// Handle auth code submission from TUI.
|
||||
fn handle_submit_code(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
#[derive(Deserialize)]
|
||||
struct SubmitCodeParams {
|
||||
code: String,
|
||||
}
|
||||
|
||||
let params: SubmitCodeParams = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
|
||||
let auth_code_tx = agent.auth_code_tx.borrow();
|
||||
if let Some(ref tx) = *auth_code_tx {
|
||||
tx.try_send(params.code).map_err(|e| {
|
||||
acp::Error::internal_error().data(format!("failed to submit auth code: {e}"))
|
||||
})?;
|
||||
to_raw_response(&serde_json::json!({ "submitted": true }))
|
||||
} else {
|
||||
Err(acp::Error::invalid_params().data("no pending auth session"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Awaits the auth URL from the oneshot channel (blocks until ready).
|
||||
async fn handle_get_url(agent: &MvpAgent) -> ExtResult {
|
||||
let rx = agent.auth_url_rx.borrow_mut().take();
|
||||
// `None` when no URL was sent (cached creds, early error, second poll):
|
||||
// report mode as `null` rather than mislabeling it `loopback`.
|
||||
let (auth_url, mode) = match rx {
|
||||
Some(rx) => match rx.await {
|
||||
Ok(info) => (Some(info.url), Some(info.mode)),
|
||||
Err(_) => (None, None),
|
||||
},
|
||||
None => (None, None),
|
||||
};
|
||||
to_raw_response(&serde_json::json!({
|
||||
"auth_url": auth_url,
|
||||
// `external_provider` kept for older clients; `mode` is authoritative.
|
||||
"external_provider": mode.is_some_and(|m| m.is_external_provider()),
|
||||
"mode": mode.map(|m| m.as_wire_str()),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_logout(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
#[derive(Deserialize)]
|
||||
struct LogoutParams {
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
let params: LogoutParams = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
|
||||
let result = crate::auth::perform_logout(&agent.auth_manager, params.scope.as_deref())
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("failed to logout: {e}")))?;
|
||||
// `auth.lifecycle` (not `auth`) avoids colliding with the pre-existing
|
||||
// per-request `AuthManager::auth()` `#[instrument]` span.
|
||||
tracing::info_span!("auth.lifecycle", action = "logout", success = true).in_scope(|| {});
|
||||
|
||||
agent.models_manager.on_auth_changed().await;
|
||||
|
||||
to_raw_response(&serde_json::json!({
|
||||
"ok": true,
|
||||
"was_logged_in": result.was_logged_in,
|
||||
"email": result.email,
|
||||
"api_key_still_set": result.api_key_still_set,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Single-shot subscription re-check (retry button on paywall screen).
|
||||
///
|
||||
/// Calls `retry_subscription_check()`, then returns the updated auth
|
||||
/// response with gate info so the pager can refresh the gate state.
|
||||
async fn handle_check_subscription(agent: &MvpAgent) -> ExtResult {
|
||||
agent.retry_subscription_check().await;
|
||||
let response = agent.auth_response_with_meta();
|
||||
to_raw_response(&serde_json::json!({
|
||||
"authenticated": response.meta.is_some(),
|
||||
"meta": response.meta,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Returns current auth method ID, user profile fields, and team/principal
|
||||
/// metadata.
|
||||
fn handle_info(agent: &MvpAgent) -> ExtResult {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AuthInfoResponse {
|
||||
method_id: Option<String>,
|
||||
email: Option<String>,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
/// `grok-asset://` URL resolved by the Electron protocol handler,
|
||||
/// or a full `http(s)://` URL passed through unchanged.
|
||||
profile_image_url: Option<String>,
|
||||
team_id: Option<String>,
|
||||
team_name: Option<String>,
|
||||
team_role: Option<String>,
|
||||
organization_id: Option<String>,
|
||||
organization_name: Option<String>,
|
||||
organization_role: Option<String>,
|
||||
principal_type: Option<String>,
|
||||
principal_id: Option<String>,
|
||||
user_blocked_reason: Option<String>,
|
||||
team_blocked_reasons: Vec<String>,
|
||||
coding_data_retention_opt_out: bool,
|
||||
}
|
||||
|
||||
let method_id = agent
|
||||
.auth_method_id
|
||||
.load()
|
||||
.as_ref()
|
||||
.map(|m| m.0.to_string());
|
||||
let auth = agent.auth_manager.current();
|
||||
let raw_asset_id = auth.as_ref().and_then(|a| a.profile_image_asset_id.clone());
|
||||
|
||||
// Return a grok-asset:// URL that the Electron renderer resolves at
|
||||
// display time via a custom protocol handler. The handler proxies
|
||||
// through cli-chat-proxy's /asset endpoint; Electron's HTTP cache
|
||||
// handles reuse. No disk-cache or network call needed here.
|
||||
let profile_image_url = match raw_asset_id.as_deref().filter(|k| !k.is_empty()) {
|
||||
Some(key) if key.starts_with("http://") || key.starts_with("https://") => {
|
||||
Some(key.to_owned())
|
||||
}
|
||||
Some(key) => Some(format!("grok-asset:///{key}")),
|
||||
None => None,
|
||||
};
|
||||
to_raw_response(&AuthInfoResponse {
|
||||
method_id,
|
||||
email: auth.as_ref().and_then(|a| a.email.clone()),
|
||||
first_name: auth.as_ref().and_then(|a| a.first_name.clone()),
|
||||
last_name: auth.as_ref().and_then(|a| a.last_name.clone()),
|
||||
profile_image_url,
|
||||
team_id: auth.as_ref().and_then(|a| a.team_id.clone()),
|
||||
team_name: auth.as_ref().and_then(|a| a.team_name.clone()),
|
||||
team_role: auth.as_ref().and_then(|a| a.team_role.clone()),
|
||||
organization_id: auth.as_ref().and_then(|a| a.organization_id.clone()),
|
||||
organization_name: auth.as_ref().and_then(|a| a.organization_name.clone()),
|
||||
organization_role: auth.as_ref().and_then(|a| a.organization_role.clone()),
|
||||
principal_type: auth.as_ref().and_then(|a| a.principal_type.clone()),
|
||||
principal_id: auth.as_ref().and_then(|a| a.principal_id.clone()),
|
||||
user_blocked_reason: auth.as_ref().and_then(|a| a.user_blocked_reason.clone()),
|
||||
team_blocked_reasons: auth
|
||||
.as_ref()
|
||||
.map(|a| a.team_blocked_reasons.clone())
|
||||
.unwrap_or_default(),
|
||||
coding_data_retention_opt_out: auth
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.coding_data_retention_opt_out),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
use crate::auth::{AuthManager, GrokAuth};
|
||||
|
||||
/// Require xAI auth from a sync context, accepting tokens in the client-side buffer window.
|
||||
pub(crate) fn require_xai_auth(
|
||||
auth_manager: &AuthManager,
|
||||
missing_message: &'static str,
|
||||
non_xai_message: &'static str,
|
||||
) -> Result<GrokAuth, acp::Error> {
|
||||
let auth = auth_manager
|
||||
.current_or_expired()
|
||||
.ok_or_else(|| acp::Error::auth_required().data(missing_message))?;
|
||||
if !auth.is_xai_auth() {
|
||||
return Err(acp::Error::auth_required().data(non_xai_message));
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
//! `x.ai/billing` extension handler.
|
||||
//!
|
||||
//! Fetches the authenticated user's Grok Build billing configuration
|
||||
//! (credit limit, usage, on-demand cap, billing period, history) from
|
||||
//! the backend. Used by the pager/desktop to display credits and usage.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ExtResult, to_raw_response};
|
||||
use crate::agent::MvpAgent;
|
||||
|
||||
/// Billing period cycle identifier.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BillingCycle {
|
||||
pub year: i32,
|
||||
pub month: i32,
|
||||
}
|
||||
|
||||
/// Cent value from the billing API (USD cents).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Cent {
|
||||
/// proto3 JSON omits zero-valued scalars, so a `$0` Cent arrives as `{}`;
|
||||
/// default to 0 rather than failing the whole parse.
|
||||
#[serde(default)]
|
||||
pub val: i64,
|
||||
}
|
||||
|
||||
/// A usage period (weekly or monthly) from the newer credits config.
|
||||
///
|
||||
/// `start`/`end` are RFC 3339 timestamps. `period_type` is the proto enum name
|
||||
/// (e.g. `USAGE_PERIOD_TYPE_WEEKLY`); kept so callers can distinguish weekly
|
||||
/// vs monthly cycles.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsagePeriod {
|
||||
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
|
||||
pub period_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub end: Option<String>,
|
||||
}
|
||||
|
||||
/// Usage summary for one past billing period.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BillingPeriodUsage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing_cycle: Option<BillingCycle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_used: Option<Cent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_demand_used: Option<Cent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_used: Option<Cent>,
|
||||
}
|
||||
|
||||
/// Current billing configuration for Grok Build coding credits.
|
||||
///
|
||||
/// Carries both the newer credits-config fields (`credit_usage_percent`,
|
||||
/// `current_period`) and the deprecated `GrokBuildBillingConfig` fields
|
||||
/// (`monthly_limit`, `used`, `billing_period_*`). Consumers should prefer the
|
||||
/// new fields and fall back to the deprecated ones, so the same struct works
|
||||
/// against both the new `GetGrokCreditsConfig` and the legacy
|
||||
/// `GetGrokBuildBillingConfig` backend responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BillingConfig {
|
||||
/// Included credit usage as a percentage of the allowance (0.0–100.0).
|
||||
/// Preferred over deriving from `monthly_limit`/`used`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credit_usage_percent: Option<f64>,
|
||||
/// Current usage period (weekly or monthly). Preferred over
|
||||
/// `billing_period_start`/`billing_period_end`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_period: Option<UsagePeriod>,
|
||||
/// Deprecated: included monthly credit budget. Use `credit_usage_percent`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub monthly_limit: Option<Cent>,
|
||||
/// Deprecated: credits used this period. Use `credit_usage_percent`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub used: Option<Cent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_demand_cap: Option<Cent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_demand_used: Option<Cent>,
|
||||
/// Remaining prepaid (purchased) credit balance, positive — the "bought
|
||||
/// credits" the user has topped up. Populated from the credits config
|
||||
/// (`GetGrokCreditsConfig.prepaid_balance`); absent in the legacy billing
|
||||
/// shape.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prepaid_balance: Option<Cent>,
|
||||
/// Whether this user is on unified usage billing (shared weekly/monthly
|
||||
/// pool). From `GrokCreditsConfig.is_unified_billing_user`, which billing
|
||||
/// sets from remote settings `unified_consumer_billing_enabled`. `None` when
|
||||
/// absent (legacy `GetGrokBuildBillingConfig` shape or older servers).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_unified_billing_user: Option<bool>,
|
||||
/// Deprecated: use `current_period.start`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing_period_start: Option<String>,
|
||||
/// Deprecated: use `current_period.end`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing_period_end: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub history: Vec<BillingPeriodUsage>,
|
||||
}
|
||||
|
||||
/// Top-level response (primarily from `GET /rest/grok/credits` + auto-topup-rule).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BillingConfigResponse {
|
||||
pub config: Option<BillingConfig>,
|
||||
/// Whether on-demand credit usage is enabled. When `false`, the pager
|
||||
/// should hide on-demand controls. Populated from `RemoteSettings`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_demand_enabled: Option<bool>,
|
||||
/// User-friendly subscription tier name (e.g. "SuperGrok Heavy").
|
||||
/// Populated from `RemoteSettings` so the pager can update its cached
|
||||
/// tier on every billing fetch without an extra request.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subscription_tier: Option<String>,
|
||||
}
|
||||
|
||||
/// Auto top-up configuration (from GetAutoTopupRule).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AutoTopupRule {
|
||||
/// proto3 JSON omits `false`, so a disabled rule arrives without this field;
|
||||
/// default to `false` rather than failing the parse (which would otherwise
|
||||
/// keep a stale cached rule in the pager).
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
pub min_before_hitting_sl: Option<Cent>,
|
||||
pub topup_amount: Option<Cent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_amount_per_month: Option<Cent>,
|
||||
}
|
||||
|
||||
/// Wrapper for the auto top-up rule response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GetAutoTopupRuleResponse {
|
||||
#[serde(default)]
|
||||
pub rule: Option<AutoTopupRule>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/billing" => {
|
||||
tracing::info!("handling billing config request");
|
||||
handle_get_billing(agent).await
|
||||
}
|
||||
"x.ai/auto-topup-rule" => {
|
||||
tracing::info!("handling auto top-up rule request");
|
||||
handle_get_auto_topup_rule(agent).await
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured context for unified-log entries from a successful billing fetch.
|
||||
///
|
||||
/// Keeps history to a count + the most recent period so `~/.kigi/logs/unified.jsonl`
|
||||
/// stays useful without dumping unbounded period arrays.
|
||||
fn billing_unified_log_ctx(billing: &BillingConfigResponse) -> serde_json::Value {
|
||||
let history_len = billing
|
||||
.config
|
||||
.as_ref()
|
||||
.map(|c| c.history.len())
|
||||
.unwrap_or(0);
|
||||
let latest_history = billing
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.history.last())
|
||||
.and_then(|p| serde_json::to_value(p).ok());
|
||||
|
||||
let mut config_value = billing
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
if let Some(obj) = config_value.as_object_mut() {
|
||||
// Drop full history array; surface length + latest entry instead.
|
||||
obj.remove("history");
|
||||
obj.insert("historyLen".into(), serde_json::json!(history_len));
|
||||
if let Some(latest) = latest_history {
|
||||
obj.insert("latestHistory".into(), latest);
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"config": config_value,
|
||||
"onDemandEnabled": billing.on_demand_enabled,
|
||||
"subscriptionTier": billing.subscription_tier,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_get_billing(agent: &MvpAgent) -> ExtResult {
|
||||
let auth = super::auth_gate::require_xai_auth(
|
||||
&agent.auth_manager,
|
||||
"Authentication required to fetch billing data",
|
||||
"Billing data requires auth with grok.com. Run `grok login` to authenticate.",
|
||||
)?;
|
||||
|
||||
let proxy_base = agent.cli_chat_proxy_base_url();
|
||||
let base = proxy_base.trim_end_matches('/');
|
||||
|
||||
// Credits balance / usage (new billing system) via the CLI proxy, which
|
||||
// forwards to the backend `GetGrokCreditsConfig`.
|
||||
let credits_url = format!("{}/billing?format=credits", base);
|
||||
let credits_resp = crate::http::shared_client()
|
||||
.get(&credits_url)
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header(
|
||||
"X-XAI-Token-Auth",
|
||||
crate::auth::GrokComConfig::default().token_header,
|
||||
)
|
||||
.header("x-userid", &auth.user_id)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
crate::http::CLIENT_MODE_HEADER,
|
||||
crate::http::process_client_mode(),
|
||||
)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "billing: upstream request failed");
|
||||
kigi_log::unified_log::warn(
|
||||
"billing: upstream request failed",
|
||||
None,
|
||||
Some(serde_json::json!({ "error": e.to_string() })),
|
||||
);
|
||||
acp::Error::internal_error().data(format!("Failed to fetch billing data: {e}"))
|
||||
})?;
|
||||
|
||||
if !credits_resp.status().is_success() {
|
||||
let status = credits_resp.status().as_u16();
|
||||
let body = credits_resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(status, url = %credits_url, "billing: upstream error");
|
||||
|
||||
let detail = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| format!("HTTP {status}"));
|
||||
|
||||
kigi_log::unified_log::warn(
|
||||
"billing: upstream error",
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
})),
|
||||
);
|
||||
|
||||
return Err(acp::Error::internal_error().data(format!("Billing service error: {detail}")));
|
||||
}
|
||||
|
||||
let mut billing: BillingConfigResponse = credits_resp.json().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "billing: failed to parse response");
|
||||
kigi_log::unified_log::warn(
|
||||
"billing: failed to parse response",
|
||||
None,
|
||||
Some(serde_json::json!({ "error": e.to_string() })),
|
||||
);
|
||||
acp::Error::internal_error().data(format!("Failed to parse billing data: {e}"))
|
||||
})?;
|
||||
|
||||
// Enrich with fields from remote settings.
|
||||
let rs = agent.cfg.borrow().remote_settings.clone();
|
||||
billing.on_demand_enabled = rs.as_ref().and_then(|rs| rs.on_demand_enabled);
|
||||
billing.subscription_tier = rs.as_ref().and_then(|rs| {
|
||||
rs.subscription_tier_display
|
||||
.clone()
|
||||
.or_else(|| rs.subscription_tier.clone())
|
||||
});
|
||||
|
||||
// Every prompt / /usage / poll path hits `x.ai/billing`; log the fetched
|
||||
// credits snapshot so support can correlate limit UX with real balances.
|
||||
kigi_log::unified_log::info(
|
||||
"billing: fetched credits config",
|
||||
None,
|
||||
Some(billing_unified_log_ctx(&billing)),
|
||||
);
|
||||
|
||||
to_raw_response(&billing)
|
||||
}
|
||||
|
||||
async fn handle_get_auto_topup_rule(agent: &MvpAgent) -> ExtResult {
|
||||
let auth = super::auth_gate::require_xai_auth(
|
||||
&agent.auth_manager,
|
||||
"Authentication required to fetch auto top-up rule",
|
||||
"Auto top-up data requires auth with grok.com. Run `grok login` to authenticate.",
|
||||
)?;
|
||||
|
||||
let proxy_base = agent.cli_chat_proxy_base_url();
|
||||
let base = proxy_base.trim_end_matches('/');
|
||||
|
||||
// Auto top-up rule via the CLI proxy, which forwards to the backend
|
||||
// `GetAutoTopupRule`.
|
||||
let url = format!("{}/auto-topup-rule", base);
|
||||
let response = crate::http::shared_client()
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header(
|
||||
"X-XAI-Token-Auth",
|
||||
crate::auth::GrokComConfig::default().token_header,
|
||||
)
|
||||
.header("x-userid", &auth.user_id)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
crate::http::CLIENT_MODE_HEADER,
|
||||
crate::http::process_client_mode(),
|
||||
)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "auto-topup: upstream request failed");
|
||||
acp::Error::internal_error().data(format!("Failed to fetch auto top-up rule: {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(status, url = %url, "auto-topup: upstream error");
|
||||
|
||||
let detail = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| format!("HTTP {status}"));
|
||||
|
||||
return Err(
|
||||
acp::Error::internal_error().data(format!("Auto top-up service error: {detail}"))
|
||||
);
|
||||
}
|
||||
|
||||
// Return the upstream response body verbatim (as a JSON value) so /usage
|
||||
// can print the exact data from this request unformatted.
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&body_text).unwrap_or(serde_json::json!({"raw": body_text}));
|
||||
to_raw_response(&value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auto_topup_disabled_rule_omits_enabled_field() {
|
||||
// proto3 JSON omits `false` / `0`, so a disabled rule arrives without
|
||||
// `enabled` (and zero Cents as `{}`). It must still deserialize (as
|
||||
// disabled) rather than erroring — otherwise the pager keeps a stale
|
||||
// cached rule.
|
||||
let json = serde_json::json!({
|
||||
"rule": { "topupAmount": {"val": 500}, "minBeforeHittingSl": {} }
|
||||
});
|
||||
let resp: GetAutoTopupRuleResponse = serde_json::from_value(json).unwrap();
|
||||
let rule = resp.rule.expect("rule present");
|
||||
assert!(!rule.enabled);
|
||||
assert_eq!(rule.topup_amount.unwrap().val, 500);
|
||||
assert_eq!(rule.min_before_hitting_sl.unwrap().val, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_response_deserializes_from_backend_json() {
|
||||
let json = serde_json::json!({
|
||||
"config": {
|
||||
"monthlyLimit": {"val": 2000},
|
||||
"used": {"val": 1234},
|
||||
"onDemandCap": {"val": 500},
|
||||
"billingPeriodStart": "2025-04-01T00:00:00Z",
|
||||
"billingPeriodEnd": "2025-05-01T00:00:00Z",
|
||||
"history": [
|
||||
{
|
||||
"billingCycle": {"year": 2025, "month": 3},
|
||||
"includedUsed": {"val": 1800},
|
||||
"onDemandUsed": {"val": 0},
|
||||
"totalUsed": {"val": 1800}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
|
||||
let config = resp.config.unwrap();
|
||||
assert_eq!(config.monthly_limit.unwrap().val, 2000);
|
||||
assert_eq!(config.used.unwrap().val, 1234);
|
||||
assert_eq!(config.on_demand_cap.unwrap().val, 500);
|
||||
assert_eq!(
|
||||
config.billing_period_start.as_deref(),
|
||||
Some("2025-04-01T00:00:00Z")
|
||||
);
|
||||
assert_eq!(config.history.len(), 1);
|
||||
let period = &config.history[0];
|
||||
let cycle = period.billing_cycle.as_ref().unwrap();
|
||||
assert_eq!(cycle.year, 2025);
|
||||
assert_eq!(cycle.month, 3);
|
||||
assert_eq!(period.included_used.as_ref().unwrap().val, 1800);
|
||||
assert_eq!(period.total_used.as_ref().unwrap().val, 1800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_unified_log_ctx_includes_credits_and_collapses_history() {
|
||||
let resp = BillingConfigResponse {
|
||||
config: Some(BillingConfig {
|
||||
credit_usage_percent: Some(42.5),
|
||||
current_period: Some(UsagePeriod {
|
||||
period_type: Some("USAGE_PERIOD_TYPE_WEEKLY".into()),
|
||||
start: Some("2025-04-01T00:00:00Z".into()),
|
||||
end: Some("2025-04-08T00:00:00Z".into()),
|
||||
}),
|
||||
monthly_limit: Some(Cent { val: 2000 }),
|
||||
used: Some(Cent { val: 850 }),
|
||||
on_demand_cap: Some(Cent { val: 500 }),
|
||||
on_demand_used: Some(Cent { val: 0 }),
|
||||
prepaid_balance: Some(Cent { val: 100 }),
|
||||
is_unified_billing_user: Some(true),
|
||||
billing_period_start: None,
|
||||
billing_period_end: None,
|
||||
history: vec![
|
||||
BillingPeriodUsage {
|
||||
billing_cycle: Some(BillingCycle {
|
||||
year: 2025,
|
||||
month: 2,
|
||||
}),
|
||||
included_used: Some(Cent { val: 1000 }),
|
||||
on_demand_used: Some(Cent { val: 0 }),
|
||||
total_used: Some(Cent { val: 1000 }),
|
||||
},
|
||||
BillingPeriodUsage {
|
||||
billing_cycle: Some(BillingCycle {
|
||||
year: 2025,
|
||||
month: 3,
|
||||
}),
|
||||
included_used: Some(Cent { val: 1800 }),
|
||||
on_demand_used: Some(Cent { val: 0 }),
|
||||
total_used: Some(Cent { val: 1800 }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
on_demand_enabled: Some(true),
|
||||
subscription_tier: Some("SuperGrok".into()),
|
||||
};
|
||||
let ctx = billing_unified_log_ctx(&resp);
|
||||
assert_eq!(ctx["onDemandEnabled"], true);
|
||||
assert_eq!(ctx["subscriptionTier"], "SuperGrok");
|
||||
let config = ctx["config"].as_object().expect("config object");
|
||||
assert!(
|
||||
config.get("history").is_none(),
|
||||
"full history must be collapsed"
|
||||
);
|
||||
assert_eq!(config["historyLen"], 2);
|
||||
assert_eq!(
|
||||
config["latestHistory"]["billingCycle"]["month"], 3,
|
||||
"latest history period retained"
|
||||
);
|
||||
assert_eq!(config["creditUsagePercent"], 42.5);
|
||||
assert_eq!(config["prepaidBalance"]["val"], 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_response_roundtrips_through_json() {
|
||||
let config = BillingConfig {
|
||||
credit_usage_percent: None,
|
||||
current_period: None,
|
||||
monthly_limit: Some(Cent { val: 5000 }),
|
||||
used: Some(Cent { val: 123 }),
|
||||
on_demand_cap: Some(Cent { val: 0 }),
|
||||
on_demand_used: Some(Cent { val: 50 }),
|
||||
prepaid_balance: Some(Cent { val: 750 }),
|
||||
is_unified_billing_user: None,
|
||||
billing_period_start: Some("2025-04-01T00:00:00Z".to_string()),
|
||||
billing_period_end: Some("2025-05-01T00:00:00Z".to_string()),
|
||||
history: vec![BillingPeriodUsage {
|
||||
billing_cycle: Some(BillingCycle {
|
||||
year: 2025,
|
||||
month: 3,
|
||||
}),
|
||||
included_used: Some(Cent { val: 4500 }),
|
||||
on_demand_used: Some(Cent { val: 100 }),
|
||||
total_used: Some(Cent { val: 4600 }),
|
||||
}],
|
||||
};
|
||||
let resp = BillingConfigResponse {
|
||||
config: Some(config),
|
||||
on_demand_enabled: None,
|
||||
subscription_tier: None,
|
||||
};
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
let roundtripped: BillingConfigResponse = serde_json::from_value(json).unwrap();
|
||||
let rt_config = roundtripped.config.unwrap();
|
||||
assert_eq!(rt_config.monthly_limit.unwrap().val, 5000);
|
||||
assert_eq!(rt_config.used.unwrap().val, 123);
|
||||
assert_eq!(rt_config.prepaid_balance.unwrap().val, 750);
|
||||
assert_eq!(rt_config.history.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_response_handles_null_config() {
|
||||
let json = serde_json::json!({"config": null});
|
||||
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
|
||||
assert!(resp.config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_response_handles_empty_history() {
|
||||
let json = serde_json::json!({
|
||||
"config": {
|
||||
"monthlyLimit": {"val": 1000},
|
||||
"used": {"val": 0}
|
||||
}
|
||||
});
|
||||
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
|
||||
let config = resp.config.unwrap();
|
||||
assert_eq!(config.monthly_limit.unwrap().val, 1000);
|
||||
assert!(config.history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_serializes_camel_case() {
|
||||
let config = BillingConfig {
|
||||
credit_usage_percent: None,
|
||||
current_period: None,
|
||||
monthly_limit: Some(Cent { val: 100 }),
|
||||
used: None,
|
||||
on_demand_cap: None,
|
||||
on_demand_used: None,
|
||||
prepaid_balance: None,
|
||||
is_unified_billing_user: None,
|
||||
billing_period_start: None,
|
||||
billing_period_end: None,
|
||||
history: vec![],
|
||||
};
|
||||
let json = serde_json::to_value(&config).unwrap();
|
||||
assert!(json.get("monthlyLimit").is_some());
|
||||
// Fields with None are skipped
|
||||
assert!(json.get("creditUsagePercent").is_none());
|
||||
assert!(json.get("currentPeriod").is_none());
|
||||
assert!(json.get("used").is_none());
|
||||
assert!(json.get("onDemandCap").is_none());
|
||||
assert!(json.get("onDemandUsed").is_none());
|
||||
assert!(json.get("prepaidBalance").is_none());
|
||||
assert!(json.get("billingPeriodStart").is_none());
|
||||
// Empty history is skipped
|
||||
assert!(json.get("history").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_config_deserializes_credits_config_shape() {
|
||||
// Newer `GetGrokCreditsConfig` response: percentage-based usage,
|
||||
// a typed current period, and history keyed by `period`.
|
||||
let json = serde_json::json!({
|
||||
"config": {
|
||||
"creditUsagePercent": 42.5,
|
||||
"currentPeriod": {
|
||||
"type": "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
"start": "2026-06-01T00:00:00Z",
|
||||
"end": "2026-06-08T00:00:00Z"
|
||||
},
|
||||
"onDemandCap": {"val": 5000},
|
||||
"onDemandUsed": {"val": 300},
|
||||
"prepaidBalance": {"val": 1250},
|
||||
"isUnifiedBillingUser": true,
|
||||
"productUsage": [
|
||||
{"product": "PRODUCT_GROK_BUILD", "usagePercent": 61.2}
|
||||
],
|
||||
"history": [
|
||||
{
|
||||
"period": {
|
||||
"type": "USAGE_PERIOD_TYPE_WEEKLY",
|
||||
"start": "2026-05-25T00:00:00Z",
|
||||
"end": "2026-06-01T00:00:00Z"
|
||||
},
|
||||
"onDemandUsed": {"val": 120}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
let resp: BillingConfigResponse = serde_json::from_value(json).unwrap();
|
||||
let config = resp.config.unwrap();
|
||||
assert_eq!(config.credit_usage_percent, Some(42.5));
|
||||
let period = config.current_period.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
period.period_type.as_deref(),
|
||||
Some("USAGE_PERIOD_TYPE_WEEKLY")
|
||||
);
|
||||
assert_eq!(period.end.as_deref(), Some("2026-06-08T00:00:00Z"));
|
||||
// Deprecated fields are absent in the credits shape.
|
||||
assert!(config.monthly_limit.is_none());
|
||||
assert!(config.billing_period_end.is_none());
|
||||
assert_eq!(config.on_demand_cap.unwrap().val, 5000);
|
||||
assert_eq!(config.on_demand_used.unwrap().val, 300);
|
||||
// Bought (prepaid) credit balance is parsed from the credits config.
|
||||
assert_eq!(config.prepaid_balance.unwrap().val, 1250);
|
||||
assert_eq!(config.is_unified_billing_user, Some(true));
|
||||
// productUsage is still unused by the CLI billing surface.
|
||||
assert_eq!(config.history.len(), 1);
|
||||
assert_eq!(config.history[0].on_demand_used.as_ref().unwrap().val, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cent_serializes_as_val_field() {
|
||||
let c = Cent { val: 4299 };
|
||||
let json = serde_json::to_value(&c).unwrap();
|
||||
assert_eq!(json, serde_json::json!({"val": 4299}));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
//! `x.ai/session/load_history`: fetch one older page of a gateway-backed
|
||||
//! conversation by client-owned cursor (`beforeId` → `nextBeforeId`).
|
||||
use super::ExtResult;
|
||||
use crate::agent::MvpAgent;
|
||||
use agent_client_protocol as acp;
|
||||
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
if true {
|
||||
let _ = (agent, args);
|
||||
return Err(acp::Error::method_not_found());
|
||||
}
|
||||
Err(acp::Error::method_not_found())
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! Code Navigation Extension Methods
|
||||
//!
|
||||
//! Provides go-to-definition, go-to-references, and symbol lookup functionality
|
||||
//! using the kigi-codebase-graph index.
|
||||
//!
|
||||
//! ## Extension Methods
|
||||
//!
|
||||
//! | Method | Description |
|
||||
//! |--------|-------------|
|
||||
//! | `x.ai/code/goto-definition` | Definition location(s) for symbol at position |
|
||||
//! | `x.ai/code/goto-references` | Reference location(s) for symbol at position |
|
||||
//! | `x.ai/code/find-definitions` | All definitions of a symbol by name |
|
||||
//! | `x.ai/code/find-references` | All references to a symbol by name |
|
||||
//! | `x.ai/code/status` | Indexing status |
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::agent::mvp_agent::{CodeNavEligibility, MvpAgent};
|
||||
use agent_client_protocol as acp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Record a structured telemetry event at the end of a code-nav handler call.
|
||||
///
|
||||
/// This is called once per request with the method name, triggering session,
|
||||
/// cwd, whether the index was newly spawned or reused, and total elapsed time.
|
||||
/// These fields make it possible to:
|
||||
/// - identify first-use latency (newly spawned + high elapsed_ms)
|
||||
/// - identify reuse latency (reused + low elapsed_ms)
|
||||
/// - attribute slowness to index startup vs query processing
|
||||
fn log_code_nav_telemetry(
|
||||
method: &str,
|
||||
session_id: Option<&acp::SessionId>,
|
||||
cwd: &Path,
|
||||
was_newly_started: bool,
|
||||
elapsed_ms: u128,
|
||||
) {
|
||||
tracing::info!(
|
||||
method,
|
||||
session_id = session_id.map(|s| s.0.as_ref()).unwrap_or(""),
|
||||
cwd = %cwd.display(),
|
||||
index_newly_started = was_newly_started,
|
||||
elapsed_ms,
|
||||
"code-nav request completed"
|
||||
);
|
||||
}
|
||||
|
||||
type ExtResult = Result<acp::ExtResponse, acp::Error>;
|
||||
|
||||
// ========== Request Types ==========
|
||||
|
||||
/// Position-based query request (for goto-definition, goto-references).
|
||||
/// Position parameters are 1-indexed (matching editor display).
|
||||
///
|
||||
/// **`sessionId` is required** for all code-nav requests. Per-client
|
||||
/// capability gating requires a valid session so eligibility is resolved
|
||||
/// correctly in both simple and leader modes. Requests without `sessionId`
|
||||
/// receive `reason: sessionRequired` in the error response.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GotoRequest {
|
||||
/// Session ID — required for code navigation.
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
/// Working directory (optional when session_id is provided).
|
||||
pub cwd: Option<String>,
|
||||
/// Relative path to the file within the cwd
|
||||
pub path: String,
|
||||
/// 1-indexed line number
|
||||
pub row: usize,
|
||||
/// 1-indexed column number
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
/// Symbol name query request (for find-definitions, find-references).
|
||||
///
|
||||
/// **`sessionId` is required** — same contract as [`GotoRequest`].
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FindSymbolRequest {
|
||||
/// Session ID — required for code navigation.
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
/// Working directory (optional when session_id is provided).
|
||||
pub cwd: Option<String>,
|
||||
/// Symbol name to search for
|
||||
pub symbol: String,
|
||||
/// Optional context file path for ranking results
|
||||
pub context_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Status request — check indexing status.
|
||||
///
|
||||
/// **`sessionId` is required** — same contract as [`GotoRequest`].
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatusRequest {
|
||||
/// Session ID — required for code navigation.
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
/// Working directory (optional when session_id is provided).
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
// ========== Response Types ==========
|
||||
|
||||
/// Response for goto-definition and goto-references queries.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeNavResponse {
|
||||
/// The symbol that was queried
|
||||
pub symbol: String,
|
||||
/// List of locations where the symbol was found
|
||||
pub locations: Vec<SymbolLocation>,
|
||||
}
|
||||
|
||||
/// A symbol location in a file.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SymbolLocation {
|
||||
/// Absolute path to the file
|
||||
pub path: String,
|
||||
/// 1-indexed line number
|
||||
pub line: usize,
|
||||
/// 1-indexed column (start of symbol, if available)
|
||||
pub column: usize,
|
||||
/// 1-indexed end line
|
||||
pub end_line: usize,
|
||||
/// 1-indexed end column
|
||||
pub end_column: usize,
|
||||
/// The matched symbol name (useful for aliases/imports)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub matched_symbol: Option<String>,
|
||||
}
|
||||
|
||||
/// Reason string for the `x.ai/code/status` response.
|
||||
///
|
||||
/// Serialised as a camelCase string so clients can pattern-match on it.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum IndexStatusReason {
|
||||
/// Index is running and ready.
|
||||
Active,
|
||||
/// Index is eligible but has not been started yet (first code-nav request
|
||||
/// will trigger lazy startup).
|
||||
NotStarted,
|
||||
/// Client type is not web (web-only for initial rollout).
|
||||
ClientNotWeb,
|
||||
/// Client did not advertise `x.ai/codeNavigation.enabled`.
|
||||
CapabilityNotAdvertised,
|
||||
/// `codebase_indexing` feature is disabled in config.
|
||||
DisabledByConfig,
|
||||
/// The cwd is not inside a git repository.
|
||||
NotGitRepo,
|
||||
/// `sessionId` is required but was absent or refers to an unknown session.
|
||||
SessionRequired,
|
||||
}
|
||||
|
||||
/// Response for status query.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatusResponse {
|
||||
/// Whether an index is currently active for this cwd.
|
||||
pub indexed: bool,
|
||||
/// Whether this client is eligible to use codebase indexing.
|
||||
pub eligible: bool,
|
||||
/// Reason code describing the current status.
|
||||
pub reason: IndexStatusReason,
|
||||
/// Number of files in the index (present only when `indexed` is true).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_count: Option<usize>,
|
||||
}
|
||||
|
||||
// ========== Handler ==========
|
||||
|
||||
/// Handle code navigation extension methods.
|
||||
///
|
||||
/// Routes through [`WorkspaceOps`]. Eligibility checks still run in shell since
|
||||
/// they depend on agent-level config (client type, feature flags).
|
||||
pub async fn handle(
|
||||
agent: &MvpAgent,
|
||||
ops: &kigi_workspace::WorkspaceOps,
|
||||
args: &acp::ExtRequest,
|
||||
) -> ExtResult {
|
||||
use kigi_workspace::workspace_ops::*;
|
||||
|
||||
match args.method.as_ref() {
|
||||
"x.ai/code/goto-definition" => {
|
||||
let req: GotoRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
let cwd = resolve_cwd(agent, req.cwd.clone(), req.session_id.as_ref())?;
|
||||
let was_newly_started =
|
||||
ensure_eligible_and_started(agent, req.session_id.as_ref(), &cwd)?;
|
||||
let start = std::time::Instant::now();
|
||||
let result = ops
|
||||
.dispatch(
|
||||
&CodeGotoDefinitionReq {
|
||||
root: Some(cwd.clone()),
|
||||
file: cwd.join(&req.path).to_string_lossy().to_string(),
|
||||
line: req.row,
|
||||
col: req.column,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("code nav error: {e}")))?;
|
||||
log_code_nav_telemetry(
|
||||
"goto-definition",
|
||||
req.session_id.as_ref(),
|
||||
&cwd,
|
||||
was_newly_started,
|
||||
start.elapsed().as_millis(),
|
||||
);
|
||||
to_code_nav_ext_response(result)
|
||||
}
|
||||
"x.ai/code/goto-references" => {
|
||||
let req: GotoRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
let cwd = resolve_cwd(agent, req.cwd.clone(), req.session_id.as_ref())?;
|
||||
let was_newly_started =
|
||||
ensure_eligible_and_started(agent, req.session_id.as_ref(), &cwd)?;
|
||||
let start = std::time::Instant::now();
|
||||
let result = ops
|
||||
.dispatch(
|
||||
&CodeGotoReferencesReq {
|
||||
root: Some(cwd.clone()),
|
||||
file: cwd.join(&req.path).to_string_lossy().to_string(),
|
||||
line: req.row,
|
||||
col: req.column,
|
||||
include_definition: true,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("code nav error: {e}")))?;
|
||||
log_code_nav_telemetry(
|
||||
"goto-references",
|
||||
req.session_id.as_ref(),
|
||||
&cwd,
|
||||
was_newly_started,
|
||||
start.elapsed().as_millis(),
|
||||
);
|
||||
to_code_nav_ext_response(result)
|
||||
}
|
||||
"x.ai/code/find-definitions" => {
|
||||
let req: FindSymbolRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
let cwd = resolve_cwd(agent, req.cwd.clone(), req.session_id.as_ref())?;
|
||||
let was_newly_started =
|
||||
ensure_eligible_and_started(agent, req.session_id.as_ref(), &cwd)?;
|
||||
let start = std::time::Instant::now();
|
||||
let result = ops
|
||||
.dispatch(
|
||||
&CodeFindDefinitionsReq {
|
||||
root: Some(cwd.clone()),
|
||||
symbol: req.symbol.clone(),
|
||||
context_file: req
|
||||
.context_path
|
||||
.as_ref()
|
||||
.map(|p| cwd.join(p).to_string_lossy().to_string()),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("code nav error: {e}")))?;
|
||||
log_code_nav_telemetry(
|
||||
"find-definitions",
|
||||
req.session_id.as_ref(),
|
||||
&cwd,
|
||||
was_newly_started,
|
||||
start.elapsed().as_millis(),
|
||||
);
|
||||
to_code_nav_ext_response(result)
|
||||
}
|
||||
"x.ai/code/find-references" => {
|
||||
let req: FindSymbolRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
let cwd = resolve_cwd(agent, req.cwd.clone(), req.session_id.as_ref())?;
|
||||
let was_newly_started =
|
||||
ensure_eligible_and_started(agent, req.session_id.as_ref(), &cwd)?;
|
||||
let start = std::time::Instant::now();
|
||||
let result = ops
|
||||
.dispatch(
|
||||
&CodeFindReferencesReq {
|
||||
root: Some(cwd.clone()),
|
||||
symbol: req.symbol.clone(),
|
||||
context_file: req
|
||||
.context_path
|
||||
.as_ref()
|
||||
.map(|p| cwd.join(p).to_string_lossy().to_string()),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("code nav error: {e}")))?;
|
||||
log_code_nav_telemetry(
|
||||
"find-references",
|
||||
req.session_id.as_ref(),
|
||||
&cwd,
|
||||
was_newly_started,
|
||||
start.elapsed().as_millis(),
|
||||
);
|
||||
to_code_nav_ext_response(result)
|
||||
}
|
||||
"x.ai/code/status" => {
|
||||
let req: StatusRequest = serde_json::from_str(args.params.get())
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
|
||||
let cwd = resolve_cwd(agent, req.cwd.clone(), req.session_id.as_ref())?;
|
||||
|
||||
// Check eligibility for the status response.
|
||||
let (eligible, reason, indexed, file_count) = match agent
|
||||
.code_nav_eligibility_for_request(req.session_id.as_ref(), &cwd)
|
||||
{
|
||||
Ok(()) => {
|
||||
let result = ops
|
||||
.dispatch(
|
||||
&CodeIndexStatusReq {
|
||||
root: Some(cwd.clone()),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
acp::Error::internal_error().data(format!("code nav error: {e}"))
|
||||
})?;
|
||||
if result.active {
|
||||
(true, IndexStatusReason::Active, true, result.file_count)
|
||||
} else {
|
||||
(true, IndexStatusReason::NotStarted, false, None)
|
||||
}
|
||||
}
|
||||
Err(ineligible) => {
|
||||
let reason = match ineligible {
|
||||
CodeNavEligibility::ClientNotWeb => IndexStatusReason::ClientNotWeb,
|
||||
CodeNavEligibility::CapabilityNotAdvertised => {
|
||||
IndexStatusReason::CapabilityNotAdvertised
|
||||
}
|
||||
CodeNavEligibility::DisabledByConfig => IndexStatusReason::DisabledByConfig,
|
||||
CodeNavEligibility::NotGitRepo => IndexStatusReason::NotGitRepo,
|
||||
CodeNavEligibility::SessionRequired => IndexStatusReason::SessionRequired,
|
||||
};
|
||||
(false, reason, false, None)
|
||||
}
|
||||
};
|
||||
|
||||
let status = StatusResponse {
|
||||
indexed,
|
||||
eligible,
|
||||
reason,
|
||||
file_count,
|
||||
};
|
||||
super::to_ext_response(Ok(status))
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert workspace CodeNavResponse to the shell's CodeNavResponse format
|
||||
/// and wrap in the `ExtMethodResult` envelope that clients expect.
|
||||
fn to_code_nav_ext_response(resp: kigi_workspace::workspace_ops::CodeNavResponse) -> ExtResult {
|
||||
let symbol = resp
|
||||
.locations
|
||||
.first()
|
||||
.and_then(|l| l.symbol.clone())
|
||||
.unwrap_or_default();
|
||||
let shell_resp = CodeNavResponse {
|
||||
symbol,
|
||||
locations: resp
|
||||
.locations
|
||||
.into_iter()
|
||||
.map(|loc| SymbolLocation {
|
||||
path: loc.path,
|
||||
line: loc.line,
|
||||
column: 0,
|
||||
end_line: loc.line,
|
||||
end_column: 0,
|
||||
matched_symbol: loc.symbol,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
super::to_ext_response(Ok(shell_resp))
|
||||
}
|
||||
|
||||
/// Check eligibility, ensure the codebase index is started, and return
|
||||
/// whether the index was newly created (for telemetry).
|
||||
fn ensure_eligible_and_started(
|
||||
agent: &MvpAgent,
|
||||
session_id: Option<&acp::SessionId>,
|
||||
cwd: &Path,
|
||||
) -> Result<bool, acp::Error> {
|
||||
if let Err(reason) = agent.code_nav_eligibility_for_request(session_id, cwd) {
|
||||
return Err(eligibility_error(reason));
|
||||
}
|
||||
// Start the index if not already running (lazy creation).
|
||||
let was_newly_started = agent
|
||||
.start_codebase_index_for_code_nav(session_id, cwd)
|
||||
.map(|(_, was_new)| was_new)
|
||||
.unwrap_or(false);
|
||||
Ok(was_newly_started)
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/// Resolve cwd from session_id or direct cwd parameter.
|
||||
fn resolve_cwd(
|
||||
agent: &MvpAgent,
|
||||
cwd: Option<String>,
|
||||
session_id: Option<&acp::SessionId>,
|
||||
) -> Result<PathBuf, acp::Error> {
|
||||
// Prefer direct cwd if provided
|
||||
if let Some(cwd_str) = cwd {
|
||||
return Ok(PathBuf::from(cwd_str));
|
||||
}
|
||||
|
||||
// Fall back to session's cwd
|
||||
if let Some(sid) = session_id
|
||||
&& let Some(session_cwd) = agent.get_session_cwd(sid)
|
||||
{
|
||||
return Ok(session_cwd);
|
||||
}
|
||||
|
||||
Err(acp::Error::invalid_params().data("either cwd or valid sessionId must be provided"))
|
||||
}
|
||||
|
||||
/// Map a `CodeNavEligibility` error to a human-readable ACP error.
|
||||
fn eligibility_error(reason: CodeNavEligibility) -> acp::Error {
|
||||
let msg = match reason {
|
||||
CodeNavEligibility::ClientNotWeb => {
|
||||
"code navigation is currently only enabled for grok-web clients"
|
||||
}
|
||||
CodeNavEligibility::CapabilityNotAdvertised => {
|
||||
"client must advertise x.ai/codeNavigation.enabled to use code navigation"
|
||||
}
|
||||
CodeNavEligibility::DisabledByConfig => "code navigation is disabled by configuration",
|
||||
CodeNavEligibility::NotGitRepo => {
|
||||
"code navigation requires the workspace to be inside a git repository"
|
||||
}
|
||||
CodeNavEligibility::SessionRequired => {
|
||||
"sessionId is required for code navigation and must refer to a valid active session"
|
||||
}
|
||||
};
|
||||
acp::Error::invalid_params().data(msg)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! `x.ai/debug/*` extension handlers for local client testing.
|
||||
//!
|
||||
//! These methods bypass heuristics, sampling, cooldowns, and enabled checks
|
||||
//! so client engineers can exercise notification → response flows without
|
||||
//! needing real experiments, real sessions, or real model inference.
|
||||
//!
|
||||
//! - `trigger_feedback`: fire a synthetic `FeedbackRequestNotification`.
|
||||
//! - `arm_auto_compact`: arm the next turn to unconditionally trigger
|
||||
//! auto-compaction, regardless of context window usage.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
use super::{ExtResult, parse_params};
|
||||
use crate::agent::MvpAgent;
|
||||
use crate::session::{ExtMethodResult, SessionCommand};
|
||||
|
||||
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/debug/trigger_feedback" => {
|
||||
tracing::info!("debug: triggering test feedback request");
|
||||
handle_trigger_feedback(agent, args).await
|
||||
}
|
||||
"x.ai/debug/arm_auto_compact" => handle_arm_auto_compact(agent, args),
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_trigger_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
use crate::session::feedback::{FeedbackMode, FeedbackTier};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DebugTriggerParams {
|
||||
#[serde(alias = "session_id")]
|
||||
session_id: String,
|
||||
/// "tier1" | "tier2" | "tier3" (default: "tier1")
|
||||
#[serde(default)]
|
||||
tier: Option<String>,
|
||||
/// "thumbs" | "stars" | "text" | "thumbs_text" | "stars_text" (default: "thumbs_text")
|
||||
#[serde(default)]
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
let params: DebugTriggerParams = parse_params(args)?;
|
||||
|
||||
let tier = match params.tier.as_deref() {
|
||||
Some("tier2") => FeedbackTier::Tier2,
|
||||
Some("tier3") => FeedbackTier::Tier3,
|
||||
Some("tier1") | None => FeedbackTier::Tier1,
|
||||
Some(other) => {
|
||||
return Err(acp::Error::invalid_params().data(format!(
|
||||
"unknown tier: {other:?} (expected tier1/tier2/tier3)"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let mode = match params.mode.as_deref() {
|
||||
Some("thumbs") => FeedbackMode::Thumbs,
|
||||
Some("stars") => FeedbackMode::Stars,
|
||||
Some("text") => FeedbackMode::Text,
|
||||
Some("stars_text") => FeedbackMode::StarsText,
|
||||
Some("thumbs_text") | None => FeedbackMode::ThumbsText,
|
||||
Some(other) => {
|
||||
return Err(acp::Error::invalid_params().data(format!(
|
||||
"unknown mode: {other:?} (expected thumbs/stars/text/thumbs_text/stars_text)"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let session_id = acp::SessionId::new(params.session_id.clone());
|
||||
let handle = agent
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
acp::Error::invalid_params().data(format!("session not found: {}", params.session_id))
|
||||
})?;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
handle
|
||||
.cmd_tx
|
||||
.send(SessionCommand::TriggerTestFeedback {
|
||||
tier,
|
||||
mode,
|
||||
respond_to: tx,
|
||||
})
|
||||
.map_err(|_| {
|
||||
acp::Error::internal_error().data("failed to dispatch debug trigger to session")
|
||||
})?;
|
||||
|
||||
rx.await
|
||||
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("Internal error: {e:?}")))
|
||||
}
|
||||
|
||||
fn handle_arm_auto_compact(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
let params: serde_json::Value = parse_params(args)?;
|
||||
|
||||
let session_id_str = params["sessionId"]
|
||||
.as_str()
|
||||
.or_else(|| params["session_id"].as_str())
|
||||
.ok_or_else(|| acp::Error::invalid_params().data("sessionId required"))?;
|
||||
let session_id = acp::SessionId::new(session_id_str);
|
||||
|
||||
let handle = agent
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| acp::Error::invalid_params().data("unknown session id"))?;
|
||||
|
||||
handle
|
||||
.force_compact
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
tracing::info!(
|
||||
session_id = %session_id_str,
|
||||
"debug: armed auto-compact for next turn"
|
||||
);
|
||||
|
||||
ExtMethodResult::success(serde_json::json!({ "armed": true }))
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! `x.ai/feedback`, `x.ai/feedback/dismiss`, `x.ai/btw`, and `x.ai/review/*`
|
||||
//! extension handlers.
|
||||
//!
|
||||
//! - `feedback`/`feedback/dismiss`: persist user ratings/text locally and
|
||||
//! forward to cli-chat-proxy.
|
||||
//! - `btw`: dispatch a side question to the active session via
|
||||
//! `SessionCommand::SideQuestion` and return the answer.
|
||||
//! - `review/comment` and `review/comment/delete`: record inline code review
|
||||
//! events to cloud storage.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::{ExtResult, parse_params};
|
||||
use crate::agent::MvpAgent;
|
||||
use crate::session::persistence::{LocalFeedbackEntry, UserFeedbackEntry};
|
||||
use crate::session::{
|
||||
ClientFeedbackInput, CommentDeleteRequest, CommentDeleteResponse, CommentRequest,
|
||||
CommentResponse, FeedbackRequestDismiss, FeedbackResponse, SessionCommand,
|
||||
};
|
||||
|
||||
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/btw" => {
|
||||
tracing::info!("handling /btw side question");
|
||||
handle_btw(agent, args).await
|
||||
}
|
||||
"x.ai/feedback" | "x.ai/feedback/dismiss" => {
|
||||
tracing::info!("handling user feedback");
|
||||
handle_feedback(agent, args).await
|
||||
}
|
||||
m if m.starts_with("x.ai/review") => {
|
||||
tracing::info!("handling review comment");
|
||||
handle_review(agent, args).await
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `x.ai/btw` -- a side question that doesn't interrupt the current turn.
|
||||
async fn handle_btw(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BtwRequest {
|
||||
session_id: String,
|
||||
question: String,
|
||||
}
|
||||
|
||||
let req: BtwRequest = parse_params(args)?;
|
||||
let sid: acp::SessionId = req.session_id.clone().into();
|
||||
let session_handle = {
|
||||
let sessions = agent.sessions.borrow();
|
||||
sessions.get(&sid).cloned()
|
||||
};
|
||||
let Some(session) = session_handle else {
|
||||
return Err(
|
||||
acp::Error::invalid_params().data(format!("session not found: {}", req.session_id))
|
||||
);
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = session.cmd_tx.send(SessionCommand::SideQuestion {
|
||||
question: req.question,
|
||||
respond_to: tx,
|
||||
});
|
||||
let result = rx
|
||||
.await
|
||||
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?;
|
||||
match result {
|
||||
Ok(answer) => super::to_ext_response(Ok(serde_json::json!({
|
||||
"answer": answer,
|
||||
}))),
|
||||
Err(e) => Err(acp::Error::internal_error().data(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
if !agent.cfg.borrow().is_feedback_enabled() {
|
||||
return Err(acp::Error::internal_error().data(
|
||||
"Feedback is disabled. To enable, set KIGI_FEEDBACK_ENABLED=true or \
|
||||
[features] feedback = true in config.toml.",
|
||||
));
|
||||
}
|
||||
|
||||
match args.method.as_ref() {
|
||||
"x.ai/feedback" => {
|
||||
// Parse the input -- try the full ClientFeedbackInput first,
|
||||
// then fall back to the simple FeedbackRequest (from /feedback slash command)
|
||||
// which only has {session_id, feedback_text} and no client_type.
|
||||
let feedback_input: ClientFeedbackInput =
|
||||
match serde_json::from_str::<ClientFeedbackInput>(args.params.get()) {
|
||||
Ok(input) => input,
|
||||
Err(_) => {
|
||||
// Fallback: parse simple FeedbackRequest from /feedback command
|
||||
let simple: crate::session::FeedbackRequest = parse_params(args)?;
|
||||
ClientFeedbackInput {
|
||||
session_id: simple.session_id,
|
||||
client_type:
|
||||
prod_mc_cli_chat_proxy_types::feedback_types::ClientType::Tui,
|
||||
rating_type: None,
|
||||
rating_value: None,
|
||||
feedback_text: Some(simple.feedback_text),
|
||||
feedback_categories: vec![],
|
||||
context_type: None,
|
||||
turn_number: None,
|
||||
request_id: None,
|
||||
client_version: None,
|
||||
metadata: None,
|
||||
terminal_info: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let session_id = acp::SessionId::new(feedback_input.session_id.clone());
|
||||
let session_handle = agent.sessions.borrow().get(&session_id).cloned();
|
||||
|
||||
let (model_id, model_metadata) = if let Some(ref session) = session_handle {
|
||||
let (tx1, rx1) = tokio::sync::oneshot::channel();
|
||||
let _ = session
|
||||
.cmd_tx
|
||||
.send(SessionCommand::GetCurrentModel { responds_to: tx1 });
|
||||
let model_id = rx1.await.ok();
|
||||
|
||||
let model_metadata = session.get_model_metadata().await;
|
||||
|
||||
(model_id, model_metadata)
|
||||
} else {
|
||||
let sampling_config = agent.sampling_config.borrow().clone();
|
||||
(Some(sampling_config.model.clone()), Default::default())
|
||||
};
|
||||
|
||||
let turn_number = feedback_input.turn_number.or_else(|| {
|
||||
agent
|
||||
.session_turn_number(&session_id)
|
||||
.map(|t| t.saturating_sub(1) as i64)
|
||||
});
|
||||
|
||||
let mut submission = feedback_input.to_submission(
|
||||
model_id.clone(),
|
||||
model_metadata.resolved_model_id,
|
||||
model_metadata.model_fingerprint,
|
||||
turn_number,
|
||||
);
|
||||
let turn_number = submission.turn_number;
|
||||
|
||||
if let Some(user_meta) =
|
||||
crate::agent::mvp_agent::parse_json_object_env("KIGI_USER_METADATA")
|
||||
{
|
||||
submission.merge_metadata(user_meta);
|
||||
}
|
||||
|
||||
// Enrich with session context for Slack notifications (best-effort).
|
||||
if let Some(ref session_handle) = session_handle {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let _ = session_handle
|
||||
.cmd_tx
|
||||
.send(SessionCommand::GetFeedbackContext {
|
||||
turn_number,
|
||||
responds_to: tx,
|
||||
});
|
||||
if let Ok(ctx) = rx.await {
|
||||
submission.tool_outcomes = ctx.tool_outcomes;
|
||||
submission.session_cwd = Some(ctx.session_cwd);
|
||||
submission.compaction_count = Some(ctx.compaction_count);
|
||||
submission.context_window_usage = Some(ctx.context_window_usage);
|
||||
submission.context_tokens_used = Some(ctx.context_tokens_used);
|
||||
submission.context_window_tokens = Some(ctx.context_window_tokens);
|
||||
}
|
||||
}
|
||||
|
||||
// Track rating in session signals
|
||||
if let (Some(session_handle), Some(rating_value)) =
|
||||
(&session_handle, feedback_input.rating_value)
|
||||
{
|
||||
use prod_mc_cli_chat_proxy_types::feedback_types::RatingType;
|
||||
let (is_positive, is_negative) = match feedback_input.rating_type {
|
||||
// Thumbs: -1 = down, 0 = neutral, 1 = up
|
||||
Some(RatingType::Thumbs) | None => (rating_value > 0, rating_value < 0),
|
||||
// Stars (1-5): >= 4 positive, <= 2 negative, 3 neutral
|
||||
Some(RatingType::Stars) => (rating_value >= 4, rating_value <= 2),
|
||||
// NPS (0-10): 9-10 promoter, 0-6 detractor, 7-8 passive
|
||||
Some(RatingType::Nps) => (rating_value >= 9, rating_value <= 6),
|
||||
};
|
||||
if is_positive {
|
||||
session_handle.signals_handle.record_positive_rating();
|
||||
} else if is_negative {
|
||||
session_handle.signals_handle.record_negative_rating();
|
||||
}
|
||||
}
|
||||
|
||||
// Log feedback type for debugging
|
||||
if feedback_input.is_solicited() {
|
||||
tracing::info!(
|
||||
session_id = %feedback_input.session_id,
|
||||
request_id = ?feedback_input.request_id(),
|
||||
turn_number = ?turn_number,
|
||||
"Solicited feedback received (response to feedback request)"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
session_id = %feedback_input.session_id,
|
||||
turn_number = ?turn_number,
|
||||
"Spontaneous user feedback received"
|
||||
);
|
||||
}
|
||||
|
||||
let client = agent.feedback_client();
|
||||
if client.is_none() {
|
||||
tracing::warn!(
|
||||
"no feedback client available (missing proxy credentials); feedback saved locally only"
|
||||
);
|
||||
}
|
||||
let outcome = crate::session::feedback_manager::submit_feedback_workflow(
|
||||
&mut submission,
|
||||
client.as_ref(),
|
||||
session_handle.as_ref().map(|h| &h.persistence_tx),
|
||||
feedback_input.is_solicited(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match &outcome {
|
||||
crate::session::feedback_manager::SubmitOutcome::Submitted => {
|
||||
tracing::info!("feedback submitted to proxy successfully");
|
||||
}
|
||||
crate::session::feedback_manager::SubmitOutcome::LocalOnly => {
|
||||
tracing::warn!("feedback saved locally only (no proxy client)");
|
||||
}
|
||||
crate::session::feedback_manager::SubmitOutcome::Failed(e) => {
|
||||
tracing::error!(error = %e, "feedback submission to proxy failed");
|
||||
return Err(acp::Error::internal_error()
|
||||
.data(format!("Feedback submission failed: {e}")));
|
||||
}
|
||||
}
|
||||
|
||||
let value = serde_json::to_value(FeedbackResponse { success: true })
|
||||
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
"x.ai/feedback/dismiss" => {
|
||||
let dismiss_input: FeedbackRequestDismiss = parse_params(args)?;
|
||||
|
||||
tracing::info!(
|
||||
session_id = %dismiss_input.session_id,
|
||||
request_id = %dismiss_input.request_id,
|
||||
"Feedback request dismissed by user"
|
||||
);
|
||||
|
||||
// Count dismissals too (else event_type is always "responded" and
|
||||
// response-rate is unknowable).
|
||||
{
|
||||
tracing::info_span!(
|
||||
"feedback.survey",
|
||||
survey_type = "session",
|
||||
event_type = "dismissed",
|
||||
appearance_id = %dismiss_input.request_id,
|
||||
has_feedback_text = false,
|
||||
is_solicited = true,
|
||||
)
|
||||
.in_scope(|| {});
|
||||
}
|
||||
|
||||
// Persist dismiss locally; flushed before storage CopyFile by the persistence actor.
|
||||
{
|
||||
let session_id = acp::SessionId::new(dismiss_input.session_id.clone());
|
||||
if let Some(session_handle) = agent.sessions.borrow().get(&session_id) {
|
||||
session_handle.persist_feedback(LocalFeedbackEntry::UserFeedback(
|
||||
UserFeedbackEntry {
|
||||
submitted_at: chrono::Utc::now(),
|
||||
session_id: dismiss_input.session_id.clone(),
|
||||
turn_number: None,
|
||||
solicited: true,
|
||||
request_id: Some(dismiss_input.request_id.clone()),
|
||||
dismissed: true,
|
||||
submission: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let request_id = dismiss_input.request_id.clone();
|
||||
let client = agent
|
||||
.feedback_client()
|
||||
.ok_or_else(|| acp::Error::internal_error().data("No credentials for feedback"))?;
|
||||
let feedback_base_url = agent.cfg.borrow().endpoints.resolve_feedback_base_url();
|
||||
match client.dismiss_request(&request_id).await {
|
||||
Ok(response) => {
|
||||
tracing::info!(
|
||||
request_id = %response.request_id,
|
||||
status = %response.status,
|
||||
feedback_url = %feedback_base_url,
|
||||
"Feedback request dismissed"
|
||||
);
|
||||
let value = serde_json::to_value(&response)
|
||||
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
request_id = %request_id,
|
||||
feedback_url = %feedback_base_url,
|
||||
"Failed to dismiss feedback request"
|
||||
);
|
||||
Err(acp::Error::internal_error()
|
||||
.data(format!("Failed to dismiss feedback request: {e}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record inline code review events.
|
||||
///
|
||||
/// Methods:
|
||||
/// - `x.ai/review/comment`: record a new inline code comment to cloud storage
|
||||
/// - `x.ai/review/comment/delete`: record a tombstone event for a deleted comment
|
||||
async fn handle_review(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/review/comment" => {
|
||||
let request: CommentRequest = parse_params(args)?;
|
||||
|
||||
let comment_id = uuid::Uuid::now_v7().to_string();
|
||||
|
||||
tracing::info!(
|
||||
comment_id = %comment_id,
|
||||
session_id = %request.session_id,
|
||||
prompt_index = request.prompt_index,
|
||||
path = %request.citation.path,
|
||||
lines = %format!("{}-{}", request.citation.start_line, request.citation.end_line),
|
||||
"Comment received"
|
||||
);
|
||||
|
||||
let value = serde_json::to_value(CommentResponse {
|
||||
comment_id,
|
||||
recorded: true,
|
||||
})
|
||||
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
"x.ai/review/comment/delete" => {
|
||||
let request: CommentDeleteRequest = parse_params(args)?;
|
||||
|
||||
tracing::info!(
|
||||
comment_id = %request.comment_id,
|
||||
session_id = %request.session_id,
|
||||
"Comment delete received"
|
||||
);
|
||||
|
||||
let value = serde_json::to_value(CommentDeleteResponse {
|
||||
comment_id: request.comment_id,
|
||||
deleted: true,
|
||||
})
|
||||
.map(|value| serde_json::value::to_raw_value(&value).map(Arc::from))
|
||||
.expect("to work")
|
||||
.expect("to work");
|
||||
Ok(acp::ExtResponse::new(value))
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Filesystem extension API layer.
|
||||
//!
|
||||
//! Routing: absolute paths work directly; relative paths require sessionId for lookup.
|
||||
//! Business logic delegated to `session::file_system::*` pure functions.
|
||||
use super::{Empty, ExtResult, parse_params, to_ext_response};
|
||||
use crate::agent::MvpAgent;
|
||||
use crate::session::ExtMethodResult;
|
||||
use crate::session::file_system::{
|
||||
self as fs, FsListParams, FsReadFileData, check_file_size_limits,
|
||||
};
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_workspace::file_system::FsReadEncoding;
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
fn default_depth() -> usize {
|
||||
1
|
||||
}
|
||||
fn default_limit() -> usize {
|
||||
1000
|
||||
}
|
||||
fn default_follow_symlinks() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_respect_git_ignore() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_max_bytes() -> usize {
|
||||
1_048_576
|
||||
}
|
||||
fn default_create_dirs() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_include_hidden() -> bool {
|
||||
true
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsListRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
pub path: String,
|
||||
#[serde(default = "default_depth")]
|
||||
pub depth: usize,
|
||||
#[serde(default = "default_include_hidden")]
|
||||
pub include_hidden: bool,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: usize,
|
||||
/// Pagination offset applied after the dirs-first sort (default 0).
|
||||
#[serde(default)]
|
||||
pub offset: u64,
|
||||
#[serde(default = "default_follow_symlinks")]
|
||||
pub follow_symlinks: bool,
|
||||
#[serde(default = "default_respect_git_ignore")]
|
||||
pub respect_git_ignore: bool,
|
||||
#[serde(default)]
|
||||
pub include_globs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub exclude_globs: Vec<String>,
|
||||
}
|
||||
impl FsListRequest {
|
||||
fn to_params(&self) -> FsListParams {
|
||||
FsListParams {
|
||||
path: self.path.clone(),
|
||||
depth: self.depth,
|
||||
limit: self.limit,
|
||||
offset: self.offset,
|
||||
follow_symlinks: self.follow_symlinks,
|
||||
respect_git_ignore: self.respect_git_ignore,
|
||||
include_hidden: self.include_hidden,
|
||||
include_globs: self.include_globs.clone(),
|
||||
exclude_globs: self.exclude_globs.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsExistsRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
pub path: String,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsReadFileRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
pub path: String,
|
||||
#[serde(default = "default_max_bytes")]
|
||||
pub max_bytes: usize,
|
||||
#[serde(default)]
|
||||
pub max_lines: Option<usize>,
|
||||
/// Byte offset for a binary-safe ranged read. When `offset`/`length`
|
||||
/// is set (or `encoding` is `base64`) the read returns the chunk
|
||||
/// `[offset, offset + length)`; otherwise the whole file is read
|
||||
/// (legacy behavior).
|
||||
#[serde(default)]
|
||||
pub offset: Option<u64>,
|
||||
/// Bytes to read for a ranged read. Absent means "to EOF", but the
|
||||
/// effective read is always capped at `max_bytes` (default 1 MiB) and the
|
||||
/// server's hard limit, so an unset `length` still yields at most
|
||||
/// `max_bytes`. Detect "more data" by comparing the returned bytes (from
|
||||
/// `offset`) against the response `size`.
|
||||
#[serde(default)]
|
||||
pub length: Option<u64>,
|
||||
/// Transfer encoding for ranged reads (default `utf8`; non-UTF-8
|
||||
/// ranges fall back to base64 regardless).
|
||||
#[serde(default)]
|
||||
pub encoding: FsReadEncoding,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsWriteFileRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
#[serde(default = "default_create_dirs")]
|
||||
pub create_dirs: bool,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsDeleteFileRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
pub path: String,
|
||||
}
|
||||
/// Resolve path from explicit value or session lookup.
|
||||
/// For absolute paths, use directly. For relative paths, resolve from session cwd.
|
||||
fn resolve_path(
|
||||
agent: &MvpAgent,
|
||||
path: &str,
|
||||
session_id: Option<&acp::SessionId>,
|
||||
) -> Result<PathBuf, acp::Error> {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() {
|
||||
return Ok(p.to_path_buf());
|
||||
}
|
||||
if let Some(sid) = session_id {
|
||||
if let Some(cwd) = agent.get_session_cwd(sid) {
|
||||
return Ok(cwd.join(p));
|
||||
}
|
||||
return Err(acp::Error::invalid_params().data(format!("session not found: {}", sid.0)));
|
||||
}
|
||||
Err(acp::Error::invalid_params().data("sessionId is required for relative paths"))
|
||||
}
|
||||
/// Confine `path` to the workspace root, falling back to the session cwd for
|
||||
/// worktree sessions (rooted outside it). Returns the resolved path and an
|
||||
/// optional confining walk root (`None` when confinement is off — the default,
|
||||
/// so the fallback and error paths only apply on a confining sandbox workspace).
|
||||
async fn confine_local(
|
||||
agent: &MvpAgent,
|
||||
path: &Path,
|
||||
session_id: Option<&acp::SessionId>,
|
||||
) -> Result<(PathBuf, Option<PathBuf>), acp::Error> {
|
||||
let ops = agent.resolve_workspace_ops()?;
|
||||
let handle = ops.workspace_handle().ok_or_else(|| {
|
||||
acp::Error::internal_error().data("no local workspace handle for fs confinement")
|
||||
})?;
|
||||
let workspace_err = match handle.confine_to_workspace_root(path).await {
|
||||
Ok(confined) => return Ok(confined),
|
||||
Err(e) => e,
|
||||
};
|
||||
if let Some(sid) = session_id
|
||||
&& let Some(session_cwd) = agent.get_session_cwd(sid)
|
||||
&& let Ok(confined) = handle.confine_to_root(path, &session_cwd).await
|
||||
{
|
||||
return Ok(confined);
|
||||
}
|
||||
Err(acp::Error::invalid_params().data(workspace_err.to_string()))
|
||||
}
|
||||
pub(crate) fn is_fs_method(method: &str) -> bool {
|
||||
method.starts_with("x.ai/fs/")
|
||||
}
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/fs/list" => {
|
||||
let req = parse_params::<FsListRequest>(args)?;
|
||||
let path = resolve_path(agent, &req.path, req.session_id.as_ref())?;
|
||||
let (path, confine_root) = confine_local(agent, &path, req.session_id.as_ref()).await?;
|
||||
let params = req.to_params();
|
||||
let result = fs::list(&path, ¶ms, confine_root).await;
|
||||
to_ext_response(result)
|
||||
}
|
||||
"x.ai/fs/exists" => {
|
||||
let req = parse_params::<FsExistsRequest>(args)?;
|
||||
let path = resolve_path(agent, &req.path, req.session_id.as_ref())?;
|
||||
let (path, _) = match confine_local(agent, &path, req.session_id.as_ref()).await {
|
||||
Ok(confined) => confined,
|
||||
Err(_) => return to_ext_response(Ok(fs::FsExistsData { exists: false })),
|
||||
};
|
||||
let result = fs::exists(&path).await;
|
||||
to_ext_response(result)
|
||||
}
|
||||
"x.ai/fs/read_file" => {
|
||||
let req = parse_params::<FsReadFileRequest>(args)?;
|
||||
let max_lines = req.max_lines;
|
||||
let path_str = req.path.clone();
|
||||
let ranged = req.offset.is_some()
|
||||
|| req.length.is_some()
|
||||
|| req.encoding == FsReadEncoding::Base64;
|
||||
let path = resolve_path(agent, &req.path, req.session_id.as_ref())?;
|
||||
let (path, _) = confine_local(agent, &path, req.session_id.as_ref()).await?;
|
||||
let read_result: anyhow::Result<FsReadFileData> = if ranged {
|
||||
fs::read_file_ranged(
|
||||
&path,
|
||||
req.offset.unwrap_or(0),
|
||||
req.length.unwrap_or(u64::MAX),
|
||||
req.max_bytes as u64,
|
||||
req.encoding,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
fs::read_file(&path).await
|
||||
};
|
||||
match read_result {
|
||||
Ok(data) => {
|
||||
let size_check = if ranged {
|
||||
Ok(())
|
||||
} else {
|
||||
check_file_size_limits(&data, &path_str, None, max_lines)
|
||||
};
|
||||
if let Err(err) = size_check {
|
||||
let ext_result: ExtMethodResult<FsReadFileData> = err.into();
|
||||
ext_result
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
} else {
|
||||
to_ext_response(Ok(data))
|
||||
}
|
||||
}
|
||||
Err(e) => to_ext_response(Err::<FsReadFileData, _>(e)),
|
||||
}
|
||||
}
|
||||
"x.ai/fs/write_file" => {
|
||||
let req = parse_params::<FsWriteFileRequest>(args)?;
|
||||
let path = resolve_path(agent, &req.path, req.session_id.as_ref())?;
|
||||
let (path, _) = confine_local(agent, &path, req.session_id.as_ref()).await?;
|
||||
let result = fs::write_file(&path, &req.content, req.create_dirs)
|
||||
.await
|
||||
.map(|_| Empty {});
|
||||
to_ext_response(result)
|
||||
}
|
||||
"x.ai/fs/delete_file" => {
|
||||
let req = parse_params::<FsDeleteFileRequest>(args)?;
|
||||
let path = resolve_path(agent, &req.path, req.session_id.as_ref())?;
|
||||
let (path, _) = confine_local(agent, &path, req.session_id.as_ref()).await?;
|
||||
let result = fs::delete_file(&path).await.map(|_| Empty {});
|
||||
to_ext_response(result)
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
//! Git extension API layer.
|
||||
//!
|
||||
//! Routing: prefers explicit `gitRoot`, falls back to session lookup via `sessionId`.
|
||||
//! Business logic delegated to `session::git::*` pure functions.
|
||||
//!
|
||||
//! **Phase 4 design note**: Git/JJ functions (`git_cli`, `status`,
|
||||
//! `detect_vcs_kind`, `find_git_root_from_path`, etc.) are stateless
|
||||
//! utilities that take a `&Path` and shell out to `git`/`jj`. They do
|
||||
//! not access workspace state and therefore remain direct calls rather
|
||||
//! than routing through `WorkspaceChannel`. The channel's VCS stubs
|
||||
//! (`git_status`, `git_diff`, etc.) are reserved for future stateful
|
||||
//! operations (e.g. cached VCS state, cross-session conflict detection).
|
||||
use super::{Empty, ExtResult, parse_params, to_ext_response, to_ext_response_partial};
|
||||
use crate::agent::MvpAgent;
|
||||
use crate::session::ExtMethodResult;
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_workspace::session::git::{
|
||||
self, DiscardScope, GIT_STATUS_CACHE_TTL, GitDiffsData, GitStatusData, check_diff_size_limits,
|
||||
};
|
||||
use kigi_workspace::workspace_ops::{
|
||||
GitBranchesReq, GitCheckoutCommitReq, GitCheckoutReq, GitCommitReq, GitCurrentCommitReq,
|
||||
GitDiffReq, GitDiscardReq, GitFilesReq, GitInfoReq, GitStageContentReq, GitStageReq,
|
||||
GitStashReq, GitStatusExtReq, GitStatusFormat, GitUnstageReq,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
/// Global cache for git status results, keyed by git_root path.
|
||||
/// This provides caching at the extension API layer while keeping git::status pure.
|
||||
static GIT_STATUS_CACHE: std::sync::LazyLock<Mutex<HashMap<PathBuf, GitStatusCacheEntry>>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
struct GitStatusCacheEntry {
|
||||
result: GitStatusData,
|
||||
commit: String,
|
||||
cached_at: Instant,
|
||||
include_untracked: bool,
|
||||
include_stats: bool,
|
||||
}
|
||||
impl GitStatusCacheEntry {
|
||||
fn is_valid(&self, commit: &str, include_untracked: bool, include_stats: bool) -> bool {
|
||||
self.commit == commit
|
||||
&& self.include_untracked == include_untracked
|
||||
&& self.include_stats == include_stats
|
||||
&& self.cached_at.elapsed() < GIT_STATUS_CACHE_TTL
|
||||
}
|
||||
}
|
||||
/// Invalidate the git status cache for a given git_root.
|
||||
/// Should be called after any mutation operation (stage, unstage, discard, commit).
|
||||
fn invalidate_status_cache(git_root: &PathBuf) {
|
||||
let mut cache = GIT_STATUS_CACHE.lock();
|
||||
cache.remove(git_root);
|
||||
}
|
||||
fn default_head() -> String {
|
||||
"HEAD".to_string()
|
||||
}
|
||||
fn default_working() -> String {
|
||||
"working".to_string()
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitStatusRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
pub include_untracked: Option<bool>,
|
||||
pub include_stats: Option<bool>,
|
||||
pub ignore_submodules: Option<bool>,
|
||||
pub include_patches: Option<bool>,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitFilesRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
pub paths: Vec<String>,
|
||||
#[serde(default = "default_head")]
|
||||
pub version: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitDiffsRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
#[serde(default)]
|
||||
pub paths: Option<Vec<String>>,
|
||||
#[serde(default = "default_head")]
|
||||
pub from: String,
|
||||
#[serde(default = "default_working")]
|
||||
pub to: String,
|
||||
#[serde(default)]
|
||||
pub include_patch: bool,
|
||||
#[serde(default)]
|
||||
pub include_content: bool,
|
||||
#[serde(default)]
|
||||
pub max_patch_bytes: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub max_patch_lines: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub merge_base: bool,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitStageRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
pub paths: Option<Vec<String>>,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitStageContentRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitUnstageRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
pub paths: Option<Vec<String>>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum GitDiscardScope {
|
||||
Working,
|
||||
Staged,
|
||||
#[default]
|
||||
Both,
|
||||
}
|
||||
impl From<GitDiscardScope> for DiscardScope {
|
||||
fn from(s: GitDiscardScope) -> Self {
|
||||
match s {
|
||||
GitDiscardScope::Working => DiscardScope::Working,
|
||||
GitDiscardScope::Staged => DiscardScope::Staged,
|
||||
GitDiscardScope::Both => DiscardScope::Both,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitDiscardRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
pub paths: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub include_untracked: bool,
|
||||
#[serde(default)]
|
||||
scope: GitDiscardScope,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitCommitRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub amend: bool,
|
||||
#[serde(default)]
|
||||
pub signoff: bool,
|
||||
#[serde(default)]
|
||||
pub push: bool,
|
||||
#[serde(default)]
|
||||
pub sync: bool,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitStashRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
#[serde(default)]
|
||||
pub include_untracked: bool,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitCheckoutRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
pub branch: String,
|
||||
#[serde(default)]
|
||||
pub create: bool,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CheckoutSessionHeadRequest {
|
||||
pub session_id: acp::SessionId,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stash_if_dirty: bool,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitInfoRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitBranchesRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitCurrentCommitRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
}
|
||||
/// Request for x.ai/git/checkout_commit extension method.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitCheckoutCommitRequest {
|
||||
#[serde(default)]
|
||||
pub session_id: Option<acp::SessionId>,
|
||||
#[serde(default)]
|
||||
pub git_root: Option<String>,
|
||||
/// Commit hash or ref to checkout.
|
||||
pub commit: String,
|
||||
#[serde(default)]
|
||||
pub stash_if_dirty: bool,
|
||||
}
|
||||
/// Resolve git_root from explicit value or session lookup via [`WorkspaceOps`].
|
||||
async fn resolve_git_root(
|
||||
agent: &MvpAgent,
|
||||
ops: &kigi_workspace::WorkspaceOps,
|
||||
git_root: Option<String>,
|
||||
session_id: Option<&acp::SessionId>,
|
||||
) -> Result<PathBuf, acp::Error> {
|
||||
if let Some(root) = git_root {
|
||||
return Ok(PathBuf::from(root));
|
||||
}
|
||||
if let Some(sid) = session_id {
|
||||
if let Some(cwd) = agent.get_session_cwd(sid) {
|
||||
let result = ops
|
||||
.dispatch(
|
||||
&kigi_workspace::workspace_ops::GitResolveRootReq { cwd },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
acp::Error::invalid_params()
|
||||
.data(format!("cannot find git root from session cwd: {}", e))
|
||||
})?;
|
||||
return result.ok_or_else(|| {
|
||||
acp::Error::invalid_params()
|
||||
.data("cannot find git root from session cwd: not a git repository")
|
||||
});
|
||||
}
|
||||
return Err(acp::Error::invalid_params().data(format!("session not found: {}", sid.0)));
|
||||
}
|
||||
Err(acp::Error::invalid_params().data("either gitRoot or sessionId is required"))
|
||||
}
|
||||
/// Try to extract a git_root from the request params (best-effort, for jj routing).
|
||||
async fn try_resolve_git_root(
|
||||
agent: &MvpAgent,
|
||||
ops: &kigi_workspace::WorkspaceOps,
|
||||
args: &acp::ExtRequest,
|
||||
) -> Option<PathBuf> {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Probe {
|
||||
git_root: Option<String>,
|
||||
session_id: Option<agent_client_protocol::SessionId>,
|
||||
}
|
||||
let probe: Probe = serde_json::from_str(args.params.get()).ok()?;
|
||||
if let Some(root) = probe.git_root {
|
||||
return Some(PathBuf::from(root));
|
||||
}
|
||||
if let Some(sid) = &probe.session_id
|
||||
&& let Some(cwd) = agent.get_session_cwd(sid)
|
||||
{
|
||||
return ops
|
||||
.dispatch(
|
||||
&kigi_workspace::workspace_ops::GitResolveRootReq { cwd },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
}
|
||||
None
|
||||
}
|
||||
pub async fn handle(
|
||||
agent: &MvpAgent,
|
||||
ops: &kigi_workspace::WorkspaceOps,
|
||||
args: &acp::ExtRequest,
|
||||
) -> ExtResult {
|
||||
if let Some(git_root) = try_resolve_git_root(agent, ops, args).await {
|
||||
let vcs_kind = ops
|
||||
.dispatch(
|
||||
&kigi_workspace::workspace_ops::DetectVcsKindReq {
|
||||
path: git_root.clone(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(kigi_workspace::session::git::VcsKind::Git);
|
||||
if vcs_kind.is_jj()
|
||||
&& let Some(result) =
|
||||
super::jj::try_handle(args.method.as_ref(), &git_root, &args.params).await
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
match args.method.as_ref() {
|
||||
"x.ai/git/git_repo_root" => {
|
||||
let req: git::GitRepoRequest = parse_params(args)?;
|
||||
let response = git::is_git_repo(&req).await?;
|
||||
super::to_raw_response(&response)
|
||||
}
|
||||
"x.ai/git/serialize_changes" => {
|
||||
let _ = (args, ops);
|
||||
to_ext_response::<()>(Err(anyhow::anyhow!(
|
||||
"git serialize_changes is unavailable in this build"
|
||||
)))
|
||||
}
|
||||
"x.ai/git/status" => {
|
||||
let req = parse_params::<GitStatusRequest>(args)?;
|
||||
let include_untracked = req.include_untracked.unwrap_or(true);
|
||||
let include_stats = req.include_stats.unwrap_or(false);
|
||||
let ignore_submodules = req.ignore_submodules.unwrap_or(true);
|
||||
let include_patches = req.include_patches.unwrap_or(false);
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
if let Some(ref git_root) = git_root {
|
||||
let current_commit = ops
|
||||
.dispatch(
|
||||
&kigi_workspace::workspace_ops::GitCurrentCommitReq {
|
||||
git_root: git_root.clone(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
if let Some(commit) = ¤t_commit {
|
||||
let cached_result = {
|
||||
let cache = GIT_STATUS_CACHE.lock();
|
||||
cache.get(git_root).and_then(|entry| {
|
||||
if entry.is_valid(commit, include_untracked, include_stats) {
|
||||
tracing::debug!("git.status (cached)");
|
||||
Some(entry.result.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
if let Some(result) = cached_result {
|
||||
return to_ext_response(Ok(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
let op = GitStatusExtReq {
|
||||
git_root: git_root.clone(),
|
||||
include_untracked,
|
||||
include_stats,
|
||||
ignore_submodules,
|
||||
include_patches,
|
||||
format: GitStatusFormat::Structured,
|
||||
};
|
||||
let response = ops
|
||||
.dispatch(&op, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
let result = response.data.ok_or_else(|| {
|
||||
acp::Error::internal_error().data("git_status_ext returned no structured data")
|
||||
})?;
|
||||
if let Some(git_root) = git_root
|
||||
&& let Some(ref commit) = result.commit
|
||||
{
|
||||
let mut cache = GIT_STATUS_CACHE.lock();
|
||||
cache.insert(
|
||||
git_root,
|
||||
GitStatusCacheEntry {
|
||||
result: result.clone(),
|
||||
commit: commit.clone(),
|
||||
cached_at: Instant::now(),
|
||||
include_untracked,
|
||||
include_stats,
|
||||
},
|
||||
);
|
||||
}
|
||||
to_ext_response(Ok(result))
|
||||
}
|
||||
"x.ai/git/files" => {
|
||||
let req = parse_params::<GitFilesRequest>(args)?;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let op = GitFilesReq {
|
||||
git_root,
|
||||
paths: req.paths.clone(),
|
||||
version: req.version.clone(),
|
||||
};
|
||||
let result = ops
|
||||
.dispatch(&op, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
to_ext_response(Ok(result))
|
||||
}
|
||||
"x.ai/git/diffs" => {
|
||||
let req = parse_params::<GitDiffsRequest>(args)?;
|
||||
let max_bytes = req.max_patch_bytes;
|
||||
let max_lines = req.max_patch_lines;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let op = GitDiffReq {
|
||||
git_root,
|
||||
paths: req.paths.clone(),
|
||||
from: req.from.clone(),
|
||||
to: req.to.clone(),
|
||||
include_patch: req.include_patch,
|
||||
include_content: req.include_content,
|
||||
merge_base: req.merge_base,
|
||||
};
|
||||
let data = ops
|
||||
.dispatch(&op, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
if let Err(err) = check_diff_size_limits(&data, max_bytes, max_lines) {
|
||||
let ext_result = ExtMethodResult::<GitDiffsData>::failure(err.message());
|
||||
ext_result
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
} else {
|
||||
to_ext_response(Ok(data))
|
||||
}
|
||||
}
|
||||
"x.ai/git/stage" => {
|
||||
let req = parse_params::<GitStageRequest>(args)?;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let op = GitStageReq {
|
||||
git_root: git_root.clone(),
|
||||
paths: req.paths,
|
||||
};
|
||||
let result = ops
|
||||
.dispatch(&op, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
if let Some(ref git_root) = git_root {
|
||||
invalidate_status_cache(git_root);
|
||||
}
|
||||
to_ext_response(Ok(result))
|
||||
}
|
||||
"x.ai/git/stage/content" => {
|
||||
let req = parse_params::<GitStageContentRequest>(args)?;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let op = GitStageContentReq {
|
||||
git_root: git_root.clone(),
|
||||
path: req.path.clone(),
|
||||
content: req.content.clone(),
|
||||
};
|
||||
ops.dispatch(&op, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
if let Some(ref git_root) = git_root {
|
||||
invalidate_status_cache(git_root);
|
||||
}
|
||||
to_ext_response(Ok(Empty {}))
|
||||
}
|
||||
"x.ai/git/unstage" => {
|
||||
let req = parse_params::<GitUnstageRequest>(args)?;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let op = GitUnstageReq {
|
||||
git_root: git_root.clone(),
|
||||
paths: req.paths,
|
||||
};
|
||||
ops.dispatch(&op, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
if let Some(ref git_root) = git_root {
|
||||
invalidate_status_cache(git_root);
|
||||
}
|
||||
to_ext_response(Ok(Empty {}))
|
||||
}
|
||||
"x.ai/git/discard" => {
|
||||
let req = parse_params::<GitDiscardRequest>(args)?;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let op = GitDiscardReq {
|
||||
git_root: git_root.clone(),
|
||||
paths: req.paths,
|
||||
scope: req.scope.into(),
|
||||
include_untracked: req.include_untracked,
|
||||
};
|
||||
ops.dispatch(&op, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
if let Some(ref git_root) = git_root {
|
||||
invalidate_status_cache(git_root);
|
||||
}
|
||||
to_ext_response(Ok(Empty {}))
|
||||
}
|
||||
"x.ai/git/commit" => {
|
||||
let req = parse_params::<GitCommitRequest>(args)?;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let op = GitCommitReq {
|
||||
git_root: git_root.clone(),
|
||||
message: req.message.clone(),
|
||||
amend: req.amend,
|
||||
signoff: req.signoff,
|
||||
push: req.push,
|
||||
sync: req.sync,
|
||||
};
|
||||
let commit_result = ops
|
||||
.dispatch(&op, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
if let Some(ref git_root) = git_root {
|
||||
invalidate_status_cache(git_root);
|
||||
}
|
||||
to_ext_response_partial(Ok(commit_result.data), commit_result.warning)
|
||||
}
|
||||
"x.ai/git/checkout" => {
|
||||
let req = parse_params::<GitCheckoutRequest>(args)?;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let op = GitCheckoutReq {
|
||||
git_root: git_root.clone(),
|
||||
branch: req.branch.clone(),
|
||||
create: req.create,
|
||||
};
|
||||
ops.dispatch(&op, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
if let Some(ref git_root) = git_root {
|
||||
invalidate_status_cache(git_root);
|
||||
}
|
||||
to_ext_response(Ok(Empty {}))
|
||||
}
|
||||
"x.ai/git/stash" => {
|
||||
let req = parse_params::<GitStashRequest>(args)?;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let op = GitStashReq {
|
||||
git_root: git_root.clone(),
|
||||
include_untracked: req.include_untracked,
|
||||
};
|
||||
ops.dispatch(&op, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
if let Some(ref git_root) = git_root {
|
||||
invalidate_status_cache(git_root);
|
||||
}
|
||||
to_ext_response(Ok(Empty {}))
|
||||
}
|
||||
"x.ai/git/info" => {
|
||||
let req = parse_params::<GitInfoRequest>(args)?;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let result = ops
|
||||
.dispatch(&GitInfoReq { git_root }, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
to_ext_response(Ok(result))
|
||||
}
|
||||
"x.ai/git/branches" => {
|
||||
let req = parse_params::<GitBranchesRequest>(args)?;
|
||||
let git_root = resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok();
|
||||
let result = ops
|
||||
.dispatch(&GitBranchesReq { git_root }, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
|
||||
to_ext_response(Ok(result))
|
||||
}
|
||||
"x.ai/git/current_commit" => {
|
||||
let req = parse_params::<GitCurrentCommitRequest>(args)?;
|
||||
let result = match resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref())
|
||||
.await
|
||||
.ok()
|
||||
{
|
||||
Some(git_root) => ops
|
||||
.dispatch(&GitCurrentCommitReq { git_root }, None)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?,
|
||||
None => None,
|
||||
};
|
||||
to_ext_response(Ok(result))
|
||||
}
|
||||
"x.ai/git/checkout_session_head" => {
|
||||
let req = parse_params::<CheckoutSessionHeadRequest>(args)?;
|
||||
let git_root =
|
||||
resolve_git_root(agent, ops, req.git_root, Some(&req.session_id)).await?;
|
||||
let vcs_kind = ops
|
||||
.dispatch(
|
||||
&kigi_workspace::workspace_ops::DetectVcsKindReq {
|
||||
path: git_root.clone(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(kigi_workspace::session::git::VcsKind::Git);
|
||||
if vcs_kind.is_jj() {
|
||||
return Err(acp::Error::invalid_request()
|
||||
.data("checkout_session_head is not supported in jj repositories"));
|
||||
}
|
||||
let summary =
|
||||
crate::session::persistence::find_summary_by_session_id(&req.session_id.0)
|
||||
.ok_or_else(|| {
|
||||
acp::Error::invalid_params()
|
||||
.data(format!("session {} not found", req.session_id.0))
|
||||
})?;
|
||||
let head_commit = summary.head_commit.ok_or_else(|| {
|
||||
acp::Error::invalid_params().data(format!(
|
||||
"session {} has no persisted HEAD commit",
|
||||
req.session_id.0
|
||||
))
|
||||
})?;
|
||||
let result = ops
|
||||
.dispatch(
|
||||
&kigi_workspace::workspace_ops::GitCheckoutCommitReq {
|
||||
git_root: git_root.clone(),
|
||||
head_commit,
|
||||
head_branch: summary.head_branch,
|
||||
stash_if_dirty: req.stash_if_dirty,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("checkout failed: {e}")))?;
|
||||
invalidate_status_cache(&git_root);
|
||||
super::to_raw_response(&result)
|
||||
}
|
||||
"x.ai/git/checkout_commit" => {
|
||||
let req = parse_params::<GitCheckoutCommitRequest>(args)?;
|
||||
let git_root =
|
||||
resolve_git_root(agent, ops, req.git_root, req.session_id.as_ref()).await?;
|
||||
let vcs_kind = ops
|
||||
.dispatch(
|
||||
&kigi_workspace::workspace_ops::DetectVcsKindReq {
|
||||
path: git_root.clone(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(kigi_workspace::session::git::VcsKind::Git);
|
||||
if vcs_kind.is_jj() {
|
||||
return Err(acp::Error::invalid_request().data(
|
||||
"checkout_commit is not supported in jj repos; use `jj new` or `jj edit`",
|
||||
));
|
||||
}
|
||||
let result = ops
|
||||
.dispatch(
|
||||
&GitCheckoutCommitReq {
|
||||
git_root: git_root.clone(),
|
||||
head_commit: req.commit,
|
||||
head_branch: None,
|
||||
stash_if_dirty: req.stash_if_dirty,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("checkout failed: {e}")))?;
|
||||
invalidate_status_cache(&git_root);
|
||||
super::to_raw_response(&result)
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! `x.ai/hooks/*` extension handlers.
|
||||
//!
|
||||
//! The file-hook list/action endpoints for the pager's hooks modal, plus the
|
||||
//! client-registered hook wire types and `parse_client_hooks`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_hooks::event::{HookEventEnvelope, HookEventName};
|
||||
use kigi_hooks::matcher::HookMatcher;
|
||||
use kigi_hooks_plugins_types::{HookEvent, HookHandlerType, HookInfo};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::agent::MvpAgent;
|
||||
|
||||
type ExtResult = Result<acp::ExtResponse, acp::Error>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ListRequest {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
pub fn hook_spec_to_info(spec: &kigi_hooks::config::HookSpec) -> HookInfo {
|
||||
use kigi_hooks::event::HookEventName;
|
||||
|
||||
let event = match spec.event {
|
||||
// Session lifecycle
|
||||
HookEventName::SessionStart => HookEvent::SessionStart,
|
||||
HookEventName::SessionEnd => HookEvent::SessionEnd,
|
||||
HookEventName::Stop => HookEvent::Stop,
|
||||
HookEventName::StopFailure => HookEvent::StopFailure,
|
||||
// Tool events
|
||||
HookEventName::PreToolUse => HookEvent::PreToolUse,
|
||||
HookEventName::PostToolUse => HookEvent::PostToolUse,
|
||||
HookEventName::PostToolUseFailure => HookEvent::PostToolUseFailure,
|
||||
HookEventName::PermissionDenied => HookEvent::PermissionDenied,
|
||||
// User / notification
|
||||
HookEventName::UserPromptSubmit => HookEvent::UserPromptSubmit,
|
||||
HookEventName::Notification => HookEvent::Notification,
|
||||
// Subagent
|
||||
HookEventName::SubagentStart => HookEvent::SubagentStart,
|
||||
HookEventName::SubagentStop | HookEventName::SubagentEnd => HookEvent::SubagentStop,
|
||||
// Compaction
|
||||
HookEventName::PreCompact => HookEvent::PreCompact,
|
||||
HookEventName::PostCompact => HookEvent::PostCompact,
|
||||
};
|
||||
|
||||
let handler_type = if spec.url.is_some() {
|
||||
HookHandlerType::Http
|
||||
} else {
|
||||
HookHandlerType::Command
|
||||
};
|
||||
|
||||
// Display the pre-expansion source string when available so the
|
||||
// pager UI / ACP DTO never leaks values resolved from the user
|
||||
// `env` map (which may contain secrets like API tokens). Fall back
|
||||
// to the post-expansion form for any future code path that builds
|
||||
// a `HookSpec` without populating the raw source.
|
||||
let command_display = spec
|
||||
.command_raw
|
||||
.clone()
|
||||
.or_else(|| spec.command.as_ref().map(|p| p.display().to_string()));
|
||||
let url_display = spec.url_raw.clone().or_else(|| spec.url.clone());
|
||||
|
||||
HookInfo {
|
||||
name: spec.name.clone(),
|
||||
event,
|
||||
handler_type,
|
||||
matcher: spec.configured_matcher.clone(),
|
||||
command: command_display,
|
||||
url: url_display,
|
||||
timeout_ms: spec.timeout_ms,
|
||||
source_dir: spec.source_dir.display().to_string(),
|
||||
disabled: kigi_hooks::trust::is_hook_disabled(&spec.name),
|
||||
}
|
||||
}
|
||||
|
||||
// Wire types for client-registered hooks (`x.ai/hooks/run`); the gate that uses
|
||||
// them lives in `session::acp_session::hooks`.
|
||||
|
||||
/// A matcher group from the client's registration: `{ matcher, hookCallbackIds, timeout }`.
|
||||
///
|
||||
/// `pub` (not `pub(crate)`) because [`ClientHooks`] flows through the public
|
||||
/// `SessionCommand::SnapshotClientHooks` so subagents can inherit the parent's hooks.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientHookGroup {
|
||||
/// `None` (wire `null`, `""`, or `"*"`) matches every tool.
|
||||
pub matcher: Option<HookMatcher>,
|
||||
pub callback_ids: Vec<String>,
|
||||
/// Per-group reply deadline for the `PreToolUse` gate (wire value in seconds). `None`
|
||||
/// falls back to the default gate timeout.
|
||||
pub timeout: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
pub type ClientHooks = HashMap<HookEventName, Vec<ClientHookGroup>>;
|
||||
|
||||
/// One hook dispatched to a client callback: the shared [`HookEventEnvelope`]
|
||||
/// (flattened, camelCase) plus the `hookCallbackId` it targets. The same shape is sent
|
||||
/// for both the `x.ai/hooks/run` request (gate) and the `x.ai/hooks/event` notification
|
||||
/// (observe-only), so the client decodes one payload for every hook.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ClientHookDispatch<'a> {
|
||||
pub hook_callback_id: &'a str,
|
||||
#[serde(flatten)]
|
||||
pub envelope: &'a HookEventEnvelope,
|
||||
}
|
||||
|
||||
/// Only `Deny` blocks the tool; every other value proceeds (fail-open).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ClientHookDecision {
|
||||
#[default]
|
||||
Continue,
|
||||
Deny,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Response payload for `x.ai/hooks/run` (client to agent). `Default` (used on
|
||||
/// timeout, transport error, or a malformed reply) proceeds.
|
||||
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ClientHookResponse {
|
||||
#[serde(default)]
|
||||
pub decision: ClientHookDecision,
|
||||
/// Deny reason surfaced to the model/user; consumed only when `decision` is `Deny`.
|
||||
#[serde(default)]
|
||||
pub system_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse client hooks from `session/new` `_meta["x.ai/hooks"]`, shaped
|
||||
/// `{ "<Event>": [{ matcher, hookCallbackIds }] }` (PascalCase or snake_case
|
||||
/// events). Each `matcher` is compiled with the agent's [`HookMatcher`] so client
|
||||
/// and file hooks match identically. Unknown events, malformed groups, invalid
|
||||
/// matchers, and callback-less groups are skipped; absent meta yields no hooks.
|
||||
pub(crate) fn parse_client_hooks(meta: Option<&acp::Meta>) -> ClientHooks {
|
||||
let mut hooks = ClientHooks::new();
|
||||
let Some(map) = meta
|
||||
.and_then(|m| m.get("x.ai/hooks"))
|
||||
.and_then(|h| h.as_object())
|
||||
else {
|
||||
return hooks;
|
||||
};
|
||||
for (event_name, value) in map {
|
||||
let de = serde::de::value::StrDeserializer::<serde::de::value::Error>::new(event_name);
|
||||
let Ok(event) = HookEventName::deserialize(de) else {
|
||||
tracing::warn!(event = %event_name, "ignoring unknown x.ai/hooks event");
|
||||
continue;
|
||||
};
|
||||
let Some(array) = value.as_array() else {
|
||||
tracing::warn!(event = %event_name, "x.ai/hooks event value is not an array; skipping");
|
||||
continue;
|
||||
};
|
||||
let groups: Vec<ClientHookGroup> = array
|
||||
.iter()
|
||||
.filter_map(|group| parse_hook_group(event, group))
|
||||
.collect();
|
||||
if !groups.is_empty() {
|
||||
// Key by the canonical event so a registration under an alias (e.g.
|
||||
// `SubagentEnd`) still matches the event the agent fires (`SubagentStop`).
|
||||
hooks.entry(event.canonical()).or_default().extend(groups);
|
||||
}
|
||||
}
|
||||
hooks
|
||||
}
|
||||
|
||||
/// Hooks to apply on a `load_session` reconnect: `Some` (possibly empty, an explicit
|
||||
/// clear) when the request meta carries `x.ai/hooks`, else `None` so a reconnect that
|
||||
/// omits the key leaves the live registrations from `session/new` untouched.
|
||||
pub(crate) fn reconnect_client_hooks(meta: Option<&acp::Meta>) -> Option<ClientHooks> {
|
||||
meta.and_then(|m| m.get("x.ai/hooks"))
|
||||
.map(|_| parse_client_hooks(meta))
|
||||
}
|
||||
|
||||
/// Parse one `{ matcher, hookCallbackIds }` registration entry. Returns `None`
|
||||
/// (with a warning) when the entry is malformed, carries no callback ids, or its
|
||||
/// matcher fails to compile.
|
||||
fn parse_hook_group(event: HookEventName, value: &serde_json::Value) -> Option<ClientHookGroup> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WireGroup {
|
||||
#[serde(default)]
|
||||
matcher: Option<String>,
|
||||
#[serde(default)]
|
||||
hook_callback_ids: Vec<String>,
|
||||
/// Per-group gate timeout in seconds.
|
||||
#[serde(default)]
|
||||
timeout: Option<f64>,
|
||||
}
|
||||
|
||||
let group = WireGroup::deserialize(value)
|
||||
.inspect_err(|err| tracing::warn!(%event, %err, "ignoring malformed x.ai/hooks group"))
|
||||
.ok()?;
|
||||
if group.hook_callback_ids.is_empty() {
|
||||
tracing::warn!(%event, "ignoring x.ai/hooks group with no hookCallbackIds");
|
||||
return None;
|
||||
}
|
||||
// Drop a non-finite/non-positive timeout (fall back to the default gate timeout) and
|
||||
// cap it so a client can't make a tool hang on the gate for an unbounded time.
|
||||
const MAX_HOOK_TIMEOUT_SECS: f64 = 300.0;
|
||||
let timeout = group
|
||||
.timeout
|
||||
.filter(|s| s.is_finite() && *s > 0.0)
|
||||
.map(|s| std::time::Duration::from_secs_f64(s.min(MAX_HOOK_TIMEOUT_SECS)));
|
||||
let matcher = match group.matcher.as_deref() {
|
||||
// Match-all tokens map to no matcher (group always fires). `HookMatcher::new`
|
||||
// also treats these as match-all; short-circuiting here keeps the intent explicit.
|
||||
None | Some("") | Some("*") => None,
|
||||
Some(pattern) => match HookMatcher::new(pattern) {
|
||||
Ok(matcher) => Some(matcher),
|
||||
Err(err) => {
|
||||
tracing::warn!(%event, pattern, %err, "ignoring x.ai/hooks group with invalid matcher");
|
||||
return None;
|
||||
}
|
||||
},
|
||||
};
|
||||
Some(ClientHookGroup {
|
||||
matcher,
|
||||
callback_ids: group.hook_callback_ids,
|
||||
timeout,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/hooks/list" => {
|
||||
let req: ListRequest = super::parse_params(args)?;
|
||||
let sid = acp::SessionId::new(req.session_id);
|
||||
|
||||
let result = agent
|
||||
.list_hooks(&sid)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("session not found"));
|
||||
super::to_ext_response(result)
|
||||
}
|
||||
"x.ai/hooks/action" => {
|
||||
let req: kigi_hooks_plugins_types::HooksActionRequest = super::parse_params(args)?;
|
||||
let sid = acp::SessionId::new(req.session_id);
|
||||
|
||||
let result = agent
|
||||
.execute_hooks_action(&sid, req.action)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("session not found"));
|
||||
super::to_ext_response(result)
|
||||
}
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kigi_hooks::config::HookSpec;
|
||||
use kigi_hooks::event::HookEventName;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Minimal `HookSpec` for `hook_spec_to_info` tests (`handler_type` is unused;
|
||||
/// the DTO derives it from `url`).
|
||||
fn make_spec(
|
||||
command_raw: Option<&str>,
|
||||
command: Option<&str>,
|
||||
url_raw: Option<&str>,
|
||||
url: Option<&str>,
|
||||
) -> HookSpec {
|
||||
HookSpec {
|
||||
name: "test:pre_tool_use[0].hooks[0]".to_string(),
|
||||
event: HookEventName::PreToolUse,
|
||||
handler_type: "command".to_string(),
|
||||
configured_matcher: None,
|
||||
matcher: None,
|
||||
enabled: true,
|
||||
command: command.map(PathBuf::from),
|
||||
command_raw: command_raw.map(str::to_string),
|
||||
url: url.map(str::to_string),
|
||||
url_raw: url_raw.map(str::to_string),
|
||||
timeout_ms: 5000,
|
||||
source_dir: PathBuf::from("/tmp"),
|
||||
extra_env: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `*_raw` (pre-expansion) wins over the resolved value so secrets never reach
|
||||
/// the DTO; then the resolved value, else `None`. Same for `command` and `url`.
|
||||
#[test]
|
||||
fn hook_spec_to_info_display_precedence() {
|
||||
let command =
|
||||
|raw, resolved| hook_spec_to_info(&make_spec(raw, resolved, None, None)).command;
|
||||
assert_eq!(
|
||||
command(Some("${VAR}/x"), Some("/resolved/x")).as_deref(),
|
||||
Some("${VAR}/x")
|
||||
);
|
||||
assert_eq!(
|
||||
command(None, Some("/legacy/x")).as_deref(),
|
||||
Some("/legacy/x")
|
||||
);
|
||||
assert!(command(None, None).is_none());
|
||||
|
||||
let url = |raw, resolved| hook_spec_to_info(&make_spec(None, None, raw, resolved)).url;
|
||||
assert_eq!(
|
||||
url(
|
||||
Some("https://${HOST}/p?token=${TOKEN}"),
|
||||
Some("https://api/p?token=ghp_X")
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://${HOST}/p?token=${TOKEN}"),
|
||||
);
|
||||
assert_eq!(
|
||||
url(None, Some("https://h/c")).as_deref(),
|
||||
Some("https://h/c")
|
||||
);
|
||||
assert!(url(None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_client_hooks_parses_valid_groups() {
|
||||
let meta = serde_json::json!({
|
||||
"x.ai/hooks": {
|
||||
"PreToolUse": [
|
||||
{ "matcher": "run_terminal_command", "hookCallbackIds": ["cb_0"] },
|
||||
{ "matcher": null, "hookCallbackIds": ["cb_1"] },
|
||||
{ "matcher": "*", "hookCallbackIds": ["cb_2"] }
|
||||
],
|
||||
"post_tool_use": [{ "hookCallbackIds": ["cb_3"] }]
|
||||
}
|
||||
});
|
||||
let hooks = parse_client_hooks(meta.as_object());
|
||||
|
||||
let pre = &hooks[&HookEventName::PreToolUse];
|
||||
assert_eq!(pre.len(), 3);
|
||||
assert_eq!(pre[0].callback_ids, ["cb_0"]);
|
||||
let matcher = pre[0].matcher.as_ref().unwrap();
|
||||
assert!(matcher.is_match("run_terminal_command"));
|
||||
assert!(!matcher.is_match("read_file"));
|
||||
assert!(pre[1].matcher.is_none()); // null / "*" = match-all
|
||||
assert!(pre[2].matcher.is_none());
|
||||
assert!(hooks.contains_key(&HookEventName::PostToolUse)); // snake_case resolves
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_client_hooks_drops_invalid_and_absent() {
|
||||
assert!(parse_client_hooks(None).is_empty());
|
||||
assert!(
|
||||
parse_client_hooks(serde_json::json!({ "askUserQuestion": true }).as_object())
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let meta = serde_json::json!({
|
||||
"NotARealEvent": [{ "hookCallbackIds": ["x"] }],
|
||||
"x.ai/hooks": {
|
||||
"PreToolUse": [
|
||||
{ "matcher": "[invalid", "hookCallbackIds": ["bad_regex"] },
|
||||
{ "matcher": "run_terminal_command", "hookCallbackIds": [] },
|
||||
{ "matcher": "read_file", "hookCallbackIds": ["good"] }
|
||||
]
|
||||
}
|
||||
});
|
||||
let groups = &parse_client_hooks(meta.as_object())[&HookEventName::PreToolUse];
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].callback_ids, ["good"]);
|
||||
}
|
||||
|
||||
/// A group's `timeout` (seconds) parses to a `Duration`; absent or non-positive falls
|
||||
/// back to the default gate timeout (`None`).
|
||||
#[test]
|
||||
fn parse_client_hooks_reads_group_timeout() {
|
||||
let meta = serde_json::json!({
|
||||
"x.ai/hooks": {
|
||||
"PreToolUse": [
|
||||
{ "hookCallbackIds": ["a"], "timeout": 5.0 },
|
||||
{ "hookCallbackIds": ["b"], "timeout": 0 },
|
||||
{ "hookCallbackIds": ["c"] },
|
||||
{ "hookCallbackIds": ["d"], "timeout": 100000 }
|
||||
]
|
||||
}
|
||||
});
|
||||
let groups = &parse_client_hooks(meta.as_object())[&HookEventName::PreToolUse];
|
||||
assert_eq!(groups[0].timeout, Some(std::time::Duration::from_secs(5)));
|
||||
assert_eq!(groups[1].timeout, None); // non-positive -> default
|
||||
assert_eq!(groups[2].timeout, None); // absent -> default
|
||||
assert_eq!(groups[3].timeout, Some(std::time::Duration::from_secs(300))); // capped
|
||||
}
|
||||
|
||||
/// A registration under the `SubagentEnd` alias must land on the canonical
|
||||
/// `SubagentStop` key the agent fires.
|
||||
#[test]
|
||||
fn parse_client_hooks_canonicalizes_subagent_alias() {
|
||||
let meta = serde_json::json!({
|
||||
"x.ai/hooks": { "SubagentEnd": [{ "hookCallbackIds": ["cb"] }] }
|
||||
});
|
||||
let hooks = parse_client_hooks(meta.as_object());
|
||||
assert!(hooks.contains_key(&HookEventName::SubagentStop));
|
||||
assert!(!hooks.contains_key(&HookEventName::SubagentEnd));
|
||||
}
|
||||
|
||||
/// Reconnect refresh applies hooks only when the load meta carries `x.ai/hooks`:
|
||||
/// an absent key returns `None` (don't wipe `session/new` registrations); a present
|
||||
/// key returns `Some` (an empty object is an explicit clear).
|
||||
#[test]
|
||||
fn reconnect_client_hooks_only_when_key_present() {
|
||||
assert!(reconnect_client_hooks(None).is_none());
|
||||
assert!(reconnect_client_hooks(serde_json::json!({ "other": true }).as_object()).is_none());
|
||||
|
||||
let cleared = reconnect_client_hooks(serde_json::json!({ "x.ai/hooks": {} }).as_object());
|
||||
assert!(cleared.is_some_and(|h| h.is_empty()));
|
||||
|
||||
let set = reconnect_client_hooks(
|
||||
serde_json::json!({
|
||||
"x.ai/hooks": { "PreToolUse": [{ "hookCallbackIds": ["cb"] }] }
|
||||
})
|
||||
.as_object(),
|
||||
);
|
||||
assert!(set.is_some_and(|h| h.contains_key(&HookEventName::PreToolUse)));
|
||||
}
|
||||
|
||||
/// `deny` parses to `Deny` (+ optional message); everything else fails open:
|
||||
/// unknown values to `Other`, missing/empty/default to `Continue`.
|
||||
#[test]
|
||||
fn client_hook_response_deserialization() {
|
||||
let deny: ClientHookResponse =
|
||||
serde_json::from_str(r#"{"decision":"deny","systemMessage":"blocked"}"#).unwrap();
|
||||
assert_eq!(deny.decision, ClientHookDecision::Deny);
|
||||
assert_eq!(deny.system_message.as_deref(), Some("blocked"));
|
||||
|
||||
let unknown: ClientHookResponse =
|
||||
serde_json::from_str(r#"{"decision":"maybe_later"}"#).unwrap();
|
||||
assert_eq!(unknown.decision, ClientHookDecision::Other);
|
||||
|
||||
let empty: ClientHookResponse = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(empty.decision, ClientHookDecision::Continue);
|
||||
assert!(empty.system_message.is_none());
|
||||
assert_eq!(
|
||||
ClientHookResponse::default().decision,
|
||||
ClientHookDecision::Continue
|
||||
);
|
||||
}
|
||||
|
||||
/// The callback id sits beside the flattened envelope (camelCase keys,
|
||||
/// `hookEventName` snake_case); the one shape sent for both run and event.
|
||||
#[test]
|
||||
fn client_hook_dispatch_serializes_envelope() {
|
||||
use kigi_hooks::event::{HookEventEnvelope, HookPayload};
|
||||
|
||||
let envelope = HookEventEnvelope {
|
||||
hook_event_name: HookEventName::PreToolUse,
|
||||
session_id: "s1".into(),
|
||||
cwd: "/work".into(),
|
||||
workspace_root: "/work".into(),
|
||||
timestamp: "t".into(),
|
||||
transcript_path: None,
|
||||
client_identifier: None,
|
||||
prompt_id: None,
|
||||
payload: HookPayload::PreToolUse {
|
||||
tool_name: "run_terminal_command".into(),
|
||||
tool_use_id: "call_1".into(),
|
||||
tool_input: serde_json::json!({ "command": "ls" }),
|
||||
tool_input_truncated: true,
|
||||
permission_mode: None,
|
||||
subagent_type: None,
|
||||
},
|
||||
};
|
||||
let dispatch = ClientHookDispatch {
|
||||
hook_callback_id: "cb_0",
|
||||
envelope: &envelope,
|
||||
};
|
||||
let value = serde_json::to_value(&dispatch).unwrap();
|
||||
assert_eq!(value["hookCallbackId"], "cb_0");
|
||||
assert_eq!(value["hookEventName"], "pre_tool_use");
|
||||
assert_eq!(value["sessionId"], "s1");
|
||||
assert_eq!(value["cwd"], "/work");
|
||||
assert_eq!(value["toolUseId"], "call_1");
|
||||
assert_eq!(value["toolName"], "run_terminal_command");
|
||||
assert_eq!(value["toolInput"]["command"], "ls");
|
||||
assert_eq!(value["toolInputTruncated"], true);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
//! `x.ai/interject` extension handler.
|
||||
//!
|
||||
//! Queues a mid-turn interjection into the active session's pending
|
||||
//! interjection buffer. The session actor drains it at the next safe
|
||||
//! point in `process_conversation_turn`.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
use super::{ExtResult, parse_params};
|
||||
use crate::agent::MvpAgent;
|
||||
use crate::session::SessionCommand;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InterjectRequest {
|
||||
session_id: String,
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
interjection_id: Option<String>,
|
||||
/// Optional structured blocks (text + images) from image-capable
|
||||
/// clients; absent = legacy text-only wire shape (empty after default).
|
||||
#[serde(default)]
|
||||
content: Vec<acp::ContentBlock>,
|
||||
}
|
||||
|
||||
/// Split a `content` array into the model-safe text and the image blocks.
|
||||
///
|
||||
/// The Text block (when present and non-empty) is the client's REWRITTEN
|
||||
/// text — failed-orphan placeholders stripped, `[Image #N: <path>]` paths
|
||||
/// dropped — and must win over the raw `text` param, which exists for
|
||||
/// legacy clients and display. Returns `(text_override, images)`.
|
||||
fn split_content(content: Vec<acp::ContentBlock>) -> (Option<String>, Vec<acp::ImageContent>) {
|
||||
let text_override = content.iter().find_map(|block| match block {
|
||||
acp::ContentBlock::Text(tb) if !tb.text.trim().is_empty() => Some(tb.text.clone()),
|
||||
_ => None,
|
||||
});
|
||||
(text_override, crate::session::image_blocks(content))
|
||||
}
|
||||
|
||||
/// Handle `x.ai/interject` — queue a mid-turn user interjection.
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
let req: InterjectRequest = parse_params(args)?;
|
||||
let sid: acp::SessionId = req.session_id.clone().into();
|
||||
// Load-race-tolerant: an interjection racing a reconnect-replayed
|
||||
// `session/load` (leader restart) waits for the load instead of failing.
|
||||
let session_handle = agent.session_handle_waiting_for_load(&sid).await;
|
||||
let Some(session) = session_handle else {
|
||||
return Err(
|
||||
acp::Error::invalid_params().data(format!("session not found: {}", req.session_id))
|
||||
);
|
||||
};
|
||||
|
||||
let (text_override, images) = split_content(req.content);
|
||||
let _ = session.cmd_tx.send(SessionCommand::Interject {
|
||||
text: text_override.unwrap_or(req.text),
|
||||
id: req.interjection_id,
|
||||
images,
|
||||
});
|
||||
|
||||
super::to_ext_response(Ok(serde_json::json!({
|
||||
"status": "queued",
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Legacy wire shape (no `content`) parses byte-identically: text-only,
|
||||
/// zero images, no text override.
|
||||
#[test]
|
||||
fn parse_without_content_is_legacy_text_only() {
|
||||
let req: InterjectRequest = serde_json::from_value(serde_json::json!({
|
||||
"sessionId": "s1",
|
||||
"text": "steer left",
|
||||
"interjectionId": "i1",
|
||||
}))
|
||||
.expect("legacy params must parse");
|
||||
assert_eq!(req.text, "steer left");
|
||||
assert_eq!(req.interjection_id.as_deref(), Some("i1"));
|
||||
let (text_override, images) = split_content(req.content);
|
||||
assert_eq!(text_override, None);
|
||||
assert!(images.is_empty());
|
||||
}
|
||||
|
||||
/// `content` with text + image blocks parses; the images are extracted
|
||||
/// and the Text block (the client's rewritten, path-stripped text)
|
||||
/// overrides the raw `text` param.
|
||||
#[test]
|
||||
fn parse_with_content_extracts_images_and_prefers_block_text() {
|
||||
let req: InterjectRequest = serde_json::from_value(serde_json::json!({
|
||||
"sessionId": "s1",
|
||||
"text": "look at [Image #1: /tmp/x.png]",
|
||||
"content": [
|
||||
{ "type": "text", "text": "look at [Image #1]" },
|
||||
{ "type": "image", "data": "aGVsbG8=", "mimeType": "image/png" },
|
||||
],
|
||||
}))
|
||||
.expect("content params must parse");
|
||||
let (text_override, images) = split_content(req.content);
|
||||
assert_eq!(
|
||||
text_override.as_deref(),
|
||||
Some("look at [Image #1]"),
|
||||
"rewritten block text must win over the raw text param"
|
||||
);
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!(images[0].mime_type, "image/png");
|
||||
assert_eq!(images[0].data, "aGVsbG8=");
|
||||
}
|
||||
|
||||
/// Garbage `content` fails the whole parse (strict, like other params)
|
||||
/// instead of silently dropping attachments.
|
||||
#[test]
|
||||
fn parse_with_garbage_content_is_an_error() {
|
||||
let result: Result<InterjectRequest, _> = serde_json::from_value(serde_json::json!({
|
||||
"sessionId": "s1",
|
||||
"text": "steer",
|
||||
"content": "not an array",
|
||||
}));
|
||||
assert!(result.is_err(), "garbage content must be rejected");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Jujutsu extension handlers — delegates to [`kigi_workspace::session::jj`].
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
use super::{Empty, ExtResult, to_ext_response, to_ext_response_partial};
|
||||
use kigi_workspace::session::git::{CommitData, StageData};
|
||||
use kigi_workspace::session::jj;
|
||||
|
||||
/// Handle a `x.ai/git/*` method for a jj-colocated repo.
|
||||
///
|
||||
/// Returns `Some(result)` if handled, `None` to fall through to git.
|
||||
pub async fn try_handle(
|
||||
method: &str,
|
||||
git_root: &std::path::Path,
|
||||
raw_params: &serde_json::value::RawValue,
|
||||
) -> Option<ExtResult> {
|
||||
match method {
|
||||
"x.ai/git/status" => Some(to_ext_response(jj::status(git_root).await)),
|
||||
"x.ai/git/info" => Some(to_ext_response(jj::info(git_root).await)),
|
||||
// git HEAD points at `@-` in a colocated repo; route to jj so we report
|
||||
// the working-copy commit (`@`), consistent with `status`/`info`.
|
||||
"x.ai/git/current_commit" => Some(to_ext_response(jj::current_commit(git_root).await)),
|
||||
"x.ai/git/branches" => Some(to_ext_response(jj::list_bookmarks(git_root).await)),
|
||||
|
||||
// jj has no staging area — stage/unstage are no-ops
|
||||
"x.ai/git/stage" => Some(to_ext_response(Ok(StageData { paths: Vec::new() }))),
|
||||
"x.ai/git/stage/content" | "x.ai/git/unstage" => Some(to_ext_response(Ok(Empty {}))),
|
||||
|
||||
"x.ai/git/discard" => {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Req {
|
||||
#[serde(default)]
|
||||
paths: Option<Vec<String>>,
|
||||
}
|
||||
let req: Req = serde_json::from_str(raw_params.get()).ok()?;
|
||||
Some(to_ext_response(
|
||||
jj::discard(git_root, req.paths).await.map(|_| Empty {}),
|
||||
))
|
||||
}
|
||||
|
||||
"x.ai/git/commit" => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Req {
|
||||
message: String,
|
||||
}
|
||||
let req: Req = serde_json::from_str(raw_params.get()).ok()?;
|
||||
let result = jj::commit(git_root, &req.message).await;
|
||||
Some(match result {
|
||||
Ok(r) => to_ext_response_partial(Ok(r.data), r.warning),
|
||||
Err(e) => to_ext_response(Err::<CommitData, _>(e)),
|
||||
})
|
||||
}
|
||||
|
||||
// Operations that don't apply to jj
|
||||
"x.ai/git/checkout" => Some(Err(acp::Error::invalid_params()
|
||||
.data("checkout is not supported in jj repos; use `jj new` or `jj edit`"))),
|
||||
"x.ai/git/stash" => Some(Err(acp::Error::invalid_params()
|
||||
.data("stash is not supported in jj repos; changes are always committed"))),
|
||||
|
||||
// Everything else (diffs, files, serialize_changes) falls through to git
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
//! `x.ai/memory/flush`, `x.ai/memory/rewrite`, and `x.ai/compact_conversation`
|
||||
//! extension handlers.
|
||||
//!
|
||||
//! - `compact_conversation`: trigger an on-demand compaction for a session.
|
||||
//! - `memory/flush`: trigger an on-demand memory flush for a session.
|
||||
//! - `memory/rewrite`: rewrite a raw memory note into structured markdown via
|
||||
//! a one-shot LLM call.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::{Empty, ExtResult, parse_params, to_ext_response, to_raw_response};
|
||||
use crate::agent::MvpAgent;
|
||||
use crate::session::{CompactConversationRequest, CompactConversationResponse, SessionCommand};
|
||||
|
||||
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
m if m.starts_with("x.ai/compact_conversation") => handle_compact(agent, args).await,
|
||||
"x.ai/memory/flush" => handle_flush(agent, args).await,
|
||||
"x.ai/memory/rewrite" => handle_rewrite(agent, args).await,
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_compact(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
let req: CompactConversationRequest = parse_params(args)?;
|
||||
// send over the compact query here properly
|
||||
let session_handle = {
|
||||
let sessions = agent.sessions.borrow();
|
||||
sessions.get(&req.session_id.into()).cloned()
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if let Some(session) = session_handle {
|
||||
let _ = session.cmd_tx.send(SessionCommand::CompactSession {
|
||||
user_context: req.user_context,
|
||||
respond_to: tx,
|
||||
});
|
||||
}
|
||||
rx.await
|
||||
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("Internal error: {:?}", e)))?;
|
||||
to_raw_response(&CompactConversationResponse {})
|
||||
}
|
||||
|
||||
async fn handle_flush(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
#[derive(Deserialize)]
|
||||
struct MemoryFlushRequest {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
let req: MemoryFlushRequest = parse_params(args)?;
|
||||
let not_found_err = format!("session not found: {}", req.session_id);
|
||||
let session_handle = {
|
||||
let sessions = agent.sessions.borrow();
|
||||
sessions.get(&req.session_id.into()).cloned()
|
||||
};
|
||||
let Some(session) = session_handle else {
|
||||
return Err(acp::Error::invalid_params().data(not_found_err));
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = session
|
||||
.cmd_tx
|
||||
.send(SessionCommand::FlushMemory { respond_to: tx });
|
||||
rx.await
|
||||
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("{:?}", e)))?;
|
||||
to_ext_response(Ok(Empty {}))
|
||||
}
|
||||
|
||||
async fn handle_rewrite(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RewriteRequest {
|
||||
session_id: String,
|
||||
raw_text: String,
|
||||
context_summary: String,
|
||||
}
|
||||
|
||||
let req: RewriteRequest = parse_params(args)?;
|
||||
let not_found_err = format!("session not found: {}", req.session_id);
|
||||
let session_handle = {
|
||||
let sessions = agent.sessions.borrow();
|
||||
sessions.get(&req.session_id.into()).cloned()
|
||||
};
|
||||
let Some(session) = session_handle else {
|
||||
return Err(acp::Error::invalid_params().data(not_found_err));
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = session.cmd_tx.send(SessionCommand::RewriteMemoryNote {
|
||||
raw_text: req.raw_text,
|
||||
context_summary: req.context_summary,
|
||||
respond_to: tx,
|
||||
});
|
||||
let rewritten = rx
|
||||
.await
|
||||
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?
|
||||
.map_err(|e| acp::Error::internal_error().data(e))?;
|
||||
to_raw_response(&serde_json::json!({ "rewritten": rewritten }))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
pub mod auth;
|
||||
pub(crate) mod auth_gate;
|
||||
pub mod billing;
|
||||
pub mod bundle;
|
||||
pub mod chat_conversation_history;
|
||||
pub mod code_nav;
|
||||
pub mod debug;
|
||||
pub mod feedback;
|
||||
pub mod fs;
|
||||
pub mod git;
|
||||
pub mod hooks;
|
||||
pub mod hunk_tracker;
|
||||
pub mod interject;
|
||||
pub mod jj;
|
||||
pub mod mcp;
|
||||
pub mod memory;
|
||||
pub mod notification;
|
||||
pub mod plugins;
|
||||
pub mod pr;
|
||||
pub mod privacy;
|
||||
pub mod prompt_history;
|
||||
pub mod prompt_meta;
|
||||
pub mod recap;
|
||||
pub mod repair;
|
||||
pub mod rewind;
|
||||
pub mod rollout;
|
||||
pub mod routing;
|
||||
pub mod search;
|
||||
pub mod session_admin;
|
||||
pub mod session_search;
|
||||
pub mod session_updates;
|
||||
pub mod share;
|
||||
pub mod skills;
|
||||
pub mod suggest;
|
||||
pub mod task;
|
||||
pub mod terminal;
|
||||
pub mod worktree;
|
||||
use crate::session::ExtMethodResult;
|
||||
use agent_client_protocol as acp;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::sync::Arc;
|
||||
pub type ExtResult = Result<acp::ExtResponse, acp::Error>;
|
||||
pub fn parse_params<T: DeserializeOwned>(args: &acp::ExtRequest) -> Result<T, acp::Error> {
|
||||
parse_params_str(args.params.get())
|
||||
}
|
||||
/// Deserialize ACP params from their raw JSON string, mapping a parse failure
|
||||
/// to `invalid_params`. Used by [`parse_params`] and the bridge `encode` hooks,
|
||||
/// which hold the params `RawValue` directly.
|
||||
pub fn parse_params_str<T: DeserializeOwned>(raw: &str) -> Result<T, acp::Error> {
|
||||
serde_json::from_str(raw)
|
||||
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {}", e)))
|
||||
}
|
||||
/// Extract the session ID from an extension request's params.
|
||||
pub fn parse_session_id(args: &acp::ExtRequest) -> Option<acp::SessionId> {
|
||||
let v: serde_json::Value = serde_json::from_str(args.params.get()).ok()?;
|
||||
let sid = v.get("sessionId")?.as_str()?;
|
||||
Some(acp::SessionId::new(sid))
|
||||
}
|
||||
pub fn to_ext_response<T: Serialize>(result: anyhow::Result<T>) -> ExtResult {
|
||||
ExtMethodResult::from_result(result)
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
/// Wrap a serializable value as an `ExtResponse` without the `ExtMethodResult` envelope.
|
||||
pub fn to_raw_response<T: Serialize>(v: &T) -> ExtResult {
|
||||
serde_json::value::to_raw_value(v)
|
||||
.map(|raw| acp::ExtResponse::new(Arc::from(raw)))
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
/// Convert a result with optional warning to an ExtResponse.
|
||||
pub fn to_ext_response_partial<T: Serialize>(
|
||||
result: anyhow::Result<T>,
|
||||
warning: Option<String>,
|
||||
) -> ExtResult {
|
||||
let ext_result = match (result, warning) {
|
||||
(Ok(data), Some(warn)) => ExtMethodResult::partial(data, warn),
|
||||
(Ok(data), None) => ExtMethodResult::success(data),
|
||||
(Err(e), _) => ExtMethodResult::failure(e),
|
||||
};
|
||||
ext_result
|
||||
.to_ext_response()
|
||||
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
|
||||
}
|
||||
/// Empty response for operations that return no data.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Empty {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user