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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user