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.
This commit is contained in:
@@ -12,8 +12,8 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
MANAGED, REQUIREMENTS_FAIL_CLOSED, forged_team_body, install_test_key, reset, signed_team_body,
|
||||
spawn_mock, team_identity, test_home, write_config, write_team_auth,
|
||||
MANAGED, REQUIREMENTS_FAIL_CLOSED, dk_identity, forged_dk_body, install_test_key, reset,
|
||||
signed_dk_body, spawn_mock, test_home, write_dk_config,
|
||||
};
|
||||
use kigi_config::signed_policy;
|
||||
use serial_test::serial;
|
||||
@@ -27,20 +27,19 @@ async fn rejected_signature_persists_nothing_and_records_no_marker() {
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
// Prior trusted state: team-b's files + marker (as if synced earlier).
|
||||
// Prior trusted state: an earlier principal's files + marker.
|
||||
std::fs::write(home.join("managed_config.toml"), "[cli]\nprior = true\n").unwrap();
|
||||
std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap();
|
||||
kigi_shell::config::mark_managed_config_synced(kigi_shell::config::SyncMarker {
|
||||
principal: Some("team-b"),
|
||||
principal: Some("dep-old"),
|
||||
had_managed_config: true,
|
||||
had_requirements: true,
|
||||
key_fingerprint: None,
|
||||
fail_closed: false,
|
||||
});
|
||||
|
||||
let url = spawn_mock(forged_team_body(&kp, "team-007"));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-007");
|
||||
let url = spawn_mock(forged_dk_body(&kp, "dep-42"));
|
||||
write_dk_config(&home, &url, "dep-key-1");
|
||||
|
||||
let wrote = kigi_shell::managed_config::sync()
|
||||
.await
|
||||
@@ -61,7 +60,7 @@ async fn rejected_signature_persists_nothing_and_records_no_marker() {
|
||||
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
||||
assert_eq!(
|
||||
v["principal"].as_str(),
|
||||
Some("team-b"),
|
||||
Some("dep-old"),
|
||||
"the marker must not be rewritten for a rejected fetch: {marker}"
|
||||
);
|
||||
}
|
||||
@@ -75,14 +74,13 @@ async fn verified_envelope_persists_policy_and_sidecar() {
|
||||
reset(&home);
|
||||
let (kp, pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(signed_team_body(
|
||||
let url = spawn_mock(signed_dk_body(
|
||||
&kp,
|
||||
"team-007",
|
||||
"dep-42",
|
||||
Some(MANAGED),
|
||||
Some(REQUIREMENTS_FAIL_CLOSED),
|
||||
));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-007");
|
||||
write_dk_config(&home, &url, "dep-key-1");
|
||||
|
||||
let wrote = kigi_shell::managed_config::sync()
|
||||
.await
|
||||
@@ -114,7 +112,7 @@ async fn verified_envelope_persists_policy_and_sidecar() {
|
||||
assert!(payload.fail_closed, "the signed opt-in is carried");
|
||||
|
||||
assert!(
|
||||
!kigi_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
!kigi_shell::config::is_managed_config_hard_stale_for(&dk_identity()),
|
||||
"a covered cache is not hard-stale"
|
||||
);
|
||||
assert!(
|
||||
@@ -133,14 +131,13 @@ async fn deleted_sidecar_under_fail_closed_marker_refuses_at_gate() {
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(signed_team_body(
|
||||
let url = spawn_mock(signed_dk_body(
|
||||
&kp,
|
||||
"team-007",
|
||||
"dep-42",
|
||||
Some(MANAGED),
|
||||
Some(REQUIREMENTS_FAIL_CLOSED),
|
||||
));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-007");
|
||||
write_dk_config(&home, &url, "dep-key-1");
|
||||
kigi_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("initial sync should succeed");
|
||||
@@ -152,11 +149,11 @@ async fn deleted_sidecar_under_fail_closed_marker_refuses_at_gate() {
|
||||
std::fs::remove_file(home.join("managed_config.sig.json")).unwrap();
|
||||
|
||||
assert!(
|
||||
kigi_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
kigi_shell::config::is_managed_config_hard_stale_for(&dk_identity()),
|
||||
"a stripped sidecar must trigger the session-start refetch"
|
||||
);
|
||||
assert!(
|
||||
kigi_shell::config::is_managed_config_stale_for(&team_identity("team-007")),
|
||||
kigi_shell::config::is_managed_config_stale_for(&dk_identity()),
|
||||
"the TIMER staleness sibling must fire too (background tick self-heal), even though the marker is timer-fresh"
|
||||
);
|
||||
let gate = kigi_shell::managed_config::managed_policy_gate();
|
||||
@@ -170,113 +167,3 @@ async fn deleted_sidecar_under_fail_closed_marker_refuses_at_gate() {
|
||||
"the refusal is the managed-policy gate message"
|
||||
);
|
||||
}
|
||||
|
||||
/// The keyed availability fix: after a fail_closed team-A install (signed sidecar + marker), an
|
||||
/// OFFLINE switch to team B previously read Compromised (the authentic sidecar is bound to A) and
|
||||
/// refused a legitimate switch. The gate's identity-change purge must shed team A's artifacts
|
||||
/// INCLUDING the sidecar, PERMIT team B, and leave the cache hard-stale so the next online start
|
||||
/// fetches team B's own policy.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn offline_team_switch_purges_sidecar_and_permits_new_team() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(signed_team_body(
|
||||
&kp,
|
||||
"team-a",
|
||||
Some(MANAGED),
|
||||
Some(REQUIREMENTS_FAIL_CLOSED),
|
||||
));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-a");
|
||||
kigi_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("team A keyed sync should succeed");
|
||||
assert!(
|
||||
home.join("managed_config.sig.json").exists(),
|
||||
"the keyed sync persists a sidecar"
|
||||
);
|
||||
assert!(
|
||||
kigi_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"team A's verified fail_closed policy must start"
|
||||
);
|
||||
|
||||
// Switch the signed-in team to B; the gate is sync, so no fetch can rebind first.
|
||||
write_team_auth(&home, "team-b");
|
||||
|
||||
// The bug this fixes: without the purge, team B evaluates against team A's
|
||||
// foreign-bound sidecar → Compromised → a legitimate switch refused startup.
|
||||
assert!(
|
||||
kigi_shell::config::managed_policy_compromised_for(&team_identity("team-b")),
|
||||
"pre-purge, the foreign-bound sidecar must read compromised for team B"
|
||||
);
|
||||
|
||||
assert!(
|
||||
kigi_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"the gate must purge team A and permit the legitimate offline switch to team B"
|
||||
);
|
||||
for f in [
|
||||
"requirements.toml",
|
||||
"managed_config.toml",
|
||||
"managed_config_cache.json",
|
||||
"managed_config.sig.json",
|
||||
] {
|
||||
assert!(
|
||||
!home.join(f).exists(),
|
||||
"{f} must be purged on the identity change"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
kigi_shell::config::is_managed_config_hard_stale_for(&team_identity("team-b")),
|
||||
"the purged cache must read hard-stale so the next online start fetches team B's policy"
|
||||
);
|
||||
}
|
||||
|
||||
/// A blank `team_id` in `auth.json` (a parse blip) over an authentic team-A-bound fail_closed
|
||||
/// sidecar: the blank→None filter resolves the identity to None, the marker principal backstops
|
||||
/// the signed binding (team-a vs team-a → Trusted), so the KEYED gate PERMITS — instead of
|
||||
/// binding to "" and refusing as Compromised — and nothing is purged.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn keyed_blank_team_id_is_not_refused_and_does_not_purge() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(signed_team_body(
|
||||
&kp,
|
||||
"team-a",
|
||||
Some(MANAGED),
|
||||
Some(REQUIREMENTS_FAIL_CLOSED),
|
||||
));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-a");
|
||||
kigi_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("team A keyed sync should succeed");
|
||||
assert!(
|
||||
kigi_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"team A's verified fail_closed policy must start"
|
||||
);
|
||||
|
||||
// auth.json now carries a team principal with a BLANK team_id.
|
||||
write_team_auth(&home, "");
|
||||
|
||||
assert!(
|
||||
kigi_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"a blank team_id must read as unknown, not a foreign binding that reads compromised"
|
||||
);
|
||||
for f in [
|
||||
"requirements.toml",
|
||||
"managed_config.toml",
|
||||
"managed_config_cache.json",
|
||||
"managed_config.sig.json",
|
||||
] {
|
||||
assert!(
|
||||
home.join(f).exists(),
|
||||
"{f} must be retained on a blank team_id (a parse blip is not an identity change)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,17 +93,8 @@ pub fn spawn_mock(body: String) -> String {
|
||||
format!("http://{addr}/deployment/config")
|
||||
}
|
||||
|
||||
pub fn write_config(home: &std::path::Path, managed_config_url: &str) {
|
||||
std::fs::write(
|
||||
home.join("config.toml"),
|
||||
format!("[endpoints]\nmanaged_config_url = \"{managed_config_url}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// [`write_config`] plus a `deployment_key` (dead-code-allowed: compiled into
|
||||
/// both binaries, called by one).
|
||||
#[allow(dead_code)]
|
||||
/// Endpoint config with a `deployment_key` (the only principal that can own
|
||||
/// managed config now).
|
||||
pub fn write_dk_config(home: &std::path::Path, managed_config_url: &str, deployment_key: &str) {
|
||||
std::fs::write(
|
||||
home.join("config.toml"),
|
||||
@@ -114,22 +105,6 @@ pub fn write_dk_config(home: &std::path::Path, managed_config_url: &str, deploym
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn write_team_auth(home: &std::path::Path, team_id: &str) {
|
||||
let scope = kigi_shell::auth::GrokComConfig::default().auth_scope();
|
||||
let auth = serde_json::json!({
|
||||
scope: {
|
||||
"key": "team-session-token",
|
||||
"auth_mode": "oidc",
|
||||
"create_time": "2026-01-01T00:00:00Z",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
"user_id": "user-1",
|
||||
"principal_type": "Team",
|
||||
"team_id": team_id,
|
||||
}
|
||||
});
|
||||
std::fs::write(home.join("auth.json"), auth.to_string()).unwrap();
|
||||
}
|
||||
|
||||
/// A fresh Ed25519 keypair plus its raw public key, installed as the sole trusted
|
||||
/// key ([`TEST_KEY_ID`]) via the test seam.
|
||||
pub fn install_test_key() -> (ring::signature::Ed25519KeyPair, Vec<u8>) {
|
||||
@@ -163,18 +138,18 @@ pub fn sign_envelope(
|
||||
})
|
||||
}
|
||||
|
||||
/// A team deployment-config response signed by `kp` under [`TEST_KEY_ID`]. The
|
||||
/// A deployment-key config response signed by `kp` under [`TEST_KEY_ID`]. The
|
||||
/// body's legacy fields mirror the payload exactly (the client rejects a divergence).
|
||||
pub fn signed_team_body(
|
||||
pub fn signed_dk_body(
|
||||
kp: &ring::signature::Ed25519KeyPair,
|
||||
team_id: &str,
|
||||
deployment_id: &str,
|
||||
managed: Option<&str>,
|
||||
requirements: Option<&str>,
|
||||
) -> String {
|
||||
let payload = SignedPayload {
|
||||
version: prod_mc_cli_chat_proxy_types::SIGNED_PAYLOAD_VERSION,
|
||||
deployment_id: None,
|
||||
team_id: Some(team_id.to_owned()),
|
||||
deployment_id: Some(deployment_id.to_owned()),
|
||||
team_id: None,
|
||||
managed_config: managed.map(str::to_owned),
|
||||
requirements: requirements.map(str::to_owned),
|
||||
fail_closed: requirements.is_some_and(kigi_config::fail_closed_flag_from_str),
|
||||
@@ -182,8 +157,8 @@ pub fn signed_team_body(
|
||||
key_id: TEST_KEY_ID.into(),
|
||||
};
|
||||
serde_json::json!({
|
||||
"deployment_id": serde_json::Value::Null,
|
||||
"team_id": team_id,
|
||||
"deployment_id": deployment_id,
|
||||
"team_id": serde_json::Value::Null,
|
||||
"managed_config": managed,
|
||||
"requirements": requirements,
|
||||
"signatures": [sign_envelope(kp, &payload)],
|
||||
@@ -191,19 +166,20 @@ pub fn signed_team_body(
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A [`signed_team_body`] (managed config only) with the signature corrupted —
|
||||
/// A [`signed_dk_body`] (managed config only) with the signature corrupted —
|
||||
/// valid base64, wrong bytes — so the verifier must reject the envelope.
|
||||
pub fn forged_team_body(kp: &ring::signature::Ed25519KeyPair, team_id: &str) -> String {
|
||||
pub fn forged_dk_body(kp: &ring::signature::Ed25519KeyPair, deployment_id: &str) -> String {
|
||||
let mut body: serde_json::Value =
|
||||
serde_json::from_str(&signed_team_body(kp, team_id, Some(MANAGED), None)).unwrap();
|
||||
serde_json::from_str(&signed_dk_body(kp, deployment_id, Some(MANAGED), None)).unwrap();
|
||||
body["signatures"][0]["signature"] = base64::engine::general_purpose::STANDARD
|
||||
.encode([0u8; 64])
|
||||
.into();
|
||||
body.to_string()
|
||||
}
|
||||
|
||||
pub fn team_identity(id: &str) -> kigi_shell::config::ServingIdentity {
|
||||
kigi_shell::config::ServingIdentity::Team(id.to_owned())
|
||||
/// The live serving identity (the configured deployment key's fingerprint).
|
||||
pub fn dk_identity() -> kigi_shell::config::ServingIdentity {
|
||||
kigi_shell::managed_config::current_serving_identity()
|
||||
}
|
||||
|
||||
/// True when `path` reads despite `chmod 000` (root / DAC bypass): chmod-based
|
||||
|
||||
@@ -11,9 +11,8 @@ mod common;
|
||||
#[cfg(unix)]
|
||||
use common::skip_as_root;
|
||||
use common::{
|
||||
MANAGED, REQUIREMENTS_FAIL_CLOSED, TEST_EXPIRES_AT, TEST_KEY_ID, forged_team_body,
|
||||
install_test_key, reset, sign_envelope, signed_team_body, spawn_mock, team_identity, test_home,
|
||||
write_config, write_dk_config, write_team_auth,
|
||||
MANAGED, REQUIREMENTS_FAIL_CLOSED, TEST_EXPIRES_AT, TEST_KEY_ID, dk_identity, forged_dk_body,
|
||||
install_test_key, reset, sign_envelope, signed_dk_body, spawn_mock, test_home, write_dk_config,
|
||||
};
|
||||
use kigi_config::signed_policy::{self, SignedPayload};
|
||||
use serial_test::serial;
|
||||
@@ -21,14 +20,13 @@ use serial_test::serial;
|
||||
/// The healthy fail-closed starting state the tamper/heal scenarios mutate;
|
||||
/// the mock keeps serving the same body, so a healing sync can refetch it.
|
||||
async fn sync_fail_closed_policy(home: &std::path::Path, kp: &ring::signature::Ed25519KeyPair) {
|
||||
let url = spawn_mock(signed_team_body(
|
||||
let url = spawn_mock(signed_dk_body(
|
||||
kp,
|
||||
"team-007",
|
||||
"dep-42",
|
||||
Some(MANAGED),
|
||||
Some(REQUIREMENTS_FAIL_CLOSED),
|
||||
));
|
||||
write_config(home, &url);
|
||||
write_team_auth(home, "team-007");
|
||||
write_dk_config(home, &url, "dep-key-1");
|
||||
kigi_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("initial sync should succeed");
|
||||
@@ -94,9 +92,8 @@ async fn rejected_signature_surfaces_as_setup_and_login_failure() {
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(forged_team_body(&kp, "team-007"));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-007");
|
||||
let url = spawn_mock(forged_dk_body(&kp, "dep-42"));
|
||||
write_dk_config(&home, &url, "dep-key-1");
|
||||
|
||||
let outcome = kigi_shell::managed_config::run_setup().await;
|
||||
assert!(
|
||||
@@ -130,8 +127,8 @@ async fn withdrawn_requirements_is_deleted_and_covered_by_the_new_sidecar() {
|
||||
sync_fail_closed_policy(&home, &kp).await;
|
||||
assert!(home.join("requirements.toml").exists());
|
||||
|
||||
let url_partial = spawn_mock(signed_team_body(&kp, "team-007", Some(MANAGED), None));
|
||||
write_config(&home, &url_partial);
|
||||
let url_partial = spawn_mock(signed_dk_body(&kp, "dep-42", Some(MANAGED), None));
|
||||
write_dk_config(&home, &url_partial, "dep-key-1");
|
||||
let wrote = kigi_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("withdrawing sync should succeed");
|
||||
@@ -156,7 +153,7 @@ async fn withdrawn_requirements_is_deleted_and_covered_by_the_new_sidecar() {
|
||||
"the new sidecar covers the absence"
|
||||
);
|
||||
assert!(
|
||||
!kigi_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
!kigi_shell::config::is_managed_config_hard_stale_for(&dk_identity()),
|
||||
"the converged, covered cache is not hard-stale"
|
||||
);
|
||||
assert!(kigi_shell::managed_config::managed_policy_gate().is_ok());
|
||||
@@ -192,7 +189,7 @@ async fn directory_squat_reads_compromised_and_online_sync_heals() {
|
||||
"the refusal is the managed-policy gate message"
|
||||
);
|
||||
assert!(
|
||||
kigi_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
kigi_shell::config::is_managed_config_hard_stale_for(&dk_identity()),
|
||||
"the squat must trigger the refetch"
|
||||
);
|
||||
|
||||
@@ -239,7 +236,7 @@ async fn sidecar_read_blip_allows_session_and_triggers_refetch() {
|
||||
"a transient sidecar read blip must not refuse the session"
|
||||
);
|
||||
assert!(
|
||||
kigi_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
kigi_shell::config::is_managed_config_hard_stale_for(&dk_identity()),
|
||||
"the blip must trigger the refetch so the self-heal runs"
|
||||
);
|
||||
// Restore so the tempdir (and later tests) stay clean.
|
||||
@@ -276,7 +273,7 @@ async fn sidecar_directory_squat_refuses_then_online_sync_heals() {
|
||||
"the refusal is the managed-policy gate message"
|
||||
);
|
||||
assert!(
|
||||
kigi_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
kigi_shell::config::is_managed_config_hard_stale_for(&dk_identity()),
|
||||
"the squat must trigger the refetch"
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -122,7 +122,7 @@ async fn test_fetch_settings_blocking_round_trip() {
|
||||
.expect("start mock server");
|
||||
|
||||
// Without settings configured: returns None (404 from mock)
|
||||
let auth = kigi_shell::auth::GrokAuth {
|
||||
let auth = kigi_shell::auth::KimiAuth {
|
||||
key: "test-key".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user