Files
Kigi-CLI/crates/codegen/kigi-shell/src/extensions/share.rs
T
ZacharyZhang-NY 021b82443d M1/F1: Kimi Code OAuth device-code flow
Replace the xAI OAuth stack with the Kimi device authorization grant:
- kimi_oauth.rs wire layer (device_authorization + token poll + refresh
  against kigi_env::oauth_host(); client_id per PRD; retryable statuses
  429/5xx with backoff; expired_token restarts authorization)
- X-Msh-Device-{Name,Model,Id} headers; device_id minted uuid4-hex at
  ~/.kigi/device_id (0600)
- Storage: system keyring service `kigi`, entry `oauth/kimi-code`
  (macOS/Windows native backends), atomic-file fallback under ~/.kigi;
  official client's keyring/~/.kimi never touched
- Refresh manager: 60s tick, threshold max(300, expires_in*0.5),
  401-tombstone keyed by rejected refresh token with 300s cooldown and
  rotation auto-clear, cross-process lock with sibling-adoption
  triple-check, sleep/wake forced refresh
- Deleted xAI machinery: enterprise OIDC (PKCE/JWKS/teams), devbox login,
  external auth provider, JWT tier gating + subscription paywall stack,
  X-XAI-Token-Auth marker headers, ZDR gates, /user enrichment
- kigi login / TUI /login both drive the device flow; login-host display
  now derives from kigi_env::oauth_host()
- 264 auth unit/wiremock tests; live contract probe of
  auth.kimi.com/api/oauth/device_authorization matches the wire shapes

Gates: check/clippy --all-targets clean, fmt, deny ok, kigi-shell lib
5131 tests green.
2026-07-17 07:37:29 -04:00

205 lines
7.1 KiB
Rust

//! `x.ai/share_session` extension handler.
//!
//! Loads a local session, exports it, and asks the backend for a public
//! share URL.
use agent_client_protocol as acp;
use super::{ExtResult, parse_params, to_raw_response};
use crate::agent::MvpAgent;
use crate::remote::client::BackendClient;
use crate::session::export::ExportedSession;
use crate::session::info::Info as SessionInfo;
use crate::session::persistence::list_summaries;
use crate::session::share::{ShareSessionRequest, ShareSessionResponse};
#[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/share_session" => {
tracing::info!("handling share session request");
handle_share_session(agent, args).await
}
_ => Err(acp::Error::method_not_found()),
}
}
async fn handle_share_session(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let request: ShareSessionRequest = parse_params(args)?;
// Get auth - required for sharing.
let auth = require_xai_auth_for_share(&agent.auth_manager)?;
// Remote settings / feature-flag gate: sharing_enabled defaults to false
// and is only enabled for eligible accounts.
let sharing_enabled = agent
.cfg
.borrow()
.remote_settings
.as_ref()
.and_then(|rs| rs.sharing_enabled)
.unwrap_or(false);
if !sharing_enabled {
return Err(
acp::Error::invalid_params().data("Session sharing is not available for your account.")
);
}
// Find session info by searching through summaries
let summaries = list_summaries(None).await.map_err(|e| {
acp::Error::internal_error().data(format!("Failed to list sessions: {}", e))
})?;
let summary = summaries
.iter()
.find(|s| s.info.id.0.as_ref() == request.session_id.as_str())
.ok_or_else(|| acp::Error::resource_not_found(Some("Session not found".into())))?;
let info = SessionInfo {
id: acp::SessionId::new(request.session_id.clone()),
cwd: summary.info.cwd.clone(),
};
// Load and export session
let exported = ExportedSession::from_local_session(&info)
.await
.map_err(|e| acp::Error::internal_error().data(format!("Failed to load session: {}", e)))?;
// Check for empty session
if exported.messages.is_empty() {
return Err(acp::Error::invalid_params().data("No messages to share yet"));
}
// Upload to backend and get share URL.
let client = BackendClient::new().with_auth_manager(agent.auth_manager.clone());
let agent_id = crate::util::agent_id::agent_id();
let share_url = client
.share_session(&exported, &agent_id)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to share session with backend");
acp::Error::internal_error().data(format!("Failed to share session: {}", e))
})?;
let response = ShareSessionResponse { share_url };
to_raw_response(&response)
}
fn require_xai_auth_for_share(
auth_manager: &crate::auth::AuthManager,
) -> Result<crate::auth::KimiAuth, acp::Error> {
super::auth_gate::require_xai_auth(
auth_manager,
"Authentication required to share session",
"Share session is disabled. Run `grok login` to authenticate.",
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::KimiCodeConfig;
use crate::auth::{AuthMode, KimiAuth};
use chrono::{Duration, Utc};
use std::sync::Arc;
use tempfile::tempdir;
fn make_auth_manager_with_token_expiring_in(
ttl: Duration,
) -> (Arc<crate::auth::AuthManager>, tempfile::TempDir) {
let dir = tempdir().expect("tempdir for share auth test");
let mgr = Arc::new(crate::auth::AuthManager::new(
dir.path(),
KimiCodeConfig::default(),
));
let expires_at = Utc::now() + ttl;
// We must explicitly set oidc_issuer to a first-party xAI issuer.
// Only OIDC tokens against https://auth.x.ai (or the local-dev equivalent)
// return true from is_xai_auth(). This is required for the share tests to
// exercise the happy path through require_xai_auth_for_share.
let auth = KimiAuth {
auth_mode: AuthMode::OAuth,
key: "test-key".into(),
expires_at: Some(expires_at),
create_time: Utc::now() - Duration::hours(1),
..Default::default()
};
mgr.hot_swap(auth);
(mgr, dir)
}
#[test]
fn share_works_outside_the_5m_early_invalidation_window() {
let (mgr, _dir) = make_auth_manager_with_token_expiring_in(Duration::minutes(10));
assert!(mgr.current().is_some());
assert!(require_xai_auth_for_share(&mgr).is_ok());
}
#[test]
fn share_succeeds_inside_the_5m_early_invalidation_window() {
let (mgr, _dir) = make_auth_manager_with_token_expiring_in(Duration::seconds(1));
// This is exactly the state that triggered the user bug:
assert!(
mgr.current().is_none(),
"current() drops the token inside the buffer"
);
assert!(mgr.expired_auth().is_some());
// Now that we use current_or_expired(), this passes.
let res = require_xai_auth_for_share(&mgr);
assert!(
res.is_ok(),
"require_xai_auth_for_share must succeed for a still-valid buffered xAI token"
);
}
#[test]
fn share_fails_with_no_auth_at_all() {
let dir = tempdir().expect("tempdir");
let mgr = Arc::new(crate::auth::AuthManager::new(
dir.path(),
KimiCodeConfig::default(),
));
assert!(require_xai_auth_for_share(&mgr).is_err());
}
#[test]
fn share_rejects_non_xai_auth_with_actionable_grok_login_message() {
let dir = tempdir().expect("tempdir");
let mgr = Arc::new(crate::auth::AuthManager::new(
dir.path(),
KimiCodeConfig::default(),
));
// API key is the simplest non-xAI credential (External and enterprise OIDC
// are also rejected the same way).
let non_xai = KimiAuth {
auth_mode: AuthMode::ApiKey,
key: "xai-test-key".into(),
create_time: Utc::now(),
..Default::default()
};
mgr.hot_swap(non_xai);
let err = require_xai_auth_for_share(&mgr)
.expect_err("non-xAI accounts (API key, External, enterprise IdP) must be rejected");
// This is the key assertion the review asked for: we must test the *exact*
// actionable data string for the non-xAI path (distinct from the generic
// "Authentication required to share session" path).
let serialized =
serde_json::to_value(&err).expect("acp::Error serializes to JSON-RPC shape");
let data = serialized
.get("data")
.and_then(|v| v.as_str())
.expect("auth_required error carries a data string");
assert_eq!(
data,
"Share session is disabled. Run `grok login` to authenticate."
);
}
}