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