Files
Kigi-CLI/crates/codegen/kigi-test-support/src/env.rs
T
ZacharyZhang-NY ebf11057f8 feat(providers): add xAI (Grok) + migrate house BYOK env to KIGI_API_KEY
13th provider (16th registry variant). Also reconciles a naming collision
the matrix flagged: this fork is house-branded "xai" (cf. xai.dev metadata,
KIGI_CODE_XAI_API_KEY legacy env), so XAI_API_KEY + method xai.api_key were
the GENERIC house BYOK, not x.ai/Grok. The provider table wants xai/XAI_API_KEY
for Grok.

Resolution (user-approved): XAI_API_KEY now keys the x.ai/Grok provider; the
house BYOK primary env moves to KIGI_API_KEY, keeping XAI_API_KEY and
KIGI_CODE_XAI_API_KEY as back-compat fallbacks (read_xai_api_key_env checks
KIGI_API_KEY first). The xai.api_key method id is unchanged (persisted-session
compat); the platform method id is the bare "xai", distinct from it.

xAI spec: api.x.ai/v1, Bearer, OpenAI listing + ChatCompletions, Passthrough
(docs confirm stream_options.include_usage accepted). /v1/models is minimal
(ids only) and requires auth, so it doubles as the key validator (401 on bad
key, no override) and metadata comes from models.dev enrichment. Live ids
match the models.dev "xai" keys byte-for-byte, so restrict_to_enriched keeps
the 5 tool-calling chat models (grok-4.5/4.3/4.20-0309-*/build-0.1) and drops
the grok-imagine-* generators + the non-tool multi-agent model. Snapshot
regenerated to include the xai provider (was stale; gen script already listed
it in TARGETS).

Env migration is comprehensive to avoid keying the xai platform (which would
trigger a live api.x.ai fetch) or leaving house-key reads stranded: routed
the trace CLI resolver + acp_agent/auth.json bridge + paste-key ext handler
through the new primary; moved all leader/pager/e2e harness setters to
KIGI_API_KEY; made every house-key isolation test unset KIGI_API_KEY too;
updated user-facing hints to name KIGI_API_KEY.

Tests: e2e proves enrichment-supplied context (wire carries none), non-vacuous
tool_call restriction, bare-id round-trip under xai/, Passthrough; validation
tests hit /models (401 reject, 200 accept); house_env_var_takes_precedence_over_xai
pins the new precedence. Registry at 16; picker 17 rows; 4 auth arrays + xai.

Review (16 findings, all fixed): caught a missed else-branch env clear in the
paste-key handler (would leak the house key past a clear) and a non-hermetic
credential-priority test; both fixed.
2026-07-21 16:23:08 -04:00

178 lines
6.1 KiB
Rust

//! Shared environment helpers: binary resolution, git workdirs, env var setup.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
/// RAII guard for a single environment variable in `#[serial]` tests: snapshots
/// the prior value on construction, applies the change, then restores the prior
/// value (or unsets it) on drop — even if an assertion panics. Restoring rather
/// than always unsetting avoids clobbering vars a parent process/harness set
/// (e.g. `RUST_LOG`).
///
/// Callers MUST be `#[serial_test::serial]`: the `unsafe` `set_var`/`remove_var`
/// are sound only when no other thread accesses the environment concurrently.
pub struct EnvGuard {
key: &'static str,
prior: Option<OsString>,
}
impl EnvGuard {
/// Set `key` to `value` for the guard's lifetime. Accepts `&str`, `&Path`,
/// `String`, etc. via `AsRef<OsStr>`.
pub fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
let prior = std::env::var_os(key);
// SAFETY: callers are `#[serial]`, so no other thread touches the env.
unsafe { std::env::set_var(key, value) };
Self { key, prior }
}
/// Unset `key` for the guard's lifetime.
pub fn unset(key: &'static str) -> Self {
let prior = std::env::var_os(key);
// SAFETY: see [`EnvGuard::set`].
unsafe { std::env::remove_var(key) };
Self { key, prior }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
// SAFETY: see [`EnvGuard::set`].
match self.prior.take() {
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
fn workspace_root() -> PathBuf {
// nth(3): crate is nested three levels below the cargo workspace root.
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.expect("workspace root")
.to_path_buf()
}
fn target_dir() -> PathBuf {
std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| workspace_root().join("target"))
}
fn local_kigi_binary_path() -> PathBuf {
target_dir()
.join("debug")
.join(format!("kigi-tui{}", std::env::consts::EXE_SUFFIX))
}
fn ensure_local_kigi_binary(binary: &Path) {
if binary.exists() {
return;
}
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let output = Command::new(&cargo)
.current_dir(workspace_root())
.args(["build", "-p", "kigi-tui", "--bin", "kigi-tui"])
.output()
.unwrap_or_else(|e| panic!("failed to spawn {cargo} to build kigi-tui: {e}"));
assert!(
output.status.success(),
"failed to build kigi-tui for lifecycle tests (exit {:?})\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
assert!(
binary.exists(),
"kigi-tui build completed but binary missing at {}",
binary.display()
);
}
/// Resolve kigi binary: `KIGI_BINARY` env (CI) or a locally built `kigi-tui` binary.
pub fn kigi_binary() -> PathBuf {
if let Ok(path) = std::env::var("KIGI_BINARY") {
let p = PathBuf::from(path);
assert!(p.exists(), "KIGI_BINARY does not exist: {}", p.display());
return p;
}
if let Ok(path) = std::env::var("CARGO_BIN_EXE_kigi-tui") {
let p = PathBuf::from(path);
if p.exists() {
return p;
}
}
let binary = local_kigi_binary_path();
ensure_local_kigi_binary(&binary);
binary
}
/// Temp dir with a git repo + one committed file.
/// Forces libgit2 to fully init (the codepath that breaks with bad OpenSSL linking).
pub fn git_workdir() -> TempDir {
let dir = TempDir::new().expect("create temp dir");
let path = dir.path();
fn run_git(args: &[&str], dir: &Path) {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("failed to spawn git {}: {e}", args.join(" ")));
assert!(
output.status.success(),
"git {} failed (exit {:?}):\n{}",
args.join(" "),
output.status.code(),
String::from_utf8_lossy(&output.stderr),
);
}
run_git(&["init"], path);
// Configure git user for commits (required in CI where no global config exists)
run_git(&["config", "user.email", "test@test.com"], path);
run_git(&["config", "user.name", "Test"], path);
std::fs::write(path.join("README.md"), "test file\n").expect("write test file");
run_git(&["add", "-A"], path);
run_git(&["commit", "-m", "init", "--no-gpg-sign"], path);
dir
}
/// Point kigi at the mock server with a fake API key and telemetry disabled.
pub fn test_env_cmd_tokio(
cmd: &mut tokio::process::Command,
mock_url: &str,
home: &std::path::Path,
) {
cmd.env("HOME", home)
// HOME alone does not sandbox kigi on Windows: the product resolves
// `~` via `USERPROFILE`/Known Folders (`std::env::home_dir()`), so
// without an explicit KIGI_SHARE_DIR every spawned child shares the real
// `%USERPROFILE%\.kigi` — test 1's models_cache.json (which embeds
// its per-test mock-server URL) then poisons every later test's
// prompt (the windows-x86_64 lifecycle "prompt timed out" failure).
// Mirrors `leader.rs` and the pty-harness `env_for_pager`.
.env("KIGI_SHARE_DIR", home.join(".kigi"))
.env("KIGI_CODE_BASE_URL", mock_url)
.env("KIGI_API_BASE_URL", mock_url)
.env("KIGI_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")
// Release binaries (CI lifecycle tests) otherwise spawn a background
// update check that hits the network and can add latency under Rosetta.
.env("KIGI_DISABLE_AUTOUPDATER", "1");
}