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:
commit
d6c20fc13f
2612 files changed
+1353757
No files matched your search
@@ -0,0 +1,354 @@
|
||||
//! Leader-mode (`grok agent --leader stdio`) test harness.
|
||||
//!
|
||||
//! Spawns the real binary as a stdio client whose bridge elects a leader
|
||||
//! subprocess hosting the actual sessions, speaks ACP over pipes, and
|
||||
//! exposes lock-file helpers for leader-lifecycle assertions. Unix-only:
|
||||
//! the leader transport is a unix socket.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use agent_client_protocol::{self as acp, Agent as _};
|
||||
use kigi_acp_lib::LineBufferedRead;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
use crate::env::grok_binary;
|
||||
use crate::mock_server::MockInferenceServer;
|
||||
use crate::process::spawn_piped_with_stderr_capture;
|
||||
|
||||
/// Env var naming the binary that elects/hosts the leader in a two-binary
|
||||
/// (version-skew) test. Falls back to [`grok_binary`]'s resolution.
|
||||
pub const LEADER_BINARY_ENV: &str = "KIGI_BINARY_LEADER";
|
||||
|
||||
/// Env var naming the binary for the second (usually newer) client in a
|
||||
/// two-binary test. Falls back to [`grok_binary`]'s resolution.
|
||||
pub const CLIENT_BINARY_ENV: &str = "KIGI_BINARY_CLIENT";
|
||||
|
||||
fn role_binary(env_key: &str) -> PathBuf {
|
||||
if let Ok(path) = std::env::var(env_key) {
|
||||
let p = PathBuf::from(path);
|
||||
assert!(p.exists(), "{env_key} does not exist: {}", p.display());
|
||||
return p;
|
||||
}
|
||||
grok_binary()
|
||||
}
|
||||
|
||||
/// Binary for the leader-electing side of a version-skew test
|
||||
/// (`KIGI_BINARY_LEADER`, else the shared [`grok_binary`] resolution).
|
||||
pub fn leader_binary() -> PathBuf {
|
||||
role_binary(LEADER_BINARY_ENV)
|
||||
}
|
||||
|
||||
/// Binary for the client side of a version-skew test (`KIGI_BINARY_CLIENT`,
|
||||
/// else the shared [`grok_binary`] resolution).
|
||||
pub fn client_binary() -> PathBuf {
|
||||
role_binary(CLIENT_BINARY_ENV)
|
||||
}
|
||||
|
||||
/// Capture for notifications + reconnect signals.
|
||||
#[derive(Default)]
|
||||
pub struct Capture {
|
||||
chunks: std::sync::Mutex<Vec<String>>,
|
||||
notification_count: AtomicU32,
|
||||
reconnected_count: AtomicU32,
|
||||
}
|
||||
|
||||
struct LeaderAcpClient {
|
||||
capture: Arc<Capture>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl acp::Client for LeaderAcpClient {
|
||||
async fn request_permission(
|
||||
&self,
|
||||
args: acp::RequestPermissionRequest,
|
||||
) -> acp::Result<acp::RequestPermissionResponse> {
|
||||
let outcome = args
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| o.kind == acp::PermissionOptionKind::AllowOnce)
|
||||
.or(args.options.first())
|
||||
.map(|o| {
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
|
||||
o.option_id.clone(),
|
||||
))
|
||||
})
|
||||
.unwrap_or(acp::RequestPermissionOutcome::Cancelled);
|
||||
Ok(acp::RequestPermissionResponse::new(outcome))
|
||||
}
|
||||
|
||||
async fn session_notification(&self, args: acp::SessionNotification) -> acp::Result<()> {
|
||||
self.capture
|
||||
.notification_count
|
||||
.fetch_add(1, Ordering::SeqCst);
|
||||
if let acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { content, .. }) =
|
||||
args.update
|
||||
&& let acp::ContentBlock::Text(t) = content
|
||||
{
|
||||
self.capture.chunks.lock().unwrap().push(t.text);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ext_notification(&self, args: acp::ExtNotification) -> acp::Result<()> {
|
||||
if &*args.method == "x.ai/leader_reconnected" {
|
||||
self.capture
|
||||
.reconnected_count
|
||||
.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A `grok agent --leader stdio` client subprocess speaking ACP over pipes.
|
||||
/// The leader subprocess it elects hosts the actual sessions.
|
||||
pub struct LeaderStdioClient {
|
||||
pub conn: acp::ClientSideConnection,
|
||||
// Exposed for PID assertions.
|
||||
pub child: tokio::process::Child,
|
||||
capture: Arc<Capture>,
|
||||
stderr: Arc<std::sync::Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl LeaderStdioClient {
|
||||
pub async fn spawn(server: &MockInferenceServer, cwd: &Path, home: &Path) -> Self {
|
||||
Self::spawn_with_binary(&grok_binary(), server, cwd, home).await
|
||||
}
|
||||
|
||||
/// [`Self::spawn`] with an explicit binary, for two-binary version-skew
|
||||
/// tests (pair with [`leader_binary`] / [`client_binary`]).
|
||||
pub async fn spawn_with_binary(
|
||||
binary: &Path,
|
||||
server: &MockInferenceServer,
|
||||
cwd: &Path,
|
||||
home: &Path,
|
||||
) -> Self {
|
||||
let mut cmd = tokio::process::Command::new(binary);
|
||||
cmd.args(["agent", "--leader", "stdio"])
|
||||
.current_dir(cwd)
|
||||
// Hermetic env: the developer's shell may export GROK_* vars
|
||||
// (e.g. KIGI_LEADER_SOCKET pointing at a REAL leader on this
|
||||
// machine). env_clear + explicit allowlist guarantees the test
|
||||
// can never touch a leader outside its sandbox home.
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("HOME", home)
|
||||
.env("KIGI_SHARE_DIR", home.join(".kigi"))
|
||||
// Pin the socket inside the sandbox. The lock file is the
|
||||
// sibling `.lock` (leader.sock -> leader.lock), and the spawned
|
||||
// leader subprocess inherits/forwards this env var, so every
|
||||
// (re-)elected leader binds the same sandboxed path.
|
||||
.env("KIGI_LEADER_SOCKET", home.join(".kigi").join("leader.sock"))
|
||||
.env("KIGI_CLI_CHAT_PROXY_BASE_URL", server.url())
|
||||
.env("KIGI_XAI_API_BASE_URL", server.url())
|
||||
.env("XAI_API_KEY", "test-key-for-ci")
|
||||
.env("KIGI_TELEMETRY_ENABLED", "false")
|
||||
.env("KIGI_FEEDBACK_ENABLED", "false")
|
||||
.env("KIGI_TRACE_UPLOAD", "false")
|
||||
.env("KIGI_INSTRUMENTATION", "disabled")
|
||||
// Inherited by the spawned leader, whose stderr goes to
|
||||
// ~/.kigi/leader.log — keep it chatty for diagnosis.
|
||||
.env("RUST_LOG", "kigi_shell=debug");
|
||||
|
||||
let (mut child, stderr) = spawn_piped_with_stderr_capture(cmd);
|
||||
|
||||
let outgoing = child.stdin.take().unwrap().compat_write();
|
||||
let incoming = child.stdout.take().unwrap().compat();
|
||||
|
||||
let capture = Arc::new(Capture::default());
|
||||
let client = LeaderAcpClient {
|
||||
capture: capture.clone(),
|
||||
};
|
||||
let incoming = LineBufferedRead::spawn_local(incoming);
|
||||
let (conn, handle_io) = acp::ClientSideConnection::new(client, outgoing, incoming, |fut| {
|
||||
tokio::task::spawn_local(fut);
|
||||
});
|
||||
tokio::task::spawn_local(handle_io);
|
||||
|
||||
Self {
|
||||
conn,
|
||||
child,
|
||||
capture,
|
||||
stderr,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stderr_text(&self) -> String {
|
||||
String::from_utf8_lossy(&self.stderr.lock().unwrap()).into_owned()
|
||||
}
|
||||
|
||||
pub fn captured_text(&self) -> String {
|
||||
self.capture.chunks.lock().unwrap().join("")
|
||||
}
|
||||
|
||||
pub async fn initialize(&self) -> acp::InitializeResponse {
|
||||
let init = tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
self.conn.initialize(
|
||||
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
|
||||
.client_capabilities(
|
||||
acp::ClientCapabilities::new()
|
||||
.fs(acp::FileSystemCapabilities::new())
|
||||
.terminal(false),
|
||||
)
|
||||
.meta(
|
||||
serde_json::json!({
|
||||
"startupHints": {
|
||||
"nonInteractive": true,
|
||||
"skipGitStatus": true,
|
||||
"skipProjectLayout": true
|
||||
},
|
||||
"clientType": "test-client",
|
||||
"clientVersion": "0.0.0-test"
|
||||
})
|
||||
.as_object()
|
||||
.cloned(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("initialize timed out\nstderr:\n{}", self.stderr_text()))
|
||||
.expect("initialize failed");
|
||||
|
||||
let api_key_method = init
|
||||
.auth_methods
|
||||
.iter()
|
||||
.find(|m| &*m.id().0 == "xai.api_key")
|
||||
.expect("xai.api_key auth method");
|
||||
self.conn
|
||||
.authenticate(
|
||||
acp::AuthenticateRequest::new(api_key_method.id().clone())
|
||||
.meta(serde_json::json!({"headless": true}).as_object().cloned()),
|
||||
)
|
||||
.await
|
||||
.expect("authenticate failed");
|
||||
init
|
||||
}
|
||||
|
||||
pub async fn create_session(&self, cwd: &Path) -> acp::SessionId {
|
||||
self.create_session_inner(cwd, None).await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_model(&self, cwd: &Path, model_id: &str) -> acp::SessionId {
|
||||
self.create_session_inner(
|
||||
cwd,
|
||||
serde_json::json!({ "modelId": model_id })
|
||||
.as_object()
|
||||
.cloned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_session_inner(&self, cwd: &Path, meta: Option<acp::Meta>) -> acp::SessionId {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
self.conn.new_session(
|
||||
acp::NewSessionRequest::new(cwd.to_path_buf())
|
||||
.mcp_servers(vec![])
|
||||
.meta(meta),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("session/new timed out\nstderr:\n{}", self.stderr_text()))
|
||||
.expect("session/new failed")
|
||||
.session_id
|
||||
}
|
||||
|
||||
pub async fn prompt(
|
||||
&self,
|
||||
session_id: &acp::SessionId,
|
||||
text: &str,
|
||||
) -> acp::Result<acp::PromptResponse> {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
self.conn.prompt(acp::PromptRequest::new(
|
||||
session_id.clone(),
|
||||
vec![acp::ContentBlock::Text(acp::TextContent::new(
|
||||
text.to_string(),
|
||||
))],
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("prompt timed out\nstderr:\n{}", self.stderr_text()))
|
||||
}
|
||||
|
||||
pub fn reconnected_count(&self) -> u32 {
|
||||
self.capture.reconnected_count.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn notification_count(&self) -> u32 {
|
||||
self.capture.notification_count.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn leader_lock_path(home: &Path) -> PathBuf {
|
||||
home.join(".kigi").join("leader.lock")
|
||||
}
|
||||
|
||||
pub fn read_leader_pid(home: &Path) -> Option<u32> {
|
||||
std::fs::read_to_string(leader_lock_path(home))
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn pid_alive(pid: u32) -> bool {
|
||||
unsafe { libc::kill(pid as i32, 0) == 0 }
|
||||
}
|
||||
|
||||
/// Wait until the leader lock file contains a live PID, return it.
|
||||
pub async fn wait_for_live_leader(home: &Path, timeout: Duration) -> Option<u32> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if let Some(pid) = read_leader_pid(home)
|
||||
&& pid_alive(pid)
|
||||
{
|
||||
return Some(pid);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Wait until the leader lock file contains a live PID *different* from `old_pid`.
|
||||
pub async fn wait_for_new_leader(home: &Path, old_pid: u32, timeout: Duration) -> Option<u32> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if let Some(pid) = read_leader_pid(home)
|
||||
&& pid != old_pid
|
||||
&& pid_alive(pid)
|
||||
{
|
||||
return Some(pid);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Wait for evidence that the bridge finished its reconnect replay.
|
||||
///
|
||||
/// The `x.ai/leader_reconnected` ext notification is dropped by the typed
|
||||
/// `ClientSideConnection` (bare `x.ai/*` methods are rejected by the ACP
|
||||
/// decoder), so we wait for the replayed `session/load` to emit session
|
||||
/// notifications instead: the notification count rises above `baseline`.
|
||||
pub async fn wait_for_replay_notifications(
|
||||
client: &LeaderStdioClient,
|
||||
baseline: u32,
|
||||
timeout: Duration,
|
||||
) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if client.reconnected_count() > 0 || client.notification_count() > baseline {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn leader_log(home: &Path) -> String {
|
||||
std::fs::read_to_string(home.join(".kigi").join("leader.log")).unwrap_or_default()
|
||||
}
|
||||
Reference in new issue
Block a user