Files
Kigi-CLI/crates/codegen/kigi-shell/src/extensions/bundle.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

1219 lines
44 KiB
Rust

//! ACP extension handlers for bundled subagent cache sync and status.
//!
//! These endpoints operate on the on-disk bundled cache only. Sync updates the
//! cache for future agent construction / future conversations; it does not live
//! reload the currently running `MvpAgent` instance.
use super::{ExtResult, parse_params, to_ext_response};
use crate::agent::MvpAgent;
use crate::bundle::{self, BundleManifest};
use crate::remote::{FetchedBundle, fetch_bundle};
use agent_client_protocol as acp;
use anyhow::Context;
use kigi_tools::implementations::skills::discovery::extract_first_paragraph;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Duration;
/// Default freshness window for the proactive bundle sync. Bypassed by `force`.
pub(crate) const BUNDLE_SYNC_TTL: Duration = Duration::from_secs(60 * 60);
/// Error message returned when no auth source is available for a bundle sync.
///
/// Hoisted to a constant so the user-facing wording stays in lockstep
/// across `sync_bundle`, `sync_bundle_to_root`, and any future call sites.
pub(crate) const NO_BUNDLE_CREDENTIALS_ERROR: &str =
"bundle sync requires either an authenticated cli-chat-proxy session or a deployment key";
/// Whether the caller has any source of authentication that the
/// cli-chat-proxy `/v1/subagents/bundle` endpoint will accept.
///
/// Centralised so the auth gate predicate stays consistent across:
/// - `sync_bundle` (user-triggered ACP entrypoint)
/// - `sync_bundle_to_root` (defense-in-depth on the public function)
/// - `maybe_sync_bundle_to_root` (proactive wrapper, silent skip on miss)
/// - `MvpAgent::maybe_sync_bundle_in_background` (post-auth pre-spawn gate)
///
/// All four call sites previously inlined the same predicate; a future
/// auth-source addition (e.g., service-account token) only needs to land
/// here.
#[inline]
pub(crate) fn has_bundle_credentials(
auth_manager: Option<&std::sync::Arc<crate::auth::AuthManager>>,
deployment_key: Option<&str>,
) -> bool {
auth_manager
.as_ref()
.is_some_and(|am| am.current_or_expired().is_some())
|| deployment_key.is_some()
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BundleSyncRequest {
#[serde(default)]
force: bool,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BundleSyncResult {
pub updated: bool,
pub version: String,
pub personas_count: usize,
pub roles_count: usize,
pub agents_count: usize,
pub skills_count: usize,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BundleStatusRequest {}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BundleStatusResult {
pub has_cache: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub personas: Vec<String>,
pub roles: Vec<String>,
pub agents: Vec<String>,
pub skills: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub persona_details: Vec<PersonaDetail>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub role_details: Vec<RoleDetail>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PersonaDetail {
pub name: String,
pub description: Option<String>,
pub has_inputs: bool,
pub has_outputs: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RoleDetail {
pub name: String,
pub description: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EntryGetRequest {
kind: String,
name: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EntryGetResult {
pub kind: String,
pub name: String,
pub content: String,
}
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
match args.method.as_ref() {
"x.ai/bundle/sync" => {
let req: BundleSyncRequest = parse_params(args)?;
to_ext_response(sync_bundle(agent, req).await)
}
"x.ai/bundle/status" => {
let _req: BundleStatusRequest = parse_params(args)?;
to_ext_response(status_bundle())
}
"x.ai/bundle/entry/get" => {
let req: EntryGetRequest = parse_params(args)?;
to_ext_response(get_entry(&req.kind, &req.name))
}
_ => Err(acp::Error::method_not_found()),
}
}
async fn sync_bundle(agent: &MvpAgent, req: BundleSyncRequest) -> anyhow::Result<BundleSyncResult> {
let deployment_key = agent.deployment_key();
if !has_bundle_credentials(Some(&agent.auth_manager), deployment_key.as_deref()) {
anyhow::bail!(NO_BUNDLE_CREDENTIALS_ERROR);
}
sync_bundle_to_root(
&bundle::bundled_root(),
&agent.cli_chat_proxy_base_url(),
Some(&agent.auth_manager),
deployment_key.as_deref(),
agent.alpha_test_key().as_deref(),
req.force,
)
.await
}
/// `true` when `<root>/manifest.json` exists, was written within `ttl`, and
/// is parseable as a [`BundleManifest`].
///
/// The parse check guards against the silent-skip failure mode where the
/// mtime is recent (e.g., a partial/aborted write) but the manifest is
/// truncated or otherwise corrupt. A bare mtime check would let
/// `maybe_sync_bundle_to_root` proactively skip a re-sync, leaving callers
/// (`status_bundle_at`, `SubagentsConfig::resolve`) to fail later with an
/// empty or stale catalog. Treating an unparseable manifest as "not fresh"
/// forces a re-sync on the next post-auth event.
pub(crate) fn bundle_cache_is_fresh(root: &Path, ttl: Duration) -> bool {
let manifest = root.join("manifest.json");
let Ok(meta) = std::fs::metadata(&manifest) else {
return false;
};
let Ok(modified) = meta.modified() else {
return false;
};
let within_ttl = modified
.elapsed()
.map(|elapsed| elapsed < ttl)
.unwrap_or(false);
if !within_ttl {
return false;
}
matches!(bundle::read_cached_manifest(root), Ok(Some(_)))
}
/// Proactive variant of [`sync_bundle_to_root`] that respects an auth gate
/// and a TTL guard.
///
/// Returns:
/// - `Ok(Some(result))` when a sync was performed.
/// - `Ok(None)` when the call was skipped (no credentials or cache fresh).
/// - `Err(_)` when sync was attempted but the network call or extract failed.
pub(crate) async fn maybe_sync_bundle_to_root(
root: &Path,
proxy_base_url: &str,
auth_manager: Option<&std::sync::Arc<crate::auth::AuthManager>>,
deployment_key: Option<&str>,
alpha_test_key: Option<&str>,
force: bool,
ttl: Duration,
) -> anyhow::Result<Option<BundleSyncResult>> {
if !has_bundle_credentials(auth_manager, deployment_key) {
tracing::debug!("proactive bundle sync skipped: no auth and no deployment key");
return Ok(None);
}
if !force && bundle_cache_is_fresh(root, ttl) {
tracing::debug!(
ttl_secs = ttl.as_secs(),
"proactive bundle sync skipped: cache is fresh"
);
return Ok(None);
}
sync_bundle_to_root(
root,
proxy_base_url,
auth_manager,
deployment_key,
alpha_test_key,
force,
)
.await
.map(Some)
}
pub(crate) async fn sync_bundle_to_root(
root: &Path,
proxy_base_url: &str,
auth_manager: Option<&std::sync::Arc<crate::auth::AuthManager>>,
deployment_key: Option<&str>,
alpha_test_key: Option<&str>,
_force: bool,
) -> anyhow::Result<BundleSyncResult> {
if !has_bundle_credentials(auth_manager, deployment_key) {
anyhow::bail!(NO_BUNDLE_CREDENTIALS_ERROR);
}
let fetched =
fetch_bundle(proxy_base_url, auth_manager, deployment_key, alpha_test_key).await?;
match fetched {
FetchedBundle::Archive(bytes) => {
let root_owned = root.to_path_buf();
let manifest = tokio::task::spawn_blocking(move || {
bundle::extract_bundle_archive(&root_owned, &bytes)
})
.await
.context("bundle extract task panicked")??;
let personas_count = bundle::count_entries_by_prefix(&manifest, "personas/");
let roles_count = bundle::count_entries_by_prefix(&manifest, "roles/");
let agents_count = bundle::count_entries_by_prefix(&manifest, "agents/");
let skills_count = bundle::count_entries_by_prefix(&manifest, "skills/");
Ok(BundleSyncResult {
updated: true,
version: manifest.version,
personas_count,
roles_count,
agents_count,
skills_count,
})
}
FetchedBundle::Legacy(legacy_bundle) => {
let version = legacy_bundle.version.clone();
let personas_count = legacy_bundle.personas.len();
let roles_count = legacy_bundle.roles.len();
let agents_count = legacy_bundle.agents.len();
let skills_count = legacy_bundle.skills.len();
let root_owned = root.to_path_buf();
tokio::task::spawn_blocking(move || {
bundle::write_bundle_to_cache(&root_owned, &legacy_bundle)
})
.await
.context("bundle write task panicked")??;
Ok(BundleSyncResult {
updated: true,
version,
personas_count,
roles_count,
agents_count,
skills_count,
})
}
}
}
fn get_entry(kind: &str, name: &str) -> anyhow::Result<EntryGetResult> {
get_entry_at(&bundle::bundled_root(), kind, name)
}
fn validate_entry_name(name: &str) -> anyhow::Result<()> {
if name.is_empty()
|| name.contains('/')
|| name.contains('\\')
|| name.contains("..")
|| name == "."
{
anyhow::bail!("invalid entry name: {name}");
}
Ok(())
}
fn get_entry_at(root: &Path, kind: &str, name: &str) -> anyhow::Result<EntryGetResult> {
validate_entry_name(name)?;
let (dir_name, ext) = match kind {
"persona" => ("personas", "toml"),
"role" => ("roles", "toml"),
"agent" => ("agents", "md"),
_ => anyhow::bail!("unknown entry kind: {kind}"),
};
let path = root.join(dir_name).join(format!("{name}.{ext}"));
let content = std::fs::read_to_string(&path)
.with_context(|| format!("{kind} '{name}' not found in bundle cache"))?;
Ok(EntryGetResult {
kind: kind.to_owned(),
name: name.to_owned(),
content,
})
}
fn status_bundle() -> anyhow::Result<BundleStatusResult> {
status_bundle_at(&bundle::bundled_root())
}
fn status_bundle_at(root: &Path) -> anyhow::Result<BundleStatusResult> {
let Some(manifest) = bundle::read_cached_manifest(root)? else {
return Ok(BundleStatusResult {
has_cache: false,
version: None,
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
});
};
let personas = list_cached_entries(root, &manifest, "personas", "toml");
let roles = list_cached_entries(root, &manifest, "roles", "toml");
let agents = list_cached_entries(root, &manifest, "agents", "md");
let skills = list_cached_skill_entries(root, &manifest);
let persona_details = personas
.iter()
.filter_map(|name| persona_detail_from_toml(name, root))
.collect();
let role_details = roles
.iter()
.filter_map(|name| role_detail_from_toml(name, root))
.collect();
Ok(BundleStatusResult {
has_cache: true,
version: Some(manifest.version.clone()),
personas,
roles,
agents,
skills,
persona_details,
role_details,
})
}
fn persona_detail_from_toml(name: &str, root: &Path) -> Option<PersonaDetail> {
let path = root.join("personas").join(format!("{name}.toml"));
let content = std::fs::read_to_string(&path).ok()?;
let table: toml::Value = toml::from_str(&content).ok()?;
let desc = table
.get("description")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(str::to_owned)
.or_else(|| {
table
.get("instructions")
.and_then(|v| v.as_str())
.and_then(extract_first_paragraph)
});
let has_inputs = table
.get("inputs")
.and_then(|v| v.as_array())
.is_some_and(|a| !a.is_empty());
let has_outputs = table
.get("outputs")
.and_then(|v| v.as_array())
.is_some_and(|a| !a.is_empty());
Some(PersonaDetail {
name: name.to_owned(),
description: desc,
has_inputs,
has_outputs,
})
}
fn role_detail_from_toml(name: &str, root: &Path) -> Option<RoleDetail> {
let path = root.join("roles").join(format!("{name}.toml"));
let content = std::fs::read_to_string(&path).ok()?;
let table: toml::Value = toml::from_str(&content).ok()?;
let desc = table
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
Some(RoleDetail {
name: name.to_owned(),
description: desc,
})
}
fn list_cached_entries(
root: &Path,
manifest: &BundleManifest,
dir_name: &str,
extension: &str,
) -> Vec<String> {
let dir = root.join(dir_name);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
if !path.is_file() {
return None;
}
let file_name = path.file_name()?.to_str()?;
let relative_path = format!("{dir_name}/{file_name}");
if !manifest.checksums.contains_key(&relative_path) {
return None;
}
match path.extension().and_then(|ext| ext.to_str()) {
Some(ext) if ext == extension => path
.file_stem()
.and_then(|stem| stem.to_str())
.map(ToOwned::to_owned),
_ => None,
}
})
.collect();
names.sort();
names
}
fn list_cached_skill_entries(root: &Path, manifest: &BundleManifest) -> Vec<String> {
let prefix = "skills/";
let mut names: Vec<String> = manifest
.checksums
.keys()
.filter_map(|k| {
let name = k.strip_prefix(prefix)?.strip_suffix("/SKILL.md")?;
root.join(k).is_file().then(|| name.to_owned())
})
.collect();
names.sort();
names
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
Router,
extract::State,
http::{HeaderMap, StatusCode},
routing::get,
};
use prod_mc_cli_chat_proxy_types::SubagentBundle;
use serial_test::serial;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
struct HomeGuard {
previous: Option<std::ffi::OsString>,
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(previous) => unsafe {
std::env::set_var("HOME", previous);
},
None => unsafe {
std::env::remove_var("HOME");
},
}
}
}
fn with_bundled_home(tmp: &TempDir) -> HomeGuard {
let previous = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", tmp.path());
}
HomeGuard { previous }
}
fn sample_bundle() -> SubagentBundle {
let mut bundle = SubagentBundle::empty("bundle-v1");
bundle.personas.insert(
"researcher".to_string(),
concat!(
"instructions = \"You are a thorough researcher.\\nDig deep.\"\n",
"[[inputs]]\nname = \"topic\"\n",
"[[outputs]]\nname = \"report\"\n",
)
.to_string(),
);
bundle.roles.insert(
"reviewer".to_string(),
"description = \"Meticulous code reviewer\"\n".to_string(),
);
bundle
.agents
.insert("default".to_string(), "# agent\n".to_string());
bundle
}
fn sample_bundle_with_skills() -> SubagentBundle {
let mut bundle = sample_bundle();
bundle
.skills
.insert("commit".to_string(), "# Commit skill\n".to_string());
bundle
.skills
.insert("review".to_string(), "# Review skill\n".to_string());
bundle
}
fn test_auth() -> crate::auth::GrokAuth {
crate::auth::GrokAuth {
key: "token".to_string(),
auth_mode: crate::auth::AuthMode::Oidc,
create_time: chrono::Utc::now(),
user_id: "user-1".to_string(),
email: Some("test@example.com".to_string()),
first_name: None,
last_name: None,
profile_image_asset_id: None,
principal_type: None,
principal_id: None,
team_id: None,
team_name: None,
team_role: None,
organization_id: None,
organization_name: None,
organization_role: None,
user_blocked_reason: None,
team_blocked_reasons: vec![],
coding_data_retention_opt_out: false,
has_grok_code_access: None,
refresh_token: None,
expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),
oidc_issuer: None,
oidc_client_id: None,
}
}
fn test_auth_manager() -> Arc<crate::auth::AuthManager> {
let dir = tempfile::tempdir().unwrap();
let mgr = crate::auth::AuthManager::new(dir.path(), crate::auth::GrokComConfig::default());
mgr.hot_swap(test_auth());
std::mem::forget(dir);
Arc::new(mgr)
}
#[derive(Debug, Default, Clone)]
struct SeenHeaders {
authorization: Option<String>,
token_auth: Option<String>,
user_id: Option<String>,
email: Option<String>,
alpha_test_key: Option<String>,
}
#[derive(Clone)]
struct BundleServerState {
body: serde_json::Value,
status_code: StatusCode,
seen_headers: Arc<Mutex<Vec<SeenHeaders>>>,
}
async fn start_bundle_server(
status_code: StatusCode,
body: serde_json::Value,
) -> (
String,
Arc<Mutex<Vec<SeenHeaders>>>,
tokio::task::JoinHandle<()>,
) {
let seen_headers = Arc::new(Mutex::new(Vec::new()));
let state = BundleServerState {
body,
status_code,
seen_headers: seen_headers.clone(),
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
let app = Router::new()
.route(
"/v1/subagents/bundle",
get(
|State(state): State<BundleServerState>, headers: HeaderMap| async move {
state.seen_headers.lock().unwrap().push(SeenHeaders {
authorization: headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(str::to_owned),
token_auth: headers
.get("x-xai-token-auth")
.and_then(|v| v.to_str().ok())
.map(str::to_owned),
user_id: headers
.get("x-userid")
.and_then(|v| v.to_str().ok())
.map(str::to_owned),
email: headers
.get("x-email")
.and_then(|v| v.to_str().ok())
.map(str::to_owned),
alpha_test_key: {
let _ = &headers;
None
},
});
(state.status_code, axum::Json(state.body))
},
),
)
.with_state(state);
let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("{base}/v1"), seen_headers, handle)
}
#[test]
#[serial]
fn status_reports_no_cache_when_manifest_missing() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let status = status_bundle_at(&bundle::bundled_root()).unwrap();
assert_eq!(
status,
BundleStatusResult {
has_cache: false,
version: None,
personas: vec![],
roles: vec![],
agents: vec![],
skills: vec![],
persona_details: vec![],
role_details: vec![],
}
);
}
#[test]
#[serial]
fn status_reports_cached_entries_from_manifest_and_disk() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let root = bundle::bundled_root();
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
std::fs::write(
root.join("personas/local-only.toml"),
"instructions = \"ignore\"",
)
.unwrap();
let status = status_bundle_at(&bundle::bundled_root()).unwrap();
assert!(status.has_cache);
assert_eq!(status.version.as_deref(), Some("bundle-v1"));
assert_eq!(status.personas, vec!["researcher"]);
assert_eq!(status.roles, vec!["reviewer"]);
assert_eq!(status.agents, vec!["default"]);
assert_eq!(status.skills, Vec::<String>::new());
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn sync_success_writes_cache_and_returns_counts() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let bundle = sample_bundle();
let (proxy_base_url, _seen_headers, server) = start_bundle_server(
axum::http::StatusCode::OK,
serde_json::to_value(&bundle).unwrap(),
)
.await;
let root = bundle::bundled_root();
let am = test_auth_manager();
let result = sync_bundle_to_root(&root, &proxy_base_url, Some(&am), None, None, false)
.await
.unwrap();
assert_eq!(result.version, "bundle-v1");
assert_eq!(result.personas_count, 1);
assert_eq!(result.roles_count, 1);
assert_eq!(result.agents_count, 1);
assert_eq!(result.skills_count, 0);
assert!(root.join("personas/researcher.toml").exists());
assert!(root.join("roles/reviewer.toml").exists());
assert!(root.join("agents/default.md").exists());
server.abort();
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn sync_force_true_has_same_write_semantics() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let bundle = sample_bundle();
let (proxy_base_url, _seen_headers, server) =
start_bundle_server(StatusCode::OK, serde_json::to_value(&bundle).unwrap()).await;
let root = bundle::bundled_root();
let am = test_auth_manager();
let normal = sync_bundle_to_root(&root, &proxy_base_url, Some(&am), None, None, false)
.await
.unwrap();
let forced = sync_bundle_to_root(&root, &proxy_base_url, Some(&am), None, None, true)
.await
.unwrap();
assert_eq!(forced, normal);
server.abort();
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn sync_http_failure_surfaces_error() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let (proxy_base_url, _seen_headers, server) = start_bundle_server(
StatusCode::UNAUTHORIZED,
serde_json::json!({ "error" : "unauthorized" }),
)
.await;
let am = test_auth_manager();
let error = sync_bundle_to_root(
&bundle::bundled_root(),
&proxy_base_url,
Some(&am),
None,
None,
false,
)
.await
.unwrap_err();
assert!(error.to_string().contains("401"));
server.abort();
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn sync_uses_deployment_key_auth_mode() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let bundle = sample_bundle();
let (proxy_base_url, seen_headers, server) =
start_bundle_server(StatusCode::OK, serde_json::to_value(&bundle).unwrap()).await;
let am = test_auth_manager();
let result = sync_bundle_to_root(
&bundle::bundled_root(),
&proxy_base_url,
Some(&am),
Some("deploy-key"),
None,
false,
)
.await
.unwrap();
assert_eq!(result.version, "bundle-v1");
let headers = seen_headers.lock().unwrap();
let headers = headers.last().unwrap();
assert_eq!(headers.authorization.as_deref(), Some("Bearer deploy-key"));
assert_eq!(headers.token_auth, None);
assert_eq!(headers.user_id, None);
assert_eq!(headers.email, None);
assert_eq!(headers.alpha_test_key, None);
server.abort();
}
#[test]
#[serial]
fn status_only_reports_bundled_cache_not_higher_priority_sources() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let root = bundle::bundled_root();
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
let project_root = tmp.path().join("workspace");
std::fs::create_dir_all(project_root.join(".kigi/personas")).unwrap();
std::fs::create_dir_all(project_root.join(".kigi/roles")).unwrap();
std::fs::write(
project_root.join(".kigi/personas/researcher.toml"),
"instructions = \"project persona\"\n",
)
.unwrap();
std::fs::write(
project_root.join(".kigi/roles/reviewer.toml"),
"description = \"project role\"\n",
)
.unwrap();
let config = crate::config::SubagentsConfig::resolve(
false,
&toml::Value::Table(Default::default()),
Some(&project_root),
);
assert_eq!(
config
.personas
.get("researcher")
.and_then(|persona| persona.instructions.as_deref()),
Some("project persona")
);
assert_eq!(
config
.roles
.get("reviewer")
.map(|role| role.description.as_str()),
Some("project role")
);
let status = status_bundle_at(&root).unwrap();
assert_eq!(status.personas, vec!["researcher"]);
assert_eq!(status.roles, vec!["reviewer"]);
assert_eq!(status.agents, vec!["default"]);
assert_eq!(status.skills, Vec::<String>::new());
}
#[test]
#[serial]
fn sync_requires_auth_or_deployment_key() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let error = futures::executor::block_on(sync_bundle_to_root(
&bundle::bundled_root(),
"http://127.0.0.1:1/v1",
None,
None,
None,
false,
))
.unwrap_err();
assert!(
error.to_string()
.contains("bundle sync requires either an authenticated cli-chat-proxy session or a deployment key")
);
}
#[test]
#[serial]
fn get_entry_reads_persona_file() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let root = bundle::bundled_root();
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
let result = get_entry_at(&root, "persona", "researcher").unwrap();
assert_eq!(result.kind, "persona");
assert_eq!(result.name, "researcher");
assert!(result.content.contains("instructions"));
}
#[test]
#[serial]
fn get_entry_unknown_kind_returns_error() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let root = bundle::bundled_root();
let err = get_entry_at(&root, "widget", "foo").unwrap_err();
assert!(err.to_string().contains("unknown entry kind: widget"));
}
#[test]
#[serial]
fn get_entry_missing_file_returns_error() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let root = bundle::bundled_root();
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
let err = get_entry_at(&root, "persona", "nonexistent").unwrap_err();
assert!(err.to_string().contains("not found in bundle cache"));
}
#[test]
fn get_entry_rejects_path_traversal() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
for bad_name in ["../../../etc/passwd", "foo/bar", "a\\b", "..", "."] {
let err = get_entry_at(&root, "persona", bad_name).unwrap_err();
assert!(
err.to_string().contains("invalid entry name"),
"expected rejection for {bad_name:?}, got: {err}"
);
}
}
#[test]
fn get_entry_rejects_empty_name() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
let err = get_entry_at(&root, "persona", "").unwrap_err();
assert!(err.to_string().contains("invalid entry name"));
}
#[test]
#[serial]
fn status_includes_persona_and_role_details() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let root = bundle::bundled_root();
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
let status = status_bundle_at(&root).unwrap();
assert_eq!(status.persona_details.len(), 1);
let pd = &status.persona_details[0];
assert_eq!(pd.name, "researcher");
assert_eq!(
pd.description.as_deref(),
Some("You are a thorough researcher. Dig deep.")
);
assert!(pd.has_inputs);
assert!(pd.has_outputs);
assert_eq!(status.role_details.len(), 1);
let rd = &status.role_details[0];
assert_eq!(rd.name, "reviewer");
assert_eq!(rd.description, "Meticulous code reviewer");
}
#[test]
#[serial]
fn status_without_toml_files_returns_empty_details() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let root = bundle::bundled_root();
let mut bundle = SubagentBundle::empty("v1");
bundle
.personas
.insert("ghost".to_string(), "instructions = \"x\"".to_string());
bundle::write_bundle_to_cache(&root, &bundle).unwrap();
std::fs::remove_file(root.join("personas/ghost.toml")).unwrap();
let status = status_bundle_at(&root).unwrap();
assert!(status.personas.is_empty());
assert!(status.persona_details.is_empty());
}
#[test]
fn malformed_toml_skipped_gracefully() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("cache");
std::fs::create_dir_all(root.join("personas")).unwrap();
std::fs::write(root.join("personas/bad.toml"), "{{{{not toml").unwrap();
assert!(persona_detail_from_toml("bad", &root).is_none());
}
#[test]
fn persona_detail_without_inputs_outputs() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("personas")).unwrap();
std::fs::write(
root.join("personas/simple.toml"),
"instructions = \"Just a simple persona\"",
)
.unwrap();
let detail = persona_detail_from_toml("simple", root).unwrap();
assert_eq!(detail.name, "simple");
assert_eq!(detail.description.as_deref(), Some("Just a simple persona"));
assert!(!detail.has_inputs);
assert!(!detail.has_outputs);
}
#[test]
fn role_detail_missing_description_defaults_empty() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("roles")).unwrap();
std::fs::write(root.join("roles/bare.toml"), "instructions = \"hi\"").unwrap();
let detail = role_detail_from_toml("bare", root).unwrap();
assert_eq!(detail.name, "bare");
assert_eq!(detail.description, "");
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn sync_with_skills_reports_skills_count() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let bundle = sample_bundle_with_skills();
let (proxy_base_url, _seen_headers, server) =
start_bundle_server(StatusCode::OK, serde_json::to_value(&bundle).unwrap()).await;
let result = sync_bundle_to_root(
&bundle::bundled_root(),
&proxy_base_url,
Some(&test_auth_manager()),
None,
None,
false,
)
.await
.unwrap();
assert_eq!(result.skills_count, 2);
assert_eq!(result.personas_count, 1);
assert_eq!(result.roles_count, 1);
assert_eq!(result.agents_count, 1);
server.abort();
}
#[test]
#[serial]
fn status_lists_skill_names_from_manifest() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let root = bundle::bundled_root();
bundle::write_bundle_to_cache(&root, &sample_bundle_with_skills()).unwrap();
let status = status_bundle_at(&root).unwrap();
assert!(status.has_cache);
assert_eq!(status.skills, vec!["commit", "review"]);
assert_eq!(status.personas, vec!["researcher"]);
}
#[test]
#[serial]
fn status_skills_only_lists_files_present_on_disk() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let root = bundle::bundled_root();
bundle::write_bundle_to_cache(&root, &sample_bundle_with_skills()).unwrap();
std::fs::remove_file(root.join("skills/commit/SKILL.md")).unwrap();
let status = status_bundle_at(&root).unwrap();
assert_eq!(status.skills, vec!["review"]);
}
use crate::bundle::test_helpers::make_test_archive;
#[derive(Clone)]
struct ArchiveServerState {
archive_bytes: Vec<u8>,
}
async fn start_archive_bundle_server(
archive_bytes: Vec<u8>,
) -> (String, tokio::task::JoinHandle<()>) {
let state = ArchiveServerState { archive_bytes };
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
let app = Router::new()
.route(
"/v1/bundle/archive",
get(|State(state): State<ArchiveServerState>| async move {
(StatusCode::OK, state.archive_bytes)
}),
)
.with_state(state);
let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("{base}/v1"), handle)
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn sync_with_archive_endpoint_extracts_and_reports_counts() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let archive = make_test_archive(&[
("bundle.json", br#"{"version":"archive-v1"}"#),
(
"subagents/personas/researcher.toml",
b"instructions = \"hello\"",
),
("subagents/roles/reviewer.toml", b"description = \"review\""),
("skills/commit/SKILL.md", b"# Commit skill"),
]);
let (proxy_base_url, server) = start_archive_bundle_server(archive).await;
let result = sync_bundle_to_root(
&bundle::bundled_root(),
&proxy_base_url,
Some(&test_auth_manager()),
None,
None,
false,
)
.await
.unwrap();
assert_eq!(result.version, "archive-v1");
assert_eq!(result.personas_count, 1);
assert_eq!(result.roles_count, 1);
assert_eq!(result.agents_count, 0);
assert_eq!(result.skills_count, 1);
assert!(
bundle::bundled_root()
.join("personas/researcher.toml")
.exists()
);
assert!(
bundle::bundled_root()
.join("skills/commit/SKILL.md")
.exists()
);
server.abort();
}
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn sync_falls_back_to_legacy_when_archive_unavailable() {
let tmp = TempDir::new().unwrap();
let _home = with_bundled_home(&tmp);
let bundle = sample_bundle_with_skills();
let (proxy_base_url, _seen_headers, server) =
start_bundle_server(StatusCode::OK, serde_json::to_value(&bundle).unwrap()).await;
let result = sync_bundle_to_root(
&bundle::bundled_root(),
&proxy_base_url,
Some(&test_auth_manager()),
None,
None,
false,
)
.await
.unwrap();
assert_eq!(result.version, "bundle-v1");
assert_eq!(result.personas_count, 1);
assert_eq!(result.skills_count, 2);
server.abort();
}
fn backdate_manifest(root: &std::path::Path, age: Duration) {
let path = root.join("manifest.json");
let stale_time = std::time::SystemTime::now() - age;
let times = std::fs::FileTimes::new().set_modified(stale_time);
std::fs::File::options()
.write(true)
.open(&path)
.expect("open manifest for backdate")
.set_times(times)
.expect("set manifest mtime");
}
#[tokio::test(flavor = "current_thread")]
async fn maybe_sync_skips_without_auth_or_deployment_key() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
let (proxy_base_url, seen_headers, server) = start_bundle_server(
StatusCode::OK,
serde_json::to_value(sample_bundle()).unwrap(),
)
.await;
let result = maybe_sync_bundle_to_root(
&root,
&proxy_base_url,
None,
None,
None,
false,
BUNDLE_SYNC_TTL,
)
.await
.unwrap();
assert!(result.is_none(), "expected sync skipped");
assert!(
seen_headers.lock().unwrap().is_empty(),
"auth-gated sync must not hit the network"
);
server.abort();
}
#[tokio::test(flavor = "current_thread")]
async fn maybe_sync_skips_when_cache_is_fresh() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
let (proxy_base_url, seen_headers, server) = start_bundle_server(
StatusCode::OK,
serde_json::to_value(sample_bundle()).unwrap(),
)
.await;
let result = maybe_sync_bundle_to_root(
&root,
&proxy_base_url,
Some(&test_auth_manager()),
None,
None,
false,
BUNDLE_SYNC_TTL,
)
.await
.unwrap();
assert!(result.is_none(), "fresh cache should skip sync");
assert!(
seen_headers.lock().unwrap().is_empty(),
"fresh cache must not hit the network"
);
server.abort();
}
#[tokio::test(flavor = "current_thread")]
async fn maybe_sync_runs_when_cache_is_stale() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
backdate_manifest(&root, BUNDLE_SYNC_TTL + Duration::from_secs(60));
let (proxy_base_url, seen_headers, server) = start_bundle_server(
StatusCode::OK,
serde_json::to_value(sample_bundle()).unwrap(),
)
.await;
let outcome = maybe_sync_bundle_to_root(
&root,
&proxy_base_url,
Some(&test_auth_manager()),
None,
None,
false,
BUNDLE_SYNC_TTL,
)
.await
.unwrap()
.expect("stale cache should trigger a sync");
assert_eq!(outcome.version, "bundle-v1");
assert_eq!(seen_headers.lock().unwrap().len(), 1);
server.abort();
}
#[tokio::test(flavor = "current_thread")]
async fn maybe_sync_force_bypasses_ttl() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
let (proxy_base_url, seen_headers, server) = start_bundle_server(
StatusCode::OK,
serde_json::to_value(sample_bundle()).unwrap(),
)
.await;
let outcome = maybe_sync_bundle_to_root(
&root,
&proxy_base_url,
Some(&test_auth_manager()),
None,
None,
true,
BUNDLE_SYNC_TTL,
)
.await
.unwrap()
.expect("force=true should always sync");
assert_eq!(outcome.version, "bundle-v1");
assert_eq!(seen_headers.lock().unwrap().len(), 1);
server.abort();
}
#[tokio::test(flavor = "current_thread")]
async fn maybe_sync_runs_when_no_manifest_exists() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
let (proxy_base_url, seen_headers, server) = start_bundle_server(
StatusCode::OK,
serde_json::to_value(sample_bundle()).unwrap(),
)
.await;
let outcome = maybe_sync_bundle_to_root(
&root,
&proxy_base_url,
Some(&test_auth_manager()),
None,
None,
false,
BUNDLE_SYNC_TTL,
)
.await
.unwrap()
.expect("missing manifest should trigger a sync");
assert_eq!(outcome.personas_count, 1);
assert_eq!(seen_headers.lock().unwrap().len(), 1);
server.abort();
}
#[test]
fn bundle_cache_is_fresh_returns_false_without_manifest() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
assert!(!bundle_cache_is_fresh(&root, BUNDLE_SYNC_TTL));
}
#[test]
fn bundle_cache_is_fresh_true_for_recent_manifest() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
assert!(bundle_cache_is_fresh(&root, BUNDLE_SYNC_TTL));
}
#[test]
fn bundle_cache_is_fresh_false_for_stale_manifest() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
backdate_manifest(&root, BUNDLE_SYNC_TTL + Duration::from_secs(60));
assert!(!bundle_cache_is_fresh(&root, BUNDLE_SYNC_TTL));
}
#[test]
fn bundle_cache_is_fresh_false_for_corrupted_manifest() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("bundled");
bundle::write_bundle_to_cache(&root, &sample_bundle()).unwrap();
std::fs::write(root.join("manifest.json"), "{not json}").unwrap();
assert!(!bundle_cache_is_fresh(&root, BUNDLE_SYNC_TTL));
}
}