Files
Kigi-CLI/crates/codegen/kigi-shell/src/auth/device.rs
T
ZacharyZhang-NY ea0ce9d15f F3: Kimi inference pipeline + full grok cloud-surface excision
Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
  dialect (thinking-field mapping, model_id stripping, empty-content
  tool-call message fix, stream_options.include_usage), with kimi-cli
  source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
  refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
  x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)

Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
  share command, credit bar: deleted (single local session lane;
  paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
  gate/tier/team/ZDR fields, app/subscription.rs watch loop,
  dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
  chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
  SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
  RPC; Kigi is zero-egress — nothing to share or retain remotely)

Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
  (https://www.kimi.com/code/authorize_device?user_code=..., verified
  against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
  URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
  wiremock fixture URLs into the real browser (root cause of the
  "garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
  kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
2026-07-17 16:05:51 -04:00

300 lines
11 KiB
Rust

//! Device identity headers for the Kimi Code OAuth endpoints.
//!
//! Every OAuth call (device authorization, token poll, refresh) carries three
//! headers identifying this installation (PRD F1):
//!
//! - `X-Msh-Device-Name` — the local hostname
//! - `X-Msh-Device-Model` — an honest local OS/arch string (e.g.
//! "macOS 15.5 arm64"), ported from kimi-cli's `_device_model()`
//! - `X-Msh-Device-Id` — a uuid4 hex persisted at `~/.kigi/device_id`
//! (owner-only), created on first use
//!
//! All values are ASCII-sanitized (ported from kimi-cli's
//! `_ascii_header_value`) since HTTP header values must be ASCII.
use std::path::PathBuf;
use std::sync::OnceLock;
use anyhow::Context as _;
/// Sanitize a header value to ASCII: non-ASCII bytes are dropped; an empty
/// result falls back to `"unknown"`. Port of kimi-cli `_ascii_header_value`.
pub(crate) fn ascii_header_value(value: &str) -> String {
let sanitized: String = value.chars().filter(char::is_ascii).collect();
let trimmed = sanitized.trim();
if trimmed.is_empty() {
"unknown".to_owned()
} else {
trimmed.to_owned()
}
}
/// The three device-identity headers sent on every OAuth call and, via
/// `agent::config::inject_url_derived_headers`, on every first-party
/// inference request (mirroring kimi-cli src/kimi_cli/llm.py:317-323).
///
/// Errors when the persistent device id cannot be created (e.g. read-only
/// `~/.kigi`): the OAuth endpoints require `X-Msh-Device-Id`, so login cannot
/// proceed without it. Inference callers treat the error as skip-with-warning.
pub fn device_headers() -> anyhow::Result<[(&'static str, String); 3]> {
Ok([
("X-Msh-Device-Name", ascii_header_value(&device_name())),
("X-Msh-Device-Model", ascii_header_value(device_model())),
("X-Msh-Device-Id", ascii_header_value(&device_id()?)),
])
}
/// Local hostname (kimi-cli: `platform.node() or socket.gethostname()`).
fn device_name() -> String {
#[cfg(unix)]
{
let mut buf = [0u8; 256];
// SAFETY: buf is a valid writable buffer of the passed length.
let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
if rc == 0 {
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let name = String::from_utf8_lossy(&buf[..end]).into_owned();
if !name.trim().is_empty() {
return name;
}
}
"unknown".to_owned()
}
#[cfg(windows)]
{
std::env::var("COMPUTERNAME").unwrap_or_else(|_| "unknown".to_owned())
}
#[cfg(not(any(unix, windows)))]
{
"unknown".to_owned()
}
}
/// Honest local device-model string, computed once per process. Port of
/// kimi-cli `_device_model()`:
/// - macOS → `macOS {product_version} {arch}` (e.g. "macOS 15.5 arm64")
/// - Windows → `Windows {10|11} {arch}` (build ≥ 22000 reports 11)
/// - other → `{sysname} {kernel_release} {machine}`
pub(crate) fn device_model() -> &'static str {
static MODEL: OnceLock<String> = OnceLock::new();
MODEL.get_or_init(compute_device_model)
}
fn compute_device_model() -> String {
#[cfg(target_os = "macos")]
{
// Match Python's platform.machine() spelling on macOS.
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
other => other,
};
match macos_product_version() {
Some(version) => format!("macOS {version} {arch}"),
None => format!("macOS {arch}"),
}
}
#[cfg(windows)]
{
let arch = std::env::consts::ARCH;
match windows_release() {
Some(release) => format!("Windows {release} {arch}"),
None => format!("Windows {arch}"),
}
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let (sysname, release, machine) = uname_fields();
match (release, machine) {
(Some(r), Some(m)) => format!("{sysname} {r} {m}"),
(Some(r), None) => format!("{sysname} {r}"),
(None, Some(m)) => format!("{sysname} {m}"),
(None, None) => sysname,
}
}
}
/// macOS product version (e.g. "15.5") from the SystemVersion plist — the
/// same source Python's `platform.mac_ver()` reads.
#[cfg(target_os = "macos")]
fn macos_product_version() -> Option<String> {
let plist = std::fs::read_to_string("/System/Library/CoreServices/SystemVersion.plist").ok()?;
plist_string_value(&plist, "ProductVersion")
}
/// Extract `<key>{key}</key><string>value</string>` from a plist XML body.
#[cfg(target_os = "macos")]
fn plist_string_value(plist: &str, key: &str) -> Option<String> {
let key_tag = format!("<key>{key}</key>");
let after_key = &plist[plist.find(&key_tag)? + key_tag.len()..];
let start = after_key.find("<string>")? + "<string>".len();
let end = after_key.find("</string>")?;
(start <= end).then(|| after_key[start..end].trim().to_owned())
}
/// Windows major release ("10" or "11"), from the build number reported by
/// `cmd /c ver` (kimi-cli: `sys.getwindowsversion().build >= 22000` → 11).
#[cfg(windows)]
fn windows_release() -> Option<String> {
let output = std::process::Command::new("cmd")
.args(["/c", "ver"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
// "Microsoft Windows [Version 10.0.22631.3155]"
let version = text.split("Version").nth(1)?.trim();
let mut parts = version.trim_end_matches(']').split('.');
let major = parts.next()?.trim().to_owned();
let _minor = parts.next()?;
let build: u32 = parts.next()?.trim().parse().ok()?;
if major == "10" && build >= 22000 {
Some("11".to_owned())
} else {
Some(major)
}
}
/// `uname(2)` sysname / release / machine for Linux and other Unix.
#[cfg(all(unix, not(target_os = "macos")))]
fn uname_fields() -> (String, Option<String>, Option<String>) {
// SAFETY: utsname is a plain-old-data struct; uname fills it in.
let mut uts: libc::utsname = unsafe { std::mem::zeroed() };
if unsafe { libc::uname(&mut uts) } != 0 {
return (std::env::consts::OS.to_owned(), None, None);
}
fn field(raw: &[libc::c_char]) -> Option<String> {
let bytes: Vec<u8> = raw
.iter()
.take_while(|&&c| c != 0)
.map(|&c| c as u8)
.collect();
let s = String::from_utf8_lossy(&bytes).trim().to_owned();
(!s.is_empty()).then_some(s)
}
(
field(&uts.sysname).unwrap_or_else(|| std::env::consts::OS.to_owned()),
field(&uts.release),
field(&uts.machine),
)
}
#[cfg(not(unix))]
#[cfg(not(windows))]
fn uname_fields() -> (String, Option<String>, Option<String>) {
(std::env::consts::OS.to_owned(), None, None)
}
/// Path of the persistent device id: `{kigi_home}/device_id`.
fn device_id_path() -> PathBuf {
kigi_config::kigi_home().join("device_id")
}
/// Persistent uuid4-hex device id, created (owner-only, 0o600) on first use
/// and cached for the process lifetime.
pub(crate) fn device_id() -> anyhow::Result<String> {
static DEVICE_ID: OnceLock<String> = OnceLock::new();
if let Some(id) = DEVICE_ID.get() {
return Ok(id.clone());
}
let id = load_or_create_device_id(&device_id_path())?;
Ok(DEVICE_ID.get_or_init(|| id).clone())
}
/// Read `path`, or mint a uuid4 hex and persist it owner-only.
fn load_or_create_device_id(path: &std::path::Path) -> anyhow::Result<String> {
if let Ok(existing) = std::fs::read_to_string(path) {
let trimmed = existing.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_owned());
}
}
let id = uuid::Uuid::new_v4().simple().to_string();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {} for device_id", parent.display()))?;
}
std::fs::write(path, &id)
.with_context(|| format!("writing device id to {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod 600 {}", path.display()))?;
}
tracing::info!(path = %path.display(), "auth: created persistent device id");
Ok(id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_header_value_passes_ascii_through() {
assert_eq!(ascii_header_value("macOS 15.5 arm64"), "macOS 15.5 arm64");
assert_eq!(ascii_header_value(" padded "), "padded");
}
#[test]
fn ascii_header_value_strips_non_ascii() {
assert_eq!(ascii_header_value("café-host"), "caf-host");
assert_eq!(ascii_header_value("机器"), "unknown");
assert_eq!(ascii_header_value(" "), "unknown");
}
#[test]
fn device_model_is_nonempty_ascii() {
let model = device_model();
assert!(!model.is_empty());
assert!(model.is_ascii(), "device model must be ASCII: {model:?}");
// The honest local OS name must lead the string.
#[cfg(target_os = "macos")]
assert!(model.starts_with("macOS "), "got {model:?}");
#[cfg(windows)]
assert!(model.starts_with("Windows"), "got {model:?}");
}
#[test]
fn load_or_create_device_id_roundtrips_and_is_owner_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("device_id");
let created = load_or_create_device_id(&path).unwrap();
assert_eq!(created.len(), 32, "uuid4 hex is 32 chars: {created:?}");
assert!(created.chars().all(|c| c.is_ascii_hexdigit()));
// Second call reads the same id back.
let reread = load_or_create_device_id(&path).unwrap();
assert_eq!(created, reread);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "device_id must be owner-only");
}
}
#[test]
fn load_or_create_device_id_ignores_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("device_id");
std::fs::write(&path, " \n").unwrap();
let created = load_or_create_device_id(&path).unwrap();
assert_eq!(created.len(), 32);
}
#[cfg(target_os = "macos")]
#[test]
fn plist_string_value_extracts_product_version() {
let plist = r#"<?xml version="1.0"?>
<dict>
<key>ProductBuildVersion</key>
<string>24F74</string>
<key>ProductVersion</key>
<string>15.5</string>
</dict>"#;
assert_eq!(
plist_string_value(plist, "ProductVersion").as_deref(),
Some("15.5")
);
assert_eq!(plist_string_value(plist, "Missing"), None);
}
}