M2 audit: excise the Computer Hub stack — Kigi's last remote-cloud surface
Removed root-and-branch for the zero-egress guarantee (the hub was xAI's remote-workspace/cloud-sandbox service): - Crates deleted: kigi-computer-hub-core, kigi-computer-hub-sdk, kigi-computer-hub-mcp-adapter, kigi-workspace-client (hub-proxied workspace RPC client), and kigi-tracing (its sole network path was the OTLP gRPC exporter; zero consumers remained). kigi-tracing-macros (purely local) stays. - kigi-workspace: every hub surface deleted — hub server/channel/auth, HITL-over-hub permissions, donation/metrics pumps, file upload RPCs, hub tool-snapshot merge (resolve pipeline is MCP-only now), WorkspaceOps::Proxy. Local worktrees, sessions, leader IPC, MCP, and the ACP permission prompt path are untouched; LocalRegistry re-homed into kigi-tool-runtime on the existing ToolDyn types so in-process tool dispatch is unchanged. - kigi-shell: leader workspace-exposure control surface (incl. the wss://computer-hub... URL), [hub] config, ObservabilityBridge, hub WebSocket proxy, dead OTLP config knobs. ClientMode::Headless (never constructed) removed. - kigi-tui/bin: hidden `kigi workspace` command removed (`kigi worktree` stays). - Renames: --xai-api-base-url → --api-base-url / KIGI_API_BASE_URL / [endpoints] api_base_url (serde alias keeps old configs working; the flag feeds BYOK/custom-endpoint routing, not main inference); grok_version → kigi_version in inspect/models-cache/trace metadata (old caches self-heal via version-mismatch refetch). - Dependency tree: dropped fastrace*, opentelemetry-otlp/http/proto, tokio-tungstenite from the workspace; fixed the 4 real useless_format violations the fastrace lint allowance was masking and removed the allowance. - marketplaceAllowlist kept: it gates the LOCAL plugin-marketplace feature, not an xAI service. Known §9 leftover (deliberate, for the M3 sweep): the BYOK default base URL string. Gates: workspace check/clippy 0/0, fmt, deny ok; suites green (workspace 1042, shell 4918, tui 6634, tools 2608, tool-runtime 47, mcp 154).
This commit is contained in:
@@ -1028,7 +1028,6 @@ impl AgentBuilder {
|
||||
video_gen_config: self.video_gen_config,
|
||||
app_builder_deployer_config: self.app_builder_deployer_config,
|
||||
api_key_provider: self.api_key_provider,
|
||||
auth_provider: None,
|
||||
attribution_callback: self.attribution_callback,
|
||||
system_reminder_tag: self.system_reminder_tag,
|
||||
},
|
||||
|
||||
@@ -37,7 +37,6 @@ use kigi_tui::app::{
|
||||
AgentCmd, Command, LeaderMgmtArgs, LeaderMgmtCommand, LeaderTargetArgs, PagerArgs,
|
||||
resolve_use_leader,
|
||||
};
|
||||
use kigi_tui::app::{WorkspaceMgmtArgs, WorkspaceMgmtCommand, WorkspaceStartArgs};
|
||||
use kigi_tui::client_identity::PAGER_CLIENT_VERSION;
|
||||
use kigi_update::{UpdateConfig, auto_update, enforce_minimum_version_or_exit};
|
||||
use std::env;
|
||||
@@ -48,8 +47,8 @@ fn apply_agent_endpoint_args(agent_args: &kigi_tui::app::AgentArgs, config: &mut
|
||||
if let Some(v) = &agent_args.coding_api_base_url {
|
||||
config.endpoints.coding_api_base_url = Some(v.clone());
|
||||
}
|
||||
if let Some(v) = &agent_args.xai_api_base_url {
|
||||
config.endpoints.xai_api_base_url = v.clone();
|
||||
if let Some(v) = &agent_args.api_base_url {
|
||||
config.endpoints.api_base_url = v.clone();
|
||||
}
|
||||
}
|
||||
/// Resolve --agent-profile path: canonicalize and verify the file exists.
|
||||
@@ -324,194 +323,6 @@ fn ensure_control_caps(reg: &LeaderRegistration) -> Result<&LeaderCapabilities>
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Leader does not advertise capabilities (legacy version)"))
|
||||
}
|
||||
/// Env override for the `kigi workspace` gate: any truthy value enables the
|
||||
/// command locally, a falsy one disables it. This is the only gate now that
|
||||
/// the server-side feature flag (xAI remote settings) is gone.
|
||||
const WORKSPACE_COMMAND_ENV: &str = "KIGI_WORKSPACE_COMMAND";
|
||||
/// The `KIGI_WORKSPACE_COMMAND` override, if set (`Some(true)`/`Some(false)`);
|
||||
/// `None` means unset (the command stays disabled by default).
|
||||
fn workspace_command_env_override() -> Option<bool> {
|
||||
std::env::var(WORKSPACE_COMMAND_ENV)
|
||||
.ok()
|
||||
.map(|v| env_flag_enabled(&v))
|
||||
}
|
||||
/// Resolve the gate: enabled exactly when the env override says so.
|
||||
fn workspace_command_gate(env_override: Option<bool>) -> bool {
|
||||
env_override.unwrap_or(false)
|
||||
}
|
||||
/// Truthy parse for grok on/off env vars: everything enables except the common
|
||||
/// falsy spellings (`0`, `false`, `off`, `no`, empty).
|
||||
fn env_flag_enabled(value: &str) -> bool {
|
||||
!matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"" | "0" | "false" | "off" | "no"
|
||||
)
|
||||
}
|
||||
async fn run_workspace_mgmt(args: WorkspaceMgmtArgs) -> Result<()> {
|
||||
if !workspace_command_gate(workspace_command_env_override()) {
|
||||
anyhow::bail!(
|
||||
"`kigi workspace` is experimental and disabled by default. \
|
||||
Set {WORKSPACE_COMMAND_ENV}=1 to enable it."
|
||||
)
|
||||
}
|
||||
match args.command {
|
||||
WorkspaceMgmtCommand::Start(a) => workspace_start(a, false).await,
|
||||
WorkspaceMgmtCommand::Restart(a) => workspace_start(a, true).await,
|
||||
WorkspaceMgmtCommand::Pause { target, json } => {
|
||||
workspace_control(&target, json, ControlCommand::WorkspacePause).await
|
||||
}
|
||||
WorkspaceMgmtCommand::Resume { target, json } => {
|
||||
workspace_control(&target, json, ControlCommand::WorkspaceResume).await
|
||||
}
|
||||
WorkspaceMgmtCommand::Stop { target, json } => {
|
||||
workspace_control(&target, json, ControlCommand::WorkspaceStop).await
|
||||
}
|
||||
WorkspaceMgmtCommand::Status { target, json } => {
|
||||
workspace_control(&target, json, ControlCommand::WorkspaceStatus).await
|
||||
}
|
||||
}
|
||||
}
|
||||
fn ensure_workspace_caps(reg: &LeaderRegistration) -> Result<()> {
|
||||
let caps = ensure_control_caps(reg)?;
|
||||
if !caps.workspace_exposure {
|
||||
anyhow::bail!(
|
||||
"the running leader does not support workspace exposure — stop the \
|
||||
leader process and re-run to pick up the new version"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn connect_workspace_control(
|
||||
_agent_config: &AgentConfig,
|
||||
target: &LeaderTargetArgs,
|
||||
) -> Result<LeaderClient> {
|
||||
if target.pid.is_some() {
|
||||
let (_descriptor, client) = connect_to_leader(target).await?;
|
||||
return Ok(client);
|
||||
}
|
||||
let socket = default_socket_path();
|
||||
LeaderClient::connect(
|
||||
socket,
|
||||
"grok-workspace-cli",
|
||||
ClientMode::Stdio,
|
||||
ClientCapabilities::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"no running leader ({e}). \
|
||||
Start a grok session, or run `grok workspace start`."
|
||||
)
|
||||
})
|
||||
}
|
||||
async fn workspace_control(
|
||||
target: &LeaderTargetArgs,
|
||||
json: bool,
|
||||
command: ControlCommand,
|
||||
) -> Result<()> {
|
||||
let raw_config = kigi_shell::config::load_effective_config_disk_only()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
|
||||
let agent_config = AgentConfig::new_from_toml_cfg(&raw_config)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
|
||||
let client = connect_workspace_control(&agent_config, target).await?;
|
||||
ensure_workspace_caps(client.registration())?;
|
||||
let payload = client.send_control(command).await??;
|
||||
render_workspace_payload(&payload, json);
|
||||
client.cancel();
|
||||
Ok(())
|
||||
}
|
||||
async fn workspace_start(args: WorkspaceStartArgs, restart: bool) -> Result<()> {
|
||||
use kigi_shell::auth::ensure_authenticated;
|
||||
let raw_config = kigi_shell::config::load_effective_config()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
|
||||
let agent_config = AgentConfig::new_from_toml_cfg(&raw_config)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
|
||||
let (use_leader, _) = resolve_use_leader(args.leader, args.no_leader, &raw_config, true);
|
||||
if !use_leader {
|
||||
anyhow::bail!(
|
||||
"`grok workspace` requires leader mode (the workspace is shared via the leader).\n\
|
||||
Enable it with `[cli] use_leader = true` in ~/.kigi/config.toml, or pass --leader."
|
||||
);
|
||||
}
|
||||
ensure_authenticated(
|
||||
&agent_config.kimi_code_config,
|
||||
false,
|
||||
Some("No cached credentials found. Run `kigi login` first."),
|
||||
)
|
||||
.await?;
|
||||
let capabilities = ClientCapabilities {
|
||||
client_version: Some(PAGER_CLIENT_VERSION.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let conn = connect_or_spawn("grok-workspace-cli", ClientMode::Stdio, capabilities)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to start or connect to leader: {e}"))?;
|
||||
drop(conn);
|
||||
let target = LeaderTargetArgs::default();
|
||||
let client = connect_workspace_control(&agent_config, &target).await?;
|
||||
ensure_workspace_caps(client.registration())?;
|
||||
if restart {
|
||||
let _ = client.send_control(ControlCommand::WorkspaceStop).await;
|
||||
}
|
||||
let cwd = match args.cwd {
|
||||
Some(p) => p,
|
||||
None => std::env::current_dir()
|
||||
.map_err(|e| anyhow::anyhow!("cannot determine current directory: {e}"))?,
|
||||
};
|
||||
let cwd = std::path::absolute(&cwd).unwrap_or(cwd);
|
||||
let payload = client
|
||||
.send_control(ControlCommand::WorkspaceStart {
|
||||
hub_url: args.hub_url.clone(),
|
||||
cwd: cwd.display().to_string(),
|
||||
})
|
||||
.await??;
|
||||
render_workspace_payload(&payload, args.json);
|
||||
client.cancel();
|
||||
Ok(())
|
||||
}
|
||||
fn render_workspace_payload(payload: &ControlPayload, json: bool) {
|
||||
let ControlPayload::WorkspaceStatus {
|
||||
state,
|
||||
hub_url,
|
||||
cwd,
|
||||
uptime_ms,
|
||||
active_tool_calls,
|
||||
sessions,
|
||||
pid,
|
||||
} = payload
|
||||
else {
|
||||
eprintln!("unexpected control response: {payload:?}");
|
||||
return;
|
||||
};
|
||||
if json {
|
||||
let value = serde_json::json!(
|
||||
{ "state" : state, "hubUrl" : hub_url, "cwd" : cwd, "uptimeMs" : uptime_ms,
|
||||
"activeToolCalls" : active_tool_calls, "sessions" : sessions, "pid" : pid, }
|
||||
);
|
||||
println!("{}", serde_json::to_string(&value).unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
if state == "none" {
|
||||
println!("Workspace exposure: not running (leader PID {pid})");
|
||||
return;
|
||||
}
|
||||
println!("Workspace exposure: {state}");
|
||||
if let Some(url) = hub_url {
|
||||
println!(" hub: {url}");
|
||||
}
|
||||
if let Some(dir) = cwd {
|
||||
println!(" cwd: {dir}");
|
||||
}
|
||||
println!(" uptime: {}s", uptime_ms / 1000);
|
||||
println!(" active: {active_tool_calls} tool call(s)");
|
||||
let session_list = if sessions.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
sessions.join(", ")
|
||||
};
|
||||
println!(" sessions: {} ({session_list})", sessions.len());
|
||||
println!(" leader: PID {pid}");
|
||||
}
|
||||
/// How to rebuild one session's `session/load` after a leader reconnect.
|
||||
#[derive(Default, Clone)]
|
||||
struct CachedSession {
|
||||
@@ -1604,10 +1415,6 @@ async fn async_main() -> Result<()> {
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
|
||||
return kigi_tui::worktree_cmd::run(worktree_args, &agent_config).await;
|
||||
}
|
||||
Command::Workspace(workspace_args) => {
|
||||
init_tracing_simple("cli");
|
||||
return run_workspace_mgmt(workspace_args).await;
|
||||
}
|
||||
Command::Sessions(sessions_args) => {
|
||||
init_tracing_simple("cli");
|
||||
return kigi_tui::sessions_cmd::run(sessions_args).await;
|
||||
@@ -2094,23 +1901,6 @@ mod tests {
|
||||
"failure path must not flag the startup hook",
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_command_gate_resolution() {
|
||||
assert!(workspace_command_gate(Some(true)));
|
||||
assert!(!workspace_command_gate(Some(false)));
|
||||
assert!(!workspace_command_gate(None), "unset env defaults to off");
|
||||
}
|
||||
#[serial_test::serial(KIGI_WORKSPACE_COMMAND)]
|
||||
#[test]
|
||||
fn workspace_command_env_override_parsing() {
|
||||
unsafe { std::env::remove_var("KIGI_WORKSPACE_COMMAND") };
|
||||
assert_eq!(workspace_command_env_override(), None);
|
||||
unsafe { std::env::set_var("KIGI_WORKSPACE_COMMAND", "1") };
|
||||
assert_eq!(workspace_command_env_override(), Some(true));
|
||||
unsafe { std::env::set_var("KIGI_WORKSPACE_COMMAND", "off") };
|
||||
assert_eq!(workspace_command_env_override(), Some(false));
|
||||
unsafe { std::env::remove_var("KIGI_WORKSPACE_COMMAND") };
|
||||
}
|
||||
fn make_state() -> std::sync::Mutex<StdioReplayState> {
|
||||
std::sync::Mutex::new(StdioReplayState::default())
|
||||
}
|
||||
|
||||
@@ -694,11 +694,6 @@ pub struct RemoteSettings {
|
||||
/// See `Config::resolve_image_edit`.
|
||||
#[serde(default)]
|
||||
pub imagine_tools_disabled: Option<Vec<String>>,
|
||||
/// remote settings gate for the `grok workspace` CLI command (Computer Hub
|
||||
/// workspace exposure), from `grok_build_settings.workspace_command_enabled`.
|
||||
/// `Some(true)` enables it; `None`/`Some(false)` (the default) keep it off.
|
||||
#[serde(default)]
|
||||
pub workspace_command_enabled: Option<bool>,
|
||||
/// Master switch for jemalloc heap sampling + threshold dumps.
|
||||
/// `Some(true)` enables, `Some(false)` kill-switch, `None` = client default off.
|
||||
#[serde(default)]
|
||||
@@ -1308,24 +1303,6 @@ mod tests {
|
||||
assert_eq!(s.folder_trust_enabled, None);
|
||||
}
|
||||
#[test]
|
||||
fn remote_settings_workspace_command_enabled_present() {
|
||||
let json = r#"{"workspace_command_enabled": true}"#;
|
||||
let s: RemoteSettings = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(s.workspace_command_enabled, Some(true));
|
||||
}
|
||||
#[test]
|
||||
fn remote_settings_workspace_command_enabled_false() {
|
||||
let json = r#"{"workspace_command_enabled": false}"#;
|
||||
let s: RemoteSettings = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(s.workspace_command_enabled, Some(false));
|
||||
}
|
||||
#[test]
|
||||
fn remote_settings_workspace_command_enabled_absent() {
|
||||
let json = r#"{}"#;
|
||||
let s: RemoteSettings = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(s.workspace_command_enabled, None);
|
||||
}
|
||||
#[test]
|
||||
fn remote_settings_permission_mode_deserializes() {
|
||||
let s: RemoteSettings = serde_json::from_str(r#"{"permission_mode": "auto"}"#).unwrap();
|
||||
assert_eq!(s.permission_mode.as_deref(), Some("auto"));
|
||||
|
||||
@@ -67,7 +67,6 @@ libc = { workspace = true }
|
||||
[dev-dependencies]
|
||||
# Used in servers.rs test `test_same_raw_name_different_servers_no_local_registry_collision`
|
||||
# to verify MCP tools register into a `LocalRegistry` without collision.
|
||||
kigi-computer-hub-sdk = { workspace = true }
|
||||
# `test-util` enables `tokio::time::pause` / `advance`, required by
|
||||
# the deterministic timing tests in `liveness.rs`.
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -5301,7 +5301,7 @@ mod tests {
|
||||
/// into a `LocalRegistry` must preserve both entries (no silent overwrite).
|
||||
#[test]
|
||||
fn test_same_raw_name_different_servers_no_local_registry_collision() {
|
||||
use kigi_computer_hub_sdk::LocalRegistry;
|
||||
use kigi_tool_runtime::LocalRegistry;
|
||||
use kigi_tool_runtime::Tool;
|
||||
|
||||
let mcp_state = Arc::new(Mutex::new(McpState::new(vec![])));
|
||||
|
||||
@@ -21,8 +21,7 @@ pub const SCHEMA_VERSION: u32 = 1;
|
||||
/// path (`kigi_sqlite_journal::JournalMode::open`) — the journal mode depends
|
||||
/// on the database's filesystem.
|
||||
pub fn schema_sql(dimensions: usize, vec_available: bool) -> String {
|
||||
let mut sql = format!(
|
||||
r#"
|
||||
let mut sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
@@ -50,7 +49,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, content='');
|
||||
|
||||
INSERT OR IGNORE INTO meta(key, value) VALUES ('reindex_claim', '');
|
||||
"#
|
||||
);
|
||||
.to_string();
|
||||
|
||||
if vec_available {
|
||||
sql.push_str(&format!(
|
||||
|
||||
@@ -91,7 +91,7 @@ impl ContentController {
|
||||
// KIGI_SHARE_DIR is set in the test runner's env).
|
||||
("KIGI_SHARE_DIR".into(), kigi_home),
|
||||
("KIGI_CODE_BASE_URL".into(), self.url()),
|
||||
("KIGI_XAI_API_BASE_URL".into(), self.url()),
|
||||
("KIGI_API_BASE_URL".into(), self.url()),
|
||||
("XAI_API_KEY".into(), "test-key-for-ci".into()),
|
||||
("KIGI_TELEMETRY_ENABLED".into(), "false".into()),
|
||||
("KIGI_FEEDBACK_ENABLED".into(), "false".into()),
|
||||
@@ -279,7 +279,7 @@ mod tests {
|
||||
content.home().join(".kigi").to_str()
|
||||
);
|
||||
assert_eq!(get("KIGI_CODE_BASE_URL"), Some(content.url()));
|
||||
assert_eq!(get("KIGI_XAI_API_BASE_URL"), Some(content.url()));
|
||||
assert_eq!(get("KIGI_API_BASE_URL"), Some(content.url()));
|
||||
assert_eq!(get("XAI_API_KEY").as_deref(), Some("test-key-for-ci"));
|
||||
assert_eq!(get("KIGI_TELEMETRY_ENABLED").as_deref(), Some("false"));
|
||||
assert_eq!(get("KIGI_FEEDBACK_ENABLED").as_deref(), Some("false"));
|
||||
|
||||
@@ -79,7 +79,6 @@ kigi-acp-lib = { workspace = true }
|
||||
axum = { workspace = true, features = ["ws", "multipart"] }
|
||||
backon = { workspace = true }
|
||||
webbrowser = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = [
|
||||
"ring",
|
||||
"logging",
|
||||
@@ -161,8 +160,7 @@ parking_lot.workspace = true
|
||||
dashmap.workspace = true
|
||||
|
||||
# Used by acp_session.rs and mcp_servers.rs for the unified
|
||||
# kigi_tool_runtime::Tool dispatch model and the computer-hub MCP adapter.
|
||||
kigi-computer-hub-sdk = { workspace = true }
|
||||
# kigi_tool_runtime::Tool dispatch model.
|
||||
kigi-tool-runtime = { workspace = true }
|
||||
kigi-tool-protocol = { workspace = true }
|
||||
kigi-tool-types = { workspace = true }
|
||||
|
||||
@@ -518,11 +518,7 @@ pub async fn run_leader(
|
||||
lock_path: lock.lock_path().clone(),
|
||||
socket_suffix: socket_suffix_from_paths(lock.lock_path(), &socket_path).unwrap_or_default(),
|
||||
leader_binary_version: kigi_version::VERSION.to_string(),
|
||||
})
|
||||
.with_default_hub_url(agent_config.hub.url.clone());
|
||||
|
||||
// Cloned before control_state moves into the IPC server; auth wired below.
|
||||
let workspace_control = control_state.workspace.clone();
|
||||
});
|
||||
|
||||
// ── Phase 3: Bind socket and start IPC server (BEFORE auth/prefetch) ──────
|
||||
//
|
||||
@@ -654,8 +650,6 @@ pub async fn run_leader(
|
||||
// process so a refresh can't straddle a suspend.
|
||||
shared_auth_manager.start_system_power_listener();
|
||||
|
||||
// Same manager as the leader, so the exposure never writes auth.json itself.
|
||||
workspace_control.set_auth_manager(shared_auth_manager.clone());
|
||||
let auth_manager_for_agent = shared_auth_manager.clone();
|
||||
let auth_manager_for_config = shared_auth_manager;
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn default_agent_type() -> String {
|
||||
DEFAULT_AGENT_TYPE.to_owned()
|
||||
}
|
||||
/// Default base URL for the public xAI API.
|
||||
pub const XAI_API_BASE_URL_DEFAULT: &str = "https://api.x.ai/v1";
|
||||
pub const API_BASE_URL_DEFAULT: &str = "https://api.x.ai/v1";
|
||||
/// One or more environment variable names that may hold a model API key.
|
||||
///
|
||||
/// Serde `untagged`: accepts a string or an array in TOML/JSON.
|
||||
@@ -142,8 +142,10 @@ pub struct EndpointsConfig {
|
||||
/// default value) lets an org pin the proxy to the default on purpose.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub coding_api_base_url: Option<String>,
|
||||
/// Base URL for the public xAI API.
|
||||
pub xai_api_base_url: String,
|
||||
/// Base URL for direct (BYOK / external-API-key) API calls.
|
||||
/// Accepts the legacy `xai_api_base_url` config key.
|
||||
#[serde(alias = "xai_api_base_url")]
|
||||
pub api_base_url: String,
|
||||
/// Optional extra access-header value (applied only with the optional
|
||||
/// non-production feature, and only for matching first-party hosts).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -166,54 +168,6 @@ pub struct EndpointsConfig {
|
||||
/// Defaults to `{proxy_url()}/deployment/config`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub managed_config_url: Option<String>,
|
||||
/// Env: `OTEL_EXPORTER_OTLP_ENDPOINT`. OTLP collector base; `/v1/traces` is
|
||||
/// appended. Legacy repoint of the INTERNAL trace pipeline — deprecated in
|
||||
/// favor of `KIGI_INTERNAL_OTLP_TRACES_ENDPOINT`, and ignored by the internal
|
||||
/// pipeline when `KIGI_EXTERNAL_OTEL` is set (the standard `OTEL_*` vars then
|
||||
/// route the external stream only).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub otel_exporter_otlp_endpoint: Option<String>,
|
||||
/// Env: `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`. Full traces endpoint, used
|
||||
/// verbatim; overrides `otel_exporter_otlp_endpoint`. Same legacy/deprecation
|
||||
/// semantics as `otel_exporter_otlp_endpoint`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub otel_exporter_otlp_traces_endpoint: Option<String>,
|
||||
/// Env: `OTEL_EXPORTER_OTLP_HEADERS`. `k=v,k2=v2`; merged onto export headers.
|
||||
/// Same legacy/deprecation semantics as `otel_exporter_otlp_endpoint`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub otel_exporter_otlp_headers: Option<String>,
|
||||
/// Env: `KIGI_INTERNAL_OTLP_TRACES_ENDPOINT`. Full INTERNAL traces endpoint,
|
||||
/// used verbatim. Dev/debug repoint of the internal span firehose (replaces
|
||||
/// the legacy `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` behavior; used by
|
||||
/// local-ic-testing / internal dev flows). Wins over the legacy `OTEL_*` vars.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grok_internal_otlp_traces_endpoint: Option<String>,
|
||||
/// Env: `KIGI_INTERNAL_OTLP_HEADERS`. `k=v,k2=v2` extra headers for the
|
||||
/// internal export (debug). Wins over the legacy `OTEL_EXPORTER_OTLP_HEADERS`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grok_internal_otlp_headers: Option<String>,
|
||||
/// External-OTEL master switch, captured at construction via
|
||||
/// [`external_otel_master_switch_resolved`] — the same layered resolution
|
||||
/// (requirement pin > `KIGI_EXTERNAL_OTEL` env > `[telemetry].otel_enabled`
|
||||
/// config, managed layers included) that activates the external stream.
|
||||
/// When set, the standard `OTEL_EXPORTER_OTLP_*` vars are reserved for the
|
||||
/// external OTEL stream and the internal trace pipeline ignores them
|
||||
/// entirely — an admin who opts in (by *any* layer, including an org
|
||||
/// enable distributed via managed config with no env var) never receives
|
||||
/// the internally-authed firehose. Held as a field (not re-read in the
|
||||
/// resolvers) so the resolvers stay pure and testable without env races.
|
||||
#[serde(skip)]
|
||||
pub external_otel_master_switch: bool,
|
||||
/// Env: `OTEL_TRACES_EXPORTER`. `otlp` (default) or `none` to disable spans.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub otel_traces_exporter: Option<String>,
|
||||
/// Env: `OTEL_BSP_SCHEDULE_DELAY` (OTel) or `OTEL_TRACES_EXPORT_INTERVAL`
|
||||
/// (Claude alias). Batch flush interval (ms).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub otel_traces_export_interval: Option<u64>,
|
||||
/// Env: `OTEL_EXPORTER_OTLP_TIMEOUT`. Export HTTP timeout (ms).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub otel_exporter_otlp_timeout: Option<u64>,
|
||||
/// Read by `load_management_api_key_sync()`. Declared for `serde_ignored`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub management_api_key: Option<String>,
|
||||
@@ -228,18 +182,6 @@ fn blank_as_unset(opt: &Option<String>) -> Option<String> {
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
/// Parse a `k=v,k2=v2` OTLP header list (the `OTEL_EXPORTER_OTLP_HEADERS`
|
||||
/// format, shared with `KIGI_INTERNAL_OTLP_HEADERS`): split on `,`,
|
||||
/// `split_once('=')`, trim key/value, skip blank keys, keep empty values.
|
||||
fn parse_otlp_header_list(raw: &str) -> Vec<(String, String)> {
|
||||
raw.split(',')
|
||||
.filter_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
let k = k.trim();
|
||||
(!k.is_empty()).then(|| (k.to_string(), v.trim().to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
impl EndpointsConfig {
|
||||
pub fn has_custom_endpoint(&self) -> bool {
|
||||
self.models_base_url.is_some() || self.models_list_url.is_some()
|
||||
@@ -257,22 +199,18 @@ impl EndpointsConfig {
|
||||
/// Layer the `[endpoints]` table from `config` over the env/default base.
|
||||
/// No field is derived from another — defaulting is done by the resolvers.
|
||||
pub(crate) fn from_config_value(config: &toml::Value) -> Self {
|
||||
let default = Self::default();
|
||||
let external_otel_master_switch = default.external_otel_master_switch;
|
||||
let mut base = match toml::Value::try_from(default) {
|
||||
let mut base = match toml::Value::try_from(Self::default()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Self::default(),
|
||||
};
|
||||
if let Some(endpoints) = config.get("endpoints") {
|
||||
crate::config::deep_merge_toml(&mut base, endpoints);
|
||||
}
|
||||
let mut resolved: Self = base.try_into().unwrap_or_default();
|
||||
resolved.external_otel_master_switch = external_otel_master_switch;
|
||||
resolved
|
||||
base.try_into().unwrap_or_default()
|
||||
}
|
||||
/// The subscription proxy base URL through which all auxiliary services (and
|
||||
/// OAuth/session inference) resolve: explicit `coding_api_base_url`, else
|
||||
/// [`kigi_env::coding_api_base_url`]. NEVER falls back to `xai_api_base_url` —
|
||||
/// [`kigi_env::coding_api_base_url`]. NEVER falls back to `api_base_url` —
|
||||
/// that is the inference endpoint (API-key auth) only.
|
||||
pub fn proxy_url(&self) -> String {
|
||||
blank_as_unset(&self.coding_api_base_url).unwrap_or_else(kigi_env::coding_api_base_url)
|
||||
@@ -283,12 +221,12 @@ impl EndpointsConfig {
|
||||
.unwrap_or_else(|| self.proxy_url())
|
||||
}
|
||||
/// Feedback endpoint — an auxiliary service, so it defaults to the
|
||||
/// cli-chat-proxy, never `xai_api_base_url`.
|
||||
/// cli-chat-proxy, never `api_base_url`.
|
||||
pub fn resolve_feedback_base_url(&self) -> String {
|
||||
blank_as_unset(&self.feedback_base_url).unwrap_or_else(|| self.proxy_url())
|
||||
}
|
||||
/// Managed deployment-config URL (`grok setup`): explicit `managed_config_url`,
|
||||
/// else `proxy_url` + `/deployment/config`. Never `xai_api_base_url`, so the
|
||||
/// else `proxy_url` + `/deployment/config`. Never `api_base_url`, so the
|
||||
/// deployment key reaches the proxy, not the inference host.
|
||||
pub fn resolve_managed_config_url(&self) -> String {
|
||||
blank_as_unset(&self.managed_config_url).unwrap_or_else(|| {
|
||||
@@ -298,99 +236,6 @@ impl EndpointsConfig {
|
||||
)
|
||||
})
|
||||
}
|
||||
/// INTERNAL OTLP traces endpoint. Precedence:
|
||||
/// 1. `grok_internal_otlp_traces_endpoint` (verbatim)
|
||||
/// 2. legacy `otel_exporter_otlp_traces_endpoint` (verbatim) >
|
||||
/// `otel_exporter_otlp_endpoint` + `/v1/traces` — ONLY when the
|
||||
/// external-OTEL master switch is unset (back-compat; deprecated)
|
||||
/// 3. `proxy_url` + `/traces`.
|
||||
/// Uses the proxy default (not the `xai_api_base_url` fallback) so
|
||||
/// telemetry reports to xAI even when inference is overridden. When the
|
||||
/// master switch IS set, the standard `OTEL_EXPORTER_OTLP_*` values are
|
||||
/// completely ignored here so the internally-authed firehose never lands
|
||||
/// at an external collector.
|
||||
pub fn resolve_otlp_traces_endpoint(&self) -> String {
|
||||
if let Some(full) = blank_as_unset(&self.grok_internal_otlp_traces_endpoint) {
|
||||
return full.trim_end_matches('/').to_string();
|
||||
}
|
||||
if !self.external_otel_master_switch
|
||||
&& let Some(legacy) = self.legacy_internal_otlp_traces_endpoint()
|
||||
{
|
||||
tracing::warn!(
|
||||
"Repointing the internal trace pipeline via OTEL_EXPORTER_OTLP_ENDPOINT / \
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is deprecated; use \
|
||||
KIGI_INTERNAL_OTLP_TRACES_ENDPOINT instead — the standard OTEL_* vars will \
|
||||
route the external OTEL stream only in a future release"
|
||||
);
|
||||
return legacy;
|
||||
}
|
||||
format!("{}/traces", self.proxy_url().trim_end_matches('/'))
|
||||
}
|
||||
/// Legacy (standard-OTEL-var) internal traces endpoint, if any:
|
||||
/// `otel_exporter_otlp_traces_endpoint` verbatim, else
|
||||
/// `otel_exporter_otlp_endpoint` + `/v1/traces`. Ignores the master switch.
|
||||
fn legacy_internal_otlp_traces_endpoint(&self) -> Option<String> {
|
||||
if let Some(full) = blank_as_unset(&self.otel_exporter_otlp_traces_endpoint) {
|
||||
return Some(full.trim_end_matches('/').to_string());
|
||||
}
|
||||
blank_as_unset(&self.otel_exporter_otlp_endpoint)
|
||||
.map(|base| format!("{}/v1/traces", base.trim_end_matches('/')))
|
||||
}
|
||||
/// Extra headers for the INTERNAL export: `grok_internal_otlp_headers`
|
||||
/// first; legacy fallback to `otel_exporter_otlp_headers` ONLY when the
|
||||
/// external-OTEL master switch is unset (back-compat for existing users).
|
||||
pub fn resolve_otlp_headers(&self) -> Vec<(String, String)> {
|
||||
if let Some(headers) = blank_as_unset(&self.grok_internal_otlp_headers) {
|
||||
return parse_otlp_header_list(&headers);
|
||||
}
|
||||
if !self.external_otel_master_switch {
|
||||
return parse_otlp_header_list(
|
||||
self.otel_exporter_otlp_headers.as_deref().unwrap_or(""),
|
||||
);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
/// Whether the legacy fallback actually supplied the internal endpoint OR
|
||||
/// internal headers from the standard `OTEL_EXPORTER_OTLP_*` vars — i.e.
|
||||
/// the master switch is unset AND (`otel_exporter_otlp_traces_endpoint` /
|
||||
/// `otel_exporter_otlp_endpoint` is non-blank for the endpoint, or
|
||||
/// `otel_exporter_otlp_headers` is non-blank for headers) AND no
|
||||
/// `grok_internal_otlp_*` override shadowed that half.
|
||||
///
|
||||
/// CONTRACT: this flag is passed to the external OTEL stream's init, which
|
||||
/// MUST refuse to activate when it is true — the same standard vars cannot
|
||||
/// feed both pipelines (no-double-send invariant, enforced in code).
|
||||
pub fn internal_otlp_consumed_standard_vars(&self) -> bool {
|
||||
if self.external_otel_master_switch {
|
||||
return false;
|
||||
}
|
||||
let endpoint_consumed = blank_as_unset(&self.grok_internal_otlp_traces_endpoint).is_none()
|
||||
&& self.legacy_internal_otlp_traces_endpoint().is_some();
|
||||
let headers_consumed = blank_as_unset(&self.grok_internal_otlp_headers).is_none()
|
||||
&& blank_as_unset(&self.otel_exporter_otlp_headers).is_some();
|
||||
endpoint_consumed || headers_consumed
|
||||
}
|
||||
/// Trace export enabled unless `OTEL_TRACES_EXPORTER=none`. Deliberately
|
||||
/// still honored by the internal pipeline even with `KIGI_EXTERNAL_OTEL`
|
||||
/// set: disabling internal span export is the safe direction.
|
||||
pub fn resolve_traces_export_enabled(&self) -> bool {
|
||||
!matches!(
|
||||
self.otel_traces_exporter.as_deref().map(str::trim),
|
||||
Some("none")
|
||||
)
|
||||
}
|
||||
/// `OTEL_BSP_SCHEDULE_DELAY` / `OTEL_TRACES_EXPORT_INTERVAL` — tuning-only,
|
||||
/// deliberately shared between the internal and external pipelines.
|
||||
pub fn resolve_otlp_export_interval(&self) -> Option<std::time::Duration> {
|
||||
self.otel_traces_export_interval
|
||||
.map(std::time::Duration::from_millis)
|
||||
}
|
||||
/// `OTEL_EXPORTER_OTLP_TIMEOUT` — tuning-only, deliberately shared between
|
||||
/// the internal and external pipelines.
|
||||
pub fn resolve_otlp_timeout(&self) -> Option<std::time::Duration> {
|
||||
self.otel_exporter_otlp_timeout
|
||||
.map(std::time::Duration::from_millis)
|
||||
}
|
||||
/// `models_list_url` > `{models_base_url}/models` > `{proxy_base_url}/models`.
|
||||
pub fn resolve_models_list_url(&self) -> String {
|
||||
if let Some(ref url) = self.models_list_url {
|
||||
@@ -407,26 +252,14 @@ impl Default for EndpointsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
coding_api_base_url: std::env::var("KIGI_CODE_BASE_URL").ok(),
|
||||
xai_api_base_url: std::env::var("KIGI_XAI_API_BASE_URL")
|
||||
.unwrap_or_else(|_| XAI_API_BASE_URL_DEFAULT.to_owned()),
|
||||
api_base_url: std::env::var("KIGI_API_BASE_URL")
|
||||
.unwrap_or_else(|_| API_BASE_URL_DEFAULT.to_owned()),
|
||||
alpha_test_key: None,
|
||||
models_base_url: env_string("KIGI_MODELS_BASE_URL"),
|
||||
models_list_url: env_string("KIGI_MODELS_LIST_URL"),
|
||||
feedback_base_url: env_string("KIGI_FEEDBACK_BASE_URL"),
|
||||
deployment_key: env_string("KIGI_DEPLOYMENT_KEY"),
|
||||
managed_config_url: env_string("KIGI_MANAGED_CONFIG_URL"),
|
||||
otel_exporter_otlp_endpoint: env_string("OTEL_EXPORTER_OTLP_ENDPOINT"),
|
||||
otel_exporter_otlp_traces_endpoint: env_string("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"),
|
||||
otel_exporter_otlp_headers: env_string("OTEL_EXPORTER_OTLP_HEADERS"),
|
||||
grok_internal_otlp_traces_endpoint: env_string("KIGI_INTERNAL_OTLP_TRACES_ENDPOINT"),
|
||||
grok_internal_otlp_headers: env_string("KIGI_INTERNAL_OTLP_HEADERS"),
|
||||
external_otel_master_switch: external_otel_master_switch_resolved(),
|
||||
otel_traces_exporter: env_string("OTEL_TRACES_EXPORTER"),
|
||||
otel_traces_export_interval: env_string("OTEL_BSP_SCHEDULE_DELAY")
|
||||
.or_else(|| env_string("OTEL_TRACES_EXPORT_INTERVAL"))
|
||||
.and_then(|s| s.parse().ok()),
|
||||
otel_exporter_otlp_timeout: env_string("OTEL_EXPORTER_OTLP_TIMEOUT")
|
||||
.and_then(|s| s.parse().ok()),
|
||||
management_api_key: None,
|
||||
gcs_service_account_key: None,
|
||||
}
|
||||
@@ -1005,30 +838,6 @@ pub struct RemoteConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
/// `[hub]` section from config.toml.
|
||||
///
|
||||
/// Optional default Computer Hub URL for **workspace provider** exposure
|
||||
/// (`grok workspace` / leader `with_default_hub_url`). Does **not** enable
|
||||
/// agent-side harness/client connections or alter local session behavior.
|
||||
///
|
||||
/// ```toml
|
||||
/// [hub]
|
||||
/// url = "wss://hub.x.ai/ws"
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HubConfig {
|
||||
/// Hub WebSocket URL (`ws://` or `wss://`) used as the leader default for
|
||||
/// `grok workspace start` when the CLI does not pass `--hub-url`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
impl HubConfig {
|
||||
/// Whether a non-empty hub URL is configured (workspace default only).
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.url.as_ref().is_some_and(|u| !u.trim().is_empty())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WorktreePoolConfig {
|
||||
@@ -1250,9 +1059,6 @@ pub struct Config {
|
||||
pub harness: HarnessConfig,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub remote: RemoteConfig,
|
||||
/// Computer Hub configuration (`[hub]` in config.toml).
|
||||
#[serde(default, skip_serializing)]
|
||||
pub hub: HubConfig,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub worktree_pool: WorktreePoolConfig,
|
||||
#[serde(default, skip_serializing)]
|
||||
@@ -1632,7 +1438,6 @@ impl Default for Config {
|
||||
platforms: PlatformsConfig::default(),
|
||||
harness: HarnessConfig::default(),
|
||||
remote: RemoteConfig::default(),
|
||||
hub: HubConfig::default(),
|
||||
worktree_pool: WorktreePoolConfig::default(),
|
||||
sandbox: SandboxSettingsConfig::default(),
|
||||
mcp_servers: std::collections::HashMap::new(),
|
||||
@@ -2703,41 +2508,6 @@ pub(crate) fn read_requirements_toml() -> Option<toml::Value> {
|
||||
pub fn deployment_id_from_key(key: &str) -> String {
|
||||
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, key.as_bytes()).to_string()
|
||||
}
|
||||
/// Resolve the external-OTEL master switch exactly the way the external
|
||||
/// stream's activation does: **requirement pin > `KIGI_EXTERNAL_OTEL` env >
|
||||
/// `[telemetry].otel_enabled` config layer (managed config included) > off**.
|
||||
///
|
||||
/// The internal trace pipeline keys its "ignore `OTEL_EXPORTER_OTLP_*`"
|
||||
/// behavior off this value ([`EndpointsConfig::external_otel_master_switch`]),
|
||||
/// so an org enable distributed via managed config / requirements (no env
|
||||
/// var) flips **both** sides together. A desync here would leave the
|
||||
/// internally-authed firehose honoring legacy `OTEL_*` repointing while
|
||||
/// `internal_pipeline_consumed_otel_vars` simultaneously blocks the external
|
||||
/// stream — exactly the split this design forbids.
|
||||
pub(crate) fn external_otel_master_switch_resolved() -> bool {
|
||||
external_otel_master_switch_from(
|
||||
kigi_config::load_merged_requirements().as_ref(),
|
||||
env_bool("KIGI_EXTERNAL_OTEL"),
|
||||
crate::config::load_effective_config().ok().as_ref(),
|
||||
)
|
||||
}
|
||||
/// Testable core of [`external_otel_master_switch_resolved`].
|
||||
pub(crate) fn external_otel_master_switch_from(
|
||||
requirements: Option<&toml::Value>,
|
||||
env_switch: Option<bool>,
|
||||
effective_config: Option<&toml::Value>,
|
||||
) -> bool {
|
||||
let table_enabled = |v: Option<&toml::Value>| -> Option<bool> {
|
||||
v?.get("telemetry")?.get("otel_enabled")?.as_bool()
|
||||
};
|
||||
if let Some(pinned) = table_enabled(requirements) {
|
||||
return pinned;
|
||||
}
|
||||
if let Some(env) = env_switch {
|
||||
return env;
|
||||
}
|
||||
table_enabled(effective_config).unwrap_or(false)
|
||||
}
|
||||
/// Seed free-function remote caches after writing `Config.remote_settings`.
|
||||
pub fn apply_remote_settings_side_effects(settings: Option<&crate::util::config::RemoteSettings>) {
|
||||
crate::util::config::cache_remote_mcp_startup_timeout_secs(
|
||||
@@ -4983,7 +4753,7 @@ reasoning_effort = "low"
|
||||
auth_scheme: AuthScheme::Bearer,
|
||||
};
|
||||
assert_eq!(
|
||||
api_key_creds.base_url, endpoints.xai_api_base_url,
|
||||
api_key_creds.base_url, endpoints.api_base_url,
|
||||
"{model_id}: ExternalApiKey must route to api.x.ai"
|
||||
);
|
||||
}
|
||||
@@ -6890,24 +6660,18 @@ reasoning_effort = "low"
|
||||
for k in [
|
||||
"KIGI_CODE_BASE_URL",
|
||||
kigi_env::CODE_BASE_URL_ENV,
|
||||
"KIGI_XAI_API_BASE_URL",
|
||||
"KIGI_API_BASE_URL",
|
||||
"KIGI_FEEDBACK_BASE_URL",
|
||||
"KIGI_TRACE_UPLOAD_URL",
|
||||
"KIGI_MANAGED_CONFIG_URL",
|
||||
"KIGI_MODELS_BASE_URL",
|
||||
"KIGI_MODELS_LIST_URL",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"KIGI_INTERNAL_OTLP_TRACES_ENDPOINT",
|
||||
"KIGI_INTERNAL_OTLP_HEADERS",
|
||||
"KIGI_EXTERNAL_OTEL",
|
||||
] {
|
||||
unsafe { std::env::remove_var(k) };
|
||||
}
|
||||
}
|
||||
/// INVARIANT: auxiliary-service resolvers resolve to the cli-chat-proxy, never
|
||||
/// `xai_api_base_url` — overriding ONLY inference keeps every aux endpoint on
|
||||
/// `api_base_url` — overriding ONLY inference keeps every aux endpoint on
|
||||
/// the proxy; explicit per-service overrides win verbatim.
|
||||
#[test]
|
||||
#[serial]
|
||||
@@ -6915,7 +6679,7 @@ reasoning_effort = "low"
|
||||
unset_endpoint_env_vars();
|
||||
let inference = "https://inference.acme-corp.example/xai/v1";
|
||||
let cfg = EndpointsConfig {
|
||||
xai_api_base_url: inference.to_string(),
|
||||
api_base_url: inference.to_string(),
|
||||
coding_api_base_url: None,
|
||||
..Default::default()
|
||||
};
|
||||
@@ -6928,11 +6692,7 @@ reasoning_effort = "low"
|
||||
format!("{proxy}/deployment/config")
|
||||
);
|
||||
assert_eq!(cfg.resolve_feedback_base_url(), proxy);
|
||||
assert_eq!(
|
||||
cfg.resolve_otlp_traces_endpoint(),
|
||||
format!("{proxy}/traces")
|
||||
);
|
||||
assert_eq!(cfg.xai_api_base_url, inference);
|
||||
assert_eq!(cfg.api_base_url, inference);
|
||||
let overridden = EndpointsConfig {
|
||||
coding_api_base_url: Some("https://proxy.enterprise.example/v1".to_string()),
|
||||
managed_config_url: Some(
|
||||
@@ -6945,10 +6705,6 @@ reasoning_effort = "low"
|
||||
overridden.proxy_url(),
|
||||
"https://proxy.enterprise.example/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
overridden.resolve_otlp_traces_endpoint(),
|
||||
"https://proxy.enterprise.example/v1/traces"
|
||||
);
|
||||
assert_eq!(
|
||||
overridden.resolve_managed_config_url(),
|
||||
"https://control.enterprise.example/deployment/config"
|
||||
@@ -6958,7 +6714,7 @@ reasoning_effort = "low"
|
||||
"https://feedback.enterprise.example"
|
||||
);
|
||||
}
|
||||
/// REGRESSION: the managed-config URL never follows `xai_api_base_url`
|
||||
/// REGRESSION: the managed-config URL never follows `api_base_url`
|
||||
/// through the full loader `Config::new_from_toml_cfg` — a distinct construction
|
||||
/// path from `from_config_value`, so the deployment key never reaches the
|
||||
/// inference host on either.
|
||||
@@ -6969,7 +6725,7 @@ reasoning_effort = "low"
|
||||
let cfg = Config::new_from_toml_cfg(
|
||||
&toml::from_str(
|
||||
r#"[endpoints]
|
||||
xai_api_base_url = "https://inference.acme-corp.example/xai/v1""#,
|
||||
api_base_url = "https://inference.acme-corp.example/xai/v1""#,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -8467,311 +8223,6 @@ agent_type = "cursor"
|
||||
"exactly the typo'd key must be flagged"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn otlp_traces_endpoint_precedence() {
|
||||
let proxy = "https://inference.acme.com/v1".to_string();
|
||||
let derived = EndpointsConfig {
|
||||
coding_api_base_url: Some(proxy.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
derived.resolve_otlp_traces_endpoint(),
|
||||
"https://inference.acme.com/v1/traces"
|
||||
);
|
||||
let base = EndpointsConfig {
|
||||
coding_api_base_url: Some(proxy.clone()),
|
||||
otel_exporter_otlp_endpoint: Some("https://otel.acme.com".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
base.resolve_otlp_traces_endpoint(),
|
||||
"https://otel.acme.com/v1/traces"
|
||||
);
|
||||
let full = EndpointsConfig {
|
||||
coding_api_base_url: Some(proxy),
|
||||
otel_exporter_otlp_endpoint: Some("https://ignored.example".to_string()),
|
||||
otel_exporter_otlp_traces_endpoint: Some("https://otel.acme.com/v1/traces".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
full.resolve_otlp_traces_endpoint(),
|
||||
"https://otel.acme.com/v1/traces"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn otlp_headers_parse() {
|
||||
let cfg = EndpointsConfig {
|
||||
otel_exporter_otlp_headers: Some("a=1, b = 2 ,=skip,c=".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.resolve_otlp_headers(),
|
||||
vec![
|
||||
("a".to_string(), "1".to_string()),
|
||||
("b".to_string(), "2".to_string()),
|
||||
("c".to_string(), String::new()),
|
||||
]
|
||||
);
|
||||
}
|
||||
/// Base config for the internal-OTLP tests: pinned proxy, every OTLP knob
|
||||
/// explicitly unset so ambient env (via `Default`) can't leak in.
|
||||
fn internal_otlp_test_config() -> EndpointsConfig {
|
||||
EndpointsConfig {
|
||||
coding_api_base_url: Some("https://proxy.example/v1".to_string()),
|
||||
otel_exporter_otlp_endpoint: None,
|
||||
otel_exporter_otlp_traces_endpoint: None,
|
||||
otel_exporter_otlp_headers: None,
|
||||
grok_internal_otlp_traces_endpoint: None,
|
||||
grok_internal_otlp_headers: None,
|
||||
external_otel_master_switch: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `grok_internal_otlp_traces_endpoint` wins over the legacy `OTEL_*`
|
||||
/// fields regardless of the master switch.
|
||||
#[test]
|
||||
fn internal_otlp_endpoint_grok_internal_wins_regardless_of_switch() {
|
||||
for switch in [false, true] {
|
||||
let cfg = EndpointsConfig {
|
||||
grok_internal_otlp_traces_endpoint: Some(
|
||||
"https://internal.example/traces/".to_string(),
|
||||
),
|
||||
otel_exporter_otlp_traces_endpoint: Some(
|
||||
"https://legacy.example/v1/traces".to_string(),
|
||||
),
|
||||
otel_exporter_otlp_endpoint: Some("https://legacy-base.example".to_string()),
|
||||
external_otel_master_switch: switch,
|
||||
..internal_otlp_test_config()
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.resolve_otlp_traces_endpoint(),
|
||||
"https://internal.example/traces",
|
||||
"switch={switch}: KIGI_INTERNAL_OTLP_TRACES_ENDPOINT must win verbatim (trailing / trimmed)"
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Master switch unset → legacy fallback preserved (back-compat).
|
||||
#[test]
|
||||
fn internal_otlp_endpoint_legacy_fallback_when_switch_unset() {
|
||||
let traces = EndpointsConfig {
|
||||
otel_exporter_otlp_traces_endpoint: Some(
|
||||
"https://legacy.example/v1/traces".to_string(),
|
||||
),
|
||||
..internal_otlp_test_config()
|
||||
};
|
||||
assert_eq!(
|
||||
traces.resolve_otlp_traces_endpoint(),
|
||||
"https://legacy.example/v1/traces"
|
||||
);
|
||||
let base = EndpointsConfig {
|
||||
otel_exporter_otlp_endpoint: Some("https://legacy-base.example/".to_string()),
|
||||
..internal_otlp_test_config()
|
||||
};
|
||||
assert_eq!(
|
||||
base.resolve_otlp_traces_endpoint(),
|
||||
"https://legacy-base.example/v1/traces"
|
||||
);
|
||||
}
|
||||
/// Master switch SET → legacy `OTEL_*` endpoint/headers are completely
|
||||
/// ignored by the internal pipeline (the external stream owns them); the
|
||||
/// internal pipeline falls back to the proxy default and
|
||||
/// `internal_otlp_consumed_standard_vars()` is false.
|
||||
#[test]
|
||||
fn internal_otlp_ignores_legacy_vars_when_switch_set() {
|
||||
let cfg = EndpointsConfig {
|
||||
otel_exporter_otlp_traces_endpoint: Some(
|
||||
"https://admin-collector.example/v1/traces".to_string(),
|
||||
),
|
||||
otel_exporter_otlp_endpoint: Some("https://admin-collector.example".to_string()),
|
||||
otel_exporter_otlp_headers: Some("authorization=Bearer admin".to_string()),
|
||||
external_otel_master_switch: true,
|
||||
..internal_otlp_test_config()
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.resolve_otlp_traces_endpoint(),
|
||||
"https://proxy.example/v1/traces",
|
||||
"internal firehose must never follow OTEL_* to the external collector"
|
||||
);
|
||||
assert_eq!(cfg.resolve_otlp_headers(), Vec::<(String, String)>::new());
|
||||
assert!(!cfg.internal_otlp_consumed_standard_vars());
|
||||
}
|
||||
/// `internal_otlp_consumed_standard_vars()` truth table.
|
||||
#[test]
|
||||
fn internal_otlp_consumed_standard_vars_cases() {
|
||||
struct Case {
|
||||
switch: bool,
|
||||
legacy_traces_ep: bool,
|
||||
legacy_base_ep: bool,
|
||||
legacy_headers: bool,
|
||||
internal_ep: bool,
|
||||
internal_headers: bool,
|
||||
expected: bool,
|
||||
why: &'static str,
|
||||
}
|
||||
let unset = Case {
|
||||
switch: false,
|
||||
legacy_traces_ep: false,
|
||||
legacy_base_ep: false,
|
||||
legacy_headers: false,
|
||||
internal_ep: false,
|
||||
internal_headers: false,
|
||||
expected: false,
|
||||
why: "nothing set",
|
||||
};
|
||||
let cases = [
|
||||
Case { ..unset },
|
||||
Case {
|
||||
legacy_traces_ep: true,
|
||||
expected: true,
|
||||
why: "legacy traces endpoint consumed",
|
||||
..unset
|
||||
},
|
||||
Case {
|
||||
legacy_base_ep: true,
|
||||
expected: true,
|
||||
why: "legacy base endpoint consumed",
|
||||
..unset
|
||||
},
|
||||
Case {
|
||||
legacy_headers: true,
|
||||
expected: true,
|
||||
why: "legacy headers consumed",
|
||||
..unset
|
||||
},
|
||||
Case {
|
||||
legacy_traces_ep: true,
|
||||
internal_ep: true,
|
||||
expected: false,
|
||||
why: "internal endpoint shadows legacy",
|
||||
..unset
|
||||
},
|
||||
Case {
|
||||
legacy_headers: true,
|
||||
internal_headers: true,
|
||||
expected: false,
|
||||
why: "internal headers shadow legacy",
|
||||
..unset
|
||||
},
|
||||
Case {
|
||||
legacy_traces_ep: true,
|
||||
legacy_headers: true,
|
||||
internal_ep: true,
|
||||
expected: true,
|
||||
why: "endpoint shadowed but legacy headers still consumed (headers half)",
|
||||
..unset
|
||||
},
|
||||
Case {
|
||||
switch: true,
|
||||
legacy_traces_ep: true,
|
||||
legacy_base_ep: true,
|
||||
legacy_headers: true,
|
||||
expected: false,
|
||||
why: "switch set: legacy vars ignored",
|
||||
..unset
|
||||
},
|
||||
];
|
||||
for case in cases {
|
||||
let cfg = EndpointsConfig {
|
||||
external_otel_master_switch: case.switch,
|
||||
otel_exporter_otlp_traces_endpoint: case
|
||||
.legacy_traces_ep
|
||||
.then(|| "https://legacy.example/v1/traces".to_string()),
|
||||
otel_exporter_otlp_endpoint: case
|
||||
.legacy_base_ep
|
||||
.then(|| "https://legacy-base.example".to_string()),
|
||||
otel_exporter_otlp_headers: case.legacy_headers.then(|| "k=v".to_string()),
|
||||
grok_internal_otlp_traces_endpoint: case
|
||||
.internal_ep
|
||||
.then(|| "https://internal.example/traces".to_string()),
|
||||
grok_internal_otlp_headers: case.internal_headers.then(|| "ik=iv".to_string()),
|
||||
..internal_otlp_test_config()
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.internal_otlp_consumed_standard_vars(),
|
||||
case.expected,
|
||||
"case: {}",
|
||||
case.why
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Headers precedence: `grok_internal_otlp_headers` wins; legacy
|
||||
/// `otel_exporter_otlp_headers` only when the master switch is unset.
|
||||
#[test]
|
||||
fn internal_otlp_headers_precedence() {
|
||||
for switch in [false, true] {
|
||||
let cfg = EndpointsConfig {
|
||||
grok_internal_otlp_headers: Some("x-debug=1".to_string()),
|
||||
otel_exporter_otlp_headers: Some("legacy=1".to_string()),
|
||||
external_otel_master_switch: switch,
|
||||
..internal_otlp_test_config()
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.resolve_otlp_headers(),
|
||||
vec![("x-debug".to_string(), "1".to_string())],
|
||||
"switch={switch}"
|
||||
);
|
||||
}
|
||||
let legacy = EndpointsConfig {
|
||||
otel_exporter_otlp_headers: Some("legacy=1".to_string()),
|
||||
..internal_otlp_test_config()
|
||||
};
|
||||
assert_eq!(
|
||||
legacy.resolve_otlp_headers(),
|
||||
vec![("legacy".to_string(), "1".to_string())]
|
||||
);
|
||||
}
|
||||
/// Regression: an org enable via `[telemetry].otel_enabled`
|
||||
/// (managed config / requirements — no `KIGI_EXTERNAL_OTEL` env var) must
|
||||
/// flip the master switch the *internal* pipeline keys off, so legacy
|
||||
/// `OTEL_EXPORTER_OTLP_*` repointing shuts off in lockstep with the
|
||||
/// external stream activating. A desync would point the internally-authed
|
||||
/// firehose at the customer collector while
|
||||
/// `internal_pipeline_consumed_otel_vars` blocks the external stream.
|
||||
#[test]
|
||||
fn external_otel_master_switch_resolves_from_all_layers() {
|
||||
let enabled_table: toml::Value =
|
||||
toml::from_str("[telemetry]\notel_enabled = true").unwrap();
|
||||
let disabled_table: toml::Value =
|
||||
toml::from_str("[telemetry]\notel_enabled = false").unwrap();
|
||||
assert!(external_otel_master_switch_from(
|
||||
None,
|
||||
None,
|
||||
Some(&enabled_table)
|
||||
));
|
||||
assert!(!external_otel_master_switch_from(None, None, None));
|
||||
assert!(!external_otel_master_switch_from(
|
||||
None,
|
||||
Some(false),
|
||||
Some(&enabled_table)
|
||||
));
|
||||
assert!(external_otel_master_switch_from(
|
||||
None,
|
||||
Some(true),
|
||||
Some(&disabled_table)
|
||||
));
|
||||
assert!(!external_otel_master_switch_from(
|
||||
Some(&disabled_table),
|
||||
Some(true),
|
||||
Some(&enabled_table)
|
||||
));
|
||||
assert!(external_otel_master_switch_from(
|
||||
Some(&enabled_table),
|
||||
Some(false),
|
||||
None
|
||||
));
|
||||
let cfg = EndpointsConfig {
|
||||
otel_exporter_otlp_traces_endpoint: Some(
|
||||
"https://collector.corp:4318/v1/traces".into(),
|
||||
),
|
||||
external_otel_master_switch: true,
|
||||
..internal_otlp_test_config()
|
||||
};
|
||||
assert!(!cfg.internal_otlp_consumed_standard_vars());
|
||||
assert!(
|
||||
!cfg.resolve_otlp_traces_endpoint()
|
||||
.contains("collector.corp")
|
||||
);
|
||||
}
|
||||
fn empty_config() -> toml::Value {
|
||||
toml::Value::Table(toml::map::Map::new())
|
||||
}
|
||||
@@ -9820,26 +9271,6 @@ default = "grok-4.5"
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn hub_config_default_has_no_url() {
|
||||
assert!(HubConfig::default().url.is_none());
|
||||
assert!(!HubConfig::default().is_enabled());
|
||||
}
|
||||
#[test]
|
||||
fn hub_config_is_enabled_only_for_nonempty_url() {
|
||||
assert!(
|
||||
HubConfig {
|
||||
url: Some("wss://hub.example/ws".into()),
|
||||
}
|
||||
.is_enabled()
|
||||
);
|
||||
assert!(
|
||||
!HubConfig {
|
||||
url: Some(" ".into()),
|
||||
}
|
||||
.is_enabled()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn resolve_model_list_prunes_bundled_entries_not_in_prefetch() {
|
||||
let cfg = Config::default();
|
||||
let mut defs = default_model_entries(&EndpointsConfig::default());
|
||||
|
||||
@@ -12,7 +12,6 @@ pub mod init;
|
||||
pub mod models;
|
||||
pub(crate) mod models_fetch;
|
||||
pub mod mvp_agent;
|
||||
pub(crate) mod proxy;
|
||||
pub(crate) mod restore_code;
|
||||
pub mod roster;
|
||||
pub mod server;
|
||||
|
||||
@@ -1323,7 +1323,7 @@ const CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
struct ModelsCache {
|
||||
fetched_at: DateTime<Utc>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
grok_version: Option<String>,
|
||||
kigi_version: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
auth_method: Option<CacheAuthMethod>,
|
||||
/// Models-list URL this catalog was fetched from
|
||||
@@ -1418,7 +1418,7 @@ impl ModelsCacheManager {
|
||||
) -> Option<ModelsCache> {
|
||||
let data = std::fs::read(&self.path).ok()?;
|
||||
let cache: ModelsCache = serde_json::from_slice(&data).ok()?;
|
||||
if cache.grok_version.as_deref() != Some(kigi_version::VERSION) {
|
||||
if cache.kigi_version.as_deref() != Some(kigi_version::VERSION) {
|
||||
tracing::debug!("models cache version mismatch");
|
||||
return None;
|
||||
}
|
||||
@@ -1447,7 +1447,7 @@ impl ModelsCacheManager {
|
||||
) {
|
||||
let cache = ModelsCache {
|
||||
fetched_at: Utc::now(),
|
||||
grok_version: Some(kigi_version::VERSION.to_string()),
|
||||
kigi_version: Some(kigi_version::VERSION.to_string()),
|
||||
auth_method: Some(auth_method),
|
||||
origin: Some(origin.to_string()),
|
||||
etag: etag.map(|s| s.to_string()),
|
||||
@@ -3064,7 +3064,7 @@ mod tests {
|
||||
let auth_method = mgr.inner.fetch_auth.read().cache_auth_method();
|
||||
let stale = ModelsCache {
|
||||
fetched_at: Utc::now() - ChronoDuration::seconds(3600),
|
||||
grok_version: Some(kigi_version::VERSION.to_string()),
|
||||
kigi_version: Some(kigi_version::VERSION.to_string()),
|
||||
auth_method: Some(auth_method),
|
||||
origin: Some(mgr.cache_origin()),
|
||||
etag: Some("etag-stale".into()),
|
||||
@@ -3140,7 +3140,7 @@ mod tests {
|
||||
let auth_method = mgr.inner.fetch_auth.read().cache_auth_method();
|
||||
let legacy = ModelsCache {
|
||||
fetched_at: Utc::now(),
|
||||
grok_version: Some(kigi_version::VERSION.to_string()),
|
||||
kigi_version: Some(kigi_version::VERSION.to_string()),
|
||||
auth_method: Some(auth_method),
|
||||
origin: None,
|
||||
etag: Some("etag-legacy".into()),
|
||||
@@ -4100,7 +4100,7 @@ mod tests {
|
||||
let cache = ModelsCacheManager::new();
|
||||
let stale = ModelsCache {
|
||||
fetched_at: Utc::now() - ChronoDuration::seconds(86_400),
|
||||
grok_version: Some(kigi_version::VERSION.to_string()),
|
||||
kigi_version: Some(kigi_version::VERSION.to_string()),
|
||||
auth_method: Some(CacheAuthMethod::Platforms),
|
||||
origin: Some(origin),
|
||||
etag: None,
|
||||
@@ -4187,7 +4187,7 @@ mod tests {
|
||||
let cache = ModelsCacheManager::new();
|
||||
cache.atomic_write(&ModelsCache {
|
||||
fetched_at: Utc::now() - ChronoDuration::seconds(86_400),
|
||||
grok_version: Some(kigi_version::VERSION.to_string()),
|
||||
kigi_version: Some(kigi_version::VERSION.to_string()),
|
||||
auth_method: Some(CacheAuthMethod::Platforms),
|
||||
origin: Some(with_key_origin),
|
||||
etag: None,
|
||||
|
||||
@@ -762,7 +762,7 @@ impl MvpAgent {
|
||||
}
|
||||
/// Build image generation config.
|
||||
///
|
||||
/// Both BYOK and session (OAuth) users go direct to `xai_api_base_url`.
|
||||
/// Both BYOK and session (OAuth) users go direct to `api_base_url`.
|
||||
/// `sampling_config.api_key` carries the OAuth bearer for session users (the
|
||||
/// `api_key_provider` refreshes it per request), so IC authenticates and
|
||||
/// meters Imagine usage per-user.
|
||||
@@ -775,7 +775,7 @@ impl MvpAgent {
|
||||
return ImageGenConfig::Disabled;
|
||||
};
|
||||
let cfg = self.cfg.borrow();
|
||||
let base_url = cfg.endpoints.xai_api_base_url.clone();
|
||||
let base_url = cfg.endpoints.api_base_url.clone();
|
||||
let version = cfg
|
||||
.client_version
|
||||
.clone()
|
||||
@@ -818,7 +818,7 @@ impl MvpAgent {
|
||||
tracing::info!("video_gen disabled by tools.disable_zdr_incompatible_tools");
|
||||
return VideoGenConfig::Disabled;
|
||||
}
|
||||
let base_url = cfg.endpoints.xai_api_base_url.clone();
|
||||
let base_url = cfg.endpoints.api_base_url.clone();
|
||||
let version = cfg
|
||||
.client_version
|
||||
.clone()
|
||||
|
||||
@@ -664,8 +664,6 @@ pub struct MvpAgent {
|
||||
plugin_registry_initialized: std::cell::Cell<bool>,
|
||||
persona_io_summaries: Vec<String>,
|
||||
/// Local workspace ops, built lazily via [`Self::ensure_local_workspace_ops`].
|
||||
/// The agent never opens Computer Hub as a harness/client; remote cloud
|
||||
/// sandboxes are gateway-owned (`gateway_bridge` / `computer_sessions`).
|
||||
workspace_ops: RefCell<Option<kigi_workspace::WorkspaceOps>>,
|
||||
/// Sessions opened with `require_gateway` / chat light-frontend (K13).
|
||||
/// Prompt-time guard consults this when the bridge map entry is missing,
|
||||
|
||||
@@ -652,7 +652,6 @@ async fn file_toolset_override_e2e_to_finalized_toolset() {
|
||||
video_gen_config: kigi_tools::implementations::grok_build::video_gen::VideoGenConfig::default(),
|
||||
app_builder_deployer_config: kigi_tools::implementations::grok_build::deploy_app::AppBuilderDeployerConfig::default(),
|
||||
api_key_provider: None,
|
||||
auth_provider: None,
|
||||
attribution_callback: None,
|
||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
||||
};
|
||||
@@ -3183,7 +3182,6 @@ fn interactive_trust_prompt_reprompts_after_untrust() {
|
||||
}
|
||||
mod direct_hub_cloud_removed {
|
||||
use super::super::{DIRECT_HUB_CLOUD_REMOVED_MSG, reject_direct_hub_cloud_meta};
|
||||
use crate::agent::config::HubConfig;
|
||||
fn assert_direct_hub_error(err: agent_client_protocol::Error) {
|
||||
assert_eq!(
|
||||
err.data.as_ref(),
|
||||
@@ -3238,38 +3236,6 @@ mod direct_hub_cloud_removed {
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn hub_url_gating_matrix() {
|
||||
let with_url = HubConfig {
|
||||
url: Some("wss://hub.example/ws".into()),
|
||||
};
|
||||
let without_url = HubConfig { url: None };
|
||||
let blank = HubConfig {
|
||||
url: Some(" ".into()),
|
||||
};
|
||||
assert!(with_url.is_enabled());
|
||||
assert!(!without_url.is_enabled());
|
||||
assert!(!blank.is_enabled());
|
||||
}
|
||||
#[test]
|
||||
fn hub_config_is_url_only_workspace_default() {
|
||||
let json = serde_json::to_value(HubConfig {
|
||||
url: Some("wss://hub.example/ws".into()),
|
||||
})
|
||||
.expect("serialize");
|
||||
let obj = json.as_object().expect("object");
|
||||
assert_eq!(
|
||||
obj.keys().collect::<Vec<_>>(),
|
||||
vec!["url"],
|
||||
"HubConfig must only serialize url (no proxy-mode fields)"
|
||||
);
|
||||
let from_legacy: HubConfig = serde_json::from_value(serde_json::json!(
|
||||
{ "url" : "wss://hub.example/ws", "workspace_mode" : "remote",
|
||||
"send_turn_hooks" : false, }
|
||||
))
|
||||
.expect("ignore unknown fields");
|
||||
assert_eq!(from_legacy.url.as_deref(), Some("wss://hub.example/ws"));
|
||||
}
|
||||
}
|
||||
mod soft_default_settings_emit {
|
||||
use super::*;
|
||||
|
||||
@@ -39,6 +39,7 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
|
||||
Vec::new(),
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
|
||||
let mut handle = make_test_handle("test-model", false, None);
|
||||
|
||||
@@ -1,619 +0,0 @@
|
||||
//! HTTP CONNECT proxy support for WebSocket connections.
|
||||
//!
|
||||
//! When running behind a corporate egress proxy,
|
||||
//! `tokio-tungstenite`'s `connect_async` cannot reach external
|
||||
//! hosts directly because it does not read the standard `HTTPS_PROXY` /
|
||||
//! `HTTP_PROXY` environment variables.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - [`resolve_proxy_for_host`]: reads proxy env vars and `NO_PROXY`, returning
|
||||
//! the proxy URL to use for a given target host (or `None` for direct).
|
||||
//! - [`connect_via_proxy`]: opens a TCP connection to the proxy, sends an HTTP
|
||||
//! CONNECT request to create a tunnel, wraps the result in TLS, and returns a
|
||||
//! stream suitable for `tokio_tungstenite::client_async`.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_tungstenite::MaybeTlsStream;
|
||||
use tracing::debug;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment-variable resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read proxy configuration from the environment and decide whether `target_host`
|
||||
/// should be connected through a proxy.
|
||||
///
|
||||
/// Resolution order (matches `curl` / `reqwest` behaviour):
|
||||
/// 1. If `NO_PROXY` contains `target_host` (or a matching domain suffix / CIDR),
|
||||
/// return `None`.
|
||||
/// 2. If `HTTPS_PROXY` (or `https_proxy`) is set, return its value.
|
||||
/// 3. If `HTTP_PROXY` (or `http_proxy`) is set, return its value.
|
||||
/// 4. Otherwise return `None`.
|
||||
pub fn resolve_proxy_for_host(target_host: &str) -> Option<String> {
|
||||
resolve_proxy_for_host_with(target_host, |key| std::env::var(key))
|
||||
}
|
||||
|
||||
/// Testable inner implementation that accepts a custom env-var reader.
|
||||
fn resolve_proxy_for_host_with<F>(target_host: &str, env: F) -> Option<String>
|
||||
where
|
||||
F: for<'a> Fn(&'a str) -> Result<String, std::env::VarError>,
|
||||
{
|
||||
// Check NO_PROXY / no_proxy.
|
||||
let no_proxy = env("NO_PROXY")
|
||||
.or_else(|_| env("no_proxy"))
|
||||
.unwrap_or_default();
|
||||
if is_host_bypassed(target_host, &no_proxy) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// HTTPS_PROXY takes precedence (our target is always wss://).
|
||||
if let Ok(url) = env("HTTPS_PROXY").or_else(|_| env("https_proxy")) {
|
||||
let url = url.trim().to_string();
|
||||
if !url.is_empty() {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to HTTP_PROXY.
|
||||
if let Ok(url) = env("HTTP_PROXY").or_else(|_| env("http_proxy")) {
|
||||
let url = url.trim().to_string();
|
||||
if !url.is_empty() {
|
||||
return Some(url);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check whether `host` is in the `no_proxy` list.
|
||||
///
|
||||
/// The `no_proxy` value is a comma-separated list of hostnames, domain
|
||||
/// suffixes (with or without a leading dot), IP addresses, or CIDR ranges.
|
||||
/// The special value `*` matches everything.
|
||||
fn is_host_bypassed(host: &str, no_proxy: &str) -> bool {
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
for entry in no_proxy.split(',') {
|
||||
let entry = entry.trim().to_ascii_lowercase();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Wildcard — bypass all hosts.
|
||||
if entry == "*" {
|
||||
return true;
|
||||
}
|
||||
// Exact match.
|
||||
if host_lower == entry {
|
||||
return true;
|
||||
}
|
||||
// Domain suffix match: ".example.com" matches "foo.example.com".
|
||||
// Also handle the common convention of omitting the leading dot:
|
||||
// "example.com" in NO_PROXY should match "sub.example.com".
|
||||
let matches_suffix = if entry.starts_with('.') {
|
||||
host_lower.ends_with(entry.as_str())
|
||||
} else {
|
||||
host_lower.len() > entry.len()
|
||||
&& host_lower.ends_with(entry.as_str())
|
||||
&& host_lower.as_bytes()[host_lower.len() - entry.len() - 1] == b'.'
|
||||
};
|
||||
if matches_suffix {
|
||||
return true;
|
||||
}
|
||||
// CIDR / IP matching is intentionally omitted here — our target host
|
||||
// is always a DNS name, not an IP literal. Keeping this simple avoids
|
||||
// pulling in a CIDR parsing dependency.
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP CONNECT tunnel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Establish a TLS-wrapped TCP stream through an HTTP CONNECT proxy.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Parse the proxy URL to get host + port.
|
||||
/// 2. Open a TCP connection to the proxy and perform the CONNECT handshake.
|
||||
/// 3. Wrap the tunnel in TLS (using rustls with native root certificates).
|
||||
/// 4. Return the stream as `MaybeTlsStream<TcpStream>` so it is compatible
|
||||
/// with `tokio_tungstenite::client_async`.
|
||||
pub async fn connect_via_proxy(
|
||||
proxy_url: &str,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
) -> anyhow::Result<MaybeTlsStream<TcpStream>> {
|
||||
let stream = open_connect_tunnel(proxy_url, target_host, target_port).await?;
|
||||
let tls_stream = tls_wrap(stream, target_host).await?;
|
||||
Ok(MaybeTlsStream::Rustls(tls_stream))
|
||||
}
|
||||
|
||||
/// Open a raw TCP tunnel through an HTTP CONNECT proxy (no TLS).
|
||||
///
|
||||
/// 1. Parse the proxy URL to get host + port.
|
||||
/// 2. Open a plain TCP connection to the proxy.
|
||||
/// 3. Send `CONNECT target_host:target_port HTTP/1.1\r\n\r\n`.
|
||||
/// 4. Read the proxy's response; expect `HTTP/1.x 200 …`.
|
||||
/// 5. Return the raw `TcpStream` positioned after the CONNECT response.
|
||||
async fn open_connect_tunnel(
|
||||
proxy_url: &str,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
) -> anyhow::Result<TcpStream> {
|
||||
// 1. Parse proxy URL.
|
||||
let (proxy_host, proxy_port) = parse_proxy_url(proxy_url)?;
|
||||
|
||||
// 2. TCP connect to proxy.
|
||||
let proxy_addr = format!("{proxy_host}:{proxy_port}");
|
||||
debug!(proxy_addr = %proxy_addr, "Opening TCP to proxy");
|
||||
let stream = TcpStream::connect(&proxy_addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to connect to proxy at {proxy_addr}: {e}"))?;
|
||||
|
||||
// 3. Send HTTP CONNECT.
|
||||
let connect_req = format!(
|
||||
"CONNECT {target_host}:{target_port} HTTP/1.1\r\n\
|
||||
Host: {target_host}:{target_port}\r\n\
|
||||
\r\n"
|
||||
);
|
||||
let (reader_half, mut writer_half) = stream.into_split();
|
||||
writer_half.write_all(connect_req.as_bytes()).await?;
|
||||
writer_half.flush().await?;
|
||||
|
||||
// 4. Read the status line from the proxy.
|
||||
let mut reader = BufReader::new(reader_half);
|
||||
let mut status_line = String::new();
|
||||
reader.read_line(&mut status_line).await?;
|
||||
debug!(status_line = %status_line.trim(), "Proxy CONNECT response");
|
||||
|
||||
if !status_line.starts_with("HTTP/1.1 200") && !status_line.starts_with("HTTP/1.0 200") {
|
||||
anyhow::bail!("Proxy CONNECT failed: {}", status_line.trim());
|
||||
}
|
||||
|
||||
// Consume remaining response headers (until empty line).
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await?;
|
||||
if line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Assert the BufReader's internal buffer is empty before reuniting.
|
||||
// BufReader::read_line may have read ahead into its buffer. If extra
|
||||
// bytes were consumed beyond the HTTP headers (e.g., from a proxy that
|
||||
// eagerly forwards data or coalesced TCP segments), dropping them would
|
||||
// corrupt the subsequent TLS handshake.
|
||||
let remaining = reader.buffer();
|
||||
if !remaining.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Proxy sent {} unexpected byte(s) after CONNECT response headers",
|
||||
remaining.len()
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Reunite the split halves back into a TcpStream.
|
||||
let stream = reader.into_inner().reunite(writer_half)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Lazily-initialized TLS client configuration.
|
||||
///
|
||||
/// Loading native root certificates involves syscalls (reading `/etc/ssl/certs/`
|
||||
/// or the macOS Keychain) and the cert store never changes at runtime. We build
|
||||
/// the `ClientConfig` once and reuse it across all proxy connections / reconnects.
|
||||
///
|
||||
/// Stores `Ok(config)` on success or `Err(message)` if cert loading fails.
|
||||
static TLS_CONFIG: OnceLock<Result<Arc<rustls::ClientConfig>, String>> = OnceLock::new();
|
||||
|
||||
/// Build (or return the cached) TLS client configuration.
|
||||
fn get_tls_config() -> anyhow::Result<Arc<rustls::ClientConfig>> {
|
||||
let result = TLS_CONFIG.get_or_init(|| {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
let cert_result = rustls_native_certs::load_native_certs();
|
||||
if cert_result.certs.is_empty() {
|
||||
let errors: Vec<_> = cert_result.errors.iter().map(|e| e.to_string()).collect();
|
||||
return Err(format!(
|
||||
"No native root certificates found. Errors: {}",
|
||||
if errors.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
errors.join("; ")
|
||||
}
|
||||
));
|
||||
}
|
||||
for cert in cert_result.certs {
|
||||
if let Err(e) = root_store.add(cert) {
|
||||
tracing::warn!(error = %e, "Skipping unparseable native root certificate");
|
||||
}
|
||||
}
|
||||
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
Ok(Arc::new(config))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(config) => Ok(config.clone()),
|
||||
Err(msg) => anyhow::bail!("{msg}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a TLS handshake over an existing TCP stream using rustls with
|
||||
/// native root certificates (cached via [`TLS_CONFIG`]).
|
||||
async fn tls_wrap(
|
||||
stream: TcpStream,
|
||||
server_name: &str,
|
||||
) -> anyhow::Result<tokio_rustls::client::TlsStream<TcpStream>> {
|
||||
let tls_config = get_tls_config()?;
|
||||
let connector = tokio_rustls::TlsConnector::from(tls_config);
|
||||
let dns_name = rustls::pki_types::ServerName::try_from(server_name.to_string())
|
||||
.map_err(|e| anyhow::anyhow!("Invalid TLS server name '{server_name}': {e}"))?;
|
||||
|
||||
let tls_stream = connector
|
||||
.connect(dns_name, stream)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("TLS handshake through proxy failed: {e}"))?;
|
||||
|
||||
Ok(tls_stream)
|
||||
}
|
||||
|
||||
/// Parse a proxy URL into (host, port).
|
||||
///
|
||||
/// Accepted formats:
|
||||
/// - `http://host:port`
|
||||
/// - `http://host` (defaults to port 80)
|
||||
/// - `host:port`
|
||||
fn parse_proxy_url(url: &str) -> anyhow::Result<(String, u16)> {
|
||||
// Strip scheme if present.
|
||||
let without_scheme = url
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| url.strip_prefix("https://"))
|
||||
.unwrap_or(url);
|
||||
|
||||
// Strip trailing path/slash.
|
||||
let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
|
||||
|
||||
if let Some((host, port_str)) = authority.rsplit_once(':') {
|
||||
let port: u16 = port_str
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid proxy port in '{url}'"))?;
|
||||
Ok((host.to_string(), port))
|
||||
} else {
|
||||
// No port — default to 80 for HTTP proxies.
|
||||
Ok((authority.to_string(), 80))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
// ===== parse_proxy_url =====
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_with_scheme_and_port() {
|
||||
let (host, port) = parse_proxy_url("http://proxy.example.com:3140").unwrap();
|
||||
assert_eq!(host, "proxy.example.com");
|
||||
assert_eq!(port, 3140);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_without_scheme() {
|
||||
let (host, port) = parse_proxy_url("proxy.example.com:8080").unwrap();
|
||||
assert_eq!(host, "proxy.example.com");
|
||||
assert_eq!(port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_without_port() {
|
||||
let (host, port) = parse_proxy_url("http://proxy.example.com").unwrap();
|
||||
assert_eq!(host, "proxy.example.com");
|
||||
assert_eq!(port, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_with_trailing_slash() {
|
||||
let (host, port) = parse_proxy_url("http://proxy.example.com:3140/").unwrap();
|
||||
assert_eq!(host, "proxy.example.com");
|
||||
assert_eq!(port, 3140);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_https_scheme() {
|
||||
let (host, port) = parse_proxy_url("https://secure-proxy:443").unwrap();
|
||||
assert_eq!(host, "secure-proxy");
|
||||
assert_eq!(port, 443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_multi_label_host() {
|
||||
let (host, port) =
|
||||
parse_proxy_url("http://http-proxy.services.internal.example:3128").unwrap();
|
||||
assert_eq!(host, "http-proxy.services.internal.example");
|
||||
assert_eq!(port, 3128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_proxy_url_invalid_port() {
|
||||
assert!(parse_proxy_url("http://proxy:notaport").is_err());
|
||||
}
|
||||
|
||||
// ===== is_host_bypassed =====
|
||||
|
||||
#[test]
|
||||
fn test_bypass_exact_match() {
|
||||
assert!(is_host_bypassed("localhost", "localhost,127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_domain_suffix_with_dot() {
|
||||
assert!(is_host_bypassed(
|
||||
"api.corp.example",
|
||||
"localhost,.corp.example"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_domain_suffix_without_dot() {
|
||||
// Common convention: "example.com" in NO_PROXY matches "api.example.com".
|
||||
assert!(is_host_bypassed("api.example.com", "localhost,example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_wildcard() {
|
||||
assert!(is_host_bypassed("anything.example.com", "*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_bypass_when_not_listed() {
|
||||
assert!(!is_host_bypassed(
|
||||
"api.external.example",
|
||||
"localhost,127.0.0.1,.corp.example,.internal.example"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_case_insensitive() {
|
||||
assert!(is_host_bypassed("API.Corp.EXAMPLE", ".corp.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_empty_no_proxy() {
|
||||
assert!(!is_host_bypassed("api.external.example", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_spaces_in_entries() {
|
||||
assert!(is_host_bypassed(
|
||||
"foo.example.com",
|
||||
" localhost , .example.com , .other.com "
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_cidr_not_matched_for_dns_names() {
|
||||
// CIDR entries like 10.0.0.0/8 should not match DNS names.
|
||||
assert!(!is_host_bypassed("api.external.example", "10.0.0.0/8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bypass_combined_no_proxy_list() {
|
||||
// A typical corporate NO_PROXY mixes loopback, private CIDRs, and domain suffixes.
|
||||
let no_proxy = "localhost,127.0.0.1,10.0.0.0/8,.internal.example,.corp.example";
|
||||
assert!(!is_host_bypassed("api.external.example", no_proxy));
|
||||
assert!(is_host_bypassed("db.internal.example", no_proxy));
|
||||
assert!(is_host_bypassed("git.corp.example", no_proxy));
|
||||
assert!(is_host_bypassed("localhost", no_proxy));
|
||||
}
|
||||
|
||||
// ===== resolve_proxy_for_host_with =====
|
||||
|
||||
#[test]
|
||||
fn test_resolve_no_proxy_vars_set() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |_| {
|
||||
Err(std::env::VarError::NotPresent)
|
||||
});
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_https_proxy_used() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"HTTPS_PROXY" => Ok("http://proxy.example.com:3128".to_string()),
|
||||
"NO_PROXY" => Err(std::env::VarError::NotPresent),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, Some("http://proxy.example.com:3128".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_http_proxy_fallback() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"HTTP_PROXY" => Ok("http://proxy.example.com:8080".to_string()),
|
||||
"NO_PROXY" => Err(std::env::VarError::NotPresent),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, Some("http://proxy.example.com:8080".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_no_proxy_bypasses() {
|
||||
let result = resolve_proxy_for_host_with("api.corp.example", |key| match key {
|
||||
"HTTPS_PROXY" => Ok("http://proxy.example.com:3128".to_string()),
|
||||
"NO_PROXY" => Ok("localhost,.corp.example".to_string()),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_https_proxy_takes_precedence() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"HTTPS_PROXY" => Ok("http://https-proxy.example.com:443".to_string()),
|
||||
"HTTP_PROXY" => Ok("http://http-proxy.example.com:80".to_string()),
|
||||
"NO_PROXY" => Err(std::env::VarError::NotPresent),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("http://https-proxy.example.com:443".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_lowercase_env_vars() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"https_proxy" => Ok("http://proxy.example.com:3128".to_string()),
|
||||
"no_proxy" => Err(std::env::VarError::NotPresent),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, Some("http://proxy.example.com:3128".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_empty_proxy_ignored() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"HTTPS_PROXY" => Ok(" ".to_string()),
|
||||
"HTTP_PROXY" => Ok("http://proxy.example.com:8080".to_string()),
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, Some("http://proxy.example.com:8080".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_respects_no_proxy_when_proxy_set() {
|
||||
let result = resolve_proxy_for_host_with("api.external.example", |key| match key {
|
||||
"HTTPS_PROXY" | "HTTP_PROXY" => Ok("http://proxy.example.com:3128".to_string()),
|
||||
"NO_PROXY" => {
|
||||
Ok("localhost,127.0.0.1,10.0.0.0/8,.internal.example,.corp.example".to_string())
|
||||
}
|
||||
_ => Err(std::env::VarError::NotPresent),
|
||||
});
|
||||
assert_eq!(result, Some("http://proxy.example.com:3128".to_string()));
|
||||
}
|
||||
|
||||
// ===== HTTP CONNECT tunnel (integration-style) =====
|
||||
|
||||
/// Helper: spawn a mock HTTP CONNECT proxy that accepts one connection.
|
||||
///
|
||||
/// On receiving a CONNECT request, it validates the request format,
|
||||
/// replies with `status_line`, and then echoes data (simulating a tunnel).
|
||||
/// Returns the proxy's listen address.
|
||||
async fn spawn_mock_proxy(status_line: &'static str) -> std::net::SocketAddr {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
|
||||
// Read CONNECT request (read until \r\n\r\n).
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut total = 0;
|
||||
loop {
|
||||
let n = stream.read(&mut buf[total..]).await.unwrap();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
total += n;
|
||||
let so_far = std::str::from_utf8(&buf[..total]).unwrap_or("");
|
||||
if so_far.contains("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let request = std::str::from_utf8(&buf[..total]).unwrap().to_string();
|
||||
assert!(
|
||||
request.contains("CONNECT ") && request.contains(" HTTP/1.1"),
|
||||
"Expected CONNECT request, got: {request}"
|
||||
);
|
||||
|
||||
// Reply with the provided status line.
|
||||
stream.write_all(status_line.as_bytes()).await.unwrap();
|
||||
|
||||
// Echo loop (simulates the transparent tunnel).
|
||||
let mut echo_buf = [0u8; 1024];
|
||||
loop {
|
||||
let n = match stream.read(&mut echo_buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
};
|
||||
if stream.write_all(&echo_buf[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addr
|
||||
}
|
||||
|
||||
/// Tests that `open_connect_tunnel` sends a correct CONNECT request,
|
||||
/// parses the proxy's 200 response, and returns a usable tunnel stream.
|
||||
#[tokio::test]
|
||||
async fn test_open_connect_tunnel_success() {
|
||||
let addr =
|
||||
spawn_mock_proxy("HTTP/1.1 200 Connection Established\r\nServer: mock\r\n\r\n").await;
|
||||
let proxy_url = format!("http://{addr}");
|
||||
|
||||
// Call the real function under test.
|
||||
let mut stream = open_connect_tunnel(&proxy_url, "example.com", 443)
|
||||
.await
|
||||
.expect("tunnel should succeed");
|
||||
|
||||
// Verify the tunnel works by echoing data through it.
|
||||
stream.write_all(b"hello tunnel").await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
|
||||
let mut response = vec![0u8; 12];
|
||||
stream.read_exact(&mut response).await.unwrap();
|
||||
assert_eq!(&response, b"hello tunnel");
|
||||
}
|
||||
|
||||
/// Tests that `open_connect_tunnel` with a non-default port sends the
|
||||
/// correct CONNECT target.
|
||||
#[tokio::test]
|
||||
async fn test_open_connect_tunnel_custom_port() {
|
||||
let addr = spawn_mock_proxy("HTTP/1.1 200 OK\r\n\r\n").await;
|
||||
let proxy_url = format!("http://{addr}");
|
||||
|
||||
let stream = open_connect_tunnel(&proxy_url, "internal.example.com", 8443).await;
|
||||
assert!(stream.is_ok(), "tunnel should succeed for custom port");
|
||||
}
|
||||
|
||||
/// Tests that `open_connect_tunnel` returns an error when the proxy
|
||||
/// rejects the CONNECT request with a non-200 status.
|
||||
#[tokio::test]
|
||||
async fn test_open_connect_tunnel_proxy_rejects() {
|
||||
let addr = spawn_mock_proxy("HTTP/1.1 403 Forbidden\r\n\r\n").await;
|
||||
let proxy_url = format!("http://{addr}");
|
||||
|
||||
let result = open_connect_tunnel(&proxy_url, "blocked.example.com", 443).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("403"),
|
||||
"Error should mention 403: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Tests that `open_connect_tunnel` returns an error when connecting
|
||||
/// to a proxy that isn't listening.
|
||||
#[tokio::test]
|
||||
async fn test_open_connect_tunnel_proxy_unreachable() {
|
||||
let result = open_connect_tunnel("http://127.0.0.1:1", "example.com", 443).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Failed to connect to proxy"),
|
||||
"Error should mention proxy connection failure: {err_msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1059,11 +1059,11 @@ fn apply_requirements_inner(
|
||||
enforce_str!("models", "default", config.models.default);
|
||||
enforce_str!("cli", "channel", config.cli.channel);
|
||||
enforce_str!("cli", "minimum_version", config.cli.minimum_version);
|
||||
if let Some(val) = req_str(req, "endpoints", "xai_api_base_url")
|
||||
&& config.endpoints.xai_api_base_url != val
|
||||
if let Some(val) = req_str(req, "endpoints", "api_base_url")
|
||||
&& config.endpoints.api_base_url != val
|
||||
{
|
||||
config.endpoints.xai_api_base_url = val.to_owned();
|
||||
push("endpoints.xai_api_base_url", val.to_owned());
|
||||
config.endpoints.api_base_url = val.to_owned();
|
||||
push("endpoints.api_base_url", val.to_owned());
|
||||
}
|
||||
if let Some(val) = req_str(req, "endpoints", "coding_api_base_url")
|
||||
&& config.endpoints.coding_api_base_url.as_deref() != Some(val)
|
||||
|
||||
@@ -2490,7 +2490,7 @@ fn enterprise_two_file_merge_routes_deployment_key_to_proxy() {
|
||||
let managed = toml::from_str(
|
||||
r#"
|
||||
[endpoints]
|
||||
xai_api_base_url = "https://inference.acme-corp.example/xai/v1"
|
||||
api_base_url = "https://inference.acme-corp.example/xai/v1"
|
||||
coding_api_base_url = "https://cli-chat-proxy.kigi.com/v1"
|
||||
|
||||
[model.kigi-build]
|
||||
@@ -2511,7 +2511,7 @@ telemetry = false
|
||||
|
||||
[endpoints]
|
||||
deployment_key = "xai-token-ENTERPRISE"
|
||||
xai_api_base_url = "https://inference.acme-corp.example/xai/v1"
|
||||
api_base_url = "https://inference.acme-corp.example/xai/v1"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -2586,13 +2586,13 @@ fn config_layers_system_managed_lowest_priority() {
|
||||
#[test]
|
||||
fn apply_requirements_value_overrides_user_settings() {
|
||||
let raw_config: toml::Value = toml::from_str(
|
||||
"[cli]\nauto_update = true\nchannel = \"beta\"\n\n[features]\nfeedback = true\nlsp_tools = true\nweb_fetch = true\nwrite_file = true\n\n[ui]\nyolo = true\n\n[models]\ndefault = \"user-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://user-proxy.example/v1\"\nxai_api_base_url = \"https://user-api.example/v1\"\nmodels_base_url = \"https://user-models.example/v1\"\nmodels_list_url = \"https://user-models.example/v1/models\"\n",
|
||||
"[cli]\nauto_update = true\nchannel = \"beta\"\n\n[features]\nfeedback = true\nlsp_tools = true\nweb_fetch = true\nwrite_file = true\n\n[ui]\nyolo = true\n\n[models]\ndefault = \"user-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://user-proxy.example/v1\"\napi_base_url = \"https://user-api.example/v1\"\nmodels_base_url = \"https://user-models.example/v1\"\nmodels_list_url = \"https://user-models.example/v1/models\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
|
||||
cfg.default_yolo_mode = true;
|
||||
let requirements: toml::Value = toml::from_str(
|
||||
"[cli]\nauto_update = false\nchannel = \"stable\"\n\n[features]\nfeedback = false\nlsp_tools = false\nweb_fetch = false\nwrite_file = false\nremote_fetch = false\n\n[ui]\nyolo = false\n\n[models]\ndefault = \"managed-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://managed-proxy.example/v1\"\nxai_api_base_url = \"https://managed-api.example/v1\"\nmodels_base_url = \"https://managed-models.example/v1\"\nmodels_list_url = \"https://managed-models.example/v1/models\"\ndeployment_key = \"enterprise-deploy-key-should-not-log\"\n",
|
||||
"[cli]\nauto_update = false\nchannel = \"stable\"\n\n[features]\nfeedback = false\nlsp_tools = false\nweb_fetch = false\nwrite_file = false\nremote_fetch = false\n\n[ui]\nyolo = false\n\n[models]\ndefault = \"managed-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://managed-proxy.example/v1\"\napi_base_url = \"https://managed-api.example/v1\"\nmodels_base_url = \"https://managed-models.example/v1\"\nmodels_list_url = \"https://managed-models.example/v1/models\"\ndeployment_key = \"enterprise-deploy-key-should-not-log\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let source = RequirementSource::Requirements {
|
||||
@@ -2617,7 +2617,7 @@ fn apply_requirements_value_overrides_user_settings() {
|
||||
Some("https://managed-proxy.example/v1"), cfg.endpoints.coding_api_base_url
|
||||
.as_deref()
|
||||
);
|
||||
assert_eq!("https://managed-api.example/v1", cfg.endpoints.xai_api_base_url);
|
||||
assert_eq!("https://managed-api.example/v1", cfg.endpoints.api_base_url);
|
||||
assert_eq!(
|
||||
Some("https://managed-models.example/v1"), cfg.endpoints.models_base_url
|
||||
.as_deref()
|
||||
|
||||
@@ -53,7 +53,7 @@ impl std::fmt::Display for Scope {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InspectReport {
|
||||
pub grok_version: String,
|
||||
pub kigi_version: String,
|
||||
pub channel: String,
|
||||
pub cwd: String,
|
||||
pub project_root: Option<String>,
|
||||
@@ -370,7 +370,7 @@ async fn build_report(cwd: &Path) -> InspectReport {
|
||||
.unwrap_or_default();
|
||||
|
||||
InspectReport {
|
||||
grok_version: kigi_version::VERSION.to_string(),
|
||||
kigi_version: kigi_version::VERSION.to_string(),
|
||||
channel: crate::util::config::channel_name_from_cache()
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
@@ -1224,7 +1224,7 @@ fn render_harness_compatibility(report: &ExternalCompatReport) -> String {
|
||||
fn print_human(r: &InspectReport) {
|
||||
println!();
|
||||
println!(" Environment");
|
||||
println!(" {TREE} Version: {} [{}]", r.grok_version, r.channel);
|
||||
println!(" {TREE} Version: {} [{}]", r.kigi_version, r.channel);
|
||||
println!(" {TREE} CWD: {}", r.cwd);
|
||||
if let Some(ref root) = r.project_root {
|
||||
println!(" {TREE} Git root: {}", root);
|
||||
|
||||
@@ -1113,7 +1113,7 @@ async fn evict_leader(conn: LeaderConnection, lock: &LeaderLock) {
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client_type` - Identifier for the client type (e.g., "grok-tui", "vscode")
|
||||
/// * `mode` - Communication mode (Stdio or Headless)
|
||||
/// * `mode` - Communication mode (Stdio)
|
||||
/// * `capabilities` - Client capabilities (e.g., yolo_mode) to register with the leader
|
||||
pub async fn connect_or_spawn(
|
||||
client_type: &str,
|
||||
|
||||
@@ -108,9 +108,6 @@ impl Default for ClientId {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClientMode {
|
||||
/// Headless mode (grok agent, grok agent headless) - uses websocket relay.
|
||||
/// Leader connects to websocket relay once and forwards messages.
|
||||
Headless,
|
||||
/// Stdio mode (grok agent stdio, grok -p) - uses local IPC.
|
||||
/// Client sends/receives ACP messages directly via IPC.
|
||||
Stdio,
|
||||
@@ -178,8 +175,6 @@ pub struct LeaderCapabilities {
|
||||
pub runtime_cpu_profile: bool,
|
||||
#[serde(default)]
|
||||
pub profile_formats: Vec<ProfileArtifactFormat>,
|
||||
#[serde(default)]
|
||||
pub workspace_exposure: bool,
|
||||
/// Whether the leader supports [`ControlCommand::RelaunchForUpdate`] — a
|
||||
/// disruptive, bounded-grace relaunch onto a freshly-installed binary
|
||||
/// (driven by `grok update`). Old leaders default to `false`, so a new
|
||||
@@ -200,15 +195,6 @@ pub enum ControlCommand {
|
||||
frequency_hz: Option<i32>,
|
||||
},
|
||||
StopCpuProfile,
|
||||
WorkspaceStart {
|
||||
#[serde(default)]
|
||||
hub_url: Option<String>,
|
||||
cwd: String,
|
||||
},
|
||||
WorkspacePause,
|
||||
WorkspaceResume,
|
||||
WorkspaceStop,
|
||||
WorkspaceStatus,
|
||||
/// Ask the leader to relaunch onto a freshly-installed binary (driven by
|
||||
/// `grok update`). The leader stops admitting new turns, waits a bounded
|
||||
/// grace period for in-flight turns to finish, flushes session state, then
|
||||
@@ -260,18 +246,6 @@ pub enum ControlPayload {
|
||||
started_at: String,
|
||||
stopped_at: String,
|
||||
},
|
||||
WorkspaceStatus {
|
||||
state: String,
|
||||
#[serde(default)]
|
||||
hub_url: Option<String>,
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
uptime_ms: u64,
|
||||
active_tool_calls: u32,
|
||||
#[serde(default)]
|
||||
sessions: Vec<String>,
|
||||
pid: u32,
|
||||
},
|
||||
/// Ack for [`ControlCommand::RelaunchForUpdate`]: the leader accepted the
|
||||
/// request and will exit after a bounded grace period of `grace_ms`.
|
||||
Relaunching {
|
||||
@@ -531,7 +505,6 @@ mod tests {
|
||||
control_v1: true,
|
||||
runtime_cpu_profile: true,
|
||||
profile_formats: vec![ProfileArtifactFormat::Svg],
|
||||
workspace_exposure: true,
|
||||
relaunch_v1: true,
|
||||
}),
|
||||
};
|
||||
@@ -549,7 +522,6 @@ mod tests {
|
||||
control_v1: true,
|
||||
runtime_cpu_profile: true,
|
||||
profile_formats,
|
||||
workspace_exposure: true,
|
||||
relaunch_v1: true,
|
||||
}),
|
||||
} if profile_formats == vec![ProfileArtifactFormat::Svg]
|
||||
@@ -622,71 +594,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_control_command_roundtrip() {
|
||||
let (mut client, mut server) = duplex(1024);
|
||||
let msg = ClientMessage::Control {
|
||||
request_id: "ws-1".into(),
|
||||
command: ControlCommand::WorkspaceStart {
|
||||
hub_url: Some("wss://hub.example/v1/tools".into()),
|
||||
cwd: "/home/u/proj".into(),
|
||||
},
|
||||
};
|
||||
|
||||
write_message(&mut client, &msg).await.unwrap();
|
||||
let received: ClientMessage = read_message(&mut server).await.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
received,
|
||||
ClientMessage::Control {
|
||||
request_id,
|
||||
command: ControlCommand::WorkspaceStart { hub_url: Some(url), cwd },
|
||||
} if request_id == "ws-1"
|
||||
&& url == "wss://hub.example/v1/tools"
|
||||
&& cwd == "/home/u/proj"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_status_payload_roundtrip() {
|
||||
let payload = ControlPayload::WorkspaceStatus {
|
||||
state: "running".into(),
|
||||
hub_url: Some("wss://hub.example/v1/tools".into()),
|
||||
cwd: Some("/home/u/proj".into()),
|
||||
uptime_ms: 4200,
|
||||
active_tool_calls: 2,
|
||||
sessions: vec!["grok-a".into(), "grok-b".into()],
|
||||
pid: 4242,
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let decoded: ControlPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded, payload);
|
||||
assert!(json.contains("\"type\":\"workspace_status\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_status_payload_defaults_optional_fields() {
|
||||
let json = r#"{"type":"workspace_status","state":"none","uptime_ms":0,"active_tool_calls":0,"pid":1}"#;
|
||||
let decoded: ControlPayload = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
ControlPayload::WorkspaceStatus {
|
||||
state,
|
||||
hub_url: None,
|
||||
cwd: None,
|
||||
sessions,
|
||||
..
|
||||
} if state == "none" && sessions.is_empty()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_exposure_capability_defaults_false() {
|
||||
let json = r#"{"control_v1":true,"runtime_cpu_profile":false,"profile_formats":[]}"#;
|
||||
let caps: LeaderCapabilities = serde_json::from_str(json).unwrap();
|
||||
assert!(!caps.workspace_exposure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_id_is_unique() {
|
||||
let ids: Vec<_> = (0..100).map(|_| ClientId::new()).collect();
|
||||
|
||||
@@ -27,8 +27,6 @@ use crate::cpu_profile::{
|
||||
};
|
||||
use agent_client_protocol::AGENT_METHOD_NAMES;
|
||||
use kanal::{AsyncReceiver, AsyncSender};
|
||||
use kigi_computer_hub_sdk::{AuthCredential, AuthIdentity, AuthProvider};
|
||||
use kigi_workspace::WorkspaceHandle;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -129,105 +127,24 @@ pub struct LeaderServerMetadata {
|
||||
pub struct LeaderServerControlState {
|
||||
pub metadata: LeaderServerMetadata,
|
||||
pub cpu_profile: Arc<Mutex<CpuProfileManager>>,
|
||||
pub workspace: Arc<WorkspaceControl>,
|
||||
}
|
||||
impl LeaderServerControlState {
|
||||
pub fn new(metadata: LeaderServerMetadata) -> Self {
|
||||
Self {
|
||||
metadata,
|
||||
cpu_profile: Arc::new(Mutex::new(CpuProfileManager::new())),
|
||||
workspace: Arc::new(WorkspaceControl::new(None)),
|
||||
}
|
||||
}
|
||||
pub fn with_default_hub_url(mut self, default_hub_url: Option<String>) -> Self {
|
||||
self.workspace = Arc::new(WorkspaceControl::new(default_hub_url));
|
||||
self
|
||||
}
|
||||
fn leader_capabilities(&self) -> LeaderCapabilities {
|
||||
let manager = self.cpu_profile.lock();
|
||||
LeaderCapabilities {
|
||||
control_v1: true,
|
||||
runtime_cpu_profile: manager.runtime_cpu_profile(),
|
||||
profile_formats: manager.profile_formats().to_vec(),
|
||||
workspace_exposure: true,
|
||||
relaunch_v1: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct WorkspaceControl {
|
||||
default_hub_url: Option<String>,
|
||||
/// Hub credential, wired to the leader's `AuthManager` once auth is ready.
|
||||
/// A `watch` so a starting leader (socket up, auth pending) can be awaited
|
||||
/// instead of failing the command.
|
||||
auth: tokio::sync::watch::Sender<Option<Arc<dyn AuthProvider>>>,
|
||||
/// Serializes mutating commands (start/pause/resume/stop) so their long
|
||||
/// awaits (drain, reconnect) never interleave.
|
||||
lock: tokio::sync::Mutex<()>,
|
||||
/// Current exposure, published for lock-free reads so `status` never
|
||||
/// blocks behind an in-flight drain/reconnect.
|
||||
exposure: arc_swap::ArcSwapOption<WorkspaceExposure>,
|
||||
}
|
||||
impl WorkspaceControl {
|
||||
fn new(default_hub_url: Option<String>) -> Self {
|
||||
Self {
|
||||
default_hub_url,
|
||||
auth: tokio::sync::watch::channel(None).0,
|
||||
lock: tokio::sync::Mutex::new(()),
|
||||
exposure: arc_swap::ArcSwapOption::empty(),
|
||||
}
|
||||
}
|
||||
/// Wire the hub credential to the leader's shared `AuthManager` (sole
|
||||
/// owner of refresh + persistence).
|
||||
pub fn set_auth_manager(&self, auth_manager: Arc<AuthManager>) {
|
||||
self.auth
|
||||
.send_replace(Some(Arc::new(LeaderAuthProvider { auth_manager })));
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for WorkspaceControl {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WorkspaceControl")
|
||||
.field("default_hub_url", &self.default_hub_url)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
/// Hub [`AuthProvider`] backed by the leader's `AuthManager`: returns the
|
||||
/// current token at each connect/reconnect; never writes auth.json.
|
||||
struct LeaderAuthProvider {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
}
|
||||
impl std::fmt::Debug for LeaderAuthProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("LeaderAuthProvider").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
impl AuthProvider for LeaderAuthProvider {
|
||||
fn current(&self) -> AuthCredential {
|
||||
let token = self
|
||||
.auth_manager
|
||||
.current_or_expired()
|
||||
.map(|a| a.key)
|
||||
.unwrap_or_default();
|
||||
AuthCredential::bearer(token)
|
||||
}
|
||||
/// Owner identity from the leader's `AuthManager`, surfaced on the auth
|
||||
/// provider instead of a separate auth.json read. The Kimi credential
|
||||
/// carries no principal metadata; only the (possibly empty) user id.
|
||||
fn identity(&self) -> Option<AuthIdentity> {
|
||||
let a = self.auth_manager.current_or_expired()?;
|
||||
Some(AuthIdentity {
|
||||
user_id: a.user_id,
|
||||
principal_type: None,
|
||||
principal_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
struct WorkspaceExposure {
|
||||
handle: WorkspaceHandle,
|
||||
hub_url: String,
|
||||
cwd: PathBuf,
|
||||
started_at: Instant,
|
||||
paused: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
/// Rewrite JSON-RPC request ID **in place** by prefixing with client ID to
|
||||
/// avoid collisions.
|
||||
///
|
||||
@@ -934,233 +851,6 @@ fn leader_info_payload(control_state: &LeaderServerControlState) -> ControlPaylo
|
||||
profile_formats: manager.profile_formats().to_vec(),
|
||||
}
|
||||
}
|
||||
const PROD_COMPUTER_HUB_URL: &str = "wss://computer-hub.kigi.com/v1/tools";
|
||||
const WORKSPACE_DRAIN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
fn workspace_err(message: impl Into<String>) -> ControlError {
|
||||
ControlError {
|
||||
code: ControlErrorCode::InternalError,
|
||||
message: message.into(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
/// Resolve the hub credential, waiting if the leader is still wiring auth
|
||||
/// (the IPC socket comes up first). Resolves the instant auth is wired or the
|
||||
/// leader cancels — event-driven, no timeout.
|
||||
async fn wait_for_leader_auth(
|
||||
ws: &WorkspaceControl,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Arc<dyn AuthProvider>, ControlError> {
|
||||
let mut rx = ws.auth.subscribe();
|
||||
tokio::select! {
|
||||
result = rx.wait_for(| v | v.is_some()) => match result { Ok(guard) => Ok(guard
|
||||
.clone().expect("waited for Some")), Err(_) =>
|
||||
Err(workspace_err("leader is shutting down; cannot expose workspace to the hub",)),
|
||||
}, _ = cancel.cancelled() =>
|
||||
Err(workspace_err("leader is shutting down; cannot expose workspace to the hub",)),
|
||||
}
|
||||
}
|
||||
fn workspace_server_id() -> String {
|
||||
let raw = gethostname::gethostname()
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase();
|
||||
let sanitized: String = raw
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let name = sanitized.trim_matches('-');
|
||||
if name.is_empty() {
|
||||
"grok-workspace".to_string()
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
async fn drain_and_disconnect(handle: &WorkspaceHandle) {
|
||||
let tracker = handle.activity_tracker().clone();
|
||||
tracker.set_draining();
|
||||
if tokio::time::timeout(WORKSPACE_DRAIN_TIMEOUT, tracker.wait_until_drained())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
active = tracker.total_active(),
|
||||
"workspace drain timed out; disconnecting hub anyway"
|
||||
);
|
||||
}
|
||||
handle.shutdown_hub().await;
|
||||
}
|
||||
fn build_workspace_status(
|
||||
metadata: &LeaderServerMetadata,
|
||||
exposure: Option<&WorkspaceExposure>,
|
||||
) -> ControlPayload {
|
||||
match exposure {
|
||||
None => ControlPayload::WorkspaceStatus {
|
||||
state: "none".to_string(),
|
||||
hub_url: None,
|
||||
cwd: None,
|
||||
uptime_ms: 0,
|
||||
active_tool_calls: 0,
|
||||
sessions: Vec::new(),
|
||||
pid: metadata.pid,
|
||||
},
|
||||
Some(exp) => {
|
||||
let snapshot = exp.handle.activity_tracker().snapshot();
|
||||
let mut sessions = exp.handle.session_ids();
|
||||
sessions.sort();
|
||||
ControlPayload::WorkspaceStatus {
|
||||
state: if exp.paused.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
"paused"
|
||||
} else {
|
||||
"running"
|
||||
}
|
||||
.to_string(),
|
||||
hub_url: Some(exp.hub_url.clone()),
|
||||
cwd: Some(exp.cwd.display().to_string()),
|
||||
uptime_ms: exp.started_at.elapsed().as_millis() as u64,
|
||||
active_tool_calls: snapshot.active_tool_calls,
|
||||
sessions,
|
||||
pid: metadata.pid,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn handle_workspace_start(
|
||||
control_state: LeaderServerControlState,
|
||||
hub_url: Option<String>,
|
||||
cwd: String,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<ControlPayload, ControlError> {
|
||||
let ws = &control_state.workspace;
|
||||
let url_str = hub_url
|
||||
.filter(|u| !u.trim().is_empty())
|
||||
.or_else(|| ws.default_hub_url.clone())
|
||||
.unwrap_or_else(|| PROD_COMPUTER_HUB_URL.to_string());
|
||||
let url = url::Url::parse(&url_str)
|
||||
.map_err(|e| workspace_err(format!("invalid hub url {url_str}: {e}")))?;
|
||||
let cwd_path = PathBuf::from(&cwd);
|
||||
let _serialize = ws.lock.lock().await;
|
||||
if let Some(existing) = ws.exposure.load_full()
|
||||
&& !existing.paused.load(Ordering::Relaxed)
|
||||
&& existing.cwd == cwd_path
|
||||
&& existing.hub_url == url_str
|
||||
{
|
||||
return Ok(build_workspace_status(
|
||||
&control_state.metadata,
|
||||
Some(existing.as_ref()),
|
||||
));
|
||||
}
|
||||
let allow_insecure_ws =
|
||||
url.scheme() == "ws" && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"));
|
||||
let status_config = kigi_workspace::StatusConfig::from_env();
|
||||
let alpha_test_key = None;
|
||||
let auth = wait_for_leader_auth(ws, &cancel).await?;
|
||||
let server_id = workspace_server_id();
|
||||
let metadata = serde_json::json!(
|
||||
{ "source" : "grok-workspace", "hostname" : gethostname::gethostname()
|
||||
.to_string_lossy(), "cwd" : cwd_path.display().to_string(), }
|
||||
);
|
||||
crate::agent::folder_trust::resolve_and_record(&cwd_path, None, false);
|
||||
let project_lsp_trusted = crate::agent::folder_trust::project_scope_allowed(&cwd_path);
|
||||
let handle = kigi_workspace::connect_local_workspace(
|
||||
cwd_path.clone(),
|
||||
url,
|
||||
auth,
|
||||
Some(metadata),
|
||||
Some(server_id),
|
||||
alpha_test_key,
|
||||
allow_insecure_ws,
|
||||
status_config,
|
||||
project_lsp_trusted,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| workspace_err(format!("failed to connect workspace to hub: {e}")))?;
|
||||
let exposure = Arc::new(WorkspaceExposure {
|
||||
handle,
|
||||
hub_url: url_str,
|
||||
cwd: cwd_path,
|
||||
started_at: Instant::now(),
|
||||
paused: AtomicBool::new(false),
|
||||
});
|
||||
let payload = build_workspace_status(&control_state.metadata, Some(exposure.as_ref()));
|
||||
if let Some(old) = ws.exposure.swap(Some(exposure)) {
|
||||
drain_and_disconnect(&old.handle).await;
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
async fn handle_workspace_pause(
|
||||
control_state: LeaderServerControlState,
|
||||
) -> Result<ControlPayload, ControlError> {
|
||||
let ws = &control_state.workspace;
|
||||
let _serialize = ws.lock.lock().await;
|
||||
let Some(exp) = ws.exposure.load_full() else {
|
||||
return Err(workspace_err("no workspace exposure is running"));
|
||||
};
|
||||
if !exp.paused.load(Ordering::Relaxed) {
|
||||
drain_and_disconnect(&exp.handle).await;
|
||||
exp.paused.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Ok(build_workspace_status(
|
||||
&control_state.metadata,
|
||||
Some(exp.as_ref()),
|
||||
))
|
||||
}
|
||||
async fn handle_workspace_resume(
|
||||
control_state: LeaderServerControlState,
|
||||
) -> Result<ControlPayload, ControlError> {
|
||||
let ws = &control_state.workspace;
|
||||
let _serialize = ws.lock.lock().await;
|
||||
let Some(exp) = ws.exposure.load_full() else {
|
||||
return Err(workspace_err("no workspace exposure is running"));
|
||||
};
|
||||
if exp.paused.load(Ordering::Relaxed) {
|
||||
exp.handle.activity_tracker().set_active();
|
||||
if let Err(e) = exp.handle.connect_hub().await {
|
||||
exp.handle.activity_tracker().set_draining();
|
||||
return Err(workspace_err(format!("failed to reconnect to hub: {e}")));
|
||||
}
|
||||
exp.paused.store(false, Ordering::Relaxed);
|
||||
}
|
||||
Ok(build_workspace_status(
|
||||
&control_state.metadata,
|
||||
Some(exp.as_ref()),
|
||||
))
|
||||
}
|
||||
async fn handle_workspace_stop(
|
||||
control_state: LeaderServerControlState,
|
||||
) -> Result<ControlPayload, ControlError> {
|
||||
let ws = &control_state.workspace;
|
||||
let _serialize = ws.lock.lock().await;
|
||||
if let Some(exp) = ws.exposure.swap(None) {
|
||||
drain_and_disconnect(&exp.handle).await;
|
||||
}
|
||||
Ok(build_workspace_status(&control_state.metadata, None))
|
||||
}
|
||||
async fn handle_workspace_status(
|
||||
control_state: LeaderServerControlState,
|
||||
) -> Result<ControlPayload, ControlError> {
|
||||
let exposure = control_state.workspace.exposure.load_full();
|
||||
Ok(build_workspace_status(
|
||||
&control_state.metadata,
|
||||
exposure.as_deref(),
|
||||
))
|
||||
}
|
||||
async fn finalize_workspace_on_shutdown(control_state: LeaderServerControlState) {
|
||||
let ws = &control_state.workspace;
|
||||
let _serialize = ws.lock.lock().await;
|
||||
if let Some(exp) = ws.exposure.swap(None) {
|
||||
info!("Draining workspace exposure on leader shutdown");
|
||||
drain_and_disconnect(&exp.handle).await;
|
||||
}
|
||||
}
|
||||
fn handle_control_command(
|
||||
control_state: &LeaderServerControlState,
|
||||
command: ControlCommand,
|
||||
@@ -1208,13 +898,6 @@ fn handle_control_command(
|
||||
ControlCommand::StopCpuProfile => {
|
||||
unreachable!("StopCpuProfile must be handled asynchronously")
|
||||
}
|
||||
ControlCommand::WorkspaceStart { .. }
|
||||
| ControlCommand::WorkspacePause
|
||||
| ControlCommand::WorkspaceResume
|
||||
| ControlCommand::WorkspaceStop
|
||||
| ControlCommand::WorkspaceStatus => {
|
||||
unreachable!("workspace control commands are handled asynchronously")
|
||||
}
|
||||
ControlCommand::RelaunchForUpdate { .. } => {
|
||||
unreachable!("RelaunchForUpdate must be handled asynchronously")
|
||||
}
|
||||
@@ -1564,13 +1247,6 @@ pub async fn run_leader_server(
|
||||
agent_activity = agent_activity.clone(); let relaunching = relaunching
|
||||
.clone(); tokio::spawn(async move { let result = match command {
|
||||
ControlCommand::StopCpuProfile => { handle_stop_cpu_profile(control_state).
|
||||
await } ControlCommand::WorkspaceStart { hub_url, cwd } => {
|
||||
handle_workspace_start(control_state, hub_url, cwd, cancel.clone(),). await }
|
||||
ControlCommand::WorkspacePause => { handle_workspace_pause(control_state).
|
||||
await } ControlCommand::WorkspaceResume => {
|
||||
handle_workspace_resume(control_state). await } ControlCommand::WorkspaceStop
|
||||
=> { handle_workspace_stop(control_state). await }
|
||||
ControlCommand::WorkspaceStatus => { handle_workspace_status(control_state).
|
||||
await } ControlCommand::RelaunchForUpdate { to_version } => {
|
||||
decide_relaunch_for_update(& control_state, to_version, & relaunching,) }
|
||||
other => handle_control_command(& control_state, other), }; let arm_relaunch
|
||||
@@ -1806,7 +1482,6 @@ pub async fn run_leader_server(
|
||||
debug!("No client available for notification routing, message dropped"); } }
|
||||
}
|
||||
}
|
||||
finalize_workspace_on_shutdown(control_state.clone()).await;
|
||||
finalize_cpu_profile_on_shutdown(control_state).await;
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
Ok(())
|
||||
@@ -2214,48 +1889,6 @@ mod tests {
|
||||
Ok(ControlPayload::RelaunchDeclined { .. })
|
||||
));
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct TestAuth;
|
||||
impl AuthProvider for TestAuth {
|
||||
fn current(&self) -> AuthCredential {
|
||||
AuthCredential::bearer("test-token")
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn wait_for_leader_auth_returns_when_already_wired() {
|
||||
let ws = WorkspaceControl::new(None);
|
||||
ws.auth.send_replace(Some(Arc::new(TestAuth)));
|
||||
let cancel = CancellationToken::new();
|
||||
let auth = wait_for_leader_auth(&ws, &cancel).await.expect("wired");
|
||||
assert!(matches!(auth.current(), AuthCredential::Bearer { .. }));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn wait_for_leader_auth_resolves_when_wired_late() {
|
||||
let ws = Arc::new(WorkspaceControl::new(None));
|
||||
let cancel = CancellationToken::new();
|
||||
let waiter = {
|
||||
let ws = ws.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::spawn(async move { wait_for_leader_auth(&ws, &cancel).await.is_ok() })
|
||||
};
|
||||
tokio::task::yield_now().await;
|
||||
ws.auth.send_replace(Some(Arc::new(TestAuth)));
|
||||
assert!(waiter.await.unwrap(), "auth wired late should resolve Ok");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn workspace_start_errors_when_cancelled_before_auth() {
|
||||
let state = default_test_control_state(Path::new("/tmp/grok-ws-auth-test.sock"));
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
let err = handle_workspace_start(state, None, "/tmp".to_string(), cancel)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.message.contains("shutting down"),
|
||||
"unexpected error: {}",
|
||||
err.message
|
||||
);
|
||||
}
|
||||
async fn setup_test_server(
|
||||
temp: &TempDir,
|
||||
) -> (PathBuf, CancellationToken, mpsc::UnboundedReceiver<String>) {
|
||||
|
||||
@@ -35,7 +35,6 @@ pub(crate) fn fake_caps(control_v1: bool, relaunch_v1: bool) -> LeaderCapabiliti
|
||||
control_v1,
|
||||
runtime_cpu_profile: false,
|
||||
profile_formats: Vec::new(),
|
||||
workspace_exposure: false,
|
||||
relaunch_v1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -928,9 +928,6 @@ pub(crate) struct SessionActor {
|
||||
/// Centralized event tracking: event log, turn-end guard, active tool,
|
||||
/// doom loop terminate flag. All event-related state lives here.
|
||||
pub(crate) events: crate::session::events::EventTracker,
|
||||
/// Optional hub-side session event emitter (always constructed without a
|
||||
/// harness client in the agent; methods no-op with `None` transport).
|
||||
pub(crate) observability_bridge: kigi_computer_hub_sdk::ObservabilityBridge,
|
||||
/// Turn number captured at the start of each turn (before prompt index
|
||||
/// increment). Used by `ToolCallStarted` bridge emissions so they
|
||||
/// report the same turn number as `TurnStarted` / `TurnEnded`.
|
||||
@@ -1522,8 +1519,6 @@ mod fs_injection_regression_tests;
|
||||
#[path = "acp_session_tests/interjection_actor_tests.rs"]
|
||||
mod interjection_actor_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "acp_session_tests/observability_bridge_mapping_tests.rs"]
|
||||
mod observability_bridge_mapping_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "acp_session_tests/permission_auto_mode_tests.rs"]
|
||||
mod permission_auto_mode_tests;
|
||||
|
||||
@@ -251,34 +251,8 @@ pub(crate) async fn spawn_session_actor(
|
||||
.as_ref()
|
||||
.map(kigi_workspace::permission::resolution::deny_read_globs_from_config)
|
||||
.unwrap_or_default();
|
||||
let hub_permission = if kigi_workspace::permission::hitl_permission_live_enabled() {
|
||||
let server = match workspace_ops.workspace_handle() {
|
||||
Some(handle) => handle.hub_server_blocking().await,
|
||||
None => None,
|
||||
};
|
||||
let transport = server
|
||||
.and_then(|server| {
|
||||
kigi_workspace::permission::ToolServerPermissionTransport::from_session_id(
|
||||
server,
|
||||
session_info.id.0.as_ref(),
|
||||
)
|
||||
})
|
||||
.map(|t| {
|
||||
std::sync::Arc::new(t)
|
||||
as std::sync::Arc<dyn kigi_workspace::permission::PermissionHookTransport>
|
||||
});
|
||||
if transport.is_none() {
|
||||
tracing::debug!(
|
||||
session_id = % session_info.id.0,
|
||||
"hitl permission live enabled but no remote transport available; using local prompt"
|
||||
);
|
||||
}
|
||||
transport
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (permissions, _permission_events_rx) =
|
||||
kigi_workspace::permission::spawn_permission_manager_with_hub(
|
||||
kigi_workspace::permission::spawn_permission_manager(
|
||||
session_info.id.clone(),
|
||||
gateway.clone(),
|
||||
tool_context.cwd.clone(),
|
||||
@@ -289,7 +263,6 @@ pub(crate) async fn spawn_session_actor(
|
||||
session_yolo_mode,
|
||||
session_client_identifier.clone(),
|
||||
crate::util::config::remember_tool_approvals_from_disk(),
|
||||
hub_permission,
|
||||
);
|
||||
if crate::util::config::auto_mode_session_active(
|
||||
crate::util::config::auto_permission_mode_enabled_from_disk(),
|
||||
@@ -975,11 +948,6 @@ pub(crate) async fn spawn_session_actor(
|
||||
let (goal_update_tx, goal_update_rx) = tokio::sync::mpsc::unbounded_channel::<
|
||||
kigi_tools::implementations::grok_build::update_goal::UpdateGoalEnvelope,
|
||||
>();
|
||||
let obs_bridge = {
|
||||
let sid = kigi_tool_protocol::SessionId::new(&*session_info.id.0)
|
||||
.unwrap_or_else(|_| kigi_tool_protocol::SessionId::new("unknown").expect("valid"));
|
||||
kigi_computer_hub_sdk::ObservabilityBridge::new(None, sid)
|
||||
};
|
||||
let mut effective_config = crate::config::load_effective_config()
|
||||
.ok()
|
||||
.and_then(|raw| crate::agent::config::Config::new_from_toml_cfg(&raw).ok())
|
||||
@@ -1199,7 +1167,6 @@ pub(crate) async fn spawn_session_actor(
|
||||
events: crate::session::events::EventTracker::new(
|
||||
&crate::session::persistence::session_dir(&session_info),
|
||||
),
|
||||
observability_bridge: obs_bridge,
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
|
||||
@@ -323,15 +323,6 @@ impl SessionActor {
|
||||
self.emit_event(crate::session::events::Event::ToolStarted {
|
||||
tool_name: call.function.name.clone(),
|
||||
});
|
||||
self.observability_bridge
|
||||
.emit(
|
||||
kigi_tool_protocol::session_event::SessionEvent::ToolCallStarted {
|
||||
tool_call_id: call.id.clone(),
|
||||
tool_name: call.function.name.clone(),
|
||||
turn_number: self.current_turn_number.get(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let call_name = call.function.name.clone();
|
||||
match self
|
||||
.prepare_tool_call(call, &mut deferred_followups)
|
||||
@@ -653,16 +644,6 @@ impl SessionActor {
|
||||
duration_ms,
|
||||
outcome: tool_outcome,
|
||||
});
|
||||
self.observability_bridge
|
||||
.emit(
|
||||
kigi_tool_protocol::session_event::SessionEvent::ToolCallCompleted {
|
||||
tool_call_id: prepared.call_id.clone(),
|
||||
tool_name: prepared.tool_name.clone(),
|
||||
duration_ms,
|
||||
outcome: map_tool_outcome(tool_outcome),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
tracing::info_span!(
|
||||
"tool.execution", tool_name = % prepared.tool_name, tool_use_id = %
|
||||
prepared.call_id, tool_input_size_bytes = prepared.raw_arguments.len() as
|
||||
|
||||
@@ -394,15 +394,6 @@ impl SessionActor {
|
||||
schema_version: crate::session::events::EVENT_SCHEMA_VERSION.into(),
|
||||
redirect_kind,
|
||||
});
|
||||
self.observability_bridge
|
||||
.emit(
|
||||
kigi_tool_protocol::session_event::SessionEvent::TurnStarted {
|
||||
turn_number,
|
||||
model_id: model_id.clone(),
|
||||
yolo_mode,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
self.send_before_turn_event(kigi_tool_protocol::turn_hook::BeforeTurnPayload {
|
||||
turn_number: self.chat_state_handle.get_prompt_index().await as u64,
|
||||
model_id: model_id.clone(),
|
||||
@@ -733,15 +724,6 @@ impl SessionActor {
|
||||
);
|
||||
let turn_tool_count = self.events.tool_count_this_turn();
|
||||
let bridge_outcome = turn_result_to_hook_outcome(&result);
|
||||
self.observability_bridge
|
||||
.emit(kigi_tool_protocol::session_event::SessionEvent::TurnEnded {
|
||||
turn_number: current_prompt_index as u64,
|
||||
outcome: bridge_outcome,
|
||||
duration_ms: turn_duration_ms,
|
||||
tool_call_count: turn_tool_count,
|
||||
model_id: turn_model_id.clone(),
|
||||
})
|
||||
.await;
|
||||
match &result {
|
||||
Ok(TurnOutcome::Completed { .. }) => {
|
||||
self.emit_turn_ended(
|
||||
@@ -1678,13 +1660,6 @@ impl SessionActor {
|
||||
self.emit_event(crate::session::events::Event::PhaseChanged {
|
||||
phase: crate::session::events::Phase::WaitingForModel,
|
||||
});
|
||||
self.observability_bridge
|
||||
.emit(
|
||||
kigi_tool_protocol::session_event::SessionEvent::PhaseChanged {
|
||||
phase: kigi_tool_protocol::session_event::SessionPhase::Sampling,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
kigi_log::unified_log::info(
|
||||
"shell.turn.inference_start",
|
||||
Some(self.session_info.id.0.as_ref()),
|
||||
@@ -1989,13 +1964,6 @@ impl SessionActor {
|
||||
self.emit_event(crate::session::events::Event::PhaseChanged {
|
||||
phase: crate::session::events::Phase::ToolExecution,
|
||||
});
|
||||
self.observability_bridge
|
||||
.emit(
|
||||
kigi_tool_protocol::session_event::SessionEvent::PhaseChanged {
|
||||
phase: kigi_tool_protocol::session_event::SessionPhase::ToolExecution,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let execute_tool_calls_result = self.execute_tool_calls(tool_call_responses).await;
|
||||
match execute_tool_calls_result {
|
||||
Ok(ToolLoop::PermissionReject { tool_name, reason }) => {
|
||||
|
||||
@@ -256,7 +256,6 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
@@ -695,7 +694,6 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
@@ -943,7 +941,6 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
@@ -1924,7 +1921,6 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
|
||||
-1
@@ -62,7 +62,6 @@ async fn tool_bridge_routes_writes_through_injected_fs() {
|
||||
video_gen_config: Default::default(),
|
||||
app_builder_deployer_config: Default::default(),
|
||||
api_key_provider: None,
|
||||
auth_provider: None,
|
||||
attribution_callback: None,
|
||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
||||
};
|
||||
|
||||
@@ -285,7 +285,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
|
||||
-3
@@ -214,7 +214,6 @@ async fn create_test_actor(
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
@@ -655,7 +654,6 @@ async fn create_test_actor_with_memory(
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
@@ -1410,7 +1408,6 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
|
||||
@@ -276,7 +276,6 @@ async fn create_test_actor_with_memory(
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
|
||||
@@ -33,6 +33,7 @@ fn install_real_permissions(actor: &mut SessionActor) {
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
actor.permissions = handle;
|
||||
}
|
||||
|
||||
-1
@@ -222,7 +222,6 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
|
||||
@@ -12,12 +12,6 @@ pub(crate) const HARNESS_VERIFIES_SENTENCE: &str =
|
||||
pub(crate) const PLAN_SEED_TODOS_PHRASE: &str =
|
||||
"Seed todos from the plan's acceptance criteria via";
|
||||
#[cfg(test)]
|
||||
pub(crate) fn noop_observability_bridge() -> kigi_computer_hub_sdk::ObservabilityBridge {
|
||||
kigi_computer_hub_sdk::ObservabilityBridge::new(
|
||||
None,
|
||||
kigi_tool_protocol::SessionId::new("test").expect("valid"),
|
||||
)
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn test_agent_default() -> kigi_agent::Agent {
|
||||
test_agent_with_tools(vec![]).await
|
||||
@@ -99,7 +93,6 @@ async fn test_agent_from_config(
|
||||
video_gen_config: Default::default(),
|
||||
app_builder_deployer_config: Default::default(),
|
||||
api_key_provider: None,
|
||||
auth_provider: None,
|
||||
attribution_callback: None,
|
||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
||||
};
|
||||
@@ -336,7 +329,6 @@ pub(crate) async fn create_test_actor_ex(
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
|
||||
@@ -41,7 +41,6 @@ async fn web_search_errors_when_disabled() {
|
||||
video_gen_config: Default::default(),
|
||||
app_builder_deployer_config: Default::default(),
|
||||
api_key_provider: None,
|
||||
auth_provider: None,
|
||||
attribution_callback: None,
|
||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
||||
};
|
||||
|
||||
@@ -2312,7 +2312,6 @@ mod inline_auto_compact_flow_tests {
|
||||
plugin_registry: std::cell::RefCell::new(None),
|
||||
plugin_registry_handle: None,
|
||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||
observability_bridge: noop_observability_bridge(),
|
||||
current_turn_number: std::cell::Cell::new(0),
|
||||
last_recap_main_turn: std::cell::Cell::new(0),
|
||||
recap_in_flight: std::cell::Cell::new(false),
|
||||
|
||||
@@ -1052,7 +1052,7 @@ async fn test_load_prompts_only_large_session() {
|
||||
info.id.clone(),
|
||||
acp::SessionUpdate::UserMessageChunk(
|
||||
acp::ContentChunk::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new(format!("part2"))),
|
||||
acp::ContentBlock::Text(acp::TextContent::new("part2".to_string())),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -432,7 +432,7 @@ fn git_rebase_refresh_storm_e2e() {
|
||||
unsafe {
|
||||
std::env::set_var("KIGI_SHARE_DIR", kigi_home.path());
|
||||
std::env::set_var("KIGI_CODE_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_XAI_API_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_API_BASE_URL", server.url());
|
||||
std::env::set_var("XAI_API_KEY", "test-key-for-ci");
|
||||
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
||||
std::env::set_var("KIGI_FEEDBACK_ENABLED", "false");
|
||||
|
||||
@@ -626,7 +626,7 @@ async fn full_session_load_e2e() {
|
||||
std::env::set_var("KIGI_INSTRUMENTATION", "log");
|
||||
std::env::set_var("KIGI_INSTRUMENTATION_LOG", &instr_log);
|
||||
std::env::set_var("KIGI_CODE_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_XAI_API_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_API_BASE_URL", server.url());
|
||||
std::env::set_var("XAI_API_KEY", "test-key-for-ci");
|
||||
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
||||
std::env::set_var("KIGI_FEEDBACK_ENABLED", "false");
|
||||
|
||||
@@ -1330,7 +1330,7 @@ async fn test_headless_managed_config_byok_sends_authorized_requests() {
|
||||
r#"
|
||||
[endpoints]
|
||||
deployment_key = "test-deployment-key"
|
||||
xai_api_base_url = "{url}"
|
||||
api_base_url = "{url}"
|
||||
|
||||
[model.kigi-build]
|
||||
api_backend = "responses"
|
||||
|
||||
@@ -137,7 +137,7 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
||||
unsafe {
|
||||
std::env::set_var("KIGI_SHARE_DIR", kigi_home.path());
|
||||
std::env::set_var("KIGI_CODE_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_XAI_API_BASE_URL", server.url());
|
||||
std::env::set_var("KIGI_API_BASE_URL", server.url());
|
||||
std::env::set_var("XAI_API_KEY", "test-key-for-ci");
|
||||
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
||||
std::env::set_var("KIGI_FEEDBACK_ENABLED", "false");
|
||||
|
||||
@@ -16,7 +16,7 @@ use kigi_workspace::permission::types::{
|
||||
};
|
||||
use kigi_workspace::permission::{
|
||||
AccessKind, ClientType, Decision, PermissionCommand, PermissionHandle, PermissionState,
|
||||
spawn_permission_manager, spawn_permission_manager_with_hub,
|
||||
spawn_permission_manager,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
@@ -221,6 +221,7 @@ async fn run_actor_test_full<F, Fut>(
|
||||
vec![],
|
||||
initial_yolo,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
body(handle, gw, cwd).await;
|
||||
})
|
||||
@@ -357,7 +358,7 @@ async fn policy_ask_suppresses_mcp_tool_allowlist() {
|
||||
|
||||
let (gw, _gw_task) = fake_gateway();
|
||||
// Gate OFF so the `ask` rule stays a hard floor over the grant.
|
||||
let (handle, _events) = spawn_permission_manager_with_hub(
|
||||
let (handle, _events) = spawn_permission_manager(
|
||||
make_session_id(),
|
||||
gw.sender.clone(),
|
||||
cwd.clone(),
|
||||
@@ -368,7 +369,6 @@ async fn policy_ask_suppresses_mcp_tool_allowlist() {
|
||||
false,
|
||||
None,
|
||||
false, // remember_tool_approvals
|
||||
None,
|
||||
);
|
||||
|
||||
// Script an outright reject so we can confirm the prompt fires.
|
||||
@@ -408,7 +408,7 @@ async fn policy_ask_suppresses_mcp_server_allowlist() {
|
||||
|
||||
let (gw, _gw_task) = fake_gateway();
|
||||
// Gate OFF so the `ask` rule stays a hard floor over the grant.
|
||||
let (handle, _events) = spawn_permission_manager_with_hub(
|
||||
let (handle, _events) = spawn_permission_manager(
|
||||
make_session_id(),
|
||||
gw.sender.clone(),
|
||||
cwd.clone(),
|
||||
@@ -419,7 +419,6 @@ async fn policy_ask_suppresses_mcp_server_allowlist() {
|
||||
false,
|
||||
None,
|
||||
false, // remember_tool_approvals
|
||||
None,
|
||||
);
|
||||
|
||||
gw.expected.send(("reject-once".to_string(), None)).unwrap();
|
||||
@@ -463,6 +462,7 @@ async fn policy_deny_takes_precedence_over_mcp_allowlist() {
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
|
||||
// Do NOT script a response: a policy Deny must short-circuit
|
||||
|
||||
@@ -17,7 +17,6 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["fmt"] }
|
||||
|
||||
@@ -165,7 +165,7 @@ pub fn test_env_cmd_tokio(
|
||||
// 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_XAI_API_BASE_URL", mock_url)
|
||||
.env("KIGI_API_BASE_URL", mock_url)
|
||||
.env("XAI_API_KEY", "test-key-for-ci")
|
||||
.env("KIGI_TELEMETRY_ENABLED", "false")
|
||||
.env("KIGI_FEEDBACK_ENABLED", "false")
|
||||
|
||||
@@ -142,7 +142,7 @@ impl LeaderStdioClient {
|
||||
// (re-)elected leader binds the same sandboxed path.
|
||||
.env("KIGI_LEADER_SOCKET", home.join(".kigi").join("leader.sock"))
|
||||
.env("KIGI_CODE_BASE_URL", server.url())
|
||||
.env("KIGI_XAI_API_BASE_URL", server.url())
|
||||
.env("KIGI_API_BASE_URL", server.url())
|
||||
.env("XAI_API_KEY", "test-key-for-ci")
|
||||
.env("KIGI_TELEMETRY_ENABLED", "false")
|
||||
.env("KIGI_FEEDBACK_ENABLED", "false")
|
||||
|
||||
@@ -4,9 +4,8 @@ fn main() {
|
||||
".", // match every message & enum
|
||||
"#[derive(serde::Serialize, serde::Deserialize)]",
|
||||
)
|
||||
// ToolConfigEntry is embedded in external JSON contracts (Computer Hub
|
||||
// `session.bind` metadata and agent-config JSON) where sparse payloads
|
||||
// must deserialize. Defaults are applied per optional field (not
|
||||
// ToolConfigEntry is embedded in external JSON contracts
|
||||
// (agent-config JSON) where sparse payloads must deserialize. Defaults are applied per optional field (not
|
||||
// type-level) so the required `id` field still fails deserialization
|
||||
// when missing instead of silently becoming "". See tests/wire_shape.rs.
|
||||
.field_attribute(
|
||||
|
||||
@@ -52,9 +52,6 @@ serde_path_to_error = { workspace = true }
|
||||
kigi-tool-runtime = { workspace = true }
|
||||
kigi-tool-types = { workspace = true }
|
||||
kigi-tool-protocol = { workspace = true }
|
||||
kigi-computer-hub-core = { workspace = true }
|
||||
kigi-computer-hub-sdk = { workspace = true }
|
||||
|
||||
futures = { workspace = true }
|
||||
similar = { workspace = true }
|
||||
strum = { version = "0.26", features = ["derive"] }
|
||||
|
||||
@@ -2093,11 +2093,11 @@ mod tests {
|
||||
assert!(validate_anchor(&padded, &lines, &scheme).is_err());
|
||||
|
||||
// Stale anchor with arrow reports the stripped anchor in error metadata.
|
||||
let stale = format!("2:zzz:zzz\u{2192}content");
|
||||
let stale = "2:zzz:zzz\u{2192}content".to_string();
|
||||
let err = validate_anchor(&stale, &lines, &scheme).unwrap_err();
|
||||
assert_eq!(err.requested_anchor.as_deref(), Some("2:zzz:zzz"));
|
||||
|
||||
let stale_ascii = format!("2:zzz:zzz->content");
|
||||
let stale_ascii = "2:zzz:zzz->content".to_string();
|
||||
let err = validate_anchor(&stale_ascii, &lines, &scheme).unwrap_err();
|
||||
assert_eq!(err.requested_anchor.as_deref(), Some("2:zzz:zzz"));
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ pub fn build_server_reminder(
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut text = format!("Connected MCP servers:\n",);
|
||||
let mut text = "Connected MCP servers:\n".to_string();
|
||||
for server in servers {
|
||||
text.push_str(&format_server_line(server));
|
||||
}
|
||||
|
||||
@@ -283,12 +283,6 @@ pub struct SessionContext {
|
||||
/// instead of using the key baked into their config at construction time.
|
||||
/// Prevents 401 failures when a session outlives the initial token lifetime.
|
||||
pub api_key_provider: Option<crate::types::SharedApiKeyProvider>,
|
||||
/// Auth provider which returns a kigi_computer_hub_sdk::AuthCredential. Can be used by
|
||||
/// tools that need to authenticate with services.
|
||||
///
|
||||
/// Not to be confused with the api_key_provider, which is a legacy
|
||||
/// provider used by the shell's auth manager.
|
||||
pub auth_provider: Option<kigi_computer_hub_sdk::SharedAuthProvider>,
|
||||
/// Optional 401-attribution callback for tool HTTP clients. When
|
||||
/// set, a 401 from `image_gen` / `video_gen` / `web_search`
|
||||
/// emits an `auth_401_attribution` event via this hook. Hosts can
|
||||
@@ -351,7 +345,7 @@ type OutputConverter =
|
||||
/// `.await`.
|
||||
struct DispatchParts {
|
||||
/// Resolved `LocalRegistry` handle to dispatch through.
|
||||
lr_handle: Arc<dyn kigi_computer_hub_core::ToolHandle>,
|
||||
lr_handle: kigi_tool_runtime::ArcTool,
|
||||
/// Runtime context built for the call (resources, renderer, cwd,
|
||||
/// behavior version, inner-dispatch).
|
||||
ctx: kigi_tool_runtime::ToolCallContext,
|
||||
@@ -394,7 +388,7 @@ struct ToolEntry {
|
||||
>,
|
||||
/// Registers this tool into a `LocalRegistry` using the concrete type.
|
||||
/// Captured at `register::<T>()` time when T is known.
|
||||
register_in_local: Box<dyn Fn(&kigi_computer_hub_sdk::LocalRegistry) + Send + Sync>,
|
||||
register_in_local: Box<dyn Fn(&kigi_tool_runtime::LocalRegistry) + Send + Sync>,
|
||||
}
|
||||
/// Per-reminder metadata stored in the builder.
|
||||
struct ReminderEntry {
|
||||
@@ -450,7 +444,7 @@ pub struct FinalizedToolset {
|
||||
scheduler_cancel: Option<tokio_util::sync::CancellationToken>,
|
||||
/// Shared local registry for in-process dispatch.
|
||||
/// Contains only config-enabled tools. Can be shared with ToolHarness.
|
||||
local_registry: kigi_computer_hub_sdk::LocalRegistry,
|
||||
local_registry: kigi_tool_runtime::LocalRegistry,
|
||||
/// Lock-free access to the template renderer for tool name/param resolution.
|
||||
/// Cloned into `ToolCallContext::extensions` on each `call()` so tools
|
||||
/// can resolve names without acquiring the `resources` mutex.
|
||||
@@ -519,7 +513,7 @@ impl RequirementError {
|
||||
pub struct ToolRegistryBuilder {
|
||||
tools: HashMap<String, ToolEntry>,
|
||||
reminders: Vec<ReminderEntry>,
|
||||
shared_local_registry: Option<kigi_computer_hub_sdk::LocalRegistry>,
|
||||
shared_local_registry: Option<kigi_tool_runtime::LocalRegistry>,
|
||||
}
|
||||
impl Default for ToolRegistryBuilder {
|
||||
fn default() -> Self {
|
||||
@@ -613,7 +607,7 @@ impl ToolRegistryBuilder {
|
||||
let typed = serde_json::from_value::<T::Args>(json)?;
|
||||
Ok(typed.into())
|
||||
}),
|
||||
register_in_local: Box::new(|lr: &kigi_computer_hub_sdk::LocalRegistry| {
|
||||
register_in_local: Box::new(|lr: &kigi_tool_runtime::LocalRegistry| {
|
||||
lr.register(T::default());
|
||||
}),
|
||||
},
|
||||
@@ -744,7 +738,7 @@ impl ToolRegistryBuilder {
|
||||
}
|
||||
b
|
||||
}
|
||||
pub fn with_local_registry(mut self, registry: kigi_computer_hub_sdk::LocalRegistry) -> Self {
|
||||
pub fn with_local_registry(mut self, registry: kigi_tool_runtime::LocalRegistry) -> Self {
|
||||
self.shared_local_registry = Some(registry);
|
||||
self
|
||||
}
|
||||
@@ -990,9 +984,6 @@ impl ToolRegistryBuilder {
|
||||
if let Some(memory_backend) = ctx.memory_backend {
|
||||
resources.insert(memory_backend);
|
||||
}
|
||||
if let Some(auth_provider) = ctx.auth_provider.clone() {
|
||||
resources.insert(auth_provider);
|
||||
}
|
||||
if let Ok(client) = crate::implementations::web_search::client::WebSearchClient::new(
|
||||
&ctx.web_search_config,
|
||||
ctx.api_key_provider.clone(),
|
||||
@@ -1265,7 +1256,7 @@ impl FinalizedToolset {
|
||||
)),
|
||||
resources_persistence: Arc::new(ResourcesPersistence::noop()),
|
||||
scheduler_cancel: None,
|
||||
local_registry: kigi_computer_hub_sdk::LocalRegistry::new(),
|
||||
local_registry: kigi_tool_runtime::LocalRegistry::new(),
|
||||
renderer: Arc::new(TemplateRenderer::new(
|
||||
std::collections::HashMap::new(),
|
||||
std::collections::HashMap::new(),
|
||||
@@ -1274,9 +1265,6 @@ impl FinalizedToolset {
|
||||
workspace_viewer_ctx: None,
|
||||
}
|
||||
}
|
||||
pub fn local_registry(&self) -> &kigi_computer_hub_sdk::LocalRegistry {
|
||||
&self.local_registry
|
||||
}
|
||||
/// Get all tool definitions to send to the client.
|
||||
pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
|
||||
self.tools
|
||||
@@ -2018,7 +2006,6 @@ mod tests {
|
||||
app_builder_deployer_config:
|
||||
crate::implementations::grok_build::deploy_app::AppBuilderDeployerConfig::default(),
|
||||
api_key_provider: None,
|
||||
auth_provider: None,
|
||||
attribution_callback: None,
|
||||
system_reminder_tag: crate::reminders::DEFAULT_REMINDER_TAG,
|
||||
}
|
||||
|
||||
@@ -175,9 +175,6 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result<
|
||||
if let Some(effort) = flags.reasoning_effort_override {
|
||||
agent_config.reasoning_effort_override = Some(effort);
|
||||
}
|
||||
// Agent connect intentionally leaves hub URL unset; provider hub is
|
||||
// WorkspaceStartArgs only.
|
||||
|
||||
if !flags.permission_rules.is_empty() {
|
||||
agent_config.cli_agent_overrides.permission_rules = flags.permission_rules.clone();
|
||||
}
|
||||
|
||||
@@ -110,12 +110,6 @@ See ~/.kigi/README.md for more information.
|
||||
},
|
||||
/// Manage git worktrees
|
||||
Worktree(crate::worktree_cmd::WorktreeArgs),
|
||||
/// Expose this workspace to the Computer Hub (via the leader).
|
||||
///
|
||||
/// Disabled by default and enabled server-side per account; set
|
||||
/// `KIGI_WORKSPACE_COMMAND=1` to enable it locally for testing.
|
||||
#[command(hide = true)]
|
||||
Workspace(WorkspaceMgmtArgs),
|
||||
/// Open the Agent Dashboard view at startup.
|
||||
///
|
||||
/// Centralised, agent-native overview of every session (top-level and
|
||||
@@ -138,7 +132,7 @@ pub struct WrapArgs {
|
||||
)]
|
||||
pub command: Vec<String>,
|
||||
}
|
||||
/// Targets a running leader process by PID (used by `kigi leader` / `kigi workspace`).
|
||||
/// Targets a running leader process by PID (used by `kigi leader`).
|
||||
#[derive(Debug, clap::Args, Clone, Default)]
|
||||
pub struct LeaderTargetArgs {
|
||||
/// Leader process ID from `kigi leader list`.
|
||||
@@ -169,69 +163,6 @@ pub enum LeaderMgmtCommand {
|
||||
/// Stop all running leader processes
|
||||
Kill,
|
||||
}
|
||||
#[derive(Debug, clap::Args, Clone)]
|
||||
pub struct WorkspaceMgmtArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: WorkspaceMgmtCommand,
|
||||
}
|
||||
#[derive(Debug, Subcommand, Clone)]
|
||||
pub enum WorkspaceMgmtCommand {
|
||||
/// Start (or update) the workspace→hub exposure.
|
||||
Start(WorkspaceStartArgs),
|
||||
/// Drain and disconnect from the hub, keeping the exposure warm.
|
||||
Pause {
|
||||
#[command(flatten)]
|
||||
target: LeaderTargetArgs,
|
||||
/// Emit machine-readable JSON output.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Reconnect a paused exposure to the hub.
|
||||
Resume {
|
||||
#[command(flatten)]
|
||||
target: LeaderTargetArgs,
|
||||
/// Emit machine-readable JSON output.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Stop exposing the workspace (the leader keeps running).
|
||||
Stop {
|
||||
#[command(flatten)]
|
||||
target: LeaderTargetArgs,
|
||||
/// Emit machine-readable JSON output.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Restart the exposure (stop, then start with the given options).
|
||||
Restart(WorkspaceStartArgs),
|
||||
/// Show the current workspace-exposure status.
|
||||
#[command(visible_alias = "list")]
|
||||
Status {
|
||||
#[command(flatten)]
|
||||
target: LeaderTargetArgs,
|
||||
/// Emit machine-readable JSON output.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, clap::Args, Clone)]
|
||||
pub struct WorkspaceStartArgs {
|
||||
/// Computer Hub WebSocket URL (default: `[hub].url`, then the prod hub).
|
||||
#[arg(long, value_name = "URL")]
|
||||
pub hub_url: Option<String>,
|
||||
/// Workspace root directory to expose. Defaults to the current directory.
|
||||
#[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)]
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Force leader mode for this command, overriding config.
|
||||
#[arg(long, conflicts_with = "no_leader")]
|
||||
pub leader: bool,
|
||||
/// Refuse to start even when config enables leader mode.
|
||||
#[arg(long, conflicts_with = "leader")]
|
||||
pub no_leader: bool,
|
||||
/// Emit machine-readable JSON output.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
/// Arguments for the `agent` subcommand.
|
||||
#[derive(Debug, clap::Args, Clone)]
|
||||
pub struct AgentArgs {
|
||||
@@ -276,9 +207,9 @@ pub struct AgentArgs {
|
||||
/// Override the CLI chat proxy base URL.
|
||||
#[arg(long = "coding-api-base-url")]
|
||||
pub coding_api_base_url: Option<String>,
|
||||
/// Override the public xAI API base URL.
|
||||
#[arg(long = "xai-api-base-url")]
|
||||
pub xai_api_base_url: Option<String>,
|
||||
/// Override the direct (BYOK / external-API-key) API base URL.
|
||||
#[arg(long = "api-base-url")]
|
||||
pub api_base_url: Option<String>,
|
||||
/// Agent runtime mode. Optional: bare `kigi agent` (and the `kigi acp`
|
||||
/// alias) default to stdio.
|
||||
#[command(subcommand)]
|
||||
|
||||
@@ -1619,7 +1619,7 @@ fn sanitize_user_error_strips_auth_prefixes() {
|
||||
#[test]
|
||||
fn sanitize_user_error_collapses_disk_full() {
|
||||
assert_eq!(
|
||||
sanitize_user_error("couldn't create worktree: Internal error: \"hub error: Worktree creation failed: not enough free disk space\""),
|
||||
sanitize_user_error("couldn't create worktree: Internal error: \"workspace error: Worktree creation failed: not enough free disk space\""),
|
||||
"Out of disk space."
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -318,7 +318,7 @@ impl PagerLeaderCluster {
|
||||
let env = vec![
|
||||
crate::test_util::EnvVarGuard::set("KIGI_SHARE_DIR", kigi_home.path()),
|
||||
crate::test_util::EnvVarGuard::set("KIGI_CODE_BASE_URL", server.url()),
|
||||
crate::test_util::EnvVarGuard::set("KIGI_XAI_API_BASE_URL", server.url()),
|
||||
crate::test_util::EnvVarGuard::set("KIGI_API_BASE_URL", server.url()),
|
||||
crate::test_util::EnvVarGuard::set("XAI_API_KEY", "test-key-for-ci"),
|
||||
crate::test_util::EnvVarGuard::set("KIGI_TELEMETRY_ENABLED", "false"),
|
||||
crate::test_util::EnvVarGuard::set("KIGI_FEEDBACK_ENABLED", "false"),
|
||||
|
||||
@@ -48,7 +48,6 @@ pub use cli::{
|
||||
AgentArgs, AgentCmd, Command, LeaderArgs, LeaderMgmtArgs, LeaderMgmtCommand, LeaderTargetArgs,
|
||||
OutputFormat, PagerArgs, ServeArgs, WrapArgs,
|
||||
};
|
||||
pub use cli::{WorkspaceMgmtArgs, WorkspaceMgmtCommand, WorkspaceStartArgs};
|
||||
use crossterm::cursor::{self, SetCursorStyle};
|
||||
use crossterm::event;
|
||||
use crossterm::execute;
|
||||
|
||||
@@ -888,9 +888,6 @@ pub async fn run_single_turn(
|
||||
None,
|
||||
);
|
||||
|
||||
// No agent-level hub client URL (gateway-only cloud; workspace provider
|
||||
// hub_url lives on `grok workspace` / WorkspaceStartArgs only).
|
||||
|
||||
apply_agent_flag(&options.agent, &mut agent_config);
|
||||
|
||||
if let Some(ref json) = options.agents_json {
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn build_session_tar(session_dir: &Path, session_id: &str) -> Result<Vec<u8>
|
||||
|
||||
let metadata = ExportMetadata {
|
||||
session_id: session_id.to_owned(),
|
||||
grok_version: env!("VERSION_WITH_COMMIT").to_owned(),
|
||||
kigi_version: env!("VERSION_WITH_COMMIT").to_owned(),
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
@@ -86,7 +86,7 @@ pub fn build_session_tar(session_dir: &Path, session_id: &str) -> Result<Vec<u8>
|
||||
#[derive(serde::Serialize)]
|
||||
struct ExportMetadata {
|
||||
session_id: String,
|
||||
grok_version: String,
|
||||
kigi_version: String,
|
||||
os: String,
|
||||
arch: String,
|
||||
exported_at: String,
|
||||
|
||||
@@ -8,9 +8,6 @@ description = "Core host-local workspace library (FS, VCS, execution, discovery)
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
# Diagnostics HTTP server (in-guest readiness/status endpoint); pinned to the
|
||||
# workspace version already used by the sibling preview proxy.
|
||||
axum = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
kigi-version = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
@@ -41,7 +38,6 @@ uuid = { workspace = true, features = ["v4", "v5", "v7"] }
|
||||
kigi-agent = { path = "../kigi-agent" }
|
||||
kigi-tools = { path = "../kigi-tools" }
|
||||
kigi-tools-api = { path = "../kigi-tools-api" }
|
||||
kigi-workspace-client = { path = "../kigi-workspace-client" }
|
||||
kigi-workspace-types = { path = "../kigi-workspace-types" }
|
||||
kigi-config = { workspace = true }
|
||||
# Leaf config value types (RemoteSettings, BoolFlag) for the folder-trust decision.
|
||||
@@ -73,53 +69,28 @@ glob = "0.3"
|
||||
kigi-sandbox = { path = "../kigi-sandbox", default-features = false }
|
||||
kigi-hooks = { path = "../kigi-hooks" }
|
||||
kigi-hunk-tracker = { path = "../kigi-hunk-tracker" }
|
||||
# `metrics` enables the SDK's metric-donation client (periodic Prometheus
|
||||
# registry gather → OTLP → hub donation pump); see metric_donation_reporter.
|
||||
kigi-computer-hub-sdk = { workspace = true, features = ["metrics"] }
|
||||
kigi-computer-hub-mcp-adapter = { path = "../../common/kigi-computer-hub-mcp-adapter" }
|
||||
kigi-mcp = { path = "../kigi-mcp" }
|
||||
kigi-file-utils = { path = "../kigi-file-utils" }
|
||||
kigi-auth = { path = "../kigi-auth" }
|
||||
kigi-log = { workspace = true }
|
||||
kigi-tty-utils = { workspace = true }
|
||||
kigi-sqlite-journal = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
kigi-tool-protocol = { workspace = true }
|
||||
kigi-tool-runtime = { workspace = true }
|
||||
kigi-tool-types = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
urlencoding = "2"
|
||||
kigi-fast-worktree = { path = "../kigi-fast-worktree", features = ["metadata"] }
|
||||
# tonic stays for `tonic::Status`/`Code` mapping in workspace_ops deploy errors.
|
||||
tonic = { workspace = true }
|
||||
|
||||
kigi-fsnotify = { path = "../kigi-fsnotify" }
|
||||
clap = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
# "enable" required for the SDK's spans to record at all.
|
||||
fastrace = { workspace = true, features = ["enable"] }
|
||||
kigi-tracing = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
||||
tempfile = { workspace = true }
|
||||
zstd = { workspace = true }
|
||||
|
||||
# Only referenced by the Unix self-daemonize path (fork/setsid/dup2/chdir).
|
||||
# Only referenced by the Unix foreign-session capability probe (O_DIRECTORY etc.).
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
# Only referenced by the Windows self-daemonize path (SetStdHandle).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "kigi-workspace-server"
|
||||
path = "src/bin/workspace_server.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "workspace-server-probe"
|
||||
path = "src/bin/workspace_server_probe.rs"
|
||||
|
||||
[features]
|
||||
default = ["sandbox-enforce"]
|
||||
compression = []
|
||||
@@ -137,12 +108,6 @@ filetime = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
kigi-test-utils = { path = "../../common/kigi-test-utils" }
|
||||
# Decode tar.gz archives produced by workspace upload helpers in tests.
|
||||
flate2 = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
|
||||
[package.metadata.cargo-shear]
|
||||
ignored = ["tokio-tungstenite"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Workspace and session configuration types.
|
||||
use crate::capability::CapabilityMode;
|
||||
use crate::hub::HubConfig;
|
||||
use kigi_tools::registry::types::{SessionContext, ToolRegistryBuilder, ToolServerConfig};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -97,605 +96,6 @@ pub trait SessionContextFactory: Send + Sync {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct MemoryConfig {}
|
||||
/// Per-session toolset/capability selection from the `session.bind`
|
||||
/// metadata. Absent fields fall back to the workspace default and `CapabilityMode::All`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct WorkspaceBindConfig {
|
||||
/// Named toolset preset from the wire. **Never resolved** (see
|
||||
/// [`Self::resolve`]); parsed only so it can be logged.
|
||||
pub preset: Option<String>,
|
||||
/// Capability mode applied to the session's toolset.
|
||||
pub capability_mode: Option<CapabilityMode>,
|
||||
/// Fully-specified toolset in the runtime serde shape. Takes precedence
|
||||
/// over `tools`.
|
||||
pub tool_config: Option<ToolServerConfig>,
|
||||
/// Per-user feature-flag bag. `None` on legacy payloads → tools
|
||||
/// fall back to their safe defaults.
|
||||
pub viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
||||
/// Initial auto-approve (YOLO) state. `None` on legacy payloads →
|
||||
/// fail-closed (false).
|
||||
pub yolo_mode: Option<bool>,
|
||||
/// Plane-configured toolset in the gRPC wire shape. An empty list is
|
||||
/// treated as unset (proto3 repeated default).
|
||||
pub tools: Option<Vec<kigi_tools_api::ToolConfigEntry>>,
|
||||
pub manifest_version: Option<String>,
|
||||
pub manifest_hash: Option<String>,
|
||||
/// Opt-in: forward `BackgroundTaskCompleted` system notifications for this session.
|
||||
pub system_notifications: bool,
|
||||
pub rpc_only: bool,
|
||||
}
|
||||
/// Outcome of resolving a [`WorkspaceBindConfig`]; lets callers fail closed
|
||||
/// instead of widening to the default toolset. Deliberately has **no preset
|
||||
/// arm** (see [`WorkspaceBindConfig::resolve`]).
|
||||
#[derive(Debug)]
|
||||
pub enum ResolvedToolset {
|
||||
/// An explicit toolset (`tool_config` or `tools`).
|
||||
Toolset(ResolvedTools),
|
||||
/// No explicit toolset was specified and the workspace allows falling
|
||||
/// back to its default catalog (local/CLI embedders only).
|
||||
UseDefault,
|
||||
/// No explicit toolset was specified and the workspace requires one
|
||||
/// (sandbox-launched standalone servers) — fail closed.
|
||||
MissingToolConfig,
|
||||
/// `tools` entries were specified but at least one failed to convert.
|
||||
InvalidToolConfig(kigi_tools::registry::proto_convert::ToolConfigEntryError),
|
||||
}
|
||||
/// A resolved toolset plus the pinned entries this binary could not serve.
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedTools {
|
||||
pub toolset: ToolServerConfig,
|
||||
/// Pinned `tools` ids unknown to this binary's registry, sorted. Always
|
||||
/// empty for `tool_config` resolutions.
|
||||
pub unserved_tool_ids: Vec<String>,
|
||||
}
|
||||
impl ResolvedTools {
|
||||
/// A fully-served toolset (no divergence).
|
||||
fn full(toolset: ToolServerConfig) -> Self {
|
||||
Self {
|
||||
toolset,
|
||||
unserved_tool_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl WorkspaceBindConfig {
|
||||
/// Parse hub `session.bind` metadata. The envelope is the shared
|
||||
/// [`kigi_tool_runtime::WorkspaceBindMetadata`] (same type the emitter
|
||||
/// serializes); `tool_config` is a consumer-only raw escape hatch read
|
||||
/// separately.
|
||||
pub fn from_metadata(metadata: &serde_json::Value) -> Self {
|
||||
let wire: kigi_tool_runtime::WorkspaceBindMetadata =
|
||||
serde_json::from_value(metadata.clone()).unwrap_or_default();
|
||||
Self {
|
||||
preset: wire.preset,
|
||||
capability_mode: wire
|
||||
.capability_mode
|
||||
.and_then(|s| serde_json::from_value(serde_json::Value::String(s)).ok()),
|
||||
tool_config: metadata
|
||||
.as_object()
|
||||
.and_then(|obj| obj.get("tool_config"))
|
||||
.and_then(|v| parse_field("tool_config", v)),
|
||||
viewer_ctx: wire.viewer_ctx,
|
||||
yolo_mode: wire.yolo_mode,
|
||||
tools: Some(wire.tools).filter(|tools| !tools.is_empty()),
|
||||
manifest_version: wire.manifest_version,
|
||||
manifest_hash: wire.manifest_hash,
|
||||
system_notifications: wire.system_notifications.unwrap_or(false),
|
||||
rpc_only: wire.rpc_only,
|
||||
}
|
||||
}
|
||||
/// Resolve the selected toolset.
|
||||
///
|
||||
/// Precedence: `tool_config` > `tools` (wire entries) > default/fail-closed.
|
||||
/// Pinned `tools` are served per entry: ids `known_id` rejects are dropped
|
||||
/// and reported in [`ResolvedTools::unserved_tool_ids`] instead of
|
||||
/// silently falling back to a different toolset.
|
||||
///
|
||||
/// **Presets are never resolved** — a `preset` on the wire is logged and
|
||||
/// ignored; only explicit `tools`/`tool_config` may select a toolset.
|
||||
///
|
||||
/// With `require_explicit_toolset` (sandbox standalone servers) a bind
|
||||
/// without an explicit toolset fails closed instead of widening to the
|
||||
/// binary's default catalog.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
known_id: &dyn Fn(&str) -> bool,
|
||||
require_explicit_toolset: bool,
|
||||
) -> ResolvedToolset {
|
||||
if let Some(cfg) = &self.tool_config {
|
||||
for (idx, tool) in cfg.tools.iter().enumerate() {
|
||||
if let Err(err) = kigi_tools_api::config_validation::validate_name_override(
|
||||
idx,
|
||||
&tool.id,
|
||||
tool.name_override.as_deref(),
|
||||
) {
|
||||
return ResolvedToolset::InvalidToolConfig(err);
|
||||
}
|
||||
}
|
||||
return ResolvedToolset::Toolset(ResolvedTools::full(cfg.clone()));
|
||||
}
|
||||
if let Some(tools) = &self.tools {
|
||||
let mut unserved_tool_ids: Vec<String> = Vec::new();
|
||||
let mut served = Vec::with_capacity(tools.len());
|
||||
for (idx, entry) in tools.iter().enumerate() {
|
||||
if !known_id(&entry.id) {
|
||||
unserved_tool_ids.push(entry.id.clone());
|
||||
continue;
|
||||
}
|
||||
match kigi_tools::registry::proto_convert::tool_config_from_entry(
|
||||
idx,
|
||||
entry.clone(),
|
||||
) {
|
||||
Ok(tc) => served.push(tc),
|
||||
Err(err) => return ResolvedToolset::InvalidToolConfig(err),
|
||||
}
|
||||
}
|
||||
unserved_tool_ids.sort_unstable();
|
||||
if !unserved_tool_ids.is_empty() {
|
||||
tracing::warn!(
|
||||
unserved = ? unserved_tool_ids, config_manifest_version = ? self
|
||||
.manifest_version, running_version = kigi_version::VERSION,
|
||||
"session.bind: serving known subset of pinned tools"
|
||||
);
|
||||
}
|
||||
return ResolvedToolset::Toolset(ResolvedTools {
|
||||
toolset: ToolServerConfig {
|
||||
tools: served,
|
||||
behavior_preset: None,
|
||||
},
|
||||
unserved_tool_ids,
|
||||
});
|
||||
}
|
||||
if let Some(preset) = self.preset.as_deref() {
|
||||
tracing::warn!(
|
||||
preset,
|
||||
"session.bind: toolset presets are not resolved by the workspace \
|
||||
server; pass an explicit `tools` config"
|
||||
);
|
||||
}
|
||||
if require_explicit_toolset {
|
||||
ResolvedToolset::MissingToolConfig
|
||||
} else {
|
||||
ResolvedToolset::UseDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Parse a single bind-metadata field, ignoring (and logging) a malformed value.
|
||||
fn parse_field<T: serde::de::DeserializeOwned>(name: &str, value: &serde_json::Value) -> Option<T> {
|
||||
match serde_json::from_value(value.clone()) {
|
||||
Ok(parsed) => Some(parsed),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
field = name, error = % e,
|
||||
"session.bind metadata: ignoring malformed field"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod bind_config_tests {
|
||||
use super::*;
|
||||
/// Predicate for tests where every pinned id is known to the binary.
|
||||
fn all_known(_: &str) -> bool {
|
||||
true
|
||||
}
|
||||
/// Predicate for tests simulating a binary that knows none of the ids.
|
||||
fn none_known(_: &str) -> bool {
|
||||
false
|
||||
}
|
||||
#[test]
|
||||
fn parses_preset_and_capability() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "capability_mode" : "read_only" }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
assert_eq!(cfg.preset.as_deref(), Some("explore"));
|
||||
assert_eq!(
|
||||
cfg.capability_mode,
|
||||
Some(crate::capability::CapabilityMode::ReadOnly)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn defaults_on_empty_or_mismatched_metadata() {
|
||||
let empty = WorkspaceBindConfig::from_metadata(&serde_json::json!({}));
|
||||
assert!(empty.preset.is_none());
|
||||
assert!(empty.capability_mode.is_none());
|
||||
assert!(matches!(
|
||||
empty.resolve(&all_known, false),
|
||||
ResolvedToolset::UseDefault
|
||||
));
|
||||
let weird = WorkspaceBindConfig::from_metadata(&serde_json::json!("hello"));
|
||||
assert!(matches!(
|
||||
weird.resolve(&all_known, false),
|
||||
ResolvedToolset::UseDefault
|
||||
));
|
||||
}
|
||||
/// Presets are banned: any preset (known or not) is ignored — never
|
||||
/// resolved to a toolset, and never widened to the default in strict mode.
|
||||
#[test]
|
||||
fn presets_are_never_resolved() {
|
||||
for preset in ["explore", "grok-computer", "bogus"] {
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&serde_json::json!({ "preset" : preset }));
|
||||
assert!(
|
||||
matches!(cfg.resolve(&all_known, false), ResolvedToolset::UseDefault),
|
||||
"lax mode must fall through to the default, preset={preset}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
cfg.resolve(&all_known, true),
|
||||
ResolvedToolset::MissingToolConfig
|
||||
),
|
||||
"strict mode must fail closed, preset={preset}"
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Strict mode (sandbox standalone server): no explicit toolset on the
|
||||
/// bind ⇒ fail closed instead of widening to the default catalog.
|
||||
#[test]
|
||||
fn strict_mode_requires_explicit_toolset() {
|
||||
let empty = WorkspaceBindConfig::from_metadata(&serde_json::json!({}));
|
||||
assert!(matches!(
|
||||
empty.resolve(&all_known, true),
|
||||
ResolvedToolset::MissingToolConfig
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn malformed_field_does_not_discard_valid_siblings() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "capability_mode" : "raed_only" }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
assert_eq!(cfg.preset.as_deref(), Some("explore"));
|
||||
assert!(cfg.capability_mode.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn workspace_bind_config_from_metadata_extracts_viewer_ctx() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "viewer_ctx" : { "stream_tool_progress" : true }, }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
assert_eq!(cfg.preset.as_deref(), Some("explore"));
|
||||
let viewer = cfg.viewer_ctx.expect("viewer_ctx parsed");
|
||||
assert!(viewer.stream_tool_progress);
|
||||
}
|
||||
/// Legacy payload without `viewer_ctx` still parses (mixed-version
|
||||
/// proxy/workspace deploys).
|
||||
#[test]
|
||||
fn workspace_bind_config_from_metadata_legacy_omitted_viewer_ctx() {
|
||||
let v = serde_json::json!({ "preset" : "explore" });
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
assert!(cfg.viewer_ctx.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn workspace_bind_config_from_metadata_extracts_yolo_mode() {
|
||||
let v = serde_json::json!({ "preset" : "explore", "yolo_mode" : true });
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
assert_eq!(cfg.yolo_mode, Some(true));
|
||||
}
|
||||
#[test]
|
||||
fn workspace_bind_config_yolo_mode_omitted_or_malformed_fails_closed() {
|
||||
let omitted =
|
||||
WorkspaceBindConfig::from_metadata(&serde_json::json!({ "preset" : "explore" }));
|
||||
assert!(omitted.yolo_mode.is_none());
|
||||
let malformed = WorkspaceBindConfig::from_metadata(
|
||||
&serde_json::json!({ "preset" : "explore", "yolo_mode" : "yes" }),
|
||||
);
|
||||
assert!(malformed.yolo_mode.is_none());
|
||||
assert_eq!(malformed.preset.as_deref(), Some("explore"));
|
||||
}
|
||||
#[test]
|
||||
fn workspace_bind_config_extracts_system_notifications_flag() {
|
||||
let on = WorkspaceBindConfig::from_metadata(
|
||||
&serde_json::json!({ "system_notifications" : true }),
|
||||
);
|
||||
assert!(on.system_notifications);
|
||||
let off = WorkspaceBindConfig::from_metadata(&serde_json::json!({ "preset" : "explore" }));
|
||||
assert!(!off.system_notifications);
|
||||
let explicit_off = WorkspaceBindConfig::from_metadata(
|
||||
&serde_json::json!({ "system_notifications" : false }),
|
||||
);
|
||||
assert!(!explicit_off.system_notifications);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_bind_config_extracts_rpc_only_flag() {
|
||||
let on = WorkspaceBindConfig::from_metadata(&serde_json::json!({ "rpc_only" : true }));
|
||||
assert!(on.rpc_only);
|
||||
let off = WorkspaceBindConfig::from_metadata(&serde_json::json!({ "preset" : "explore" }));
|
||||
assert!(!off.rpc_only);
|
||||
let explicit_off =
|
||||
WorkspaceBindConfig::from_metadata(&serde_json::json!({ "rpc_only" : false }));
|
||||
assert!(!explicit_off.rpc_only);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_bind_config_from_metadata_extracts_manifest_fields() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "manifest_version" : "v1", "manifest_hash" :
|
||||
"abc123", }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
assert_eq!(cfg.manifest_version.as_deref(), Some("v1"));
|
||||
assert_eq!(cfg.manifest_hash.as_deref(), Some("abc123"));
|
||||
}
|
||||
#[test]
|
||||
fn workspace_bind_config_manifest_fields_default_to_none_when_absent() {
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&serde_json::json!({ "preset" : "explore" }));
|
||||
assert!(cfg.manifest_version.is_none());
|
||||
assert!(cfg.manifest_hash.is_none());
|
||||
}
|
||||
/// Consumer-side parity test for the bind-metadata `tools` contract;
|
||||
/// pairs with the producer-side pin test in agentic-sampler's
|
||||
/// `configs::plane` tests.
|
||||
#[test]
|
||||
fn tools_entries_resolve_to_tool_server_config() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "tools" : [{ "id" : "GrokBuild:grep", "params_json" :
|
||||
"{\"max_results\":50}", "name_override" : "search", "params_name_overrides" :
|
||||
{ "pattern" : "query" }, "behavior_version" : "legacy-0.4.10",
|
||||
"description_override" : "Search the codebase", }, { "id" :
|
||||
"GrokBuild:read_file" },], }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
let ResolvedToolset::Toolset(resolved) = cfg.resolve(&all_known, false) else {
|
||||
panic!("tools entries must resolve to an explicit toolset");
|
||||
};
|
||||
assert!(resolved.unserved_tool_ids.is_empty());
|
||||
let toolset = resolved.toolset;
|
||||
assert_eq!(
|
||||
toolset.behavior_preset, None,
|
||||
"always the 'current' default"
|
||||
);
|
||||
assert_eq!(toolset.tools.len(), 2);
|
||||
let grep = &toolset.tools[0];
|
||||
assert_eq!(grep.id, "GrokBuild:grep");
|
||||
assert_eq!(
|
||||
grep.params,
|
||||
serde_json::json!({ "max_results" : 50 })
|
||||
.as_object()
|
||||
.cloned()
|
||||
);
|
||||
assert_eq!(grep.name_override.as_deref(), Some("search"));
|
||||
assert_eq!(
|
||||
grep.params_name_overrides.as_ref().unwrap()["pattern"],
|
||||
"query"
|
||||
);
|
||||
assert_eq!(grep.behavior_version.as_deref(), Some("legacy-0.4.10"));
|
||||
assert_eq!(
|
||||
grep.description_override.as_deref(),
|
||||
Some("Search the codebase")
|
||||
);
|
||||
assert_eq!(grep.kind, None);
|
||||
assert_eq!(toolset.tools[1].id, "GrokBuild:read_file");
|
||||
}
|
||||
#[test]
|
||||
fn explicit_tool_config_wins_over_tools_entries() {
|
||||
let v = serde_json::json!(
|
||||
{ "tool_config" : { "tools" : [{ "id" : "raw:tool" }] }, "tools" : [{ "id" :
|
||||
"wire:tool" }], }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
let ResolvedToolset::Toolset(resolved) = cfg.resolve(&all_known, false) else {
|
||||
panic!("must resolve to a toolset");
|
||||
};
|
||||
assert_eq!(resolved.toolset.tools.len(), 1);
|
||||
assert_eq!(resolved.toolset.tools[0].id, "raw:tool");
|
||||
}
|
||||
#[test]
|
||||
fn tools_entries_win_even_with_preset_present() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "tools" : [{ "id" : "wire:tool" }], }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
let ResolvedToolset::Toolset(resolved) = cfg.resolve(&all_known, false) else {
|
||||
panic!("must resolve to a toolset");
|
||||
};
|
||||
assert_eq!(resolved.toolset.tools.len(), 1);
|
||||
assert_eq!(resolved.toolset.tools[0].id, "wire:tool");
|
||||
}
|
||||
#[test]
|
||||
fn empty_tools_array_is_treated_as_unset() {
|
||||
let v = serde_json::json!({ "preset" : "explore", "tools" : [] });
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
assert!(cfg.tools.is_none());
|
||||
assert!(matches!(
|
||||
cfg.resolve(&all_known, false),
|
||||
ResolvedToolset::UseDefault
|
||||
));
|
||||
assert!(matches!(
|
||||
cfg.resolve(&all_known, true),
|
||||
ResolvedToolset::MissingToolConfig
|
||||
));
|
||||
let no_preset = serde_json::json!({ "tools" : [] });
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&no_preset);
|
||||
assert!(matches!(
|
||||
cfg.resolve(&all_known, false),
|
||||
ResolvedToolset::UseDefault
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn invalid_tools_entry_fails_closed() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "tools" : [{ "id" : "bad:tool", "params_json" :
|
||||
"{not json" }], }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
match cfg.resolve(&all_known, false) {
|
||||
ResolvedToolset::InvalidToolConfig(err) => {
|
||||
assert_eq!(err.tool_id, "bad:tool");
|
||||
assert_eq!(err.index, 0);
|
||||
}
|
||||
other => panic!("expected InvalidToolConfig, got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn invalid_name_override_fails_closed() {
|
||||
let v = serde_json::json!(
|
||||
{ "tools" : [{ "id" : "wire:ok", "name_override" : "fine_name" }, { "id" :
|
||||
"wire:bad", "name_override" : "not a tool id!" },], }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
match cfg.resolve(&all_known, true) {
|
||||
ResolvedToolset::InvalidToolConfig(err) => {
|
||||
assert_eq!(err.tool_id, "wire:bad");
|
||||
assert_eq!(err.field_path(), "tools[1].name_override");
|
||||
}
|
||||
other => panic!("expected InvalidToolConfig, got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn tool_config_escape_hatch_invalid_name_override_fails_closed() {
|
||||
let v = serde_json::json!(
|
||||
{ "tool_config" : { "tools" : [{ "id" : "raw:ok", "name_override" :
|
||||
"fine_name" }, { "id" : "raw:bad", "name_override" : "not a tool id!" },] },
|
||||
}
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
match cfg.resolve(&all_known, true) {
|
||||
ResolvedToolset::InvalidToolConfig(err) => {
|
||||
assert_eq!(err.tool_id, "raw:bad");
|
||||
assert_eq!(err.field_path(), "tools[1].name_override");
|
||||
}
|
||||
other => panic!("expected InvalidToolConfig, got {other:?}"),
|
||||
}
|
||||
let v = serde_json::json!(
|
||||
{ "tool_config" : { "tools" : [{ "id" : "raw:ok", "name_override" :
|
||||
"fine_name" }] }, }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
let ResolvedToolset::Toolset(resolved) = cfg.resolve(&all_known, true) else {
|
||||
panic!("valid escape-hatch config must resolve");
|
||||
};
|
||||
assert_eq!(resolved.toolset.tools.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn invalid_entry_error_reports_wire_index_after_unknown_drop() {
|
||||
let v = serde_json::json!(
|
||||
{ "tools" : [{ "id" : "wire:unknown" }, { "id" : "wire:bad", "params_json" :
|
||||
"{not json" },], }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
let known = |id: &str| id != "wire:unknown";
|
||||
match cfg.resolve(&known, false) {
|
||||
ResolvedToolset::InvalidToolConfig(err) => {
|
||||
assert_eq!(err.tool_id, "wire:bad");
|
||||
assert_eq!(
|
||||
err.index, 1,
|
||||
"index must be the wire position, not the known-subset position"
|
||||
);
|
||||
}
|
||||
other => panic!("expected InvalidToolConfig, got {other:?}"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn valid_name_overrides_resolve_intact() {
|
||||
let v = serde_json::json!(
|
||||
{ "tools" : [{ "id" : "wire:a", "name_override" : "renamed_a" }, { "id" :
|
||||
"wire:b" },], }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
let ResolvedToolset::Toolset(resolved) = cfg.resolve(&all_known, true) else {
|
||||
panic!("well-formed overrides must resolve to a toolset");
|
||||
};
|
||||
assert_eq!(resolved.toolset.tools.len(), 2);
|
||||
assert_eq!(
|
||||
resolved.toolset.tools[0].name_override.as_deref(),
|
||||
Some("renamed_a")
|
||||
);
|
||||
assert_eq!(resolved.toolset.tools[1].name_override, None);
|
||||
}
|
||||
#[test]
|
||||
fn pinned_tools_all_known_serves_full_expansion() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "tools" : [{ "id" : "wire:tool" }],
|
||||
"manifest_version" : "9.9.9-any", }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
let ResolvedToolset::Toolset(resolved) = cfg.resolve(&all_known, false) else {
|
||||
panic!("known pinned tools must use the tools expansion");
|
||||
};
|
||||
assert!(resolved.unserved_tool_ids.is_empty());
|
||||
assert_eq!(resolved.toolset.tools.len(), 1);
|
||||
assert_eq!(resolved.toolset.tools[0].id, "wire:tool");
|
||||
}
|
||||
/// Unknown ids must be partitioned and reported, never silently replaced
|
||||
/// by live preset resolution.
|
||||
#[test]
|
||||
fn pinned_tools_unknown_ids_are_partitioned_and_reported() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "tools" : [{ "id" : "wire:known" }, { "id" :
|
||||
"wire:zz_unknown" }, { "id" : "wire:aa_unknown" },], "manifest_version" :
|
||||
"0.0.0-stale", }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
let known = |id: &str| id == "wire:known";
|
||||
let ResolvedToolset::Toolset(resolved) = cfg.resolve(&known, false) else {
|
||||
panic!("partial coverage must still resolve to the known subset");
|
||||
};
|
||||
assert_eq!(resolved.toolset.tools.len(), 1);
|
||||
assert_eq!(resolved.toolset.tools[0].id, "wire:known");
|
||||
assert_eq!(
|
||||
resolved.unserved_tool_ids,
|
||||
vec!["wire:aa_unknown".to_owned(), "wire:zz_unknown".to_owned()],
|
||||
"unserved ids are reported sorted"
|
||||
);
|
||||
}
|
||||
/// A fully-unknown expansion serves empty and reports every id — it never
|
||||
/// widens to preset/default.
|
||||
#[test]
|
||||
fn pinned_tools_all_unknown_serves_empty_and_reports_all() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "tools" : [{ "id" : "wire:tool" }],
|
||||
"manifest_version" : "0.0.0-stale", }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
let ResolvedToolset::Toolset(resolved) = cfg.resolve(&none_known, false) else {
|
||||
panic!("all-unknown expansion must resolve (empty), not fall back");
|
||||
};
|
||||
assert!(resolved.toolset.tools.is_empty());
|
||||
assert_eq!(resolved.unserved_tool_ids, vec!["wire:tool".to_owned()]);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_tools_without_manifest_version_are_not_gated() {
|
||||
let v = serde_json::json!(
|
||||
{ "preset" : "explore", "tools" : [{ "id" : "wire:tool" }], }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
assert!(cfg.manifest_version.is_none());
|
||||
let ResolvedToolset::Toolset(resolved) = cfg.resolve(&all_known, false) else {
|
||||
panic!("legacy unpinned tools must resolve without gating");
|
||||
};
|
||||
assert_eq!(resolved.toolset.tools.len(), 1);
|
||||
assert_eq!(resolved.toolset.tools[0].id, "wire:tool");
|
||||
}
|
||||
#[test]
|
||||
fn tool_config_wins_regardless_of_stale_manifest_version() {
|
||||
let v = serde_json::json!(
|
||||
{ "tool_config" : { "tools" : [{ "id" : "raw:tool" }] }, "tools" : [{ "id" :
|
||||
"wire:tool" }], "manifest_version" : "0.0.0-stale", }
|
||||
);
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
let ResolvedToolset::Toolset(resolved) = cfg.resolve(&none_known, false) else {
|
||||
panic!("tool_config must always win");
|
||||
};
|
||||
assert!(resolved.unserved_tool_ids.is_empty());
|
||||
assert_eq!(resolved.toolset.tools.len(), 1);
|
||||
assert_eq!(resolved.toolset.tools[0].id, "raw:tool");
|
||||
}
|
||||
#[test]
|
||||
fn malformed_tools_field_is_dropped_keeping_siblings() {
|
||||
let v = serde_json::json!({ "preset" : "explore", "tools" : "not-a-list" });
|
||||
let cfg = WorkspaceBindConfig::from_metadata(&v);
|
||||
assert!(cfg.tools.is_none());
|
||||
assert!(matches!(
|
||||
cfg.resolve(&all_known, false),
|
||||
ResolvedToolset::UseDefault
|
||||
));
|
||||
assert!(matches!(
|
||||
cfg.resolve(&all_known, true),
|
||||
ResolvedToolset::MissingToolConfig
|
||||
));
|
||||
}
|
||||
}
|
||||
/// Top-level config required to construct a [`crate::handle::WorkspaceHandle`].
|
||||
///
|
||||
/// `#[non_exhaustive]` so future fields are non-breaking.
|
||||
@@ -726,18 +126,6 @@ pub struct WorkspaceConfig {
|
||||
/// and disabled/enabled lists. Stored on `WorkspaceShared` for
|
||||
/// `discover_plugins` calls. Defaults to empty.
|
||||
pub plugin_discovery_config: crate::discovery::PluginDiscoveryConfig,
|
||||
/// Optional server configuration. When `Some`, the workspace
|
||||
/// can connect to the server after construction via
|
||||
/// [`WorkspaceHandle::connect_hub`](crate::handle::WorkspaceHandle::connect_hub).
|
||||
pub hub_config: Option<HubConfig>,
|
||||
/// Auth provider for xAI service calls made from workspace-scoped code.
|
||||
/// `None` for workspaces that do not configure service auth.
|
||||
pub auth_provider: Option<kigi_computer_hub_sdk::SharedAuthProvider>,
|
||||
/// Metadata attached to the tool server registration.
|
||||
/// Propagated through the server to `ServerInfo.metadata` in
|
||||
/// `servers.list` responses so harness clients can identify the
|
||||
/// sandbox that started the tool server.
|
||||
pub server_metadata: Option<serde_json::Value>,
|
||||
/// Runtime-tunable timing/threshold config for the tool server.
|
||||
pub status_config: crate::status_config::StatusConfig,
|
||||
/// Folder-trust verdict for repo-local (project-scoped) LSP servers from
|
||||
@@ -745,108 +133,12 @@ pub struct WorkspaceConfig {
|
||||
/// shell caller resolves the verdict and threads it in; callers without a
|
||||
/// folder-trust decision pass `true`.
|
||||
pub project_lsp_trusted: bool,
|
||||
/// Fail `session.bind`s without an explicit toolset closed instead of
|
||||
/// widening to `default_tool_config`. Set by sandbox-launched standalone
|
||||
/// servers; local/CLI embedders keep the default-catalog fallback.
|
||||
pub require_explicit_toolset: bool,
|
||||
/// Confine `x.ai/fs/*` / `workspace.fs_*` resolution to the workspace root
|
||||
/// (reject `..`, absolute-outside-root, symlink escapes). Default `false`
|
||||
/// (unconfined) — set to `true` only by the workspace server on a remote
|
||||
/// sandbox, where the root is a real tenant boundary.
|
||||
pub confine_fs_to_workspace_root: bool,
|
||||
}
|
||||
/// Metadata a tool server announces so hub consumers can identify and route
|
||||
/// to it. Every field is optional and independently sourced; a local process
|
||||
/// announces none.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct WorkspaceServerMetadata {
|
||||
/// Sandbox that provisioned this server. Absent for local servers.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox_id: Option<String>,
|
||||
/// Logical sandbox-service session UUID, from the `KIGI_SESSION_ID` env
|
||||
/// var. Present whenever that var is set (every sandbox container, start
|
||||
/// and restore), absent otherwise.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
/// Provider that provisioned this server. Populated on the start path
|
||||
/// only (no container-side source on restore); absent for local servers.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_id: Option<String>,
|
||||
/// Per-spawn launch nonce minted by the sandbox orchestrator and echoed
|
||||
/// verbatim on the diagnostics `/ready` endpoint. Absent for local/legacy
|
||||
/// launches.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub launch_id: Option<String>,
|
||||
}
|
||||
impl WorkspaceServerMetadata {
|
||||
/// Merge an env-sourced logical session id into caller-supplied
|
||||
/// tool-server metadata (`None` on the restore/local path).
|
||||
///
|
||||
/// `env_session_id` is the raw `KIGI_SESSION_ID`; empty is normalized to
|
||||
/// absent. An explicit `session_id` already in `metadata` is never
|
||||
/// clobbered. A non-object `metadata` value is returned unchanged (a
|
||||
/// defensive no-op — the sole caller always sends an object).
|
||||
pub fn merge_session_metadata(
|
||||
metadata: Option<serde_json::Value>,
|
||||
env_session_id: Option<String>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let env_session_id = env_session_id.filter(|s| !s.is_empty());
|
||||
match metadata {
|
||||
Some(mut value) => {
|
||||
if let Some(session_id) = env_session_id
|
||||
&& let Some(obj) = value.as_object_mut()
|
||||
&& !obj.contains_key("session_id")
|
||||
{
|
||||
obj.insert(
|
||||
"session_id".to_owned(),
|
||||
serde_json::Value::String(session_id),
|
||||
);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
None => serde_json::to_value(WorkspaceServerMetadata {
|
||||
sandbox_id: None,
|
||||
session_id: env_session_id,
|
||||
provider_id: None,
|
||||
launch_id: None,
|
||||
})
|
||||
.ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl WorkspaceConfig {
|
||||
/// Construct a minimal config suitable for proxy-mode workspaces
|
||||
/// where the workspace is used primarily as a ToolServer host.
|
||||
pub fn new_for_proxy(
|
||||
root_cwd: PathBuf,
|
||||
session_factory: Arc<dyn SessionContextFactory>,
|
||||
hub_config: HubConfig,
|
||||
auth_provider: kigi_computer_hub_sdk::SharedAuthProvider,
|
||||
server_metadata: Option<serde_json::Value>,
|
||||
status_config: crate::status_config::StatusConfig,
|
||||
tool_config: ToolServerConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
root_cwd,
|
||||
default_tool_config: tool_config,
|
||||
respect_gitignore: false,
|
||||
memory_config: None,
|
||||
event_buffer_capacity: crate::config::DEFAULT_EVENT_BUFFER_CAPACITY,
|
||||
session_factory,
|
||||
hook_global_sources: vec![],
|
||||
hook_project_sources: vec![],
|
||||
skills_config: Default::default(),
|
||||
plugin_discovery_config: Default::default(),
|
||||
auth_provider: Some(auth_provider),
|
||||
hub_config: Some(hub_config),
|
||||
server_metadata,
|
||||
project_lsp_trusted: true,
|
||||
require_explicit_toolset: false,
|
||||
confine_fs_to_workspace_root: false,
|
||||
status_config,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Configuration for spawning a subagent session within a workspace.
|
||||
#[derive(Clone)]
|
||||
#[non_exhaustive]
|
||||
@@ -928,158 +220,3 @@ pub enum IsolationMode {
|
||||
/// Run the subagent inside a sandbox/container.
|
||||
Sandbox,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::WorkspaceServerMetadata;
|
||||
#[test]
|
||||
fn workspace_server_metadata_serializes_all_present_fields() {
|
||||
let meta = WorkspaceServerMetadata {
|
||||
sandbox_id: Some("sb-123".to_owned()),
|
||||
session_id: Some("11111111-1111-1111-1111-111111111111".to_owned()),
|
||||
provider_id: Some("test-provider".to_owned()),
|
||||
launch_id: Some("33333333-3333-3333-3333-333333333333".to_owned()),
|
||||
};
|
||||
let value = serde_json::to_value(&meta).unwrap();
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!({ "sandbox_id" : "sb-123", "session_id" :
|
||||
"11111111-1111-1111-1111-111111111111", "provider_id" : "test-provider",
|
||||
"launch_id" : "33333333-3333-3333-3333-333333333333", })
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_server_metadata_omits_none_fields() {
|
||||
let meta = WorkspaceServerMetadata {
|
||||
sandbox_id: Some("sb-123".to_owned()),
|
||||
session_id: None,
|
||||
provider_id: None,
|
||||
launch_id: None,
|
||||
};
|
||||
let value = serde_json::to_value(&meta).unwrap();
|
||||
assert_eq!(value, serde_json::json!({ "sandbox_id" : "sb-123" }));
|
||||
let empty = serde_json::to_value(WorkspaceServerMetadata::default()).unwrap();
|
||||
assert_eq!(empty, serde_json::json!({}));
|
||||
}
|
||||
#[test]
|
||||
fn workspace_server_metadata_deserializes_legacy_payload_without_new_fields() {
|
||||
let legacy = serde_json::json!(
|
||||
{ "sandbox_id" : "sb-legacy", "cwd" : "/workspace", "mode" : "remote", }
|
||||
);
|
||||
let meta: WorkspaceServerMetadata = serde_json::from_value(legacy).unwrap();
|
||||
assert_eq!(meta.sandbox_id.as_deref(), Some("sb-legacy"));
|
||||
assert_eq!(meta.session_id, None);
|
||||
assert_eq!(meta.provider_id, None);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_server_metadata_round_trips_with_new_fields() {
|
||||
let meta = WorkspaceServerMetadata {
|
||||
sandbox_id: Some("sb-123".to_owned()),
|
||||
session_id: Some("22222222-2222-2222-2222-222222222222".to_owned()),
|
||||
provider_id: Some("test-provider".to_owned()),
|
||||
launch_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
let back: WorkspaceServerMetadata = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.sandbox_id, meta.sandbox_id);
|
||||
assert_eq!(back.session_id, meta.session_id);
|
||||
assert_eq!(back.provider_id, meta.provider_id);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_server_metadata_deserializes_partial_new_fields() {
|
||||
let only_session = serde_json::json!(
|
||||
{ "sandbox_id" : "sb-1", "session_id" :
|
||||
"33333333-3333-3333-3333-333333333333", }
|
||||
);
|
||||
let meta: WorkspaceServerMetadata = serde_json::from_value(only_session).unwrap();
|
||||
assert_eq!(
|
||||
meta.session_id.as_deref(),
|
||||
Some("33333333-3333-3333-3333-333333333333")
|
||||
);
|
||||
assert_eq!(meta.provider_id, None);
|
||||
let only_provider = serde_json::json!(
|
||||
{ "sandbox_id" : "sb-1", "provider_id" : "test-provider", }
|
||||
);
|
||||
let meta: WorkspaceServerMetadata = serde_json::from_value(only_provider).unwrap();
|
||||
assert_eq!(meta.provider_id.as_deref(), Some("test-provider"));
|
||||
assert_eq!(meta.session_id, None);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_server_metadata_reads_start_path_shaped_payload() {
|
||||
let start_path = serde_json::json!(
|
||||
{ "cwd" : "/workspace", "mode" : "remote", "sandbox_id" : "sb-start",
|
||||
"session_id" : "44444444-4444-4444-4444-444444444444", "provider_id" :
|
||||
"test-provider", }
|
||||
);
|
||||
let meta: WorkspaceServerMetadata = serde_json::from_value(start_path).unwrap();
|
||||
assert_eq!(meta.sandbox_id.as_deref(), Some("sb-start"));
|
||||
assert_eq!(
|
||||
meta.session_id.as_deref(),
|
||||
Some("44444444-4444-4444-4444-444444444444")
|
||||
);
|
||||
assert_eq!(meta.provider_id.as_deref(), Some("test-provider"));
|
||||
}
|
||||
#[test]
|
||||
fn merge_session_metadata_builds_struct_from_env_on_none_branch() {
|
||||
let merged =
|
||||
WorkspaceServerMetadata::merge_session_metadata(None, Some("sess-1".to_owned()))
|
||||
.unwrap();
|
||||
assert_eq!(merged, serde_json::json!({ "session_id" : "sess-1" }));
|
||||
let empty = WorkspaceServerMetadata::merge_session_metadata(None, None).unwrap();
|
||||
assert_eq!(empty, serde_json::json!({}));
|
||||
}
|
||||
#[test]
|
||||
fn merge_session_metadata_overlays_into_object_without_clobbering() {
|
||||
let base = serde_json::json!({ "sandbox_id" : "sb-9", "mode" : "remote" });
|
||||
let merged =
|
||||
WorkspaceServerMetadata::merge_session_metadata(Some(base), Some("env-id".to_owned()))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
merged,
|
||||
serde_json::json!({ "sandbox_id" : "sb-9", "mode" : "remote",
|
||||
"session_id" : "env-id", })
|
||||
);
|
||||
let explicit = serde_json::json!({ "session_id" : "explicit" });
|
||||
let merged = WorkspaceServerMetadata::merge_session_metadata(
|
||||
Some(explicit),
|
||||
Some("env-id".to_owned()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(merged, serde_json::json!({ "session_id" : "explicit" }));
|
||||
}
|
||||
#[test]
|
||||
fn merge_session_metadata_leaves_object_untouched_when_no_env_id() {
|
||||
let base = serde_json::json!({ "sandbox_id" : "sb-9" });
|
||||
let merged =
|
||||
WorkspaceServerMetadata::merge_session_metadata(Some(base.clone()), None).unwrap();
|
||||
assert_eq!(merged, base);
|
||||
}
|
||||
#[test]
|
||||
fn merge_session_metadata_non_object_is_returned_unchanged() {
|
||||
let scalar = serde_json::json!("just-a-string");
|
||||
let merged = WorkspaceServerMetadata::merge_session_metadata(
|
||||
Some(scalar.clone()),
|
||||
Some("env-id".to_owned()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(merged, scalar);
|
||||
}
|
||||
#[test]
|
||||
fn merge_session_metadata_treats_empty_env_id_as_absent() {
|
||||
let none_branch =
|
||||
WorkspaceServerMetadata::merge_session_metadata(None, Some(String::new())).unwrap();
|
||||
assert_eq!(none_branch, serde_json::json!({}));
|
||||
let base = serde_json::json!({ "sandbox_id" : "sb-9" });
|
||||
let overlay = WorkspaceServerMetadata::merge_session_metadata(
|
||||
Some(base.clone()),
|
||||
Some(String::new()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(overlay, base);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_server_metadata_rejects_wrong_typed_field() {
|
||||
let bad = serde_json::json!({ "sandbox_id" : "sb-1", "session_id" : 42 });
|
||||
let result: Result<WorkspaceServerMetadata, _> = serde_json::from_value(bad);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,9 @@ pub enum WorkspaceError {
|
||||
#[error("hunk action failed: {0}")]
|
||||
HunkActionFailed(String),
|
||||
|
||||
/// An error from the server connection or tool server.
|
||||
#[error("hub error: {0}")]
|
||||
HubError(String),
|
||||
/// An internal workspace error.
|
||||
#[error("workspace error: {0}")]
|
||||
Internal(String),
|
||||
|
||||
/// Deploy-service error tagged with its gRPC status class; see
|
||||
/// [`DeployError`] for how the class crosses the workspace RPC boundary.
|
||||
|
||||
@@ -62,7 +62,7 @@ impl WorkspaceOp for FsListReq {
|
||||
let req = self.clone();
|
||||
tokio::task::spawn_blocking(move || list(&abs, &req, confine_root))
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ impl WorkspaceOp for FsReadFileReq {
|
||||
if !ranged {
|
||||
let bytes = tokio::fs::read(&abs)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
return Ok(build_file_entry(&bytes));
|
||||
}
|
||||
|
||||
@@ -106,9 +106,9 @@ impl WorkspaceOp for FsReadFileReq {
|
||||
// chunk is `[offset, offset + min(length, max_bytes, cap))`.
|
||||
let md = tokio::fs::metadata(&abs)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
if md.is_dir() {
|
||||
return Err(WorkspaceError::HubError(format!(
|
||||
return Err(WorkspaceError::Internal(format!(
|
||||
"not a file: {}",
|
||||
self.path
|
||||
)));
|
||||
@@ -120,7 +120,7 @@ impl WorkspaceOp for FsReadFileReq {
|
||||
let length = super::walk::clamp_read_length(self.length, self.max_bytes);
|
||||
let chunk = super::walk::read_range(&abs, offset, length)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
Ok(build_ranged_entry(chunk, size, self.encoding))
|
||||
}
|
||||
}
|
||||
@@ -143,8 +143,8 @@ impl WorkspaceOp for FsWriteFileReq {
|
||||
std::fs::write(&abs, content.as_bytes())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ impl WorkspaceOp for FsDeleteFileReq {
|
||||
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
|
||||
tokio::fs::remove_file(&abs)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,8 @@ pub use ext_fs::{
|
||||
FsReadFileReq, FsWriteFileReq,
|
||||
};
|
||||
|
||||
// Client-facing read-only fs ops (`workspace.client_fs_*`). Not re-exported:
|
||||
// its wire types live in `kigi_workspace_types::rpc::fs` (the `ClientFs*`
|
||||
// types) and would collide with the shell-facing `ext_fs` names above.
|
||||
pub(crate) mod client_fs;
|
||||
|
||||
// Shared filesystem core: paginated listing + binary-safe ranged reads,
|
||||
// used by `client_fs`, `ext_fs`, and the shell-local `session::file_system`.
|
||||
// used by `ext_fs` and the shell-local `session::file_system`.
|
||||
mod walk;
|
||||
pub use walk::{
|
||||
ChunkPayload, ListOptions, ListPage, ListedEntry, MAX_LIST_COLLECT, MAX_READ_BYTES,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,6 @@ pub mod activity;
|
||||
pub mod capability;
|
||||
pub mod channel;
|
||||
pub mod config;
|
||||
pub mod daemonize;
|
||||
pub mod diag_server;
|
||||
pub mod discovery;
|
||||
pub mod envrc;
|
||||
pub mod error;
|
||||
@@ -20,17 +18,8 @@ pub mod folder_trust;
|
||||
pub mod foreign_sessions;
|
||||
pub mod fs_notify;
|
||||
pub mod handle;
|
||||
pub mod hub;
|
||||
pub mod hub_auth;
|
||||
pub mod hub_channel;
|
||||
pub mod hub_ids;
|
||||
pub mod hub_server;
|
||||
pub mod mcp;
|
||||
pub mod permission;
|
||||
pub mod preview_supervisor;
|
||||
pub mod project_config;
|
||||
pub mod recovery;
|
||||
pub mod rpc_envelope;
|
||||
pub mod session;
|
||||
pub mod status_config;
|
||||
pub use status_config::StatusConfig;
|
||||
@@ -46,25 +35,18 @@ pub use config::{
|
||||
};
|
||||
pub use error::{WorkspaceError, WorkspaceResult};
|
||||
pub use file_system::*;
|
||||
pub use handle::{
|
||||
DrainOutcome, DrainReason, WorkspaceHandle, connect_local_workspace, resolve_workspace_home,
|
||||
termination_grace_from_env,
|
||||
};
|
||||
pub use hub::HubConfig;
|
||||
pub use handle::WorkspaceHandle;
|
||||
pub use kigi_hunk_tracker::HunkTrackerHandle;
|
||||
pub use kigi_workspace_client::WorkspaceClient;
|
||||
pub use kigi_workspace_types::WorkspaceEvent;
|
||||
pub use permission::*;
|
||||
pub use session::{WorkspaceSession, WorkspaceShared};
|
||||
pub use session::{file_state, git, jj};
|
||||
pub use workspace_ops::{WorkspaceOp, WorkspaceOps};
|
||||
/// Zero-init every workspace metric family so idle panels render a `0` baseline
|
||||
/// instead of "No data". Idempotent; call once at workspace-server startup.
|
||||
/// instead of "No data". Idempotent; call once at startup.
|
||||
pub fn init_metrics() {
|
||||
handle::init_metrics();
|
||||
session::swap_policy::init_metrics();
|
||||
permission::init_metrics();
|
||||
hub_server::init_metrics();
|
||||
}
|
||||
/// Crate-wide lock serializing every test that mutates the process-global
|
||||
/// environment (`KIGI_SHARE_DIR`, `HOME`, …). nextest isolates each test in its own
|
||||
@@ -167,22 +149,18 @@ mod init_metrics_tests {
|
||||
})
|
||||
})
|
||||
};
|
||||
assert!(has(
|
||||
"grok_workspace_rpc_requests_total",
|
||||
&[("method", "unknown"), ("result", "error")]
|
||||
));
|
||||
assert!(has(
|
||||
"grok_workspace_drain_started_total",
|
||||
&[("reason", "sigterm")]
|
||||
));
|
||||
assert!(has(
|
||||
"grok_workspace_toolset_swap_rejected_total",
|
||||
&[("reason", "turn_active"), ("trigger", "update_tool_config")]
|
||||
));
|
||||
assert!(has(
|
||||
"grok_workspace_rewind_checkpoint_capture_total",
|
||||
&[("domain", "fs"), ("outcome", "completed")]
|
||||
));
|
||||
assert!(
|
||||
families
|
||||
.iter()
|
||||
.any(|mf| mf.name() == "grok_workspace_permission_timeout_total")
|
||||
.any(|mf| mf.name() == "grok_workspace_terminal_backend_orphaned_total")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,6 +820,10 @@ fn session_grant_pre_decision(
|
||||
|
||||
/// Spawns the permission manager actor, returning a handle and the telemetry
|
||||
/// event receiver.
|
||||
///
|
||||
/// `remember_tool_approvals` — resolved gate: shows the per-tool always-allow
|
||||
/// options and lets an explicit grant satisfy an `ask` rule (ask once, remember).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_permission_manager(
|
||||
session_id: acp::SessionId,
|
||||
gateway: GatewaySender,
|
||||
@@ -833,45 +837,7 @@ pub fn spawn_permission_manager(
|
||||
web_fetch_allowed_domains: Vec<String>,
|
||||
initial_yolo: bool,
|
||||
client_identifier: Option<String>,
|
||||
) -> (PermissionHandle, mpsc::UnboundedReceiver<PermissionEvent>) {
|
||||
spawn_permission_manager_with_hub(
|
||||
session_id,
|
||||
gateway,
|
||||
cwd,
|
||||
client_type,
|
||||
permission_config,
|
||||
deny_read_globs,
|
||||
web_fetch_allowed_domains,
|
||||
initial_yolo,
|
||||
client_identifier,
|
||||
// Legacy/test entry point: preserve the full option set. Production uses
|
||||
// `spawn_permission_manager_with_hub` with the resolved gate.
|
||||
true,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Like [`spawn_permission_manager`] but routes the permission prompt to chat
|
||||
/// over the server (the HITL live path) when `hub_permission` is `Some`. The
|
||||
/// caller builds the transport only when [`hitl_permission_live_enabled`] and a
|
||||
/// server is connected; `None` keeps the local ACP prompt.
|
||||
///
|
||||
/// [`hitl_permission_live_enabled`]: crate::permission::hitl_permission_live_enabled
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_permission_manager_with_hub(
|
||||
session_id: acp::SessionId,
|
||||
gateway: GatewaySender,
|
||||
cwd: AbsPathBuf,
|
||||
client_type: ClientType,
|
||||
permission_config: Option<crate::permission::types::PermissionConfig>,
|
||||
deny_read_globs: Vec<String>,
|
||||
web_fetch_allowed_domains: Vec<String>,
|
||||
initial_yolo: bool,
|
||||
client_identifier: Option<String>,
|
||||
// Resolved `remember_tool_approvals` gate: shows the per-tool always-allow
|
||||
// options and lets an explicit grant satisfy an `ask` rule (ask once, remember).
|
||||
remember_tool_approvals: bool,
|
||||
hub_permission: Option<Arc<dyn crate::permission::PermissionHookTransport>>,
|
||||
) -> (PermissionHandle, mpsc::UnboundedReceiver<PermissionEvent>) {
|
||||
// Read the pin ONCE (file I/O) and cache it; never re-read per tool-call.
|
||||
// Every yolo ingestion path funnels through construction or SetYoloMode.
|
||||
@@ -887,7 +853,6 @@ pub fn spawn_permission_manager_with_hub(
|
||||
client_identifier,
|
||||
remember_tool_approvals,
|
||||
crate::permission::resolution::yolo_disabled_by_policy(),
|
||||
hub_permission,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -905,7 +870,6 @@ fn spawn_permission_manager_with_pin(
|
||||
client_identifier: Option<String>,
|
||||
remember_tool_approvals: bool,
|
||||
yolo_pin: Option<&'static str>,
|
||||
hub_permission: Option<Arc<dyn crate::permission::PermissionHookTransport>>,
|
||||
) -> (PermissionHandle, mpsc::UnboundedReceiver<PermissionEvent>) {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<PermissionCommand>();
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<PermissionEvent>();
|
||||
@@ -969,7 +933,6 @@ fn spawn_permission_manager_with_pin(
|
||||
}
|
||||
|
||||
let prompter = AcpPrompter::new(session_id.clone(), gateway.clone(), client_type)
|
||||
.with_hub_permission(hub_permission)
|
||||
.with_remember_tool_approvals(remember_tool_approvals);
|
||||
let mut yolo_mode = initial_yolo;
|
||||
let mut auto_mode = seed_auto;
|
||||
@@ -1849,7 +1812,6 @@ mod tests {
|
||||
None,
|
||||
true,
|
||||
yolo_pin,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1871,7 +1833,6 @@ mod tests {
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1939,187 +1900,6 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Like [`test_manager`] but routes prompts through a hub permission transport.
|
||||
fn test_manager_with_hub(
|
||||
cwd: &AbsPathBuf,
|
||||
hub_permission: Arc<dyn crate::permission::PermissionHookTransport>,
|
||||
) -> (PermissionHandle, mpsc::UnboundedReceiver<PermissionEvent>) {
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
spawn_permission_manager_with_pin(
|
||||
acp::SessionId::new(Arc::from("test-session")),
|
||||
GatewaySender::new(tx),
|
||||
cwd.clone(),
|
||||
ClientType::Generic,
|
||||
None,
|
||||
vec![],
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
Some(hub_permission),
|
||||
)
|
||||
}
|
||||
|
||||
/// Records every emitted payload and replies with a canned decision, so the
|
||||
/// hub permission prompt path is exercised without a live hub.
|
||||
struct FakeHubTransport {
|
||||
reply: serde_json::Value,
|
||||
seen: std::sync::Mutex<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::permission::PermissionHookTransport for FakeHubTransport {
|
||||
async fn request_permission(
|
||||
&self,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
self.seen.lock().unwrap().push(payload);
|
||||
Ok(self.reply.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_hub(reply: serde_json::Value) -> Arc<FakeHubTransport> {
|
||||
Arc::new(FakeHubTransport {
|
||||
reply,
|
||||
seen: std::sync::Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hub_permission_approve_allows_and_emits_payload() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
|
||||
let transport = fake_hub(serde_json::json!({ "outcome": "approve" }));
|
||||
let (mgr, _e) = test_manager_with_hub(&cwd, transport.clone());
|
||||
let d = mgr
|
||||
.request(
|
||||
AccessKind::Edit("src/main.rs".into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(d, Decision::Allow);
|
||||
let seen = transport.seen.lock().unwrap();
|
||||
assert_eq!(seen.len(), 1, "exactly one permission hook emitted");
|
||||
assert_eq!(seen[0]["tool_call_id"], "tc");
|
||||
assert_eq!(seen[0]["tool_name"], "search_replace");
|
||||
assert_eq!(seen[0]["description"], "Edit src/main.rs");
|
||||
assert_eq!(seen[0]["scope"], "write");
|
||||
assert_eq!(
|
||||
seen[0]["edit_file_paths"],
|
||||
serde_json::json!(["src/main.rs"])
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hub_permission_reject_aborts() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
|
||||
let (mgr, _e) = test_manager_with_hub(
|
||||
&cwd,
|
||||
fake_hub(serde_json::json!({ "outcome": "reject" })),
|
||||
);
|
||||
let d = mgr
|
||||
.request(
|
||||
AccessKind::Edit("a.rs".into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(d, Decision::Reject(_)),
|
||||
"reject must abort, got {d:?}"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// `cancelled` reply (turn-end drain) → abort, distinct from a user reject.
|
||||
#[tokio::test]
|
||||
async fn hub_permission_cancelled_aborts_distinctly() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
|
||||
let (mgr, _e) = test_manager_with_hub(
|
||||
&cwd,
|
||||
fake_hub(serde_json::json!({ "outcome": "cancelled" })),
|
||||
);
|
||||
let d = mgr
|
||||
.request(
|
||||
AccessKind::Edit("a.rs".into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(d, Decision::Cancelled);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hub_permission_always_approve_persists_scope() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
|
||||
let transport = fake_hub(serde_json::json!({
|
||||
"outcome": "always_approve",
|
||||
"scope": { "kind": "server_prefix", "value": "linear" },
|
||||
}));
|
||||
let (mgr, _e) = test_manager_with_hub(&cwd, transport.clone());
|
||||
let first = mgr
|
||||
.request(
|
||||
AccessKind::MCPTool {
|
||||
name: "linear__list".into(),
|
||||
input: serde_json::Value::Null,
|
||||
},
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first, Decision::Allow);
|
||||
let second = mgr
|
||||
.request(
|
||||
AccessKind::MCPTool {
|
||||
name: "linear__create".into(),
|
||||
input: serde_json::Value::Null,
|
||||
},
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(second, Decision::Allow);
|
||||
assert_eq!(
|
||||
transport.seen.lock().unwrap().len(),
|
||||
1,
|
||||
"always_approve must persist so the second call needs no hook"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// A managed `Ask` rule on a direct `Read`/`Grep` must reach the prompt, not
|
||||
/// the unconditional auto-allow. With no responder wired, that surfaces as a
|
||||
/// non-`Allow` decision; a non-ask read still auto-allows.
|
||||
@@ -2484,7 +2264,6 @@ mod tests {
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
handle.deny_read_globs(),
|
||||
@@ -2654,7 +2433,6 @@ mod tests {
|
||||
None,
|
||||
remember_tool_approvals,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3386,7 +3164,6 @@ mod tests {
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let PermissionHandle::Actor { ref cmd_tx, .. } = mgr else {
|
||||
panic!("manager must be actor-backed");
|
||||
@@ -3577,7 +3354,6 @@ mod tests {
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Request A parks in the gated prompt; B then arrives and overlaps it.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod auto_mode;
|
||||
pub mod claude_settings;
|
||||
mod hub_permission;
|
||||
mod manager;
|
||||
mod policy;
|
||||
mod prompter;
|
||||
@@ -19,19 +18,7 @@ pub use auto_mode::{
|
||||
classifier_output_json_schema, default_auto_mode_classifier, is_auto_mode_allowlisted_access,
|
||||
is_auto_mode_allowlisted_tool_name, parse_classifier_model_text, permission_decision_args,
|
||||
};
|
||||
pub use hub_permission::{
|
||||
PermissionHookTransport, ToolServerPermissionTransport, access_kind_for_hub_tool,
|
||||
hitl_permission_live_enabled, prompt_outcome_allows, request_permission_via_hub,
|
||||
};
|
||||
|
||||
/// Zero-init this module's metric families. See [`crate::init_metrics`].
|
||||
pub(crate) fn init_metrics() {
|
||||
hub_permission::init_metrics();
|
||||
}
|
||||
pub use manager::{
|
||||
PermissionHandle, default_always_allow_scope, spawn_permission_manager,
|
||||
spawn_permission_manager_with_hub,
|
||||
};
|
||||
pub use manager::{PermissionHandle, default_always_allow_scope, spawn_permission_manager};
|
||||
pub use policy::CompiledPolicy;
|
||||
pub use prompter::{
|
||||
ALLOW_EDITS_SESSION_OPTION_ID, AcpPrompter, BashCommandPermission, BashCommandSelectedTerms,
|
||||
|
||||
@@ -321,9 +321,6 @@ pub struct AcpPrompter {
|
||||
/// at decision-time through it. `EventWriter::noop()` when events recording
|
||||
/// is disabled (the default for the permission scaffolding's own tests).
|
||||
event_writer: EventWriter,
|
||||
/// Server permission transport: when set, [`request`](Self::request) asks chat for the
|
||||
/// decision over the server; `None` keeps the local prompt.
|
||||
hub_permission: Option<Arc<dyn crate::permission::PermissionHookTransport>>,
|
||||
/// When `false` (default, fail-safe), the per-tool "Always allow …" options
|
||||
/// are stripped (see [`REMEMBER_TOOL_APPROVALS_GATED_IDS`]).
|
||||
remember_tool_approvals: bool,
|
||||
@@ -496,7 +493,6 @@ impl AcpPrompter {
|
||||
// must NOT double-emit. A workspace-server-side caller that owns the
|
||||
// per-session `events.jsonl` opts in via [`with_event_writer`].
|
||||
event_writer: EventWriter::noop(),
|
||||
hub_permission: None,
|
||||
// Fail-safe default; opt in via `with_remember_tool_approvals`.
|
||||
remember_tool_approvals: false,
|
||||
}
|
||||
@@ -509,16 +505,6 @@ impl AcpPrompter {
|
||||
self
|
||||
}
|
||||
|
||||
/// Route the permission prompt to chat over the server when `Some`;
|
||||
/// `None` keeps the local prompt.
|
||||
pub fn with_hub_permission(
|
||||
mut self,
|
||||
hub_permission: Option<Arc<dyn crate::permission::PermissionHookTransport>>,
|
||||
) -> Self {
|
||||
self.hub_permission = hub_permission;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a per-session `events.jsonl` writer so [`request`](Self::request)
|
||||
/// records `PermissionRequested` / `PermissionResolved`. Used by the
|
||||
/// workspace-server permission path (which owns the session log); the shell
|
||||
@@ -737,41 +723,29 @@ impl AcpPrompter {
|
||||
prompt_start,
|
||||
};
|
||||
|
||||
let outcome = match &self.hub_permission {
|
||||
// Route the prompt to chat over the server (see
|
||||
// `ToolServerPermissionTransport` for the await/release contract).
|
||||
Some(transport) => {
|
||||
crate::permission::hub_permission::request_permission_via_hub(
|
||||
transport.as_ref(),
|
||||
access,
|
||||
tool_call_update.tool_call_id.0.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
let permission_options = self.build_options(access);
|
||||
let req = acp::RequestPermissionRequest::new(
|
||||
self.session_id.clone(),
|
||||
tool_call_update.clone(),
|
||||
permission_options.values().cloned().collect(),
|
||||
)
|
||||
.meta(self.bash_selection_meta(access));
|
||||
match self.gateway.request_permission(req).await {
|
||||
Ok(resp) => match resp.outcome {
|
||||
acp::RequestPermissionOutcome::Cancelled => PromptOutcome::Cancelled,
|
||||
acp::RequestPermissionOutcome::Selected(selected) => map_selected_outcome(
|
||||
&permission_options,
|
||||
&selected.option_id,
|
||||
resp.meta.as_ref(),
|
||||
access,
|
||||
),
|
||||
// TODO(acp-0.10): `RequestPermissionOutcome` is #[non_exhaustive].
|
||||
_ => PromptOutcome::Error("unknown permission outcome".to_owned()),
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "failed to request permission");
|
||||
PromptOutcome::Error("failed to request permission".to_owned())
|
||||
}
|
||||
let outcome = {
|
||||
let permission_options = self.build_options(access);
|
||||
let req = acp::RequestPermissionRequest::new(
|
||||
self.session_id.clone(),
|
||||
tool_call_update.clone(),
|
||||
permission_options.values().cloned().collect(),
|
||||
)
|
||||
.meta(self.bash_selection_meta(access));
|
||||
match self.gateway.request_permission(req).await {
|
||||
Ok(resp) => match resp.outcome {
|
||||
acp::RequestPermissionOutcome::Cancelled => PromptOutcome::Cancelled,
|
||||
acp::RequestPermissionOutcome::Selected(selected) => map_selected_outcome(
|
||||
&permission_options,
|
||||
&selected.option_id,
|
||||
resp.meta.as_ref(),
|
||||
access,
|
||||
),
|
||||
// TODO(acp-0.10): `RequestPermissionOutcome` is #[non_exhaustive].
|
||||
_ => PromptOutcome::Error("unknown permission outcome".to_owned()),
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "failed to request permission");
|
||||
PromptOutcome::Error("failed to request permission".to_owned())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,12 +8,8 @@ pub mod tool_config;
|
||||
use crate::capability::CapabilityMode;
|
||||
use crate::config::{MemoryConfig, SessionContextFactory};
|
||||
use crate::file_system::{AsyncFsWrapper, LocalFs};
|
||||
use crate::hub::{HubConfig, HubHandle};
|
||||
use crate::session::file_state::FileStateTracker;
|
||||
use kigi_computer_hub_mcp_adapter::McpBridgeHandle;
|
||||
use kigi_hunk_tracker::HunkTrackerHandle;
|
||||
use kigi_mcp::servers::McpState;
|
||||
use kigi_tool_protocol::ToolId;
|
||||
use kigi_tool_runtime::WorkspaceViewerContext;
|
||||
use kigi_tools::notification::types::{ToolNotification, ToolNotificationHandle};
|
||||
use kigi_tools::registry::types::{FinalizedToolset, ToolConfig, ToolServerConfig};
|
||||
@@ -76,12 +72,6 @@ pub struct WorkspaceSession {
|
||||
inner: RwLock<WorkspaceSessionInner>,
|
||||
/// Per-session lock that serialises `update_tool_config` calls.
|
||||
pub(crate) update_lock: tokio::sync::Mutex<()>,
|
||||
/// Per-session MCP state (owned clients, etc.).
|
||||
pub(crate) mcp_state: Arc<tokio::sync::Mutex<McpState>>,
|
||||
/// MCP bridges kept alive for the session lifetime.
|
||||
pub(crate) mcp_bridges: tokio::sync::Mutex<Vec<McpBridgeHandle>>,
|
||||
/// Qualified tool IDs registered on the server for this session's MCP tools.
|
||||
pub(crate) mcp_tool_ids: tokio::sync::Mutex<Vec<ToolId>>,
|
||||
/// Per-user feature-flag bag resolved at session-bind time, frozen for
|
||||
/// the session lifetime. `None` → tools use their safe defaults.
|
||||
pub(crate) viewer_ctx: Option<WorkspaceViewerContext>,
|
||||
@@ -107,8 +97,7 @@ pub struct WorkspaceSession {
|
||||
/// created (or last rebound) with. `None` when the session was resolved
|
||||
/// from the workspace default (no explicit toolset in the bind metadata).
|
||||
/// Lets a rebind detect a config change and re-resolve instead of silently
|
||||
/// reusing a stale toolset (e.g. a session created by a metadata-less
|
||||
/// hub revive bind that a config-carrying client rebind must correct).
|
||||
/// reusing a stale toolset.
|
||||
bind_tool_config_fingerprint: std::sync::Mutex<Option<serde_json::Value>>,
|
||||
/// The last snapshot-driven rebuild failed and kept a stale toolset;
|
||||
/// cleared by any successful install. While set, an identical-config
|
||||
@@ -190,9 +179,6 @@ impl WorkspaceSession {
|
||||
update_lock: tokio::sync::Mutex::new(()),
|
||||
bind_tool_config_fingerprint: std::sync::Mutex::new(None),
|
||||
stale_resolve: std::sync::atomic::AtomicBool::new(false),
|
||||
mcp_state: Arc::new(tokio::sync::Mutex::new(McpState::new(vec![]))),
|
||||
mcp_bridges: tokio::sync::Mutex::new(Vec::new()),
|
||||
mcp_tool_ids: tokio::sync::Mutex::new(Vec::new()),
|
||||
viewer_ctx,
|
||||
yolo_mode: std::sync::atomic::AtomicBool::new(false),
|
||||
system_notifications,
|
||||
@@ -440,9 +426,6 @@ pub type ClientExtSink = std::sync::Arc<dyn Fn(String, serde_json::Value) + Send
|
||||
/// Workspace-wide shared state.
|
||||
pub struct WorkspaceShared {
|
||||
pub(crate) default_tool_config: ToolServerConfig,
|
||||
/// Require an explicit toolset on every `session.bind`; see
|
||||
/// [`crate::config::WorkspaceConfig::require_explicit_toolset`].
|
||||
pub(crate) require_explicit_toolset: bool,
|
||||
/// See [`crate::config::WorkspaceConfig::confine_fs_to_workspace_root`].
|
||||
/// Default `false`; enabled only for remote-sandbox workspace servers.
|
||||
pub(crate) confine_fs_to_workspace_root: bool,
|
||||
@@ -464,38 +447,16 @@ pub struct WorkspaceShared {
|
||||
/// disabled/enabled lists). Used by `discover_plugins` via the
|
||||
/// `discovery` module.
|
||||
pub(crate) plugin_discovery_config: crate::discovery::PluginDiscoveryConfig,
|
||||
/// Live server connection handle. `None` until
|
||||
/// [`WorkspaceHandle::connect_hub`](crate::handle::WorkspaceHandle::connect_hub)
|
||||
/// is called (or if no [`HubConfig`] was provided).
|
||||
///
|
||||
/// Uses `tokio::sync::Mutex` so the guard can be held across the
|
||||
/// async `HubHandle::connect()` call, preventing TOCTOU races.
|
||||
pub(crate) hub_handle: tokio::sync::Mutex<Option<HubHandle>>,
|
||||
/// Remote-origin tool configs (consumer direction), updated by the
|
||||
/// notification listener.
|
||||
pub(crate) hub_tools_snapshot: arc_swap::ArcSwap<Vec<ToolConfig>>,
|
||||
/// Server config stashed at construction time for deferred connect.
|
||||
pub(crate) hub_config: Option<HubConfig>,
|
||||
/// Auth provider for xAI service calls.
|
||||
pub(crate) auth_provider: Option<kigi_computer_hub_sdk::SharedAuthProvider>,
|
||||
/// Connection-level sink feeding the `ActivityTracker` (drained by
|
||||
/// `run_activity_feed`); not a network egress. `None` until `connect_hub()` sets it.
|
||||
pub(crate) activity_notify_handle:
|
||||
arc_swap::ArcSwap<Option<kigi_tools::notification::types::ToolNotificationHandle>>,
|
||||
/// Sink for workspace-originated ext-notifications to the client (e.g.
|
||||
/// `x.ai/search/fuzzy/status`). Mode-agnostic: the shell wires it to the
|
||||
/// agent gateway in local mode, and to the server in proxy mode. `None` until
|
||||
/// set via [`WorkspaceHandle::set_client_ext_sink`](crate::handle::WorkspaceHandle::set_client_ext_sink).
|
||||
pub(crate) client_ext_sink: arc_swap::ArcSwap<Option<ClientExtSink>>,
|
||||
pub(crate) local_registry: kigi_computer_hub_sdk::LocalRegistry,
|
||||
pub(crate) local_registry: kigi_tool_runtime::LocalRegistry,
|
||||
pub(crate) activity_tracker: std::sync::Arc<crate::activity::ActivityTracker>,
|
||||
/// Runtime-tunable timing/threshold config for the tool server.
|
||||
/// Read by the status publisher task and at shutdown.
|
||||
pub(crate) status_config: crate::status_config::StatusConfig,
|
||||
/// Opaque metadata for the tool server registration, forwarded verbatim to
|
||||
/// the server; structured access goes through
|
||||
/// [`WorkspaceShared::server_metadata_typed`].
|
||||
pub(crate) server_metadata: Option<serde_json::Value>,
|
||||
/// Workspace-level fuzzy search manager. Separate from the shell's
|
||||
/// own `FuzzySearchManager` — this instance serves remote (hub/RPC)
|
||||
/// clients.
|
||||
@@ -533,7 +494,6 @@ pub struct WorkspaceShared {
|
||||
/// turn start inside the check→install window deterministically.
|
||||
#[cfg(test)]
|
||||
pub(crate) post_resolve_test_hook: parking_lot::Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
|
||||
pub(crate) client_fs_hash_memo: crate::file_system::client_fs::FileHashMemo,
|
||||
}
|
||||
impl WorkspaceShared {
|
||||
/// Workspace root directory.
|
||||
@@ -578,40 +538,6 @@ impl WorkspaceShared {
|
||||
.get(session_id)
|
||||
.map(|w| w.value().clone())
|
||||
}
|
||||
/// Stable hub server id (`--server-id`), if a hub config is present.
|
||||
pub(crate) fn server_id(&self) -> Option<String> {
|
||||
self.hub_config.as_ref().and_then(|c| c.server_id.clone())
|
||||
}
|
||||
/// Auth provider used for xAI service calls.
|
||||
pub fn auth_provider(&self) -> Option<&kigi_computer_hub_sdk::SharedAuthProvider> {
|
||||
self.auth_provider.as_ref()
|
||||
}
|
||||
/// Parse the opaque [`server_metadata`](Self::server_metadata) blob into
|
||||
/// the typed subset the workspace needs (currently `sandbox_id`);
|
||||
/// unknown/missing fields default cleanly. A present-but-malformed blob is
|
||||
/// logged and salvaged field-by-field (a bad sibling field must not
|
||||
/// silently drop `sandbox_id` from every environment artifact).
|
||||
pub(crate) fn server_metadata_typed(&self) -> crate::config::WorkspaceServerMetadata {
|
||||
let Some(v) = self.server_metadata.as_ref() else {
|
||||
return Default::default();
|
||||
};
|
||||
match serde_json::from_value(v.clone()) {
|
||||
Ok(typed) => typed,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = % e,
|
||||
"workspace: malformed server_metadata; salvaging sandbox_id field-wise"
|
||||
);
|
||||
crate::config::WorkspaceServerMetadata {
|
||||
sandbox_id: v
|
||||
.get("sandbox_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn default_tool_config(&self) -> &ToolServerConfig {
|
||||
&self.default_tool_config
|
||||
}
|
||||
@@ -624,49 +550,6 @@ impl WorkspaceShared {
|
||||
pub fn mcp_tools_snapshot(&self) -> Arc<Vec<ToolConfig>> {
|
||||
self.mcp_tools_snapshot.load_full()
|
||||
}
|
||||
/// The tool server, if a server connection is active.
|
||||
///
|
||||
/// Returns a clone of the [`ToolServer`](kigi_computer_hub_sdk::ToolServer)
|
||||
/// which is cheap (`Arc` bump). Uses `try_lock` to avoid blocking
|
||||
/// on the async mutex from synchronous contexts. Returns `None` if
|
||||
/// the lock is held (i.e. a `connect_hub` call is in progress).
|
||||
pub fn hub_server(&self) -> Option<kigi_computer_hub_sdk::ToolServer> {
|
||||
self.hub_handle
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|h| h.server.clone()))
|
||||
}
|
||||
/// Like [`Self::hub_server`] but awaits the `hub_handle` lock instead of
|
||||
/// returning `None` on contention. Use from async contexts that must not
|
||||
/// confuse a transient `connect_hub` lock-hold with "no hub connected";
|
||||
/// `None` means no hub is connected.
|
||||
pub async fn hub_server_blocking(&self) -> Option<kigi_computer_hub_sdk::ToolServer> {
|
||||
self.hub_handle
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|h| h.server.clone())
|
||||
}
|
||||
/// Current snapshot of hub-provided tool configs (consumer direction).
|
||||
pub fn hub_tools_snapshot(&self) -> Arc<Vec<ToolConfig>> {
|
||||
self.hub_tools_snapshot.load_full()
|
||||
}
|
||||
/// Compose a session's tool `ctx.notification_handle` as a fan-out of the
|
||||
/// connection-level activity feed (internal tracker accounting) and the
|
||||
/// opt-in per-session `system.notify` sender. Only the `system.notify` leg
|
||||
/// reaches a client, so the fan-out can't double-wake. `None` → factory default.
|
||||
pub(crate) fn compose_session_notification_handle(
|
||||
&self,
|
||||
system_notify_handle: Option<ToolNotificationHandle>,
|
||||
) -> Option<ToolNotificationHandle> {
|
||||
let activity = self.activity_notify_handle.load_full().as_ref().clone();
|
||||
match (activity, system_notify_handle) {
|
||||
(None, None) => None,
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(s)) => Some(s),
|
||||
(Some(a), Some(s)) => Some(ToolNotificationHandle::tee(vec![a, s])),
|
||||
}
|
||||
}
|
||||
pub fn activity_tracker(&self) -> &std::sync::Arc<crate::activity::ActivityTracker> {
|
||||
&self.activity_tracker
|
||||
}
|
||||
@@ -717,7 +600,6 @@ impl WorkspaceShared {
|
||||
};
|
||||
let trigger = SwapTrigger::from_rebuild_source(source);
|
||||
let mcp_snap = self.mcp_tools_snapshot.load_full();
|
||||
let hub_snap = self.hub_tools_snapshot.load_full();
|
||||
let sessions: Vec<(String, Arc<WorkspaceSession>)> = {
|
||||
let guard = self.sessions.read();
|
||||
guard
|
||||
@@ -779,7 +661,6 @@ impl WorkspaceShared {
|
||||
baseline,
|
||||
session.capability_mode(),
|
||||
&mcp_snap,
|
||||
&hub_snap,
|
||||
session.cwd().to_path_buf(),
|
||||
session.session_env().clone(),
|
||||
&sid,
|
||||
@@ -787,7 +668,7 @@ impl WorkspaceShared {
|
||||
Some(self.local_registry.clone()),
|
||||
self.lsp.clone(),
|
||||
session.viewer_ctx().cloned(),
|
||||
self.compose_session_notification_handle(session.system_notify_handle()),
|
||||
session.system_notify_handle(),
|
||||
session.terminal_backend().clone(),
|
||||
) {
|
||||
Ok((effective, toolset)) => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Tool config resolution pipeline.
|
||||
//!
|
||||
//! Five-step resolution:
|
||||
//! Four-step resolution:
|
||||
//! 1. `effective_tool_config = config.tool_config.unwrap_or_else(|| parent.effective_tool_config.clone())`
|
||||
//! 2. `merged = merge_mcp_tools(effective_tool_config, shared.mcp_servers.snapshot())`
|
||||
//! 3. `merged = merge_hub_tools(merged, shared.hub_tools_snapshot())`
|
||||
//! 4. `filtered = config.capability_mode.filter(merged)`
|
||||
//! 5. `toolset = build_finalized_toolset(filtered, &session.cwd, &session.session_env, ...)`
|
||||
//! 3. `filtered = config.capability_mode.filter(merged)`
|
||||
//! 4. `toolset = build_finalized_toolset(filtered, &session.cwd, &session.session_env, ...)`
|
||||
use crate::capability::{CapabilityMode, kind_allowed};
|
||||
use crate::config::SessionContextFactory;
|
||||
use crate::error::{WorkspaceError, WorkspaceResult};
|
||||
@@ -19,19 +18,16 @@ use std::sync::Arc;
|
||||
/// Create-shaped entry of the resolution pipeline: run
|
||||
/// [`resolve_session_toolset_rebuild`] around a FRESH factory-built
|
||||
/// session-lifetime terminal backend, and return that backend so the caller
|
||||
/// can store it on the session it is creating. Session-less resolves (the
|
||||
/// `__template__` catalog resolve in `connect_hub`) also use this entry and
|
||||
/// simply drop the returned backend with the toolset.
|
||||
/// can store it on the session it is creating.
|
||||
pub(crate) fn resolve_session_toolset(
|
||||
effective_tool_config: ToolServerConfig,
|
||||
capability_mode: CapabilityMode,
|
||||
mcp_snapshot: &[ToolConfig],
|
||||
hub_snapshot: &[ToolConfig],
|
||||
cwd: PathBuf,
|
||||
session_env: Arc<HashMap<String, String>>,
|
||||
session_id: &str,
|
||||
factory: &dyn SessionContextFactory,
|
||||
local_registry: Option<kigi_computer_hub_sdk::LocalRegistry>,
|
||||
local_registry: Option<kigi_tool_runtime::LocalRegistry>,
|
||||
lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
|
||||
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
||||
notification_handle: Option<kigi_tools::notification::types::ToolNotificationHandle>,
|
||||
@@ -45,7 +41,6 @@ pub(crate) fn resolve_session_toolset(
|
||||
effective_tool_config,
|
||||
capability_mode,
|
||||
mcp_snapshot,
|
||||
hub_snapshot,
|
||||
cwd,
|
||||
session_env,
|
||||
session_id,
|
||||
@@ -66,24 +61,23 @@ pub(crate) fn resolve_session_toolset(
|
||||
///
|
||||
/// Returns the *unmodified* `effective_tool_config` (step-1 baseline) so
|
||||
/// the caller can store it on the session. The FinalizedToolset reflects
|
||||
/// MCP + hub merging and capability filtering on top of that baseline.
|
||||
/// MCP merging and capability filtering on top of that baseline.
|
||||
///
|
||||
/// **MCP-origin and hub-origin `kind: None` tools are dropped under
|
||||
/// every non-`All` mode.** Baseline `kind: None` tools are always kept —
|
||||
/// but before filtering, kind-less baseline entries whose id the binary's
|
||||
/// registry knows get their [`ToolKind`] backfilled (see
|
||||
/// [`backfill_tool_kinds`]), so the capability filter applies to pinned
|
||||
/// server-bind toolsets whose wire entries cannot carry a kind.
|
||||
/// **MCP-origin `kind: None` tools are dropped under every non-`All`
|
||||
/// mode.** Baseline `kind: None` tools are always kept — but before
|
||||
/// filtering, kind-less baseline entries whose id the binary's registry
|
||||
/// knows get their [`ToolKind`] backfilled (see [`backfill_tool_kinds`]),
|
||||
/// so the capability filter applies to pinned toolsets whose wire entries
|
||||
/// cannot carry a kind.
|
||||
pub(crate) fn resolve_session_toolset_rebuild(
|
||||
effective_tool_config: ToolServerConfig,
|
||||
capability_mode: CapabilityMode,
|
||||
mcp_snapshot: &[ToolConfig],
|
||||
hub_snapshot: &[ToolConfig],
|
||||
cwd: PathBuf,
|
||||
session_env: Arc<HashMap<String, String>>,
|
||||
session_id: &str,
|
||||
factory: &dyn SessionContextFactory,
|
||||
local_registry: Option<kigi_computer_hub_sdk::LocalRegistry>,
|
||||
local_registry: Option<kigi_tool_runtime::LocalRegistry>,
|
||||
lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
|
||||
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
||||
notification_handle: Option<kigi_tools::notification::types::ToolNotificationHandle>,
|
||||
@@ -94,24 +88,7 @@ pub(crate) fn resolve_session_toolset_rebuild(
|
||||
builder = builder.with_local_registry(lr);
|
||||
}
|
||||
let baseline = backfill_tool_kinds(&effective_tool_config, &builder.known_tool_kinds());
|
||||
let filtered = merge_and_filter(
|
||||
&baseline,
|
||||
mcp_snapshot,
|
||||
hub_snapshot,
|
||||
capability_mode,
|
||||
session_id,
|
||||
);
|
||||
let hub_ids: std::collections::HashSet<&str> =
|
||||
hub_snapshot.iter().map(|t| t.id.as_str()).collect();
|
||||
let finalize_config = ToolServerConfig {
|
||||
tools: filtered
|
||||
.tools
|
||||
.iter()
|
||||
.filter(|t| !hub_ids.contains(t.id.as_str()))
|
||||
.cloned()
|
||||
.collect(),
|
||||
behavior_preset: filtered.behavior_preset.clone(),
|
||||
};
|
||||
let finalize_config = merge_and_filter(&baseline, mcp_snapshot, capability_mode, session_id);
|
||||
let mut ctx = factory.build_session_context(session_id, cwd, session_env, terminal_backend);
|
||||
if let Some(lsp_handle) = lsp {
|
||||
ctx.lsp = Some(lsp_handle);
|
||||
@@ -157,22 +134,20 @@ fn backfill_tool_kinds(
|
||||
behavior_preset: config.behavior_preset.clone(),
|
||||
}
|
||||
}
|
||||
/// Steps 2-4 of the resolution pipeline, without step 5 (`finalize`):
|
||||
/// Steps 2-3 of the resolution pipeline, without the `finalize` step:
|
||||
///
|
||||
/// - **Step 2** -- MCP merge: append MCP-origin tools, skipping ID/name collisions with baseline.
|
||||
/// - **Step 3** -- Hub merge: append hub-origin tools, skipping ID/name collisions with baseline or MCP.
|
||||
/// - **Step 4** -- Capability filter: drop tools whose `kind` is not allowed by the mode.
|
||||
/// External (MCP/hub) `kind: None` tools are only kept under `CapabilityMode::All`.
|
||||
/// - **Step 3** -- Capability filter: drop tools whose `kind` is not allowed by the mode.
|
||||
/// External (MCP) `kind: None` tools are only kept under `CapabilityMode::All`.
|
||||
///
|
||||
/// Priority on ID/name collision: baseline wins > MCP wins > hub is skipped.
|
||||
/// Priority on ID/name collision: baseline wins over MCP.
|
||||
pub(crate) fn merge_and_filter(
|
||||
baseline: &ToolServerConfig,
|
||||
mcp_snapshot: &[ToolConfig],
|
||||
hub_snapshot: &[ToolConfig],
|
||||
mode: CapabilityMode,
|
||||
session_id: &str,
|
||||
) -> ToolServerConfig {
|
||||
if mcp_snapshot.is_empty() && hub_snapshot.is_empty() {
|
||||
if mcp_snapshot.is_empty() {
|
||||
return mode.filter(baseline);
|
||||
}
|
||||
let baseline_ids: std::collections::HashSet<&str> =
|
||||
@@ -187,7 +162,6 @@ pub(crate) fn merge_and_filter(
|
||||
.collect();
|
||||
let mut tagged: Vec<(ToolConfig, bool)> =
|
||||
baseline.tools.iter().cloned().map(|t| (t, false)).collect();
|
||||
let mut mcp_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
for mcp_tool in mcp_snapshot {
|
||||
if baseline_ids.contains(mcp_tool.id.as_str()) {
|
||||
tracing::warn!(
|
||||
@@ -205,35 +179,8 @@ pub(crate) fn merge_and_filter(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
mcp_tool_ids.insert(mcp_tool.id.as_str());
|
||||
tagged.push((mcp_tool.clone(), true));
|
||||
}
|
||||
for hub_tool in hub_snapshot {
|
||||
if baseline_ids.contains(hub_tool.id.as_str()) {
|
||||
tracing::debug!(
|
||||
hub_id = % hub_tool.id, session = % session_id,
|
||||
"skipping remote tool: id collides with baseline"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if mcp_tool_ids.contains(hub_tool.id.as_str()) {
|
||||
tracing::debug!(
|
||||
hub_id = % hub_tool.id, session = % session_id,
|
||||
"skipping remote tool: id collides with MCP tool"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let client_name = hub_tool.resolve_client_name(&hub_tool.id);
|
||||
if !taken_names.insert(client_name.clone()) {
|
||||
tracing::debug!(
|
||||
hub_id = % hub_tool.id, client_name = % client_name, session = %
|
||||
session_id,
|
||||
"skipping remote tool: resolved client name collides with another tool"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
tagged.push((hub_tool.clone(), true));
|
||||
}
|
||||
let kept: Vec<ToolConfig> = tagged
|
||||
.into_iter()
|
||||
.filter(|(tool, is_external)| match tool.kind {
|
||||
@@ -250,19 +197,13 @@ pub(crate) fn merge_and_filter(
|
||||
}
|
||||
/// Alias for backward compatibility.
|
||||
pub type NoopSessionContextFactory = WorkspaceSessionContextFactory;
|
||||
/// Whether per-session `tool_state.json` persistence + per-turn upload is
|
||||
/// enabled (`KIGI_WORKSPACE_TOOL_STATE_ENABLED=true`; any other value keeps
|
||||
/// legacy behavior).
|
||||
pub fn tool_state_enabled() -> bool {
|
||||
std::env::var("KIGI_WORKSPACE_TOOL_STATE_ENABLED").as_deref() == Ok("true")
|
||||
}
|
||||
/// Sanitize a `session_id` into a single safe filesystem path segment: chars
|
||||
/// outside `[A-Za-z0-9_-]` become `_`, empty becomes `anon`. When any
|
||||
/// replacement happened, an 8-hex digest of the ORIGINAL id is appended so the
|
||||
/// mapping stays injective — plain substitution would collide distinct ids
|
||||
/// (`sess/1` and `sess_1`) into one directory, cross-contaminating
|
||||
/// persistence, rehydration, and [`crate::recovery::cleanup_stale_sessions`].
|
||||
/// Already-safe ids (the common UUID case) map to themselves.
|
||||
/// persistence and rehydration. Already-safe ids (the common UUID case)
|
||||
/// map to themselves.
|
||||
fn sanitize_session_id(session_id: &str) -> String {
|
||||
let mut safe = String::with_capacity(session_id.len());
|
||||
let mut modified = false;
|
||||
@@ -297,16 +238,7 @@ fn ensure_session_dir(root: &std::path::Path, session_id: &str) -> (PathBuf, std
|
||||
/// hazard is the global `environ` array, not the variable's value).
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::ENV_TEST_LOCK as TOOL_STATE_ENV_LOCK;
|
||||
/// [`SessionContextFactory`] for workspace server sessions.
|
||||
///
|
||||
/// When constructed with an [`AuthProvider`] and API base URL, gen tools
|
||||
/// (image_gen, video_gen) are enabled using the provider's current
|
||||
/// OAuth token. Without auth, gen tools default to `Disabled`.
|
||||
///
|
||||
/// When [`with_tool_state_home`](Self::with_tool_state_home) is set, each
|
||||
/// session's [`SessionContext::state_path`] is rooted at
|
||||
/// `<home>/sessions/<session_id>/`; left unset, `state_path` stays empty
|
||||
/// (legacy behavior).
|
||||
/// [`SessionContextFactory`] for workspace sessions.
|
||||
///
|
||||
/// [`SessionContext::session_folder`] is `/tmp/sessions/<sanitized_id>/`
|
||||
/// (terminal logs and other tool artifacts — not the project `cwd`).
|
||||
@@ -318,64 +250,11 @@ pub(crate) use crate::ENV_TEST_LOCK as TOOL_STATE_ENV_LOCK;
|
||||
/// [`build_terminal_backend`]: crate::config::SessionContextFactory::build_terminal_backend
|
||||
/// [`build_session_context`]: crate::config::SessionContextFactory::build_session_context
|
||||
/// [`LocalTerminalBackend`]: kigi_tools::computer::local::LocalTerminalBackend
|
||||
pub struct WorkspaceSessionContextFactory {
|
||||
auth: Option<kigi_computer_hub_sdk::SharedAuthProvider>,
|
||||
api_base_url: Option<String>,
|
||||
/// Resolved `$KIGI_WORKSPACE_HOME` when tool-state persistence is enabled;
|
||||
/// `None` disables it. Resolved once by the caller so the factory performs
|
||||
/// no per-build env reads.
|
||||
tool_state_home: Option<PathBuf>,
|
||||
}
|
||||
impl Default for WorkspaceSessionContextFactory {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct WorkspaceSessionContextFactory;
|
||||
impl WorkspaceSessionContextFactory {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
auth: None,
|
||||
api_base_url: None,
|
||||
tool_state_home: None,
|
||||
}
|
||||
}
|
||||
/// Factory with auth — gen tools use the provider's live token.
|
||||
pub fn with_auth(
|
||||
auth: kigi_computer_hub_sdk::SharedAuthProvider,
|
||||
api_base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth: Some(auth),
|
||||
api_base_url: Some(api_base_url),
|
||||
tool_state_home: None,
|
||||
}
|
||||
}
|
||||
/// Enable session-keyed tool-state persistence rooted at `home`
|
||||
/// (`$KIGI_WORKSPACE_HOME`). Callers should only invoke this when
|
||||
/// [`tool_state_enabled`] is `true`.
|
||||
pub fn with_tool_state_home(mut self, home: PathBuf) -> Self {
|
||||
self.tool_state_home = Some(home);
|
||||
self
|
||||
}
|
||||
/// `<tool_state_home>/sessions/<sanitized_id>/tool_state.json`, or empty
|
||||
/// when persistence is disabled / dir creation fails.
|
||||
fn resolve_state_path(&self, session_id: &str) -> PathBuf {
|
||||
let Some(home) = self.tool_state_home.as_ref() else {
|
||||
return PathBuf::new();
|
||||
};
|
||||
let (dir, created) = ensure_session_dir(home, session_id);
|
||||
if let Err(e) = created {
|
||||
tracing::warn!(
|
||||
session = % session_id, dir = % dir.display(), error = % e,
|
||||
"tool_state: failed to create session dir; persistence disabled for session"
|
||||
);
|
||||
return PathBuf::new();
|
||||
}
|
||||
tracing::debug!(
|
||||
session = % session_id, dir = % dir.display(),
|
||||
"tool_state: persistence bound to session-keyed dir"
|
||||
);
|
||||
dir.join("tool_state.json")
|
||||
Self
|
||||
}
|
||||
/// `/tmp/sessions/<sanitized_id>/` for terminal logs and other tool artifacts.
|
||||
fn resolve_session_folder(session_id: &str) -> PathBuf {
|
||||
@@ -397,59 +276,9 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
||||
session_env: Arc<HashMap<String, String>>,
|
||||
backend: Arc<dyn kigi_tools::computer::types::TerminalBackend>,
|
||||
) -> kigi_tools::registry::types::SessionContext {
|
||||
use kigi_tools::implementations::grok_build::deploy_app::AppBuilderDeployerConfig;
|
||||
use kigi_tools::implementations::grok_build::image_gen::ImageGenConfig;
|
||||
use kigi_tools::implementations::grok_build::video_gen::VideoGenConfig;
|
||||
use kigi_tools::implementations::web_search::WebSearchConfig;
|
||||
let fs = Arc::new(kigi_tools::computer::local::LocalFs)
|
||||
as Arc<dyn kigi_tools::computer::types::AsyncFileSystem>;
|
||||
let notification_handle = kigi_tools::notification::ToolNotificationHandle::noop();
|
||||
let (image_gen_config, video_gen_config, web_search_config, app_builder_deployer_config) =
|
||||
if let (Some(auth), Some(url)) = (&self.auth, &self.api_base_url) {
|
||||
let cred = auth.current();
|
||||
match cred {
|
||||
kigi_computer_hub_sdk::AuthCredential::Bearer { token, .. } => {
|
||||
let headers = build_proxy_headers(url);
|
||||
(
|
||||
ImageGenConfig::Enabled {
|
||||
api_key: token.clone(),
|
||||
base_url: url.clone(),
|
||||
extra_headers: headers.clone(),
|
||||
image_gen_enabled: true,
|
||||
image_edit_enabled: true,
|
||||
model_override: None,
|
||||
tier_restricted: false,
|
||||
},
|
||||
VideoGenConfig::Enabled {
|
||||
api_key: token.clone(),
|
||||
base_url: url.clone(),
|
||||
extra_headers: headers.clone(),
|
||||
zdr_video_output_s3: None,
|
||||
tier_restricted: false,
|
||||
},
|
||||
WebSearchConfig::Enabled {
|
||||
search_url: format!("{}/search", url.trim_end_matches('/')),
|
||||
api_key: token,
|
||||
extra_headers: headers,
|
||||
},
|
||||
AppBuilderDeployerConfig::default(),
|
||||
)
|
||||
}
|
||||
_ => (
|
||||
ImageGenConfig::default(),
|
||||
VideoGenConfig::default(),
|
||||
WebSearchConfig::default(),
|
||||
AppBuilderDeployerConfig::default(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
(
|
||||
ImageGenConfig::default(),
|
||||
VideoGenConfig::default(),
|
||||
WebSearchConfig::default(),
|
||||
AppBuilderDeployerConfig::default(),
|
||||
)
|
||||
};
|
||||
kigi_tools::registry::types::SessionContext {
|
||||
backend,
|
||||
fs,
|
||||
@@ -460,16 +289,18 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
||||
owner_session_id: None,
|
||||
parent_scheduler_handle: None,
|
||||
skills: vec![],
|
||||
state_path: self.resolve_state_path(session_id),
|
||||
state_path: PathBuf::new(),
|
||||
memory_backend: None,
|
||||
web_search_config,
|
||||
web_search_config: kigi_tools::implementations::web_search::WebSearchConfig::default(),
|
||||
web_fetch_config: build_web_fetch_config(),
|
||||
lsp: None,
|
||||
image_gen_config,
|
||||
video_gen_config,
|
||||
app_builder_deployer_config,
|
||||
image_gen_config:
|
||||
kigi_tools::implementations::grok_build::image_gen::ImageGenConfig::default(),
|
||||
video_gen_config:
|
||||
kigi_tools::implementations::grok_build::video_gen::VideoGenConfig::default(),
|
||||
app_builder_deployer_config:
|
||||
kigi_tools::implementations::grok_build::deploy_app::AppBuilderDeployerConfig::default(),
|
||||
api_key_provider: None,
|
||||
auth_provider: self.auth.clone(),
|
||||
attribution_callback: None,
|
||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
||||
}
|
||||
@@ -488,18 +319,6 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
||||
IDS.clone()
|
||||
}
|
||||
}
|
||||
/// Build extra headers for API calls routed through the chat proxy.
|
||||
/// Mirrors the shell's `inject_proxy_headers` logic.
|
||||
fn build_proxy_headers(base_url: &str) -> indexmap::IndexMap<String, String> {
|
||||
let mut headers = indexmap::IndexMap::new();
|
||||
let version = kigi_version::VERSION;
|
||||
headers.insert(
|
||||
"user-agent".to_string(),
|
||||
format!("kigi-workspace/{version}"),
|
||||
);
|
||||
headers.insert("x-grok-client-version".to_string(), version.to_string());
|
||||
headers
|
||||
}
|
||||
/// Build web fetch config. Enabled with default params unless
|
||||
/// `KIGI_DISABLE_WEB_FETCH=1` is set.
|
||||
fn build_web_fetch_config() -> kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig {
|
||||
@@ -574,7 +393,6 @@ pub mod test_support {
|
||||
video_gen_config: Default::default(),
|
||||
app_builder_deployer_config: Default::default(),
|
||||
api_key_provider: None,
|
||||
auth_provider: None,
|
||||
attribution_callback: None,
|
||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
||||
}
|
||||
@@ -635,7 +453,6 @@ mod tests {
|
||||
baseline,
|
||||
CapabilityMode::ReadWrite,
|
||||
&[],
|
||||
&[],
|
||||
cwd,
|
||||
empty_env(),
|
||||
"main",
|
||||
@@ -672,7 +489,6 @@ mod tests {
|
||||
baseline,
|
||||
CapabilityMode::ReadWrite,
|
||||
&snapshot,
|
||||
&[],
|
||||
PathBuf::from("/tmp"),
|
||||
empty_env(),
|
||||
"main",
|
||||
@@ -752,7 +568,6 @@ mod tests {
|
||||
baseline,
|
||||
CapabilityMode::ReadOnly,
|
||||
&[],
|
||||
&[],
|
||||
PathBuf::from("/tmp"),
|
||||
empty_env(),
|
||||
"main",
|
||||
@@ -792,13 +607,7 @@ mod tests {
|
||||
behavior_preset: None,
|
||||
};
|
||||
let mcp_edit = test_support::tc("mcp.editor", Some(ToolKind::Edit));
|
||||
let filtered = merge_and_filter(
|
||||
&baseline,
|
||||
&[mcp_edit],
|
||||
&[],
|
||||
CapabilityMode::ReadOnly,
|
||||
"test",
|
||||
);
|
||||
let filtered = merge_and_filter(&baseline, &[mcp_edit], CapabilityMode::ReadOnly, "test");
|
||||
assert!(!filtered.tools.iter().any(|t| t.id == "mcp.editor"));
|
||||
}
|
||||
#[tokio::test]
|
||||
@@ -812,13 +621,7 @@ mod tests {
|
||||
behavior_preset: None,
|
||||
};
|
||||
let mcp = vec![test_support::tc("mcp.opaque", None)];
|
||||
let filtered = merge_and_filter(
|
||||
&baseline,
|
||||
&mcp,
|
||||
&[],
|
||||
CapabilityMode::ReadOnly,
|
||||
"test_session",
|
||||
);
|
||||
let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::ReadOnly, "test_session");
|
||||
let kept_ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(
|
||||
kept_ids.contains(&"baseline.opaque"),
|
||||
@@ -841,7 +644,7 @@ mod tests {
|
||||
behavior_preset: None,
|
||||
};
|
||||
let mcp = vec![test_support::tc("mcp.opaque", None)];
|
||||
let filtered = merge_and_filter(&baseline, &mcp, &[], CapabilityMode::All, "test_session");
|
||||
let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::All, "test_session");
|
||||
let kept_ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(
|
||||
kept_ids.contains(&"mcp.opaque"),
|
||||
@@ -859,13 +662,7 @@ mod tests {
|
||||
let mut mcp_b = test_support::tc("mcp.tool_b", Some(ToolKind::Read));
|
||||
mcp_b.name_override = Some("shared_name".into());
|
||||
let mcp = vec![mcp_a, mcp_b];
|
||||
let filtered = merge_and_filter(
|
||||
&baseline,
|
||||
&mcp,
|
||||
&[],
|
||||
CapabilityMode::ReadOnly,
|
||||
"test_session",
|
||||
);
|
||||
let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::ReadOnly, "test_session");
|
||||
let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(ids.contains(&"mcp.tool_a"), "first wins: {ids:?}");
|
||||
assert!(
|
||||
@@ -874,144 +671,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn hub_tool_merged_into_empty_baseline() {
|
||||
let baseline = ToolServerConfig {
|
||||
tools: vec![],
|
||||
behavior_preset: None,
|
||||
};
|
||||
let hub = vec![test_support::tc("hub:remote_exec", None)];
|
||||
let filtered = merge_and_filter(&baseline, &[], &hub, CapabilityMode::All, "test");
|
||||
let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(
|
||||
ids.contains(&"hub:remote_exec"),
|
||||
"remote tool should appear under All mode: {ids:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn hub_tool_dropped_under_readonly_because_kind_none() {
|
||||
let baseline = ToolServerConfig {
|
||||
tools: vec![test_support::tc(
|
||||
"GrokBuild:read_file",
|
||||
Some(ToolKind::Read),
|
||||
)],
|
||||
behavior_preset: None,
|
||||
};
|
||||
let hub = vec![test_support::tc("hub:remote_exec", None)];
|
||||
let filtered = merge_and_filter(&baseline, &[], &hub, CapabilityMode::ReadOnly, "test");
|
||||
let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(
|
||||
!ids.contains(&"hub:remote_exec"),
|
||||
"hub kind: None MUST be dropped under ReadOnly: {ids:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn hub_tool_dedup_baseline_wins() {
|
||||
let baseline = ToolServerConfig {
|
||||
tools: vec![test_support::tc("hub:read_file", Some(ToolKind::Read))],
|
||||
behavior_preset: None,
|
||||
};
|
||||
let hub = vec![test_support::tc("hub:read_file", None)];
|
||||
let filtered = merge_and_filter(&baseline, &[], &hub, CapabilityMode::All, "test");
|
||||
let count = filtered
|
||||
.tools
|
||||
.iter()
|
||||
.filter(|t| t.id == "hub:read_file")
|
||||
.count();
|
||||
assert_eq!(count, 1, "duplicate should be deduped");
|
||||
}
|
||||
#[test]
|
||||
fn hub_tool_dedup_mcp_wins_over_hub() {
|
||||
let baseline = ToolServerConfig {
|
||||
tools: vec![],
|
||||
behavior_preset: None,
|
||||
};
|
||||
let mcp = vec![test_support::tc("hub:shared_tool", Some(ToolKind::Read))];
|
||||
let hub = vec![test_support::tc("hub:shared_tool", None)];
|
||||
let filtered = merge_and_filter(&baseline, &mcp, &hub, CapabilityMode::All, "test");
|
||||
let count = filtered
|
||||
.tools
|
||||
.iter()
|
||||
.filter(|t| t.id == "hub:shared_tool")
|
||||
.count();
|
||||
assert_eq!(count, 1, "MCP wins; hub duplicate skipped");
|
||||
let tool = filtered
|
||||
.tools
|
||||
.iter()
|
||||
.find(|t| t.id == "hub:shared_tool")
|
||||
.unwrap();
|
||||
assert_eq!(tool.kind, Some(ToolKind::Read));
|
||||
}
|
||||
#[test]
|
||||
fn hub_tool_name_collision_with_baseline_skipped() {
|
||||
let baseline = ToolServerConfig {
|
||||
tools: vec![test_support::tc(
|
||||
"GrokBuild:read_file",
|
||||
Some(ToolKind::Read),
|
||||
)],
|
||||
behavior_preset: None,
|
||||
};
|
||||
let mut hub_tool = test_support::tc("hub:read_file_v2", None);
|
||||
hub_tool.name_override = Some("read_file".into());
|
||||
let hub = vec![hub_tool];
|
||||
let filtered = merge_and_filter(&baseline, &[], &hub, CapabilityMode::All, "test");
|
||||
let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(
|
||||
!ids.contains(&"hub:read_file_v2"),
|
||||
"remote tool with colliding client name must be skipped: {ids:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn empty_hub_snapshot_is_noop() {
|
||||
let baseline = test_support::baseline_config();
|
||||
let baseline_ids: Vec<String> = baseline.tools.iter().map(|t| t.id.clone()).collect();
|
||||
let filtered = merge_and_filter(&baseline, &[], &[], CapabilityMode::ReadWrite, "test");
|
||||
let filtered_ids: Vec<String> = filtered.tools.iter().map(|t| t.id.clone()).collect();
|
||||
assert_eq!(filtered_ids, baseline_ids);
|
||||
}
|
||||
/// Only the literal `"true"` enables tool-state persistence.
|
||||
#[test]
|
||||
fn tool_state_enabled_only_true_enables() {
|
||||
let _guard = super::TOOL_STATE_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let var = "KIGI_WORKSPACE_TOOL_STATE_ENABLED";
|
||||
unsafe { std::env::remove_var(var) };
|
||||
assert!(!tool_state_enabled(), "unset → disabled");
|
||||
unsafe { std::env::set_var(var, "false") };
|
||||
assert!(!tool_state_enabled(), "false → disabled");
|
||||
unsafe { std::env::set_var(var, "1") };
|
||||
assert!(!tool_state_enabled(), "1 → disabled (only \"true\")");
|
||||
unsafe { std::env::set_var(var, "true") };
|
||||
assert!(tool_state_enabled(), "true → enabled");
|
||||
unsafe { std::env::remove_var(var) };
|
||||
}
|
||||
/// With a tool-state home set, state is rooted at
|
||||
/// `<home>/sessions/<session_id>/tool_state.json` and the dir is created.
|
||||
#[test]
|
||||
fn factory_resolves_session_keyed_state_path_when_home_set() {
|
||||
let home = tempfile::TempDir::new().unwrap();
|
||||
let factory =
|
||||
WorkspaceSessionContextFactory::new().with_tool_state_home(home.path().to_path_buf());
|
||||
let p = factory.resolve_state_path("sess-1");
|
||||
assert_eq!(
|
||||
p,
|
||||
home.path()
|
||||
.join("sessions")
|
||||
.join("sess-1")
|
||||
.join("tool_state.json")
|
||||
);
|
||||
assert!(
|
||||
home.path().join("sessions").join("sess-1").is_dir(),
|
||||
"the session dir must be created so the persistence writer can rename into it"
|
||||
);
|
||||
}
|
||||
/// Without a tool-state home, `state_path` stays empty (legacy behavior).
|
||||
#[test]
|
||||
fn factory_state_path_empty_when_home_unset() {
|
||||
let factory = WorkspaceSessionContextFactory::new();
|
||||
assert_eq!(factory.resolve_state_path("sess-1"), PathBuf::new());
|
||||
}
|
||||
#[test]
|
||||
fn factory_session_folder_is_tmp_sessions_not_project_cwd() {
|
||||
let cwd = PathBuf::from("/workspace");
|
||||
let folder = WorkspaceSessionContextFactory::resolve_session_folder("sess-1");
|
||||
@@ -1063,18 +722,16 @@ mod tests {
|
||||
/// A hostile `session_id` (`../../etc`) is sanitized to a single safe
|
||||
/// segment and cannot traverse outside `<home>/sessions/`.
|
||||
#[test]
|
||||
fn factory_sanitizes_malicious_session_id_no_traversal() {
|
||||
fn ensure_session_dir_sanitizes_malicious_session_id_no_traversal() {
|
||||
let home = tempfile::TempDir::new().unwrap();
|
||||
let factory =
|
||||
WorkspaceSessionContextFactory::new().with_tool_state_home(home.path().to_path_buf());
|
||||
let sessions = home.path().join("sessions");
|
||||
let p = factory.resolve_state_path("../../etc");
|
||||
let (session_dir, created) = ensure_session_dir(home.path(), "../../etc");
|
||||
assert!(created.is_ok());
|
||||
assert!(
|
||||
p.starts_with(&sessions),
|
||||
"state path escaped sessions/: {}",
|
||||
p.display()
|
||||
session_dir.starts_with(&sessions),
|
||||
"session dir escaped sessions/: {}",
|
||||
session_dir.display()
|
||||
);
|
||||
let session_dir = p.parent().expect("state path has a parent dir");
|
||||
assert_eq!(
|
||||
session_dir.parent(),
|
||||
Some(sessions.as_path()),
|
||||
@@ -1123,7 +780,6 @@ mod tests {
|
||||
test_support::baseline_config(),
|
||||
CapabilityMode::ReadWrite,
|
||||
&[],
|
||||
&[],
|
||||
cwd.clone(),
|
||||
empty_env(),
|
||||
"sess-A",
|
||||
@@ -1145,7 +801,6 @@ mod tests {
|
||||
test_support::baseline_config(),
|
||||
CapabilityMode::ReadWrite,
|
||||
&[],
|
||||
&[],
|
||||
cwd.clone(),
|
||||
empty_env(),
|
||||
"sess-A",
|
||||
@@ -1170,7 +825,6 @@ mod tests {
|
||||
test_support::baseline_config(),
|
||||
CapabilityMode::ReadWrite,
|
||||
&[],
|
||||
&[],
|
||||
cwd,
|
||||
empty_env(),
|
||||
"sess-B",
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
//! [`WorkspaceOps`] — dual-mode workspace operations handle.
|
||||
//! [`WorkspaceOps`] — workspace operations handle.
|
||||
//!
|
||||
//! Two modes:
|
||||
//!
|
||||
//! - **`Local`** — extensions dispatch through [`WorkspaceHandle`]; tool
|
||||
//! calls dispatch through the workspace session's [`FinalizedToolset`].
|
||||
//! The toolset is installed via [`WorkspaceOps::bind_local_session`]
|
||||
//! after the agent is built.
|
||||
//!
|
||||
//! - **`Proxy`** — everything routes through hub WebSocket to a remote
|
||||
//! workspace server.
|
||||
//! Extensions dispatch through [`WorkspaceHandle`]; tool calls dispatch
|
||||
//! through the workspace session's [`FinalizedToolset`]. The toolset is
|
||||
//! installed via [`WorkspaceOps::bind_local_session`] after the agent is
|
||||
//! built.
|
||||
//!
|
||||
//! ## Type safety
|
||||
//!
|
||||
//! Each RPC method has a corresponding request struct that implements
|
||||
//! Each operation has a corresponding request struct that implements
|
||||
//! [`WorkspaceRpc`]. The struct carries a `METHOD` constant and derives
|
||||
//! `Serialize + Deserialize`. Both the proxy client (`WorkspaceOps`) and
|
||||
//! the server (`WorkspaceRpcHandler::dispatch`) use the same struct —
|
||||
//! add/rename a field and the compiler catches both sides.
|
||||
//! `Serialize + Deserialize`, so op identity and payload shape live in
|
||||
//! one place.
|
||||
use crate::error::{WorkspaceError, WorkspaceResult};
|
||||
use crate::file_system::ContentSearchRequest;
|
||||
use crate::handle::WorkspaceHandle;
|
||||
use crate::worktree::{ApplyWorktreeRequest, CreateWorktreeRequest, RemoveWorktreeRequest};
|
||||
use async_trait::async_trait;
|
||||
use kigi_computer_hub_sdk::ToolHarness;
|
||||
use kigi_tools::types::output::ToolRunResult;
|
||||
use kigi_workspace_client::{WorkspaceClient, is_transport_fatal};
|
||||
pub use kigi_workspace_types::rpc::WorkspaceRpc;
|
||||
pub use kigi_workspace_types::rpc::agents_md::DiscoverAgentsMdReq;
|
||||
pub use kigi_workspace_types::rpc::code_nav::{
|
||||
@@ -32,9 +24,8 @@ pub use kigi_workspace_types::rpc::code_nav::{
|
||||
CodeIndexStats, CodeIndexStatusReq, CodeIndexStatusResponse, CodeNavLocation, CodeNavResponse,
|
||||
};
|
||||
pub use kigi_workspace_types::rpc::fs::{
|
||||
ClientFsListNode, ClientFsListReq, ClientFsListRes, ClientFsReadFileReq, ClientFsReadFileRes,
|
||||
ClientFsStatReq, ClientFsStatRes, GetFileEntry, GetFileResult, GetFilesReq, GetFilesRes,
|
||||
PutFileEntry, PutFileResult, PutFilesReq, PutFilesRes,
|
||||
GetFileEntry, GetFileResult, GetFilesReq, GetFilesRes, PutFileEntry, PutFileResult,
|
||||
PutFilesReq, PutFilesRes,
|
||||
};
|
||||
pub use kigi_workspace_types::rpc::git::{
|
||||
BinaryFileInfoData, CheckoutCommitResponse, CommitWithPatchData, DetectVcsKindReq,
|
||||
@@ -69,7 +60,6 @@ use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
/// Implements [`WorkspaceRpc`] for request types whose responses
|
||||
/// reference crate-internal types and so cannot live in the types crate.
|
||||
macro_rules! workspace_rpc {
|
||||
@@ -214,7 +204,7 @@ fn session_tracker(
|
||||
session_id: Option<&str>,
|
||||
) -> WorkspaceResult<kigi_hunk_tracker::HunkTrackerHandle> {
|
||||
let sid = session_id
|
||||
.ok_or_else(|| WorkspaceError::HubError("per-session hunk op requires a session".into()))?;
|
||||
.ok_or_else(|| WorkspaceError::Internal("per-session hunk op requires a session".into()))?;
|
||||
let session = ws
|
||||
.session(sid)
|
||||
.ok_or_else(|| WorkspaceError::SessionNotFound(sid.to_owned()))?;
|
||||
@@ -251,13 +241,13 @@ impl WorkspaceOp for GitStatusExtReq {
|
||||
self.include_patches,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
Ok(GitStatusExtResponse::structured(data))
|
||||
}
|
||||
GitStatusFormat::Prompt => {
|
||||
let result = crate::file_system::git_status(cwd)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
Ok(GitStatusExtResponse::prompt(result))
|
||||
}
|
||||
}
|
||||
@@ -273,7 +263,7 @@ impl WorkspaceOp for GitFilesReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::read_files(&cwd, &self.paths, &self.version)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -294,7 +284,7 @@ impl WorkspaceOp for GitDiffReq {
|
||||
self.merge_base,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -307,7 +297,7 @@ impl WorkspaceOp for GitStageReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::stage(&cwd, self.paths.clone())
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -320,7 +310,7 @@ impl WorkspaceOp for GitStageContentReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::stage_content(&cwd, &self.path, &self.content)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -333,7 +323,7 @@ impl WorkspaceOp for GitUnstageReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::unstage(&cwd, self.paths.clone())
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -346,7 +336,7 @@ impl WorkspaceOp for GitDiscardReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::discard(&cwd, self.paths.clone(), self.scope, self.include_untracked)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -366,7 +356,7 @@ impl WorkspaceOp for GitCommitReq {
|
||||
self.sync,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -379,7 +369,7 @@ impl WorkspaceOp for GitCheckoutReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::checkout_branch(&cwd, &self.branch, self.create)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -392,7 +382,7 @@ impl WorkspaceOp for GitStashReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::stash(&cwd, self.include_untracked)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -405,7 +395,7 @@ impl WorkspaceOp for GitInfoReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::git_info(&cwd)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -418,7 +408,7 @@ impl WorkspaceOp for GitBranchesReq {
|
||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||
crate::session::git::list_branches(&cwd)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -429,7 +419,7 @@ impl WorkspaceOp for GitCollectChangesReq {
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
{
|
||||
return Err(WorkspaceError::HubError(
|
||||
return Err(WorkspaceError::Internal(
|
||||
"git collect changes is unavailable in this build".to_string(),
|
||||
));
|
||||
}
|
||||
@@ -550,7 +540,7 @@ impl WorkspaceOp for PrepareWorktreeFromWorktreeReq {
|
||||
spawn_task: result.spawn_task,
|
||||
response: Some(
|
||||
serde_json::to_value(&resp)
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?,
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?,
|
||||
),
|
||||
error: None,
|
||||
}),
|
||||
@@ -572,7 +562,7 @@ impl WorkspaceOp for CreateWorktreeFromWorktreeSyncReq {
|
||||
let req = crate::worktree::CreateWorktreeFromWorktreeRequest::from(self.inner.clone());
|
||||
crate::worktree::create_worktree_from_worktree_sync(&req)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -583,8 +573,8 @@ impl WorkspaceOp for WorktreeDbRebuildReq {
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let report = crate::worktree::worktree_db_rebuild()
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(report).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(report).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -595,7 +585,7 @@ impl WorkspaceOp for WorktreeDbPathReq {
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let path = crate::worktree::worktree_db_path()
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
Ok(WorktreeDbPathResponse {
|
||||
path: Some(path.display().to_string()),
|
||||
})
|
||||
@@ -864,8 +854,8 @@ fn hook_registry_to_wire(
|
||||
registry: &kigi_hooks::discovery::HookRegistry,
|
||||
) -> WorkspaceResult<HookRegistryWire> {
|
||||
let value =
|
||||
serde_json::to_value(registry).map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
serde_json::to_value(registry).map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::from_value(value).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
/// Inverse of [`hook_registry_to_wire`]. The compiled `matcher` is absent from
|
||||
/// the wire (and from this result); callers recompile it via
|
||||
@@ -873,8 +863,8 @@ fn hook_registry_to_wire(
|
||||
fn wire_to_hook_registry(
|
||||
wire: &HookRegistryWire,
|
||||
) -> WorkspaceResult<kigi_hooks::discovery::HookRegistry> {
|
||||
let value = serde_json::to_value(wire).map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
let value = serde_json::to_value(wire).map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::from_value(value).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for HookRegistryReq {
|
||||
@@ -886,56 +876,6 @@ impl WorkspaceOp for HookRegistryReq {
|
||||
hook_registry_to_wire(&ws.hook_registry())
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for PutFilesReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
ws.put_files(self.files.clone()).await
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for GetFilesReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
ws.get_files(self.files.clone()).await
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for ClientFsListReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
crate::file_system::client_fs::list(ws, self).await
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for ClientFsStatReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
crate::file_system::client_fs::stat(ws, self).await
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for ClientFsReadFileReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
crate::file_system::client_fs::read_file(ws, self).await
|
||||
}
|
||||
}
|
||||
/// Resolve the index root for a code-nav op. Prefers the explicit per-session
|
||||
/// `root` (the cwd the client sends per window), else the workspace root.
|
||||
/// Without this, code nav in a non-primary window would query the launch
|
||||
@@ -972,7 +912,7 @@ impl WorkspaceOp for CodeGotoDefinitionReq {
|
||||
let result = handle
|
||||
.goto_definition(std::path::PathBuf::from(&self.file), self.line, self.col)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(format!("index channel closed: {e}")))?;
|
||||
.map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?;
|
||||
Ok(query_result_to_response(result))
|
||||
}
|
||||
}
|
||||
@@ -992,7 +932,7 @@ impl WorkspaceOp for CodeGotoReferencesReq {
|
||||
self.include_definition,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(format!("index channel closed: {e}")))?;
|
||||
.map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?;
|
||||
Ok(query_result_to_response(result))
|
||||
}
|
||||
}
|
||||
@@ -1010,7 +950,7 @@ impl WorkspaceOp for CodeFindDefinitionsReq {
|
||||
self.context_file.as_ref().map(std::path::PathBuf::from),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(format!("index channel closed: {e}")))?;
|
||||
.map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?;
|
||||
Ok(symbol_locations_to_response(result))
|
||||
}
|
||||
}
|
||||
@@ -1028,7 +968,7 @@ impl WorkspaceOp for CodeFindReferencesReq {
|
||||
self.context_file.as_ref().map(std::path::PathBuf::from),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(format!("index channel closed: {e}")))?;
|
||||
.map_err(|e| WorkspaceError::Internal(format!("index channel closed: {e}")))?;
|
||||
Ok(symbol_locations_to_response(result))
|
||||
}
|
||||
}
|
||||
@@ -1105,9 +1045,9 @@ impl WorkspaceOp for CreateWorktreeRequest {
|
||||
let result = crate::worktree::prepare_worktree_creation(self).await;
|
||||
match result.response {
|
||||
Ok(resp) => {
|
||||
serde_json::to_value(resp).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
serde_json::to_value(resp).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
Err(e) => Err(WorkspaceError::HubError(e.to_string())),
|
||||
Err(e) => Err(WorkspaceError::Internal(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1121,8 +1061,8 @@ impl WorkspaceOp for RemoveWorktreeRequest {
|
||||
let copy_ctx = crate::worktree::BackgroundCopyContext::new();
|
||||
let result = crate::worktree::remove_worktree(self, ©_ctx)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(result).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(result).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -1134,8 +1074,8 @@ impl WorkspaceOp for ApplyWorktreeRequest {
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let result = crate::worktree::apply_worktree(self)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(result).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(result).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -1147,8 +1087,8 @@ impl WorkspaceOp for WorktreeListReq {
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let records =
|
||||
crate::worktree::list_worktrees(self.repo.as_deref(), &self.types, self.include_all)
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(records).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(records).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -1159,8 +1099,8 @@ impl WorkspaceOp for WorktreeShowReq {
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let record = crate::worktree::show_worktree(&self.id_or_path)
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(record).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(record).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -1175,9 +1115,9 @@ impl WorkspaceOp for WorktreeGcReq {
|
||||
crate::worktree::gc_worktrees_mgmt(dry_run, max_age_secs, force)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(report).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(report).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -1188,27 +1128,22 @@ impl WorkspaceOp for WorktreeDbStatsReq {
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
let stats = crate::worktree::worktree_db_stats()
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::to_value(stats).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||
serde_json::to_value(stats).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
/// Dual-mode workspace operations handle.
|
||||
///
|
||||
/// - **`Local`** — wraps a [`WorkspaceHandle`]. Extensions dispatch
|
||||
/// through the handle; tool calls dispatch through the workspace
|
||||
/// session's [`FinalizedToolset`](kigi_tools::registry::types::FinalizedToolset).
|
||||
/// Call [`bind_local_session`](Self::bind_local_session) after building
|
||||
/// the agent to install the toolset on the workspace session.
|
||||
///
|
||||
/// - **`Proxy`** — wraps a [`WorkspaceClient`] connected to a remote hub.
|
||||
/// Everything routes through hub WebSocket to a remote workspace server.
|
||||
/// Wraps a [`WorkspaceHandle`]. Extensions dispatch through the handle;
|
||||
/// tool calls dispatch through the workspace session's
|
||||
/// [`FinalizedToolset`](kigi_tools::registry::types::FinalizedToolset).
|
||||
/// Call [`bind_local_session`](Self::bind_local_session) after building
|
||||
/// the agent to install the toolset on the workspace session.
|
||||
#[derive(Clone)]
|
||||
pub enum WorkspaceOps {
|
||||
/// Local in-process mode — extensions through the handle, tool calls
|
||||
/// through the workspace session's toolset.
|
||||
Local { handle: WorkspaceHandle },
|
||||
/// Proxy mode — routes through hub RPC.
|
||||
Proxy { client: WorkspaceClient },
|
||||
}
|
||||
impl WorkspaceOps {
|
||||
/// Construct a local-mode ops handle.
|
||||
@@ -1219,36 +1154,10 @@ impl WorkspaceOps {
|
||||
pub fn local(handle: WorkspaceHandle) -> Self {
|
||||
Self::Local { handle }
|
||||
}
|
||||
/// Construct a proxy-mode ops handle.
|
||||
pub fn proxy(harness: Arc<ToolHarness>) -> Self {
|
||||
Self::Proxy {
|
||||
client: WorkspaceClient::new((*harness).clone()),
|
||||
}
|
||||
}
|
||||
/// Construct a proxy-mode ops handle sharing a pre-created connected
|
||||
/// flag. The same `Arc<AtomicBool>` should be wired into the harness
|
||||
/// builder's `on_reconnect` callback so reconnects reset the flag.
|
||||
pub fn proxy_with_connected(harness: Arc<ToolHarness>, connected: Arc<AtomicBool>) -> Self {
|
||||
Self::Proxy {
|
||||
client: WorkspaceClient::with_connected_flag((*harness).clone(), connected),
|
||||
}
|
||||
}
|
||||
/// Whether this handle routes through the server (proxy mode).
|
||||
pub fn is_proxy(&self) -> bool {
|
||||
matches!(self, Self::Proxy { .. })
|
||||
}
|
||||
/// Access the underlying workspace RPC client (proxy mode only).
|
||||
pub fn client(&self) -> Option<&WorkspaceClient> {
|
||||
match self {
|
||||
Self::Proxy { client } => Some(client),
|
||||
Self::Local { .. } => None,
|
||||
}
|
||||
}
|
||||
/// Access the underlying workspace handle (local mode only).
|
||||
/// Access the underlying workspace handle.
|
||||
pub fn workspace_handle(&self) -> Option<&WorkspaceHandle> {
|
||||
match self {
|
||||
Self::Local { handle } => Some(handle),
|
||||
Self::Proxy { .. } => None,
|
||||
}
|
||||
}
|
||||
/// Create the workspace session and bind the agent's toolset for local mode.
|
||||
@@ -1274,9 +1183,7 @@ impl WorkspaceOps {
|
||||
toolset: Arc<kigi_tools::registry::types::FinalizedToolset>,
|
||||
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
||||
) -> WorkspaceResult<()> {
|
||||
let Self::Local { handle } = self else {
|
||||
return Ok(());
|
||||
};
|
||||
let Self::Local { handle } = self;
|
||||
if handle.session(session_id).is_none() {
|
||||
handle.create_session_with_tracker_and_viewer_ctx(
|
||||
session_id,
|
||||
@@ -1296,9 +1203,7 @@ impl WorkspaceOps {
|
||||
}
|
||||
/// Release the workspace session. No-op in proxy mode.
|
||||
pub fn end_local_session(&self, session_id: &str) {
|
||||
let Self::Local { handle } = self else {
|
||||
return;
|
||||
};
|
||||
let Self::Local { handle } = self;
|
||||
handle.on_session_ended(session_id);
|
||||
if let Err(e) = handle.drop_session(session_id, session_id) {
|
||||
tracing::debug!(
|
||||
@@ -1312,82 +1217,26 @@ impl WorkspaceOps {
|
||||
session_id: &str,
|
||||
payload: &kigi_tool_protocol::turn_hook::BeforeTurnPayload,
|
||||
) {
|
||||
match self {
|
||||
Self::Local { handle } => {
|
||||
handle.on_before_turn(session_id, payload).await;
|
||||
}
|
||||
Self::Proxy { .. } => {
|
||||
tracing::debug!("on_before_turn called on Proxy WorkspaceOps (no-op)");
|
||||
}
|
||||
}
|
||||
let Self::Local { handle } = self;
|
||||
handle.on_before_turn(session_id, payload).await;
|
||||
}
|
||||
pub async fn on_after_turn(
|
||||
&self,
|
||||
session_id: &str,
|
||||
payload: &kigi_tool_protocol::turn_hook::AfterTurnPayload,
|
||||
) {
|
||||
match self {
|
||||
Self::Local { handle } => {
|
||||
handle.on_after_turn(session_id, payload).await;
|
||||
}
|
||||
Self::Proxy { .. } => {
|
||||
tracing::debug!("on_after_turn called on Proxy WorkspaceOps (no-op)");
|
||||
}
|
||||
}
|
||||
let Self::Local { handle } = self;
|
||||
handle.on_after_turn(session_id, payload).await;
|
||||
}
|
||||
pub async fn rpc_raw(&self, method: &str, params: Value) -> WorkspaceResult<Value> {
|
||||
let client = match self {
|
||||
Self::Proxy { client } => client,
|
||||
Self::Local { .. } => {
|
||||
return Err(WorkspaceError::HubError(
|
||||
"rpc not available in local mode".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
client
|
||||
.rpc_raw(method, params)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
}
|
||||
async fn rpc<R: WorkspaceRpc>(&self, req: &R) -> WorkspaceResult<R::Response> {
|
||||
let params = serde_json::to_value(req)
|
||||
.map_err(|e| WorkspaceError::HubError(format!("serialize failed: {e}")))?;
|
||||
let terminal = self.rpc_raw(R::METHOD, params).await?;
|
||||
let envelope: crate::rpc_envelope::RpcEnvelope<R::Response> =
|
||||
serde_json::from_value(terminal)
|
||||
.map_err(|e| WorkspaceError::HubError(format!("envelope parse failed: {e}")))?;
|
||||
envelope
|
||||
.into_result()
|
||||
.map_err(crate::rpc_envelope::rpc_error_to_workspace)
|
||||
}
|
||||
/// Dispatch a typed operation in either local or proxy mode.
|
||||
///
|
||||
/// - **Local mode**: calls `op.execute(handle, session_id)` directly.
|
||||
/// - **Proxy mode**: serializes the op and routes through the server RPC.
|
||||
/// The server handler owns session context, so `session_id` is only
|
||||
/// needed for local `execute()`.
|
||||
/// Dispatch a typed operation: calls `op.execute(handle, session_id)`.
|
||||
pub async fn dispatch<Op: WorkspaceOp>(
|
||||
&self,
|
||||
op: &Op,
|
||||
session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Op::Response> {
|
||||
let mode = match self {
|
||||
Self::Local { .. } => "local",
|
||||
Self::Proxy { .. } => "proxy",
|
||||
};
|
||||
tracing::debug!(method = Op::METHOD, mode, "WorkspaceOps::dispatch");
|
||||
match self {
|
||||
Self::Local { handle } => op.execute(handle, session_id).await,
|
||||
Self::Proxy { .. } => self.rpc(op).await,
|
||||
}
|
||||
}
|
||||
pub async fn workspace_info(&self) -> WorkspaceResult<Value> {
|
||||
self.rpc(&WorkspaceInfoReq {}).await
|
||||
}
|
||||
/// **DEPRECATED**: Use [`Self::git_status_ext`] with `format: GitStatusFormat::Prompt`
|
||||
/// instead. This method will be removed in a future release.
|
||||
pub async fn git_status(&self) -> WorkspaceResult<Value> {
|
||||
self.rpc(&GitStatusReq {}).await
|
||||
tracing::debug!(method = Op::METHOD, "WorkspaceOps::dispatch");
|
||||
let Self::Local { handle } = self;
|
||||
op.execute(handle, session_id).await
|
||||
}
|
||||
/// Get git status with configurable output format.
|
||||
///
|
||||
@@ -1408,52 +1257,9 @@ impl WorkspaceOps {
|
||||
let wire = self.dispatch(&HookRegistryReq {}, None).await?;
|
||||
wire_to_hook_registry(&wire)
|
||||
}
|
||||
pub async fn begin_prompt(&self, session_id: &str, prompt_index: usize) -> WorkspaceResult<()> {
|
||||
self.rpc(&BeginPromptReq {
|
||||
session_id: session_id.to_owned(),
|
||||
prompt_index,
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn end_prompt(&self, session_id: &str, prompt_index: usize) -> WorkspaceResult<()> {
|
||||
self.rpc(&EndPromptReq {
|
||||
session_id: session_id.to_owned(),
|
||||
prompt_index,
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn get_rewind_points(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> WorkspaceResult<Vec<crate::session::file_state::RewindPoint>> {
|
||||
self.rpc(&GetRewindPointsReq {
|
||||
session_id: session_id.to_owned(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn rewind_to(
|
||||
&self,
|
||||
session_id: &str,
|
||||
target_prompt_index: usize,
|
||||
) -> WorkspaceResult<crate::session::file_state::FileRewindResponse> {
|
||||
self.rpc(&RewindToReq {
|
||||
session_id: session_id.to_owned(),
|
||||
target_prompt_index,
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn put_files(&self, req: PutFilesReq) -> WorkspaceResult<PutFilesRes> {
|
||||
self.dispatch(&req, None).await
|
||||
}
|
||||
pub async fn get_files(&self, req: GetFilesReq) -> WorkspaceResult<GetFilesRes> {
|
||||
self.dispatch(&req, None).await
|
||||
}
|
||||
/// Dispatch a tool call through the workspace.
|
||||
///
|
||||
/// - **Local**: dispatches through the workspace session's
|
||||
/// [`FinalizedToolset`](kigi_tools::registry::types::FinalizedToolset)
|
||||
/// (in-process). Requires `session_id` to look up the session.
|
||||
/// - **Proxy**: routes through the server `ToolHarness` (remote).
|
||||
/// Dispatch a tool call through the workspace session's
|
||||
/// [`FinalizedToolset`](kigi_tools::registry::types::FinalizedToolset)
|
||||
/// (in-process). Requires `session_id` to look up the session.
|
||||
pub async fn call_tool(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -1461,57 +1267,23 @@ impl WorkspaceOps {
|
||||
call_id: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<ToolRunResult, kigi_tool_runtime::ToolError> {
|
||||
match self {
|
||||
Self::Local { handle } => {
|
||||
let session_id = session_id.ok_or_else(|| {
|
||||
kigi_tool_runtime::ToolError::custom(
|
||||
"missing_session",
|
||||
"session_id required for local tool dispatch",
|
||||
)
|
||||
})?;
|
||||
let session = handle.session(session_id).ok_or_else(|| {
|
||||
kigi_tool_runtime::ToolError::custom(
|
||||
"session_not_found",
|
||||
format!(
|
||||
"workspace session not found: {session_id} \
|
||||
— call bind_local_session() first"
|
||||
),
|
||||
)
|
||||
})?;
|
||||
session.toolset().call(name, args, call_id, None).await
|
||||
}
|
||||
Self::Proxy { client } => {
|
||||
if !client.is_connected() {
|
||||
return Err(kigi_tool_runtime::ToolError::network_error(
|
||||
"The workspace server connection was lost. \
|
||||
Please restart your session to reconnect.",
|
||||
));
|
||||
}
|
||||
let tool_id = kigi_tool_protocol::ToolId::new(name).map_err(|e| {
|
||||
kigi_tool_runtime::ToolError::custom(
|
||||
"hub_proxy_error",
|
||||
format!("invalid tool name: {e}"),
|
||||
)
|
||||
})?;
|
||||
let mut ctx = kigi_tool_runtime::ToolCallContext::default();
|
||||
ctx.call_id =
|
||||
kigi_tool_protocol::ToolCallId::new(call_id.to_owned()).unwrap_or(ctx.call_id);
|
||||
let mut stream = client.harness().call(tool_id, args, ctx).await;
|
||||
let typed = crate::hub_channel::consume_stream_terminal(&mut stream)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
if is_transport_fatal(e) {
|
||||
client.mark_disconnected();
|
||||
}
|
||||
})?;
|
||||
serde_json::from_value::<ToolRunResult>(typed.value).map_err(|e| {
|
||||
kigi_tool_runtime::ToolError::custom(
|
||||
"tool_result_deserialize",
|
||||
format!("tool result deserialization failed: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
let Self::Local { handle } = self;
|
||||
let session_id = session_id.ok_or_else(|| {
|
||||
kigi_tool_runtime::ToolError::custom(
|
||||
"missing_session",
|
||||
"session_id required for local tool dispatch",
|
||||
)
|
||||
})?;
|
||||
let session = handle.session(session_id).ok_or_else(|| {
|
||||
kigi_tool_runtime::ToolError::custom(
|
||||
"session_not_found",
|
||||
format!(
|
||||
"workspace session not found: {session_id} \
|
||||
— call bind_local_session() first"
|
||||
),
|
||||
)
|
||||
})?;
|
||||
session.toolset().call(name, args, call_id, None).await
|
||||
}
|
||||
}
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
@@ -1569,9 +1341,7 @@ mod tests {
|
||||
#[test]
|
||||
fn git_op_cwd_uses_explicit_git_root_per_window() {
|
||||
let ops = WorkspaceOps::for_test();
|
||||
let WorkspaceOps::Local { handle } = &ops else {
|
||||
unreachable!("for_test builds a local handle");
|
||||
};
|
||||
let WorkspaceOps::Local { handle } = &ops;
|
||||
let workspace_root = handle.root_cwd().unwrap();
|
||||
let window_a = std::path::PathBuf::from("/repos/xai-main");
|
||||
let window_b = std::path::PathBuf::from("/repos/xai-main-2");
|
||||
@@ -1594,9 +1364,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn end_local_session_drops_bound_toolset() {
|
||||
let ops = WorkspaceOps::for_test();
|
||||
let WorkspaceOps::Local { handle } = &ops else {
|
||||
unreachable!("for_test builds a local handle");
|
||||
};
|
||||
let WorkspaceOps::Local { handle } = &ops;
|
||||
let sid = "sess-teardown";
|
||||
let toolset =
|
||||
std::sync::Arc::new(kigi_tools::registry::types::FinalizedToolset::empty_for_test());
|
||||
|
||||
@@ -3,7 +3,7 @@ license = "Apache-2.0"
|
||||
name = "kigi-tool-protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Wire-protocol types for the xAI Computer Hub"
|
||||
description = "Tool wire-protocol types"
|
||||
|
||||
[dependencies]
|
||||
kigi-tool-types = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! xAI Computer Hub — wire-protocol types.
|
||||
//! Tool wire-protocol types.
|
||||
//!
|
||||
//! Identifier newtypes, registration payloads, capabilities, hook events,
|
||||
//! handshake messages, the JSON-RPC 2.0 envelope and method catalog, the
|
||||
|
||||
@@ -3,17 +3,18 @@ license = "Apache-2.0"
|
||||
name = "kigi-tool-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Unified Tool trait, dispatch trait, error taxonomy, notifications, and search index for the xAI Computer Hub"
|
||||
description = "Unified Tool trait, dispatch trait, error taxonomy, notifications, local tool registry, and search index"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
kigi-tools-api = { workspace = true }
|
||||
kigi-tool-protocol = { workspace = true }
|
||||
kigi-tool-types = { workspace = true }
|
||||
|
||||
|
||||
@@ -152,185 +152,3 @@ pub struct WorkspaceViewerContext {
|
||||
#[serde(default)]
|
||||
pub stream_tool_progress: bool,
|
||||
}
|
||||
|
||||
/// Wire shape of the Computer Hub `session.bind` metadata — one definition
|
||||
/// shared by the emitter (serializes) and the workspace consumer
|
||||
/// (deserializes), so the two can't drift on field names/types.
|
||||
///
|
||||
/// Excludes anything not meant for the workspace (cached tool definitions,
|
||||
/// and terminal-provisioning inputs like image/fuse/isolation) so they can
|
||||
/// never reach the wire. Every field tolerates a missing/malformed value
|
||||
/// (drops to default) to keep valid siblings and mixed-version compatibility.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct WorkspaceBindMetadata {
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "ok_or_default",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub preset: Option<String>,
|
||||
/// Raw string; the workspace maps it to its own capability enum.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "ok_or_default",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub capability_mode: Option<String>,
|
||||
/// Explicit toolset in the grok-tools gRPC wire shape. Empty = unset.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "ok_or_default",
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub tools: Vec<kigi_tools_api::ToolConfigEntry>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "ok_or_default",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub viewer_ctx: Option<WorkspaceViewerContext>,
|
||||
/// Initial auto-approve (YOLO) state for the bound session. Omitted when
|
||||
/// unset (legacy emitters / wire compat with older workspace servers);
|
||||
/// consumers fail closed on `None`.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "ok_or_default",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub yolo_mode: Option<bool>,
|
||||
/// Optional/additive: omitted by emitters that don't yet write it.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "ok_or_default",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub manifest_version: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "ok_or_default",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub manifest_hash: Option<String>,
|
||||
/// Opt-in: forward SystemNotifications produced in this session to the gateway.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "ok_or_default",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub system_notifications: Option<bool>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "ok_or_default",
|
||||
skip_serializing_if = "std::ops::Not::not"
|
||||
)]
|
||||
pub rpc_only: bool,
|
||||
}
|
||||
|
||||
/// Deserialize a field, falling back to its default on a malformed value
|
||||
/// instead of failing the whole struct.
|
||||
fn ok_or_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: serde::de::DeserializeOwned + Default,
|
||||
{
|
||||
let value = <serde_json::Value as serde::Deserialize>::deserialize(deserializer)?;
|
||||
Ok(serde_json::from_value(value).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bind_metadata_tests {
|
||||
use super::WorkspaceBindMetadata;
|
||||
|
||||
#[test]
|
||||
fn serialize_omits_empty_fields() {
|
||||
let md = WorkspaceBindMetadata::default();
|
||||
assert_eq!(serde_json::to_value(&md).unwrap(), serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_populated() {
|
||||
let md = WorkspaceBindMetadata {
|
||||
preset: Some("explore".to_owned()),
|
||||
capability_mode: Some("read_only".to_owned()),
|
||||
tools: vec![kigi_tools_api::ToolConfigEntry {
|
||||
id: "GrokBuild:grep".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
viewer_ctx: Some(super::WorkspaceViewerContext {
|
||||
stream_tool_progress: true,
|
||||
}),
|
||||
yolo_mode: Some(true),
|
||||
manifest_version: Some("v1".to_owned()),
|
||||
manifest_hash: Some("abc123".to_owned()),
|
||||
system_notifications: Some(true),
|
||||
rpc_only: true,
|
||||
};
|
||||
let value = serde_json::to_value(&md).unwrap();
|
||||
let back: WorkspaceBindMetadata = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(back.preset.as_deref(), Some("explore"));
|
||||
assert_eq!(back.capability_mode.as_deref(), Some("read_only"));
|
||||
assert_eq!(back.tools.len(), 1);
|
||||
assert!(back.viewer_ctx.unwrap().stream_tool_progress);
|
||||
assert_eq!(back.yolo_mode, Some(true));
|
||||
assert_eq!(back.manifest_version.as_deref(), Some("v1"));
|
||||
assert_eq!(back.manifest_hash.as_deref(), Some("abc123"));
|
||||
assert_eq!(back.system_notifications, Some(true));
|
||||
assert!(back.rpc_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_only_omitted_when_false_wire_compatible() {
|
||||
let md = WorkspaceBindMetadata::default();
|
||||
let value = serde_json::to_value(&md).unwrap();
|
||||
assert!(value.get("rpc_only").is_none());
|
||||
|
||||
let md: WorkspaceBindMetadata =
|
||||
serde_json::from_value(serde_json::json!({"preset": "explore"})).unwrap();
|
||||
assert!(!md.rpc_only);
|
||||
|
||||
let md: WorkspaceBindMetadata =
|
||||
serde_json::from_value(serde_json::json!({"rpc_only": true})).unwrap();
|
||||
assert!(md.rpc_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_notifications_is_wire_compatible() {
|
||||
let md = WorkspaceBindMetadata::default();
|
||||
let value = serde_json::to_value(&md).unwrap();
|
||||
assert!(value.get("system_notifications").is_none());
|
||||
|
||||
let md = WorkspaceBindMetadata {
|
||||
system_notifications: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(&md).unwrap();
|
||||
let back: WorkspaceBindMetadata = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(back.system_notifications, Some(true));
|
||||
|
||||
let md: WorkspaceBindMetadata =
|
||||
serde_json::from_value(serde_json::json!({"preset": "explore"})).unwrap();
|
||||
assert!(md.system_notifications.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_field_falls_back_to_default_keeping_siblings() {
|
||||
// `tools` is the wrong type and `capability_mode` is fine: the bad
|
||||
// field drops to default, the good sibling survives.
|
||||
let value = serde_json::json!({
|
||||
"preset": "explore",
|
||||
"capability_mode": "read_only",
|
||||
"tools": "not-a-list",
|
||||
});
|
||||
let md: WorkspaceBindMetadata = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(md.preset.as_deref(), Some("explore"));
|
||||
assert_eq!(md.capability_mode.as_deref(), Some("read_only"));
|
||||
assert!(md.tools.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_payload_without_viewer_ctx_parses() {
|
||||
let md: WorkspaceBindMetadata =
|
||||
serde_json::from_value(serde_json::json!({"preset": "explore"})).unwrap();
|
||||
assert!(md.viewer_ctx.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
//! xAI Computer Hub — unified runtime contract.
|
||||
//! Unified tool runtime contract.
|
||||
//!
|
||||
//! Single home for the `Tool` trait, `ToolDispatch`, `ToolError`,
|
||||
//! `ToolNotification`, `ToolSearchIndex`, `ToolCallContext`, `ToolStream`,
|
||||
//! and the helper constructors that build well-formed streams. Adapters
|
||||
//! for individual tool sources re-export from here so every tool author
|
||||
//! sees the same surface.
|
||||
//! the in-process `LocalRegistry`, and the helper constructors that build
|
||||
//! well-formed streams. Adapters for individual tool sources re-export
|
||||
//! from here so every tool author sees the same surface.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod context;
|
||||
pub mod dispatch;
|
||||
pub mod error;
|
||||
pub mod local_registry;
|
||||
pub mod notification;
|
||||
pub mod render;
|
||||
pub mod search;
|
||||
@@ -19,10 +20,11 @@ pub mod tool;
|
||||
|
||||
pub use context::{
|
||||
BehaviorVersion, Cancellation, Cwd, ListToolsContext, SessionContext, ToolCallContext,
|
||||
TraceContext, TypedExtensions, WorkspaceBindMetadata, WorkspaceViewerContext,
|
||||
TraceContext, TypedExtensions, WorkspaceViewerContext,
|
||||
};
|
||||
pub use dispatch::ToolDispatch;
|
||||
pub use error::{ToolError, ToolErrorKind};
|
||||
pub use local_registry::LocalRegistry;
|
||||
pub use notification::{
|
||||
BashExecutionBackgrounded, BashExecutionComplete, BashExecutionFailed, BashExecutionTimeout,
|
||||
BashNotificationBase, BashOutputChunk, FileRead, FileWritten, LspServerCrashed,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! In-process tool registry for local dispatch.
|
||||
//!
|
||||
//! [`LocalRegistry`] maps [`ToolId`]s to type-erased
|
||||
//! [`ToolDyn`](crate::tool::ToolDyn) handles.
|
||||
//! Toolset finalization registers every config-enabled tool here and
|
||||
//! dispatch resolves handles via [`LocalRegistry::find`], so a call
|
||||
//! executes in-process without any wire round-trip.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::context::ListToolsContext;
|
||||
use crate::tool::{ArcTool, Tool};
|
||||
use kigi_tool_protocol::ToolId;
|
||||
use kigi_tool_types::ToolDescription;
|
||||
|
||||
/// In-process registry of tool handles.
|
||||
///
|
||||
/// Mutations are concurrency-safe (`RwLock` on the entry map), so
|
||||
/// callers MAY hot-add or hot-remove tools while dispatch is in use.
|
||||
///
|
||||
/// Entries use `RwLock<IndexMap>` to preserve insertion order so that
|
||||
/// [`list_tools`](Self::list_tools) returns descriptions in the same
|
||||
/// order tools were registered (matching the config-defined order).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LocalRegistry {
|
||||
entries: Arc<RwLock<IndexMap<ToolId, ArcTool>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LocalRegistry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("LocalRegistry")
|
||||
.field("entries", &self.entries.read().len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalRegistry {
|
||||
/// Construct an empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a typed [`Tool`] implementation by value. Subsequent
|
||||
/// registrations of the same id replace the previous handle and
|
||||
/// return the displaced handle for inspection / drop ordering.
|
||||
pub fn register<T>(&self, tool: T) -> Option<ArcTool>
|
||||
where
|
||||
T: Tool + 'static,
|
||||
{
|
||||
self.register_arc(Arc::new(tool))
|
||||
}
|
||||
|
||||
/// Register a typed [`Tool`] implementation already wrapped in `Arc`.
|
||||
pub fn register_arc<T>(&self, tool: Arc<T>) -> Option<ArcTool>
|
||||
where
|
||||
T: Tool + 'static,
|
||||
{
|
||||
let id = tool.id();
|
||||
self.entries.write().insert(id, tool as ArcTool)
|
||||
}
|
||||
|
||||
/// Register a type-erased [`ToolDyn`](crate::tool::ToolDyn) directly.
|
||||
///
|
||||
/// Use this for inherently dynamic tools (e.g. MCP tools retrieved
|
||||
/// from a registry as `Arc<dyn ToolDyn>`) where the concrete type
|
||||
/// is not available. For native tools with a concrete type, prefer
|
||||
/// [`register`](Self::register).
|
||||
pub fn register_dyn(&self, tool: ArcTool) -> Option<ArcTool> {
|
||||
let id = tool.id();
|
||||
self.entries.write().insert(id, tool)
|
||||
}
|
||||
|
||||
/// Resolve `tool_id` to its in-process handle, if registered.
|
||||
/// Returns a clone of the handle so the caller can dispatch without
|
||||
/// holding the lock across an await point.
|
||||
pub fn find(&self, tool_id: &ToolId) -> Option<ArcTool> {
|
||||
self.entries.read().get(tool_id).cloned()
|
||||
}
|
||||
|
||||
/// Drop the handle bound to `tool_id`. Returns `true` iff a
|
||||
/// matching entry was removed.
|
||||
pub fn unregister(&self, tool_id: &ToolId) -> bool {
|
||||
self.entries.write().shift_remove(tool_id).is_some()
|
||||
}
|
||||
|
||||
/// Number of tools currently registered.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.read().len()
|
||||
}
|
||||
|
||||
/// `true` iff no tools are registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.read().is_empty()
|
||||
}
|
||||
|
||||
/// `true` iff `tool_id` is currently registered.
|
||||
pub fn contains(&self, tool_id: &ToolId) -> bool {
|
||||
self.entries.read().contains_key(tool_id)
|
||||
}
|
||||
|
||||
/// Descriptions of registered tools filtered by `should_list`.
|
||||
///
|
||||
/// Returns descriptions in **insertion order** — the order tools
|
||||
/// were registered — so the caller sees the same ordering as the
|
||||
/// config-defined tool list.
|
||||
pub fn list_tools(&self, ctx: &ListToolsContext) -> Vec<ToolDescription> {
|
||||
self.entries
|
||||
.read()
|
||||
.values()
|
||||
.filter(|handle| handle.should_list(ctx))
|
||||
.map(|handle| handle.description(ctx))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ license = "Apache-2.0"
|
||||
name = "kigi-tool-types"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Canonical tool-description types for the xAI platform"
|
||||
description = "Canonical tool-description types"
|
||||
|
||||
[features]
|
||||
# Enables `BuiltinSubagent::render_prompt` (MiniJinja rendering of the
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
[package]
|
||||
license = "Apache-2.0"
|
||||
edition.workspace = true
|
||||
name = "kigi-tracing"
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
http = { workspace = true }
|
||||
log = { workspace = true }
|
||||
fastrace = { workspace = true }
|
||||
fastrace-tonic = { workspace = true }
|
||||
fastrace-opentelemetry = { workspace = true }
|
||||
fastrace-reqwest = { workspace = true }
|
||||
opentelemetry = { workspace = true }
|
||||
opentelemetry-http = { workspace = true }
|
||||
opentelemetry-otlp = { workspace = true }
|
||||
opentelemetry_sdk = { workspace = true, features = ["testing"] }
|
||||
reqwest = { workspace = true }
|
||||
reqwest-middleware = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tonic = { workspace = true }
|
||||
tower = { workspace = true, features = ["full"] }
|
||||
tower-http = { workspace = true, features = ["trace"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-opentelemetry = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
bytes = { workspace = true }
|
||||
http-body-util = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
tracing-subscriber = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,45 +0,0 @@
|
||||
use tracing::subscriber::NoSubscriber;
|
||||
|
||||
/// Returns `true` when a `tracing` dispatcher (subscriber) is active in the
|
||||
/// current context — either the thread-scoped default
|
||||
/// (`tracing::subscriber::with_default` / `set_default`) or the global one.
|
||||
///
|
||||
/// When this returns `false`, spans have no consumer. Worse than useless: when
|
||||
/// `tracing` is compiled with its `log` compatibility feature, every span
|
||||
/// creation and every later `Span::record(...)` is downgraded to a `log`
|
||||
/// record at the span's level. In processes that only configure a `log`
|
||||
/// logger — e.g. integration tests or fastrace-only binaries — that prints
|
||||
/// noise like:
|
||||
///
|
||||
/// ```text
|
||||
/// I grpc; otel.name="POST_/pkg.Service/Method" ...
|
||||
/// I grpc; status_code=200 OK
|
||||
/// I grpc; trace_id=00000000000000000000000000000000
|
||||
/// ```
|
||||
///
|
||||
/// Request-span factories (gRPC/HTTP server and client middleware) call this
|
||||
/// and return `Span::none()` when no dispatcher is active, so the span is
|
||||
/// neither built nor downgraded to log spam.
|
||||
pub fn dispatcher_active() -> bool {
|
||||
tracing::dispatcher::get_default(|dispatch| !dispatch.is::<NoSubscriber>())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// NOTE: relies on no test in this binary installing a *global* subscriber
|
||||
// (`OtelTestEnv` and friends use thread-scoped `set_default` guards).
|
||||
#[test]
|
||||
fn without_dispatcher_inactive() {
|
||||
assert!(!dispatcher_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_dispatcher_active() {
|
||||
tracing::subscriber::with_default(tracing_subscriber::registry(), || {
|
||||
assert!(dispatcher_active());
|
||||
});
|
||||
assert!(!dispatcher_active());
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
use fastrace::prelude::*;
|
||||
use fastrace_opentelemetry::OpenTelemetryReporter;
|
||||
use opentelemetry::InstrumentationScope;
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry_otlp::WithExportConfig;
|
||||
use opentelemetry_otlp::{ExporterBuildError, SpanExporter};
|
||||
use opentelemetry_sdk::Resource;
|
||||
use std::borrow::Cow;
|
||||
use std::iter;
|
||||
|
||||
// Fastrace initialization
|
||||
pub fn init_fastrace(
|
||||
endpoint: String,
|
||||
name: String,
|
||||
resource_attributes: impl IntoIterator<Item = (String, String)>,
|
||||
) -> Result<(), ExporterBuildError> {
|
||||
let exporter = SpanExporter::builder()
|
||||
.with_tonic()
|
||||
.with_endpoint(endpoint)
|
||||
.with_protocol(opentelemetry_otlp::Protocol::Grpc)
|
||||
.with_timeout(opentelemetry_otlp::OTEL_EXPORTER_OTLP_TIMEOUT_DEFAULT)
|
||||
.build()?;
|
||||
let attributes = resource_attributes
|
||||
.into_iter()
|
||||
.chain(iter::once(("service.name".into(), name.clone())))
|
||||
.map(|(k, v)| KeyValue::new(k, v));
|
||||
let reporter = OpenTelemetryReporter::new(
|
||||
exporter,
|
||||
Cow::Owned(Resource::builder().with_attributes(attributes).build()),
|
||||
InstrumentationScope::builder(name)
|
||||
.with_version(env!("CARGO_PKG_VERSION"))
|
||||
.build(),
|
||||
);
|
||||
fastrace::set_reporter(reporter, fastrace::collector::Config::default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn current_trace_id() -> Option<String> {
|
||||
SpanContext::current_local_parent().map(|current| current.encode_w3c_traceparent())
|
||||
}
|
||||
|
||||
pub fn local_or_random_span_ctx() -> SpanContext {
|
||||
SpanContext::current_local_parent().unwrap_or_else(SpanContext::random)
|
||||
}
|
||||
|
||||
pub fn enter_span_with_traceparent(name: impl Into<Cow<'static, str>>, traceparent: &str) -> Span {
|
||||
if let Some(span_ctx) = SpanContext::decode_w3c_traceparent(traceparent) {
|
||||
Span::root(name, span_ctx)
|
||||
} else {
|
||||
Span::enter_with_local_parent(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Tonic channel (TODO: Move into grpc_client when deprecated tracing)
|
||||
#[allow(dead_code)]
|
||||
pub type FastraceChannel = fastrace_tonic::FastraceClientService<tonic::transport::Channel>;
|
||||
|
||||
pub fn fastrace_channel(
|
||||
channel: tonic::transport::Channel,
|
||||
) -> fastrace_tonic::FastraceClientService<tonic::transport::Channel> {
|
||||
tower::ServiceBuilder::new()
|
||||
.layer(fastrace_tonic::FastraceClientLayer)
|
||||
.service(channel)
|
||||
}
|
||||
|
||||
// Request middleware (TODO: Move into http_client when deprecated tracing)
|
||||
#[derive(Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TraceparentMiddleware;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl reqwest_middleware::Middleware for TraceparentMiddleware {
|
||||
async fn handle(
|
||||
&self,
|
||||
mut req: reqwest::Request,
|
||||
extensions: &mut http::Extensions,
|
||||
next: reqwest_middleware::Next<'_>,
|
||||
) -> reqwest_middleware::Result<reqwest::Response> {
|
||||
req.headers_mut()
|
||||
.extend(fastrace_reqwest::traceparent_headers());
|
||||
next.run(req, extensions).await
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
use http::{HeaderMap, Request};
|
||||
use opentelemetry::{global, propagation::Extractor, propagation::Injector};
|
||||
use std::task::{Context, Poll};
|
||||
use tonic::transport::Channel;
|
||||
use tonic::{
|
||||
Status,
|
||||
metadata::{MetadataKey, MetadataMap, MetadataValue},
|
||||
};
|
||||
use tower::{Layer, Service, ServiceBuilder};
|
||||
use tower_http::classify::{GrpcErrorsAsFailures, SharedClassifier};
|
||||
use tower_http::trace::{MakeSpan, Trace, TraceLayer};
|
||||
use tracing::{Span, warn};
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
pub type TracedChannel = Trace<
|
||||
InjectTraceContextService<Channel>,
|
||||
SharedClassifier<GrpcErrorsAsFailures>,
|
||||
MakeClientSpan,
|
||||
>;
|
||||
|
||||
/// Wraps the input channel with a tracing layer. This function can be used to create a traced gRPC
|
||||
/// client as follows:
|
||||
///
|
||||
/// ```rust
|
||||
/// use tonic::transport::Endpoint;
|
||||
/// use std::str::FromStr;
|
||||
/// use kigi_tracing::traced_channel;
|
||||
///
|
||||
/// let channel = Endpoint::from_str("http://foo").unwrap();
|
||||
/// //let client = SomeClient::new(traced_channel(channel));
|
||||
///```
|
||||
pub fn traced_channel(channel: Channel) -> TracedChannel {
|
||||
ServiceBuilder::new()
|
||||
.layer(TraceLayer::new_for_grpc().make_span_with(MakeClientSpan))
|
||||
.layer(InjectTraceContextLayer)
|
||||
.service(channel)
|
||||
}
|
||||
|
||||
/// Implements the [`MakeSpan`] trait, to trace outgoing gRPC requests.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MakeClientSpan;
|
||||
|
||||
impl<B> MakeSpan<B> for MakeClientSpan {
|
||||
fn make_span(&mut self, request: &Request<B>) -> Span {
|
||||
// No active dispatcher → the span has no consumer and would only be
|
||||
// downgraded to `log` spam. See `crate::dispatcher_active`.
|
||||
if !crate::dispatcher_active() {
|
||||
return Span::none();
|
||||
}
|
||||
tracing::info_span!(
|
||||
"grpc_request",
|
||||
otel.kind = "client",
|
||||
method = %request.method(),
|
||||
uri = %request.uri(),
|
||||
version = ?request.version(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct InjectTraceContextLayer;
|
||||
|
||||
impl<S> Layer<S> for InjectTraceContextLayer {
|
||||
type Service = InjectTraceContextService<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
InjectTraceContextService { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InjectTraceContextService<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S, B> Service<Request<B>> for InjectTraceContextService<S>
|
||||
where
|
||||
S: Service<Request<B>>,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = S::Future;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, mut req: Request<B>) -> Self::Future {
|
||||
crate::http_client::attach_trace_to_http_request(req.headers_mut());
|
||||
self.inner.call(req)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject W3C `traceparent` / `tracestate` from the active span into gRPC
|
||||
/// metadata. Mutates in place so callers never lose the request body.
|
||||
pub fn attach_trace_to_grpc_request_mut(metadata: &mut MetadataMap) {
|
||||
global::get_text_map_propagator(|propagator| {
|
||||
let context = Span::current().context();
|
||||
propagator.inject_context(&context, &mut MetadataInjector(metadata));
|
||||
});
|
||||
}
|
||||
|
||||
/// Trace context propagation: send the trace context by injecting it into the metadata of the given
|
||||
/// request.
|
||||
pub fn attach_trace_to_grpc_request<T>(
|
||||
mut request: tonic::Request<T>,
|
||||
) -> Result<tonic::Request<T>, Status> {
|
||||
attach_trace_to_grpc_request_mut(request.metadata_mut());
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
// Need a custom Injector to inject OTel headers
|
||||
pub struct MetadataInjector<'a>(&'a mut MetadataMap);
|
||||
|
||||
impl Injector for MetadataInjector<'_> {
|
||||
fn set(&mut self, key: &str, value: String) {
|
||||
match MetadataKey::from_bytes(key.as_bytes()) {
|
||||
Ok(key) => match MetadataValue::try_from(&value) {
|
||||
Ok(value) => {
|
||||
self.0.insert(key, value);
|
||||
}
|
||||
|
||||
Err(error) => warn!(value, error = format!("{error:#}"), "parse metadata value"),
|
||||
},
|
||||
|
||||
Err(error) => warn!(key, error = format!("{error:#}"), "parse metadata key"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HeaderExtractor<'a>(pub &'a HeaderMap);
|
||||
|
||||
impl Extractor for HeaderExtractor<'_> {
|
||||
fn get(&self, key: &str) -> Option<&str> {
|
||||
self.0.get(key).and_then(|v| {
|
||||
let s = v.to_str();
|
||||
if let Err(ref error) = s {
|
||||
warn!(%error, ?v, "cannot convert header value to ASCII")
|
||||
};
|
||||
s.ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn keys(&self) -> Vec<&str> {
|
||||
self.0.keys().map(|k| k.as_str()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::{OtelTestEnv, otel_span_id_hex, otel_trace_id_hex, parse_traceparent};
|
||||
use http_body_util::Empty;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tower_http::classify::GrpcFailureClass;
|
||||
use tracing::Instrument;
|
||||
|
||||
type EmptyBody = Empty<bytes::Bytes>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CaptureService {
|
||||
seen: Arc<Mutex<Option<HeaderMap>>>,
|
||||
response_grpc_status: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl CaptureService {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
seen: Arc::new(Mutex::new(None)),
|
||||
response_grpc_status: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_grpc_status(status: &'static str) -> Self {
|
||||
Self {
|
||||
seen: Arc::new(Mutex::new(None)),
|
||||
response_grpc_status: Some(status),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<B> Service<Request<B>> for CaptureService {
|
||||
type Response = http::Response<EmptyBody>;
|
||||
type Error = Infallible;
|
||||
type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
|
||||
|
||||
fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<B>) -> Self::Future {
|
||||
*self.seen.lock().unwrap() = Some(req.headers().clone());
|
||||
let mut builder = http::Response::builder().status(200);
|
||||
if let Some(status) = self.response_grpc_status {
|
||||
builder = builder.header("grpc-status", status);
|
||||
}
|
||||
std::future::ready(Ok(builder.body(Empty::new()).unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
fn post_req() -> Request<EmptyBody> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("http://svc/package.Service/Method")
|
||||
.body(Empty::new())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// With no dispatcher active, the client span must not be created —
|
||||
// `tracing`'s `log` compat would downgrade it into `grpc_request; ...`
|
||||
// log spam in processes that only configure a `log` logger.
|
||||
// See `crate::dispatcher_active`.
|
||||
#[test]
|
||||
fn make_client_span_without_dispatcher_is_none() {
|
||||
assert!(MakeClientSpan.make_span(&post_req()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_client_span_with_scoped_dispatcher_is_enabled() {
|
||||
let _env = OtelTestEnv::install();
|
||||
assert!(!MakeClientSpan.make_span(&post_req()).is_disabled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inject_under_trace_layer_uses_client_span_not_parent() {
|
||||
let _env = OtelTestEnv::install();
|
||||
|
||||
let capture = CaptureService::new();
|
||||
let seen = Arc::clone(&capture.seen);
|
||||
let mut svc = ServiceBuilder::new()
|
||||
.layer(TraceLayer::new_for_grpc().make_span_with(MakeClientSpan))
|
||||
.layer(InjectTraceContextLayer)
|
||||
.service(capture);
|
||||
|
||||
let parent = tracing::info_span!("parent_handler");
|
||||
let parent_span_id = otel_span_id_hex(&parent);
|
||||
let parent_trace_id = otel_trace_id_hex(&parent);
|
||||
assert_ne!(parent_span_id, "0000000000000000");
|
||||
|
||||
async {
|
||||
let fut = Service::call(&mut svc, post_req());
|
||||
fut.await.unwrap();
|
||||
}
|
||||
.instrument(parent)
|
||||
.await;
|
||||
|
||||
let headers = seen.lock().unwrap().clone().expect("headers");
|
||||
let tp = headers
|
||||
.get("traceparent")
|
||||
.expect("traceparent")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let (_ver, injected_trace_id, injected_span_id) = parse_traceparent(tp);
|
||||
|
||||
assert_eq!(injected_trace_id, parent_trace_id);
|
||||
assert_ne!(injected_span_id, parent_span_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inject_without_trace_layer_uses_parent_span() {
|
||||
let _env = OtelTestEnv::install();
|
||||
|
||||
let capture = CaptureService::new();
|
||||
let seen = Arc::clone(&capture.seen);
|
||||
let mut svc = ServiceBuilder::new()
|
||||
.layer(InjectTraceContextLayer)
|
||||
.service(capture);
|
||||
|
||||
let parent = tracing::info_span!("parent_handler");
|
||||
let parent_span_id = otel_span_id_hex(&parent);
|
||||
let parent_trace_id = otel_trace_id_hex(&parent);
|
||||
|
||||
async {
|
||||
let fut = Service::call(&mut svc, post_req());
|
||||
fut.await.unwrap();
|
||||
}
|
||||
.instrument(parent)
|
||||
.await;
|
||||
|
||||
let headers = seen.lock().unwrap().clone().expect("headers captured");
|
||||
let tp = headers.get("traceparent").unwrap().to_str().unwrap();
|
||||
let (_ver, injected_trace_id, injected_span_id) = parse_traceparent(tp);
|
||||
|
||||
assert_eq!(injected_trace_id, parent_trace_id);
|
||||
assert_eq!(injected_span_id, parent_span_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grpc_status_non_ok_invokes_on_failure_classifier() {
|
||||
let _env = OtelTestEnv::install();
|
||||
|
||||
let failures: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let failures_cb = Arc::clone(&failures);
|
||||
|
||||
let capture = CaptureService::with_grpc_status("13");
|
||||
let mut svc = ServiceBuilder::new()
|
||||
.layer(
|
||||
TraceLayer::new_for_grpc()
|
||||
.make_span_with(MakeClientSpan)
|
||||
.on_failure(
|
||||
move |class: GrpcFailureClass,
|
||||
_latency: Duration,
|
||||
_span: &tracing::Span| {
|
||||
failures_cb.lock().unwrap().push(class.to_string());
|
||||
},
|
||||
),
|
||||
)
|
||||
.layer(InjectTraceContextLayer)
|
||||
.service(capture);
|
||||
|
||||
let fut = Service::call(&mut svc, post_req());
|
||||
let _ = fut.await.unwrap();
|
||||
|
||||
let recorded = failures.lock().unwrap().clone();
|
||||
assert_eq!(recorded.len(), 1, "{recorded:?}");
|
||||
assert!(
|
||||
recorded[0].contains("13") || recorded[0].to_lowercase().contains("code"),
|
||||
"{}",
|
||||
recorded[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grpc_status_ok_does_not_invoke_on_failure() {
|
||||
let _env = OtelTestEnv::install();
|
||||
|
||||
let failures: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let failures_cb = Arc::clone(&failures);
|
||||
|
||||
let capture = CaptureService::with_grpc_status("0");
|
||||
let mut svc = ServiceBuilder::new()
|
||||
.layer(
|
||||
TraceLayer::new_for_grpc()
|
||||
.make_span_with(MakeClientSpan)
|
||||
.on_failure(
|
||||
move |class: GrpcFailureClass,
|
||||
_latency: Duration,
|
||||
_span: &tracing::Span| {
|
||||
failures_cb.lock().unwrap().push(class.to_string());
|
||||
},
|
||||
),
|
||||
)
|
||||
.layer(InjectTraceContextLayer)
|
||||
.service(capture);
|
||||
|
||||
let fut = Service::call(&mut svc, post_req());
|
||||
let _ = fut.await.unwrap();
|
||||
|
||||
assert!(failures.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_trace_to_grpc_request_sets_traceparent_metadata() {
|
||||
let _env = OtelTestEnv::install();
|
||||
let span = tracing::info_span!("handler");
|
||||
let span_id = otel_span_id_hex(&span);
|
||||
let _enter = span.enter();
|
||||
|
||||
let req = attach_trace_to_grpc_request(tonic::Request::new(())).unwrap();
|
||||
let tp = req
|
||||
.metadata()
|
||||
.get("traceparent")
|
||||
.expect("traceparent in metadata")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let (_ver, _tid, injected_span_id) = parse_traceparent(tp);
|
||||
assert_eq!(injected_span_id, span_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn make_client_span_records_otel_kind_client() {
|
||||
let env = OtelTestEnv::install();
|
||||
{
|
||||
let req = post_req();
|
||||
let mut make = MakeClientSpan;
|
||||
let span = make.make_span(&req);
|
||||
let _e = span.enter();
|
||||
}
|
||||
let spans = env.finished_spans();
|
||||
let grpc = spans
|
||||
.iter()
|
||||
.find(|s| s.name == "grpc_request")
|
||||
.expect("grpc_request");
|
||||
assert_eq!(grpc.span_kind, opentelemetry::trace::SpanKind::Client);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use opentelemetry::global;
|
||||
use opentelemetry_http::HeaderInjector;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, Middleware};
|
||||
use tracing::{Instrument, Span, field};
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
pub fn attach_trace_to_http_request(headers: &mut HeaderMap) {
|
||||
global::get_text_map_propagator(|propagator| {
|
||||
let context = Span::current().context();
|
||||
propagator.inject_context(&context, &mut HeaderInjector(headers));
|
||||
});
|
||||
}
|
||||
|
||||
pub type TracedHttpClient = ClientWithMiddleware;
|
||||
|
||||
pub fn traced_client(client: reqwest::Client) -> TracedHttpClient {
|
||||
ClientBuilder::new(client).with(TracingMiddleware).build()
|
||||
}
|
||||
|
||||
pub fn traced_client_new() -> TracedHttpClient {
|
||||
traced_client(reqwest::Client::new())
|
||||
}
|
||||
|
||||
pub fn traced_client_from_builder(
|
||||
builder: reqwest::ClientBuilder,
|
||||
) -> Result<TracedHttpClient, reqwest::Error> {
|
||||
Ok(traced_client(builder.build()?))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct TracingMiddleware;
|
||||
|
||||
#[async_trait]
|
||||
impl Middleware for TracingMiddleware {
|
||||
async fn handle(
|
||||
&self,
|
||||
mut req: reqwest::Request,
|
||||
extensions: &mut http::Extensions,
|
||||
next: reqwest_middleware::Next<'_>,
|
||||
) -> reqwest_middleware::Result<reqwest::Response> {
|
||||
let method = req.method().as_str().to_owned();
|
||||
let url = req.url().clone();
|
||||
// No active dispatcher → the span has no consumer and would only be
|
||||
// downgraded to `log` spam. See `crate::dispatcher_active`.
|
||||
let span = if crate::dispatcher_active() {
|
||||
tracing::info_span!(
|
||||
"http_request",
|
||||
otel.kind = "client",
|
||||
"http.request.method" = %method,
|
||||
"url.full" = %url,
|
||||
"http.response.status_code" = field::Empty,
|
||||
)
|
||||
} else {
|
||||
Span::none()
|
||||
};
|
||||
|
||||
let result = async move {
|
||||
attach_trace_to_http_request(req.headers_mut());
|
||||
next.run(req, extensions).await
|
||||
}
|
||||
.instrument(span.clone())
|
||||
.await;
|
||||
|
||||
if let Ok(ref response) = result {
|
||||
span.record("http.response.status_code", response.status().as_u16());
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::{OtelTestEnv, otel_span_id_hex, otel_trace_id_hex, parse_traceparent};
|
||||
use tracing::Instrument;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_trace_to_http_request_writes_traceparent() {
|
||||
let _env = OtelTestEnv::install();
|
||||
let span = tracing::info_span!("http_request", otel.kind = "client");
|
||||
let span_id = otel_span_id_hex(&span);
|
||||
let mut headers = HeaderMap::new();
|
||||
let _enter = span.enter();
|
||||
attach_trace_to_http_request(&mut headers);
|
||||
let tp = headers.get("traceparent").unwrap().to_str().unwrap();
|
||||
let (_ver, _tid, injected_span_id) = parse_traceparent(tp);
|
||||
assert_eq!(injected_span_id, span_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn traced_client_injects_client_span_not_parent_on_wire() {
|
||||
let _env = OtelTestEnv::install();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = traced_client(reqwest::Client::new());
|
||||
let parent = tracing::info_span!("parent_handler");
|
||||
let parent_span_id = otel_span_id_hex(&parent);
|
||||
let parent_trace_id = otel_trace_id_hex(&parent);
|
||||
|
||||
async {
|
||||
client
|
||||
.get(format!("{}/health", server.uri()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
.instrument(parent)
|
||||
.await;
|
||||
|
||||
let received = server.received_requests().await.unwrap();
|
||||
assert_eq!(received.len(), 1);
|
||||
let tp = received[0]
|
||||
.headers
|
||||
.get("traceparent")
|
||||
.expect("traceparent on wire")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
let (_ver, injected_trace_id, injected_span_id) = parse_traceparent(tp);
|
||||
|
||||
assert_eq!(injected_trace_id, parent_trace_id);
|
||||
assert_ne!(injected_span_id, parent_span_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn traced_client_returns_response_status() {
|
||||
let _env = OtelTestEnv::install();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = traced_client_new();
|
||||
let resp = client
|
||||
.get(format!("{}/missing", server.uri()))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 404);
|
||||
assert!(
|
||||
server.received_requests().await.unwrap()[0]
|
||||
.headers
|
||||
.get("traceparent")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
mod dispatch;
|
||||
mod grpc_client;
|
||||
mod timer;
|
||||
|
||||
pub mod fastrace;
|
||||
pub mod http_client;
|
||||
pub mod tokio;
|
||||
|
||||
#[cfg(test)]
|
||||
mod testing;
|
||||
|
||||
pub use dispatch::*;
|
||||
pub use fastrace::*;
|
||||
pub use grpc_client::*;
|
||||
pub use http_client::{
|
||||
TracedHttpClient, attach_trace_to_http_request, traced_client, traced_client_from_builder,
|
||||
traced_client_new,
|
||||
};
|
||||
pub use timer::*;
|
||||
@@ -1,65 +0,0 @@
|
||||
use opentelemetry::global;
|
||||
use opentelemetry::trace::{SpanContext, TraceContextExt, TracerProvider as _};
|
||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||
use opentelemetry_sdk::trace::{
|
||||
InMemorySpanExporter, InMemorySpanExporterBuilder, SdkTracerProvider, SimpleSpanProcessor,
|
||||
};
|
||||
use tracing::Span;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
pub struct OtelTestEnv {
|
||||
_guard: tracing::subscriber::DefaultGuard,
|
||||
provider: SdkTracerProvider,
|
||||
exporter: InMemorySpanExporter,
|
||||
}
|
||||
|
||||
impl OtelTestEnv {
|
||||
pub fn install() -> Self {
|
||||
global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
let exporter = InMemorySpanExporterBuilder::new().build();
|
||||
let provider = SdkTracerProvider::builder()
|
||||
.with_span_processor(SimpleSpanProcessor::new(exporter.clone()))
|
||||
.build();
|
||||
let tracer = provider.tracer("kigi-tracing-test");
|
||||
let otel_layer = tracing_opentelemetry::layer()
|
||||
.with_tracer(tracer)
|
||||
.with_context_activation(false)
|
||||
.with_filter(tracing_subscriber::filter::LevelFilter::INFO);
|
||||
let guard = tracing_subscriber::registry()
|
||||
.with(otel_layer)
|
||||
.set_default();
|
||||
Self {
|
||||
_guard: guard,
|
||||
provider,
|
||||
exporter,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finished_spans(&self) -> Vec<opentelemetry_sdk::trace::SpanData> {
|
||||
let _ = self.provider.force_flush();
|
||||
self.exporter.get_finished_spans().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_traceparent(value: &str) -> (&str, &str, &str) {
|
||||
let mut parts = value.split('-');
|
||||
let version = parts.next().expect("version");
|
||||
let trace_id = parts.next().expect("trace_id");
|
||||
let span_id = parts.next().expect("span_id");
|
||||
(version, trace_id, span_id)
|
||||
}
|
||||
|
||||
pub fn otel_span_id_hex(span: &Span) -> String {
|
||||
let cx = span.context();
|
||||
let span_ref = cx.span();
|
||||
let sc: &SpanContext = span_ref.span_context();
|
||||
format!("{:016x}", u64::from_be_bytes(sc.span_id().to_bytes()))
|
||||
}
|
||||
|
||||
pub fn otel_trace_id_hex(span: &Span) -> String {
|
||||
let cx = span.context();
|
||||
let span_ref = cx.span();
|
||||
let sc: &SpanContext = span_ref.span_context();
|
||||
format!("{:032x}", u128::from_be_bytes(sc.trace_id().to_bytes()))
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
use log::error;
|
||||
use log::info;
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// A simple timer that logs the runtime of an operation.
|
||||
pub struct Timer {
|
||||
/// Time the operation started.
|
||||
start: Instant,
|
||||
/// An ID shown in the logs to associate the log messages from the timer with each other-
|
||||
id: uuid::Uuid,
|
||||
/// A string that is being logged.
|
||||
message: String,
|
||||
/// True if the timer has been stopped already.
|
||||
stopped: bool,
|
||||
}
|
||||
|
||||
impl Timer {
|
||||
/// Creates a new Timer instance and starts the timer.
|
||||
pub fn new<S: AsRef<str>>(message: S) -> Self {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
info!("[{}] START: {}", id, message.as_ref());
|
||||
Self {
|
||||
start: Instant::now(),
|
||||
id,
|
||||
message: message.as_ref().to_string(),
|
||||
stopped: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the timer and logs the result.
|
||||
pub fn stop<T>(&mut self, result: T) -> T {
|
||||
if !self.stopped {
|
||||
let runtime = self.start.elapsed().as_secs_f32();
|
||||
info!(
|
||||
"[{}] FINISHED in {:.3}s: {}",
|
||||
self.id, runtime, self.message
|
||||
);
|
||||
self.stopped = true;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Stop the timer prematurely and logs an error.
|
||||
pub fn force_stop(&mut self) {
|
||||
if !self.stopped {
|
||||
let runtime = self.start.elapsed().as_secs_f32();
|
||||
error!(
|
||||
"[{}] FAILED after {:.3}s: {}",
|
||||
self.id, runtime, self.message
|
||||
);
|
||||
self.stopped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Automatically report the runtime when the object is dropped.
|
||||
impl Drop for Timer {
|
||||
fn drop(&mut self) {
|
||||
self.force_stop();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
use std::future::Future;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{Instrument, Span};
|
||||
|
||||
/// Utility macro for propagating the current tracing context to a newly spawned task.
|
||||
///
|
||||
/// Note: The spawned task will be associated with the currently active span. To create a *new*
|
||||
/// span for the spawned task, manually instrument the future using [tracing::Instrument] instead of
|
||||
/// using this macro. For example:
|
||||
///
|
||||
/// use tracing::{info_span, Instrument};
|
||||
///
|
||||
/// let fut = tokio::spawn(async move {
|
||||
/// print!("do stuff")
|
||||
/// }.instrument(info_span!("spawned task")));
|
||||
pub fn spawn_traced<F>(future: F) -> JoinHandle<F::Output>
|
||||
where
|
||||
F: Future + Send + 'static,
|
||||
F::Output: Send + 'static,
|
||||
{
|
||||
tokio::spawn(future.instrument(Span::current()))
|
||||
}
|
||||
Reference in New Issue
Block a user