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,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user