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:
Generated
+6
-396
@@ -1177,7 +1177,7 @@ dependencies = [
|
|||||||
"sha1 0.10.7",
|
"sha1 0.10.7",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -1912,18 +1912,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "const-hex"
|
|
||||||
version = "1.19.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures 0.2.17",
|
|
||||||
"proptest",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -3110,16 +3098,6 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastant"
|
|
||||||
version = "0.1.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2e825441bfb2d831c47c97d05821552db8832479f44c571b97fededbf0099c07"
|
|
||||||
dependencies = [
|
|
||||||
"small_ctor",
|
|
||||||
"web-time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "faster-hex"
|
name = "faster-hex"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -3130,67 +3108,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastrace"
|
|
||||||
version = "0.7.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "74539685f6baa3c33251571272eac7939fc3b93dff97c30381d7a6267394e528"
|
|
||||||
dependencies = [
|
|
||||||
"fastant",
|
|
||||||
"fastrace-macro",
|
|
||||||
"parking_lot",
|
|
||||||
"pin-project",
|
|
||||||
"rand 0.10.2",
|
|
||||||
"rtrb",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastrace-macro"
|
|
||||||
version = "0.7.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "390d4f754b751905fa9294716c4ead717ffb5fa733a0c2417358b7779fceaeed"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastrace-opentelemetry"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67d87b05a82ea59d6d2a11066f4e0cc1e169ce50bc8c276279249ba374936d91"
|
|
||||||
dependencies = [
|
|
||||||
"fastrace",
|
|
||||||
"log",
|
|
||||||
"opentelemetry",
|
|
||||||
"opentelemetry_sdk",
|
|
||||||
"pollster",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastrace-reqwest"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c4705b6c98308ef10f178dadc516a460ca9191c0e1c6e07c73104c9679e82d4e"
|
|
||||||
dependencies = [
|
|
||||||
"fastrace",
|
|
||||||
"reqwest 0.12.28",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastrace-tonic"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9bfe91596cd82e4b4fbd390f0e789fe3cf327b635fa713347f1509db97aa482c"
|
|
||||||
dependencies = [
|
|
||||||
"fastrace",
|
|
||||||
"http 1.4.2",
|
|
||||||
"tower-layer",
|
|
||||||
"tower-service",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -4686,16 +4603,6 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bab0c77d09c0d9b144d429d0bf0fcd4c63c01d5f6c33f9b8ed501283e0f1ef76"
|
checksum = "bab0c77d09c0d9b144d429d0bf0fcd4c63c01d5f6c33f9b8ed501283e0f1ef76"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hdrhistogram"
|
|
||||||
version = "7.5.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heapless"
|
name = "heapless"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -4927,7 +4834,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots 1.0.8",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5700,81 +5607,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kigi-computer-hub-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"chrono",
|
|
||||||
"dashmap",
|
|
||||||
"futures",
|
|
||||||
"kigi-tool-protocol",
|
|
||||||
"kigi-tool-runtime",
|
|
||||||
"kigi-tool-types",
|
|
||||||
"schemars 1.2.1",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kigi-computer-hub-mcp-adapter"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"futures",
|
|
||||||
"kigi-computer-hub-sdk",
|
|
||||||
"kigi-tool-protocol",
|
|
||||||
"kigi-tool-runtime",
|
|
||||||
"kigi-tool-types",
|
|
||||||
"prometheus",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kigi-computer-hub-sdk"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"arc-swap",
|
|
||||||
"async-trait",
|
|
||||||
"axum",
|
|
||||||
"base64",
|
|
||||||
"chrono",
|
|
||||||
"dashmap",
|
|
||||||
"fastrace",
|
|
||||||
"fastrace-opentelemetry",
|
|
||||||
"futures",
|
|
||||||
"http 1.4.2",
|
|
||||||
"indexmap",
|
|
||||||
"kigi-computer-hub-core",
|
|
||||||
"kigi-tool-protocol",
|
|
||||||
"kigi-tool-runtime",
|
|
||||||
"kigi-tool-types",
|
|
||||||
"kigi-tracing",
|
|
||||||
"opentelemetry",
|
|
||||||
"opentelemetry-proto",
|
|
||||||
"opentelemetry_sdk",
|
|
||||||
"parking_lot",
|
|
||||||
"prometheus",
|
|
||||||
"prost",
|
|
||||||
"reqwest 0.12.28",
|
|
||||||
"schemars 1.2.1",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tokio-tungstenite 0.27.0",
|
|
||||||
"tokio-util",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"url",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config"
|
name = "kigi-config"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -6046,7 +5878,6 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"futures",
|
"futures",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
"kigi-computer-hub-sdk",
|
|
||||||
"kigi-config",
|
"kigi-config",
|
||||||
"kigi-file-utils",
|
"kigi-file-utils",
|
||||||
"kigi-log",
|
"kigi-log",
|
||||||
@@ -6440,7 +6271,6 @@ dependencies = [
|
|||||||
"kigi-chat-state",
|
"kigi-chat-state",
|
||||||
"kigi-codebase-graph",
|
"kigi-codebase-graph",
|
||||||
"kigi-compaction",
|
"kigi-compaction",
|
||||||
"kigi-computer-hub-sdk",
|
|
||||||
"kigi-config",
|
"kigi-config",
|
||||||
"kigi-config-types",
|
"kigi-config-types",
|
||||||
"kigi-env",
|
"kigi-env",
|
||||||
@@ -6516,7 +6346,6 @@ dependencies = [
|
|||||||
"tokio-retry",
|
"tokio-retry",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tokio-tungstenite 0.27.0",
|
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"toml",
|
"toml",
|
||||||
"toml_edit 0.22.27",
|
"toml_edit 0.22.27",
|
||||||
@@ -6632,7 +6461,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.27.0",
|
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -6669,9 +6497,10 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
|
"indexmap",
|
||||||
"kigi-tool-protocol",
|
"kigi-tool-protocol",
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
"kigi-tools-api",
|
"parking_lot",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6717,8 +6546,6 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"infer 0.19.0",
|
"infer 0.19.0",
|
||||||
"kigi-computer-hub-core",
|
|
||||||
"kigi-computer-hub-sdk",
|
|
||||||
"kigi-config",
|
"kigi-config",
|
||||||
"kigi-env",
|
"kigi-env",
|
||||||
"kigi-file-utils",
|
"kigi-file-utils",
|
||||||
@@ -6781,36 +6608,6 @@ dependencies = [
|
|||||||
"tonic-prost",
|
"tonic-prost",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kigi-tracing"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"bytes",
|
|
||||||
"fastrace",
|
|
||||||
"fastrace-opentelemetry",
|
|
||||||
"fastrace-reqwest",
|
|
||||||
"fastrace-tonic",
|
|
||||||
"http 1.4.2",
|
|
||||||
"http-body-util",
|
|
||||||
"log",
|
|
||||||
"opentelemetry",
|
|
||||||
"opentelemetry-http",
|
|
||||||
"opentelemetry-otlp",
|
|
||||||
"opentelemetry_sdk",
|
|
||||||
"reqwest 0.12.28",
|
|
||||||
"reqwest-middleware",
|
|
||||||
"tokio",
|
|
||||||
"tonic",
|
|
||||||
"tower",
|
|
||||||
"tower-http",
|
|
||||||
"tracing",
|
|
||||||
"tracing-opentelemetry",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"uuid",
|
|
||||||
"wiremock",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tracing-macros"
|
name = "kigi-tracing-macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -6953,17 +6750,13 @@ dependencies = [
|
|||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
|
||||||
"base64",
|
"base64",
|
||||||
"bstr",
|
"bstr",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
"dunce",
|
"dunce",
|
||||||
"fastrace",
|
|
||||||
"filetime",
|
"filetime",
|
||||||
"flate2",
|
|
||||||
"fs2",
|
"fs2",
|
||||||
"futures",
|
"futures",
|
||||||
"git2",
|
"git2",
|
||||||
@@ -6977,8 +6770,6 @@ dependencies = [
|
|||||||
"kigi-agent",
|
"kigi-agent",
|
||||||
"kigi-auth",
|
"kigi-auth",
|
||||||
"kigi-codebase-graph",
|
"kigi-codebase-graph",
|
||||||
"kigi-computer-hub-mcp-adapter",
|
|
||||||
"kigi-computer-hub-sdk",
|
|
||||||
"kigi-config",
|
"kigi-config",
|
||||||
"kigi-config-types",
|
"kigi-config-types",
|
||||||
"kigi-env",
|
"kigi-env",
|
||||||
@@ -6998,10 +6789,8 @@ dependencies = [
|
|||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
"kigi-tools",
|
"kigi-tools",
|
||||||
"kigi-tools-api",
|
"kigi-tools-api",
|
||||||
"kigi-tracing",
|
|
||||||
"kigi-tty-utils",
|
"kigi-tty-utils",
|
||||||
"kigi-version",
|
"kigi-version",
|
||||||
"kigi-workspace-client",
|
|
||||||
"kigi-workspace-types",
|
"kigi-workspace-types",
|
||||||
"libc",
|
"libc",
|
||||||
"md5",
|
"md5",
|
||||||
@@ -7010,22 +6799,17 @@ dependencies = [
|
|||||||
"parking_lot",
|
"parking_lot",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tar",
|
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.27.0",
|
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"toml",
|
"toml",
|
||||||
"tonic",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
@@ -7033,27 +6817,9 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows 0.61.3",
|
|
||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kigi-workspace-client"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"kigi-computer-hub-sdk",
|
|
||||||
"kigi-tool-protocol",
|
|
||||||
"kigi-tool-runtime",
|
|
||||||
"kigi-tool-types",
|
|
||||||
"kigi-workspace-types",
|
|
||||||
"schemars 1.2.1",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace-types"
|
name = "kigi-workspace-types"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -8184,54 +7950,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opentelemetry-http"
|
|
||||||
version = "0.32.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"bytes",
|
|
||||||
"http 1.4.2",
|
|
||||||
"opentelemetry",
|
|
||||||
"reqwest 0.13.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opentelemetry-otlp"
|
|
||||||
version = "0.32.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35"
|
|
||||||
dependencies = [
|
|
||||||
"http 1.4.2",
|
|
||||||
"opentelemetry",
|
|
||||||
"opentelemetry-http",
|
|
||||||
"opentelemetry-proto",
|
|
||||||
"opentelemetry_sdk",
|
|
||||||
"prost",
|
|
||||||
"reqwest 0.13.4",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
|
||||||
"tonic",
|
|
||||||
"tonic-types",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opentelemetry-proto"
|
|
||||||
version = "0.32.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"const-hex",
|
|
||||||
"opentelemetry",
|
|
||||||
"opentelemetry_sdk",
|
|
||||||
"prost",
|
|
||||||
"serde",
|
|
||||||
"tonic",
|
|
||||||
"tonic-prost",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry_sdk"
|
name = "opentelemetry_sdk"
|
||||||
version = "0.32.1"
|
version = "0.32.1"
|
||||||
@@ -8246,7 +7964,6 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"rand 0.9.5",
|
"rand 0.9.5",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8859,12 +8576,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pollster"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polycool"
|
name = "polycool"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -9070,21 +8781,6 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proptest"
|
|
||||||
version = "1.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"num-traits",
|
|
||||||
"rand 0.9.5",
|
|
||||||
"rand_chacha 0.9.0",
|
|
||||||
"rand_xorshift",
|
|
||||||
"regex-syntax",
|
|
||||||
"unarray",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prost"
|
name = "prost"
|
||||||
version = "0.14.4"
|
version = "0.14.4"
|
||||||
@@ -9421,15 +9117,6 @@ dependencies = [
|
|||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_xorshift"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core 0.9.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rapidhash"
|
name = "rapidhash"
|
||||||
version = "4.5.1"
|
version = "4.5.1"
|
||||||
@@ -9673,7 +9360,7 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams 0.4.2",
|
"wasm-streams 0.4.2",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots 1.0.8",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9872,12 +9559,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rtrb"
|
|
||||||
version = "0.3.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "runfiles"
|
name = "runfiles"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -10852,12 +10533,6 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "small_ctor"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "88414a5ca1f85d82cc34471e975f0f74f6aa54c40f062efa42c0080e7f763f81"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.2"
|
version = "1.15.2"
|
||||||
@@ -11650,23 +11325,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-tungstenite"
|
|
||||||
version = "0.27.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"log",
|
|
||||||
"rustls",
|
|
||||||
"rustls-native-certs",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tungstenite 0.27.0",
|
|
||||||
"webpki-roots 0.26.11",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -11676,7 +11334,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tungstenite 0.29.0",
|
"tungstenite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -11803,7 +11461,6 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"rustls-native-certs",
|
|
||||||
"socket2",
|
"socket2",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -11855,17 +11512,6 @@ dependencies = [
|
|||||||
"tonic-build",
|
"tonic-build",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tonic-types"
|
|
||||||
version = "0.14.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8"
|
|
||||||
dependencies = [
|
|
||||||
"prost",
|
|
||||||
"prost-types",
|
|
||||||
"tonic",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -11874,7 +11520,6 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hdrhistogram",
|
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
@@ -11906,7 +11551,6 @@ dependencies = [
|
|||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -12154,25 +11798,6 @@ dependencies = [
|
|||||||
"ratatui-core",
|
"ratatui-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tungstenite"
|
|
||||||
version = "0.27.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"data-encoding",
|
|
||||||
"http 1.4.2",
|
|
||||||
"httparse",
|
|
||||||
"log",
|
|
||||||
"rand 0.9.5",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"sha1 0.10.7",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"utf-8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -12276,12 +11901,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unarray"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
@@ -12834,15 +12453,6 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webpki-roots"
|
|
||||||
version = "0.26.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
|
||||||
dependencies = [
|
|
||||||
"webpki-roots 1.0.8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
|
|||||||
-20
@@ -52,7 +52,6 @@ members = [
|
|||||||
"crates/codegen/kigi-update",
|
"crates/codegen/kigi-update",
|
||||||
"crates/codegen/kigi-version",
|
"crates/codegen/kigi-version",
|
||||||
"crates/codegen/kigi-workspace",
|
"crates/codegen/kigi-workspace",
|
||||||
"crates/codegen/kigi-workspace-client",
|
|
||||||
"crates/codegen/kigi-workspace-types",
|
"crates/codegen/kigi-workspace-types",
|
||||||
"crates/codegen/kigi-hooks-plugins-types",
|
"crates/codegen/kigi-hooks-plugins-types",
|
||||||
"crates/codegen/kigi-hunk-tracker",
|
"crates/codegen/kigi-hunk-tracker",
|
||||||
@@ -65,16 +64,12 @@ members = [
|
|||||||
"crates/codegen/kigi-token-estimation",
|
"crates/codegen/kigi-token-estimation",
|
||||||
"crates/codegen/kigi-tracing-macros",
|
"crates/codegen/kigi-tracing-macros",
|
||||||
"crates/codegen/kigi-tty-utils",
|
"crates/codegen/kigi-tty-utils",
|
||||||
"crates/common/kigi-computer-hub-core",
|
|
||||||
"crates/common/kigi-computer-hub-mcp-adapter",
|
|
||||||
"crates/common/kigi-computer-hub-sdk",
|
|
||||||
"crates/common/kigi-compaction",
|
"crates/common/kigi-compaction",
|
||||||
"crates/common/kigi-interjection-core",
|
"crates/common/kigi-interjection-core",
|
||||||
"crates/common/kigi-test-utils",
|
"crates/common/kigi-test-utils",
|
||||||
"crates/common/kigi-tool-protocol",
|
"crates/common/kigi-tool-protocol",
|
||||||
"crates/common/kigi-tool-runtime",
|
"crates/common/kigi-tool-runtime",
|
||||||
"crates/common/kigi-tool-types",
|
"crates/common/kigi-tool-types",
|
||||||
"crates/common/kigi-tracing",
|
|
||||||
"third_party/dagre_rust",
|
"third_party/dagre_rust",
|
||||||
"third_party/graphlib_rust",
|
"third_party/graphlib_rust",
|
||||||
"third_party/mermaid-to-svg",
|
"third_party/mermaid-to-svg",
|
||||||
@@ -130,10 +125,6 @@ educe = "0.6.0"
|
|||||||
enum_delegate = "0.2"
|
enum_delegate = "0.2"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
eventsource-stream = "0.2"
|
eventsource-stream = "0.2"
|
||||||
fastrace = { version = "0.7" }
|
|
||||||
fastrace-opentelemetry = { version = "0.18" }
|
|
||||||
fastrace-reqwest = { version = "0.2" }
|
|
||||||
fastrace-tonic = { version = "0.1" }
|
|
||||||
fastrand = "2"
|
fastrand = "2"
|
||||||
filetime = "0.2.25"
|
filetime = "0.2.25"
|
||||||
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }
|
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }
|
||||||
@@ -178,9 +169,6 @@ oauth2 = "5"
|
|||||||
obfstr = "0.4"
|
obfstr = "0.4"
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
opentelemetry = "0.32"
|
opentelemetry = "0.32"
|
||||||
opentelemetry-http = { version = "0.32", features = ["reqwest-blocking"] }
|
|
||||||
opentelemetry-otlp = { version = "0.32", features = ["grpc-tonic", "reqwest-blocking-client", "tls-roots"] }
|
|
||||||
opentelemetry-proto = { version = "0.32", features = ["gen-tonic"] }
|
|
||||||
opentelemetry_sdk = { version = "0.32.1", features = ["spec_unstable_metrics_views"] }
|
opentelemetry_sdk = { version = "0.32.1", features = ["spec_unstable_metrics_views"] }
|
||||||
parking_lot = "0.12.4"
|
parking_lot = "0.12.4"
|
||||||
pbjson-build = "0.9"
|
pbjson-build = "0.9"
|
||||||
@@ -239,7 +227,6 @@ tiny-skia = "0.12"
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-retry = "0.3"
|
tokio-retry = "0.3"
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
tokio-tungstenite = "0.27"
|
|
||||||
tokio-util = { version = "0.7", features = ["rt"] }
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
toml = "0.9"
|
toml = "0.9"
|
||||||
toml_edit = "0.22"
|
toml_edit = "0.22"
|
||||||
@@ -269,8 +256,6 @@ wiremock = "0.6"
|
|||||||
wl-clipboard-rs = "0.9"
|
wl-clipboard-rs = "0.9"
|
||||||
kigi-acp-lib = { path = "crates/codegen/kigi-acp-lib" }
|
kigi-acp-lib = { path = "crates/codegen/kigi-acp-lib" }
|
||||||
kigi-agent-lifecycle = { path = "crates/codegen/kigi-agent-lifecycle" }
|
kigi-agent-lifecycle = { path = "crates/codegen/kigi-agent-lifecycle" }
|
||||||
kigi-computer-hub-core = { path = "crates/common/kigi-computer-hub-core" }
|
|
||||||
kigi-computer-hub-sdk = { path = "crates/common/kigi-computer-hub-sdk" }
|
|
||||||
kigi-gix-status = { path = "crates/codegen/kigi-gix-status" }
|
kigi-gix-status = { path = "crates/codegen/kigi-gix-status" }
|
||||||
kigi-agent = { path = "crates/codegen/kigi-agent" }
|
kigi-agent = { path = "crates/codegen/kigi-agent" }
|
||||||
kigi-auth = { path = "crates/codegen/kigi-auth" }
|
kigi-auth = { path = "crates/codegen/kigi-auth" }
|
||||||
@@ -310,7 +295,6 @@ kigi-token-estimation = { path = "crates/codegen/kigi-token-estimation" }
|
|||||||
kigi-tool-protocol = { path = "crates/common/kigi-tool-protocol" }
|
kigi-tool-protocol = { path = "crates/common/kigi-tool-protocol" }
|
||||||
kigi-tool-runtime = { path = "crates/common/kigi-tool-runtime" }
|
kigi-tool-runtime = { path = "crates/common/kigi-tool-runtime" }
|
||||||
kigi-tool-types = { path = "crates/common/kigi-tool-types" }
|
kigi-tool-types = { path = "crates/common/kigi-tool-types" }
|
||||||
kigi-tracing = { path = "crates/common/kigi-tracing" }
|
|
||||||
kigi-tty-utils = { path = "crates/codegen/kigi-tty-utils" }
|
kigi-tty-utils = { path = "crates/codegen/kigi-tty-utils" }
|
||||||
zbus = { version = "5" }
|
zbus = { version = "5" }
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
@@ -388,7 +372,3 @@ too_many_arguments = "allow"
|
|||||||
# TODO: -> "deny" once/if merge queue enabled
|
# TODO: -> "deny" once/if merge queue enabled
|
||||||
uninlined_format_args = "allow"
|
uninlined_format_args = "allow"
|
||||||
|
|
||||||
# The `fastrace::trace(properties = { … })` proc-macro expands `"{param}"`
|
|
||||||
# into `format!("{}", param)`, which clippy flags as useless_format.
|
|
||||||
# Suppressed until the upstream crate fixes its codegen (fixed in 0.7.16+).
|
|
||||||
useless_format = "allow"
|
|
||||||
|
|||||||
@@ -1028,7 +1028,6 @@ impl AgentBuilder {
|
|||||||
video_gen_config: self.video_gen_config,
|
video_gen_config: self.video_gen_config,
|
||||||
app_builder_deployer_config: self.app_builder_deployer_config,
|
app_builder_deployer_config: self.app_builder_deployer_config,
|
||||||
api_key_provider: self.api_key_provider,
|
api_key_provider: self.api_key_provider,
|
||||||
auth_provider: None,
|
|
||||||
attribution_callback: self.attribution_callback,
|
attribution_callback: self.attribution_callback,
|
||||||
system_reminder_tag: self.system_reminder_tag,
|
system_reminder_tag: self.system_reminder_tag,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ use kigi_tui::app::{
|
|||||||
AgentCmd, Command, LeaderMgmtArgs, LeaderMgmtCommand, LeaderTargetArgs, PagerArgs,
|
AgentCmd, Command, LeaderMgmtArgs, LeaderMgmtCommand, LeaderTargetArgs, PagerArgs,
|
||||||
resolve_use_leader,
|
resolve_use_leader,
|
||||||
};
|
};
|
||||||
use kigi_tui::app::{WorkspaceMgmtArgs, WorkspaceMgmtCommand, WorkspaceStartArgs};
|
|
||||||
use kigi_tui::client_identity::PAGER_CLIENT_VERSION;
|
use kigi_tui::client_identity::PAGER_CLIENT_VERSION;
|
||||||
use kigi_update::{UpdateConfig, auto_update, enforce_minimum_version_or_exit};
|
use kigi_update::{UpdateConfig, auto_update, enforce_minimum_version_or_exit};
|
||||||
use std::env;
|
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 {
|
if let Some(v) = &agent_args.coding_api_base_url {
|
||||||
config.endpoints.coding_api_base_url = Some(v.clone());
|
config.endpoints.coding_api_base_url = Some(v.clone());
|
||||||
}
|
}
|
||||||
if let Some(v) = &agent_args.xai_api_base_url {
|
if let Some(v) = &agent_args.api_base_url {
|
||||||
config.endpoints.xai_api_base_url = v.clone();
|
config.endpoints.api_base_url = v.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Resolve --agent-profile path: canonicalize and verify the file exists.
|
/// Resolve --agent-profile path: canonicalize and verify the file exists.
|
||||||
@@ -324,194 +323,6 @@ fn ensure_control_caps(reg: &LeaderRegistration) -> Result<&LeaderCapabilities>
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Leader does not advertise capabilities (legacy version)"))
|
.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.
|
/// How to rebuild one session's `session/load` after a leader reconnect.
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
struct CachedSession {
|
struct CachedSession {
|
||||||
@@ -1604,10 +1415,6 @@ async fn async_main() -> Result<()> {
|
|||||||
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Failed to create agent config: {e}"))?;
|
||||||
return kigi_tui::worktree_cmd::run(worktree_args, &agent_config).await;
|
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) => {
|
Command::Sessions(sessions_args) => {
|
||||||
init_tracing_simple("cli");
|
init_tracing_simple("cli");
|
||||||
return kigi_tui::sessions_cmd::run(sessions_args).await;
|
return kigi_tui::sessions_cmd::run(sessions_args).await;
|
||||||
@@ -2094,23 +1901,6 @@ mod tests {
|
|||||||
"failure path must not flag the startup hook",
|
"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> {
|
fn make_state() -> std::sync::Mutex<StdioReplayState> {
|
||||||
std::sync::Mutex::new(StdioReplayState::default())
|
std::sync::Mutex::new(StdioReplayState::default())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -694,11 +694,6 @@ pub struct RemoteSettings {
|
|||||||
/// See `Config::resolve_image_edit`.
|
/// See `Config::resolve_image_edit`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub imagine_tools_disabled: Option<Vec<String>>,
|
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.
|
/// Master switch for jemalloc heap sampling + threshold dumps.
|
||||||
/// `Some(true)` enables, `Some(false)` kill-switch, `None` = client default off.
|
/// `Some(true)` enables, `Some(false)` kill-switch, `None` = client default off.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -1308,24 +1303,6 @@ mod tests {
|
|||||||
assert_eq!(s.folder_trust_enabled, None);
|
assert_eq!(s.folder_trust_enabled, None);
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn remote_settings_permission_mode_deserializes() {
|
||||||
let s: RemoteSettings = serde_json::from_str(r#"{"permission_mode": "auto"}"#).unwrap();
|
let s: RemoteSettings = serde_json::from_str(r#"{"permission_mode": "auto"}"#).unwrap();
|
||||||
assert_eq!(s.permission_mode.as_deref(), Some("auto"));
|
assert_eq!(s.permission_mode.as_deref(), Some("auto"));
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ libc = { workspace = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Used in servers.rs test `test_same_raw_name_different_servers_no_local_registry_collision`
|
# 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.
|
# 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
|
# `test-util` enables `tokio::time::pause` / `advance`, required by
|
||||||
# the deterministic timing tests in `liveness.rs`.
|
# the deterministic timing tests in `liveness.rs`.
|
||||||
tokio = { workspace = true, features = ["test-util"] }
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
|
|||||||
@@ -5301,7 +5301,7 @@ mod tests {
|
|||||||
/// into a `LocalRegistry` must preserve both entries (no silent overwrite).
|
/// into a `LocalRegistry` must preserve both entries (no silent overwrite).
|
||||||
#[test]
|
#[test]
|
||||||
fn test_same_raw_name_different_servers_no_local_registry_collision() {
|
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;
|
use kigi_tool_runtime::Tool;
|
||||||
|
|
||||||
let mcp_state = Arc::new(Mutex::new(McpState::new(vec![])));
|
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
|
/// path (`kigi_sqlite_journal::JournalMode::open`) — the journal mode depends
|
||||||
/// on the database's filesystem.
|
/// on the database's filesystem.
|
||||||
pub fn schema_sql(dimensions: usize, vec_available: bool) -> String {
|
pub fn schema_sql(dimensions: usize, vec_available: bool) -> String {
|
||||||
let mut sql = format!(
|
let mut sql = r#"
|
||||||
r#"
|
|
||||||
CREATE TABLE IF NOT EXISTS meta (
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
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', '');
|
INSERT OR IGNORE INTO meta(key, value) VALUES ('reindex_claim', '');
|
||||||
"#
|
"#
|
||||||
);
|
.to_string();
|
||||||
|
|
||||||
if vec_available {
|
if vec_available {
|
||||||
sql.push_str(&format!(
|
sql.push_str(&format!(
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ impl ContentController {
|
|||||||
// KIGI_SHARE_DIR is set in the test runner's env).
|
// KIGI_SHARE_DIR is set in the test runner's env).
|
||||||
("KIGI_SHARE_DIR".into(), kigi_home),
|
("KIGI_SHARE_DIR".into(), kigi_home),
|
||||||
("KIGI_CODE_BASE_URL".into(), self.url()),
|
("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()),
|
("XAI_API_KEY".into(), "test-key-for-ci".into()),
|
||||||
("KIGI_TELEMETRY_ENABLED".into(), "false".into()),
|
("KIGI_TELEMETRY_ENABLED".into(), "false".into()),
|
||||||
("KIGI_FEEDBACK_ENABLED".into(), "false".into()),
|
("KIGI_FEEDBACK_ENABLED".into(), "false".into()),
|
||||||
@@ -279,7 +279,7 @@ mod tests {
|
|||||||
content.home().join(".kigi").to_str()
|
content.home().join(".kigi").to_str()
|
||||||
);
|
);
|
||||||
assert_eq!(get("KIGI_CODE_BASE_URL"), Some(content.url()));
|
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("XAI_API_KEY").as_deref(), Some("test-key-for-ci"));
|
||||||
assert_eq!(get("KIGI_TELEMETRY_ENABLED").as_deref(), Some("false"));
|
assert_eq!(get("KIGI_TELEMETRY_ENABLED").as_deref(), Some("false"));
|
||||||
assert_eq!(get("KIGI_FEEDBACK_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"] }
|
axum = { workspace = true, features = ["ws", "multipart"] }
|
||||||
backon = { workspace = true }
|
backon = { workspace = true }
|
||||||
webbrowser = { workspace = true }
|
webbrowser = { workspace = true }
|
||||||
tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] }
|
|
||||||
tokio-rustls = { version = "0.26", default-features = false, features = [
|
tokio-rustls = { version = "0.26", default-features = false, features = [
|
||||||
"ring",
|
"ring",
|
||||||
"logging",
|
"logging",
|
||||||
@@ -161,8 +160,7 @@ parking_lot.workspace = true
|
|||||||
dashmap.workspace = true
|
dashmap.workspace = true
|
||||||
|
|
||||||
# Used by acp_session.rs and mcp_servers.rs for the unified
|
# 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_tool_runtime::Tool dispatch model.
|
||||||
kigi-computer-hub-sdk = { workspace = true }
|
|
||||||
kigi-tool-runtime = { workspace = true }
|
kigi-tool-runtime = { workspace = true }
|
||||||
kigi-tool-protocol = { workspace = true }
|
kigi-tool-protocol = { workspace = true }
|
||||||
kigi-tool-types = { workspace = true }
|
kigi-tool-types = { workspace = true }
|
||||||
|
|||||||
@@ -518,11 +518,7 @@ pub async fn run_leader(
|
|||||||
lock_path: lock.lock_path().clone(),
|
lock_path: lock.lock_path().clone(),
|
||||||
socket_suffix: socket_suffix_from_paths(lock.lock_path(), &socket_path).unwrap_or_default(),
|
socket_suffix: socket_suffix_from_paths(lock.lock_path(), &socket_path).unwrap_or_default(),
|
||||||
leader_binary_version: kigi_version::VERSION.to_string(),
|
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) ──────
|
// ── 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.
|
// process so a refresh can't straddle a suspend.
|
||||||
shared_auth_manager.start_system_power_listener();
|
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_agent = shared_auth_manager.clone();
|
||||||
let auth_manager_for_config = shared_auth_manager;
|
let auth_manager_for_config = shared_auth_manager;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub fn default_agent_type() -> String {
|
|||||||
DEFAULT_AGENT_TYPE.to_owned()
|
DEFAULT_AGENT_TYPE.to_owned()
|
||||||
}
|
}
|
||||||
/// Default base URL for the public xAI API.
|
/// 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.
|
/// One or more environment variable names that may hold a model API key.
|
||||||
///
|
///
|
||||||
/// Serde `untagged`: accepts a string or an array in TOML/JSON.
|
/// 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.
|
/// default value) lets an org pin the proxy to the default on purpose.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub coding_api_base_url: Option<String>,
|
pub coding_api_base_url: Option<String>,
|
||||||
/// Base URL for the public xAI API.
|
/// Base URL for direct (BYOK / external-API-key) API calls.
|
||||||
pub xai_api_base_url: String,
|
/// 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
|
/// Optional extra access-header value (applied only with the optional
|
||||||
/// non-production feature, and only for matching first-party hosts).
|
/// non-production feature, and only for matching first-party hosts).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -166,54 +168,6 @@ pub struct EndpointsConfig {
|
|||||||
/// Defaults to `{proxy_url()}/deployment/config`.
|
/// Defaults to `{proxy_url()}/deployment/config`.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub managed_config_url: Option<String>,
|
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`.
|
/// Read by `load_management_api_key_sync()`. Declared for `serde_ignored`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub management_api_key: Option<String>,
|
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())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.map(str::to_owned)
|
.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 {
|
impl EndpointsConfig {
|
||||||
pub fn has_custom_endpoint(&self) -> bool {
|
pub fn has_custom_endpoint(&self) -> bool {
|
||||||
self.models_base_url.is_some() || self.models_list_url.is_some()
|
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.
|
/// Layer the `[endpoints]` table from `config` over the env/default base.
|
||||||
/// No field is derived from another — defaulting is done by the resolvers.
|
/// No field is derived from another — defaulting is done by the resolvers.
|
||||||
pub(crate) fn from_config_value(config: &toml::Value) -> Self {
|
pub(crate) fn from_config_value(config: &toml::Value) -> Self {
|
||||||
let default = Self::default();
|
let mut base = match toml::Value::try_from(Self::default()) {
|
||||||
let external_otel_master_switch = default.external_otel_master_switch;
|
|
||||||
let mut base = match toml::Value::try_from(default) {
|
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(_) => return Self::default(),
|
Err(_) => return Self::default(),
|
||||||
};
|
};
|
||||||
if let Some(endpoints) = config.get("endpoints") {
|
if let Some(endpoints) = config.get("endpoints") {
|
||||||
crate::config::deep_merge_toml(&mut base, endpoints);
|
crate::config::deep_merge_toml(&mut base, endpoints);
|
||||||
}
|
}
|
||||||
let mut resolved: Self = base.try_into().unwrap_or_default();
|
base.try_into().unwrap_or_default()
|
||||||
resolved.external_otel_master_switch = external_otel_master_switch;
|
|
||||||
resolved
|
|
||||||
}
|
}
|
||||||
/// The subscription proxy base URL through which all auxiliary services (and
|
/// The subscription proxy base URL through which all auxiliary services (and
|
||||||
/// OAuth/session inference) resolve: explicit `coding_api_base_url`, else
|
/// 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.
|
/// that is the inference endpoint (API-key auth) only.
|
||||||
pub fn proxy_url(&self) -> String {
|
pub fn proxy_url(&self) -> String {
|
||||||
blank_as_unset(&self.coding_api_base_url).unwrap_or_else(kigi_env::coding_api_base_url)
|
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())
|
.unwrap_or_else(|| self.proxy_url())
|
||||||
}
|
}
|
||||||
/// Feedback endpoint — an auxiliary service, so it defaults to the
|
/// 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 {
|
pub fn resolve_feedback_base_url(&self) -> String {
|
||||||
blank_as_unset(&self.feedback_base_url).unwrap_or_else(|| self.proxy_url())
|
blank_as_unset(&self.feedback_base_url).unwrap_or_else(|| self.proxy_url())
|
||||||
}
|
}
|
||||||
/// Managed deployment-config URL (`grok setup`): explicit `managed_config_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.
|
/// deployment key reaches the proxy, not the inference host.
|
||||||
pub fn resolve_managed_config_url(&self) -> String {
|
pub fn resolve_managed_config_url(&self) -> String {
|
||||||
blank_as_unset(&self.managed_config_url).unwrap_or_else(|| {
|
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`.
|
/// `models_list_url` > `{models_base_url}/models` > `{proxy_base_url}/models`.
|
||||||
pub fn resolve_models_list_url(&self) -> String {
|
pub fn resolve_models_list_url(&self) -> String {
|
||||||
if let Some(ref url) = self.models_list_url {
|
if let Some(ref url) = self.models_list_url {
|
||||||
@@ -407,26 +252,14 @@ impl Default for EndpointsConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
coding_api_base_url: std::env::var("KIGI_CODE_BASE_URL").ok(),
|
coding_api_base_url: std::env::var("KIGI_CODE_BASE_URL").ok(),
|
||||||
xai_api_base_url: std::env::var("KIGI_XAI_API_BASE_URL")
|
api_base_url: std::env::var("KIGI_API_BASE_URL")
|
||||||
.unwrap_or_else(|_| XAI_API_BASE_URL_DEFAULT.to_owned()),
|
.unwrap_or_else(|_| API_BASE_URL_DEFAULT.to_owned()),
|
||||||
alpha_test_key: None,
|
alpha_test_key: None,
|
||||||
models_base_url: env_string("KIGI_MODELS_BASE_URL"),
|
models_base_url: env_string("KIGI_MODELS_BASE_URL"),
|
||||||
models_list_url: env_string("KIGI_MODELS_LIST_URL"),
|
models_list_url: env_string("KIGI_MODELS_LIST_URL"),
|
||||||
feedback_base_url: env_string("KIGI_FEEDBACK_BASE_URL"),
|
feedback_base_url: env_string("KIGI_FEEDBACK_BASE_URL"),
|
||||||
deployment_key: env_string("KIGI_DEPLOYMENT_KEY"),
|
deployment_key: env_string("KIGI_DEPLOYMENT_KEY"),
|
||||||
managed_config_url: env_string("KIGI_MANAGED_CONFIG_URL"),
|
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,
|
management_api_key: None,
|
||||||
gcs_service_account_key: None,
|
gcs_service_account_key: None,
|
||||||
}
|
}
|
||||||
@@ -1005,30 +838,6 @@ pub struct RemoteConfig {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub secret: Option<String>,
|
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)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct WorktreePoolConfig {
|
pub struct WorktreePoolConfig {
|
||||||
@@ -1250,9 +1059,6 @@ pub struct Config {
|
|||||||
pub harness: HarnessConfig,
|
pub harness: HarnessConfig,
|
||||||
#[serde(default, skip_serializing)]
|
#[serde(default, skip_serializing)]
|
||||||
pub remote: RemoteConfig,
|
pub remote: RemoteConfig,
|
||||||
/// Computer Hub configuration (`[hub]` in config.toml).
|
|
||||||
#[serde(default, skip_serializing)]
|
|
||||||
pub hub: HubConfig,
|
|
||||||
#[serde(default, skip_serializing)]
|
#[serde(default, skip_serializing)]
|
||||||
pub worktree_pool: WorktreePoolConfig,
|
pub worktree_pool: WorktreePoolConfig,
|
||||||
#[serde(default, skip_serializing)]
|
#[serde(default, skip_serializing)]
|
||||||
@@ -1632,7 +1438,6 @@ impl Default for Config {
|
|||||||
platforms: PlatformsConfig::default(),
|
platforms: PlatformsConfig::default(),
|
||||||
harness: HarnessConfig::default(),
|
harness: HarnessConfig::default(),
|
||||||
remote: RemoteConfig::default(),
|
remote: RemoteConfig::default(),
|
||||||
hub: HubConfig::default(),
|
|
||||||
worktree_pool: WorktreePoolConfig::default(),
|
worktree_pool: WorktreePoolConfig::default(),
|
||||||
sandbox: SandboxSettingsConfig::default(),
|
sandbox: SandboxSettingsConfig::default(),
|
||||||
mcp_servers: std::collections::HashMap::new(),
|
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 {
|
pub fn deployment_id_from_key(key: &str) -> String {
|
||||||
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, key.as_bytes()).to_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`.
|
/// Seed free-function remote caches after writing `Config.remote_settings`.
|
||||||
pub fn apply_remote_settings_side_effects(settings: Option<&crate::util::config::RemoteSettings>) {
|
pub fn apply_remote_settings_side_effects(settings: Option<&crate::util::config::RemoteSettings>) {
|
||||||
crate::util::config::cache_remote_mcp_startup_timeout_secs(
|
crate::util::config::cache_remote_mcp_startup_timeout_secs(
|
||||||
@@ -4983,7 +4753,7 @@ reasoning_effort = "low"
|
|||||||
auth_scheme: AuthScheme::Bearer,
|
auth_scheme: AuthScheme::Bearer,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
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"
|
"{model_id}: ExternalApiKey must route to api.x.ai"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6890,24 +6660,18 @@ reasoning_effort = "low"
|
|||||||
for k in [
|
for k in [
|
||||||
"KIGI_CODE_BASE_URL",
|
"KIGI_CODE_BASE_URL",
|
||||||
kigi_env::CODE_BASE_URL_ENV,
|
kigi_env::CODE_BASE_URL_ENV,
|
||||||
"KIGI_XAI_API_BASE_URL",
|
"KIGI_API_BASE_URL",
|
||||||
"KIGI_FEEDBACK_BASE_URL",
|
"KIGI_FEEDBACK_BASE_URL",
|
||||||
"KIGI_TRACE_UPLOAD_URL",
|
"KIGI_TRACE_UPLOAD_URL",
|
||||||
"KIGI_MANAGED_CONFIG_URL",
|
"KIGI_MANAGED_CONFIG_URL",
|
||||||
"KIGI_MODELS_BASE_URL",
|
"KIGI_MODELS_BASE_URL",
|
||||||
"KIGI_MODELS_LIST_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) };
|
unsafe { std::env::remove_var(k) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// INVARIANT: auxiliary-service resolvers resolve to the cli-chat-proxy, never
|
/// 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.
|
/// the proxy; explicit per-service overrides win verbatim.
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
@@ -6915,7 +6679,7 @@ reasoning_effort = "low"
|
|||||||
unset_endpoint_env_vars();
|
unset_endpoint_env_vars();
|
||||||
let inference = "https://inference.acme-corp.example/xai/v1";
|
let inference = "https://inference.acme-corp.example/xai/v1";
|
||||||
let cfg = EndpointsConfig {
|
let cfg = EndpointsConfig {
|
||||||
xai_api_base_url: inference.to_string(),
|
api_base_url: inference.to_string(),
|
||||||
coding_api_base_url: None,
|
coding_api_base_url: None,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -6928,11 +6692,7 @@ reasoning_effort = "low"
|
|||||||
format!("{proxy}/deployment/config")
|
format!("{proxy}/deployment/config")
|
||||||
);
|
);
|
||||||
assert_eq!(cfg.resolve_feedback_base_url(), proxy);
|
assert_eq!(cfg.resolve_feedback_base_url(), proxy);
|
||||||
assert_eq!(
|
assert_eq!(cfg.api_base_url, inference);
|
||||||
cfg.resolve_otlp_traces_endpoint(),
|
|
||||||
format!("{proxy}/traces")
|
|
||||||
);
|
|
||||||
assert_eq!(cfg.xai_api_base_url, inference);
|
|
||||||
let overridden = EndpointsConfig {
|
let overridden = EndpointsConfig {
|
||||||
coding_api_base_url: Some("https://proxy.enterprise.example/v1".to_string()),
|
coding_api_base_url: Some("https://proxy.enterprise.example/v1".to_string()),
|
||||||
managed_config_url: Some(
|
managed_config_url: Some(
|
||||||
@@ -6945,10 +6705,6 @@ reasoning_effort = "low"
|
|||||||
overridden.proxy_url(),
|
overridden.proxy_url(),
|
||||||
"https://proxy.enterprise.example/v1"
|
"https://proxy.enterprise.example/v1"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
overridden.resolve_otlp_traces_endpoint(),
|
|
||||||
"https://proxy.enterprise.example/v1/traces"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
overridden.resolve_managed_config_url(),
|
overridden.resolve_managed_config_url(),
|
||||||
"https://control.enterprise.example/deployment/config"
|
"https://control.enterprise.example/deployment/config"
|
||||||
@@ -6958,7 +6714,7 @@ reasoning_effort = "low"
|
|||||||
"https://feedback.enterprise.example"
|
"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
|
/// through the full loader `Config::new_from_toml_cfg` — a distinct construction
|
||||||
/// path from `from_config_value`, so the deployment key never reaches the
|
/// path from `from_config_value`, so the deployment key never reaches the
|
||||||
/// inference host on either.
|
/// inference host on either.
|
||||||
@@ -6969,7 +6725,7 @@ reasoning_effort = "low"
|
|||||||
let cfg = Config::new_from_toml_cfg(
|
let cfg = Config::new_from_toml_cfg(
|
||||||
&toml::from_str(
|
&toml::from_str(
|
||||||
r#"[endpoints]
|
r#"[endpoints]
|
||||||
xai_api_base_url = "https://inference.acme-corp.example/xai/v1""#,
|
api_base_url = "https://inference.acme-corp.example/xai/v1""#,
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
@@ -8467,311 +8223,6 @@ agent_type = "cursor"
|
|||||||
"exactly the typo'd key must be flagged"
|
"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 {
|
fn empty_config() -> toml::Value {
|
||||||
toml::Value::Table(toml::map::Map::new())
|
toml::Value::Table(toml::map::Map::new())
|
||||||
}
|
}
|
||||||
@@ -9820,26 +9271,6 @@ default = "grok-4.5"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn resolve_model_list_prunes_bundled_entries_not_in_prefetch() {
|
||||||
let cfg = Config::default();
|
let cfg = Config::default();
|
||||||
let mut defs = default_model_entries(&EndpointsConfig::default());
|
let mut defs = default_model_entries(&EndpointsConfig::default());
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ pub mod init;
|
|||||||
pub mod models;
|
pub mod models;
|
||||||
pub(crate) mod models_fetch;
|
pub(crate) mod models_fetch;
|
||||||
pub mod mvp_agent;
|
pub mod mvp_agent;
|
||||||
pub(crate) mod proxy;
|
|
||||||
pub(crate) mod restore_code;
|
pub(crate) mod restore_code;
|
||||||
pub mod roster;
|
pub mod roster;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|||||||
@@ -1323,7 +1323,7 @@ const CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
|
|||||||
struct ModelsCache {
|
struct ModelsCache {
|
||||||
fetched_at: DateTime<Utc>,
|
fetched_at: DateTime<Utc>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
grok_version: Option<String>,
|
kigi_version: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
auth_method: Option<CacheAuthMethod>,
|
auth_method: Option<CacheAuthMethod>,
|
||||||
/// Models-list URL this catalog was fetched from
|
/// Models-list URL this catalog was fetched from
|
||||||
@@ -1418,7 +1418,7 @@ impl ModelsCacheManager {
|
|||||||
) -> Option<ModelsCache> {
|
) -> Option<ModelsCache> {
|
||||||
let data = std::fs::read(&self.path).ok()?;
|
let data = std::fs::read(&self.path).ok()?;
|
||||||
let cache: ModelsCache = serde_json::from_slice(&data).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");
|
tracing::debug!("models cache version mismatch");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -1447,7 +1447,7 @@ impl ModelsCacheManager {
|
|||||||
) {
|
) {
|
||||||
let cache = ModelsCache {
|
let cache = ModelsCache {
|
||||||
fetched_at: Utc::now(),
|
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),
|
auth_method: Some(auth_method),
|
||||||
origin: Some(origin.to_string()),
|
origin: Some(origin.to_string()),
|
||||||
etag: etag.map(|s| s.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 auth_method = mgr.inner.fetch_auth.read().cache_auth_method();
|
||||||
let stale = ModelsCache {
|
let stale = ModelsCache {
|
||||||
fetched_at: Utc::now() - ChronoDuration::seconds(3600),
|
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),
|
auth_method: Some(auth_method),
|
||||||
origin: Some(mgr.cache_origin()),
|
origin: Some(mgr.cache_origin()),
|
||||||
etag: Some("etag-stale".into()),
|
etag: Some("etag-stale".into()),
|
||||||
@@ -3140,7 +3140,7 @@ mod tests {
|
|||||||
let auth_method = mgr.inner.fetch_auth.read().cache_auth_method();
|
let auth_method = mgr.inner.fetch_auth.read().cache_auth_method();
|
||||||
let legacy = ModelsCache {
|
let legacy = ModelsCache {
|
||||||
fetched_at: Utc::now(),
|
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),
|
auth_method: Some(auth_method),
|
||||||
origin: None,
|
origin: None,
|
||||||
etag: Some("etag-legacy".into()),
|
etag: Some("etag-legacy".into()),
|
||||||
@@ -4100,7 +4100,7 @@ mod tests {
|
|||||||
let cache = ModelsCacheManager::new();
|
let cache = ModelsCacheManager::new();
|
||||||
let stale = ModelsCache {
|
let stale = ModelsCache {
|
||||||
fetched_at: Utc::now() - ChronoDuration::seconds(86_400),
|
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),
|
auth_method: Some(CacheAuthMethod::Platforms),
|
||||||
origin: Some(origin),
|
origin: Some(origin),
|
||||||
etag: None,
|
etag: None,
|
||||||
@@ -4187,7 +4187,7 @@ mod tests {
|
|||||||
let cache = ModelsCacheManager::new();
|
let cache = ModelsCacheManager::new();
|
||||||
cache.atomic_write(&ModelsCache {
|
cache.atomic_write(&ModelsCache {
|
||||||
fetched_at: Utc::now() - ChronoDuration::seconds(86_400),
|
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),
|
auth_method: Some(CacheAuthMethod::Platforms),
|
||||||
origin: Some(with_key_origin),
|
origin: Some(with_key_origin),
|
||||||
etag: None,
|
etag: None,
|
||||||
|
|||||||
@@ -762,7 +762,7 @@ impl MvpAgent {
|
|||||||
}
|
}
|
||||||
/// Build image generation config.
|
/// 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
|
/// `sampling_config.api_key` carries the OAuth bearer for session users (the
|
||||||
/// `api_key_provider` refreshes it per request), so IC authenticates and
|
/// `api_key_provider` refreshes it per request), so IC authenticates and
|
||||||
/// meters Imagine usage per-user.
|
/// meters Imagine usage per-user.
|
||||||
@@ -775,7 +775,7 @@ impl MvpAgent {
|
|||||||
return ImageGenConfig::Disabled;
|
return ImageGenConfig::Disabled;
|
||||||
};
|
};
|
||||||
let cfg = self.cfg.borrow();
|
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
|
let version = cfg
|
||||||
.client_version
|
.client_version
|
||||||
.clone()
|
.clone()
|
||||||
@@ -818,7 +818,7 @@ impl MvpAgent {
|
|||||||
tracing::info!("video_gen disabled by tools.disable_zdr_incompatible_tools");
|
tracing::info!("video_gen disabled by tools.disable_zdr_incompatible_tools");
|
||||||
return VideoGenConfig::Disabled;
|
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
|
let version = cfg
|
||||||
.client_version
|
.client_version
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
@@ -664,8 +664,6 @@ pub struct MvpAgent {
|
|||||||
plugin_registry_initialized: std::cell::Cell<bool>,
|
plugin_registry_initialized: std::cell::Cell<bool>,
|
||||||
persona_io_summaries: Vec<String>,
|
persona_io_summaries: Vec<String>,
|
||||||
/// Local workspace ops, built lazily via [`Self::ensure_local_workspace_ops`].
|
/// 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>>,
|
workspace_ops: RefCell<Option<kigi_workspace::WorkspaceOps>>,
|
||||||
/// Sessions opened with `require_gateway` / chat light-frontend (K13).
|
/// Sessions opened with `require_gateway` / chat light-frontend (K13).
|
||||||
/// Prompt-time guard consults this when the bridge map entry is missing,
|
/// 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(),
|
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(),
|
app_builder_deployer_config: kigi_tools::implementations::grok_build::deploy_app::AppBuilderDeployerConfig::default(),
|
||||||
api_key_provider: None,
|
api_key_provider: None,
|
||||||
auth_provider: None,
|
|
||||||
attribution_callback: None,
|
attribution_callback: None,
|
||||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
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 {
|
mod direct_hub_cloud_removed {
|
||||||
use super::super::{DIRECT_HUB_CLOUD_REMOVED_MSG, reject_direct_hub_cloud_meta};
|
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) {
|
fn assert_direct_hub_error(err: agent_client_protocol::Error) {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
err.data.as_ref(),
|
err.data.as_ref(),
|
||||||
@@ -3238,38 +3236,6 @@ mod direct_hub_cloud_removed {
|
|||||||
.is_ok()
|
.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 {
|
mod soft_default_settings_emit {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
|
|||||||
Vec::new(),
|
Vec::new(),
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut handle = make_test_handle("test-model", false, None);
|
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!("models", "default", config.models.default);
|
||||||
enforce_str!("cli", "channel", config.cli.channel);
|
enforce_str!("cli", "channel", config.cli.channel);
|
||||||
enforce_str!("cli", "minimum_version", config.cli.minimum_version);
|
enforce_str!("cli", "minimum_version", config.cli.minimum_version);
|
||||||
if let Some(val) = req_str(req, "endpoints", "xai_api_base_url")
|
if let Some(val) = req_str(req, "endpoints", "api_base_url")
|
||||||
&& config.endpoints.xai_api_base_url != val
|
&& config.endpoints.api_base_url != val
|
||||||
{
|
{
|
||||||
config.endpoints.xai_api_base_url = val.to_owned();
|
config.endpoints.api_base_url = val.to_owned();
|
||||||
push("endpoints.xai_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")
|
if let Some(val) = req_str(req, "endpoints", "coding_api_base_url")
|
||||||
&& config.endpoints.coding_api_base_url.as_deref() != Some(val)
|
&& 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(
|
let managed = toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[endpoints]
|
[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"
|
coding_api_base_url = "https://cli-chat-proxy.kigi.com/v1"
|
||||||
|
|
||||||
[model.kigi-build]
|
[model.kigi-build]
|
||||||
@@ -2511,7 +2511,7 @@ telemetry = false
|
|||||||
|
|
||||||
[endpoints]
|
[endpoints]
|
||||||
deployment_key = "xai-token-ENTERPRISE"
|
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();
|
.unwrap();
|
||||||
@@ -2586,13 +2586,13 @@ fn config_layers_system_managed_lowest_priority() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn apply_requirements_value_overrides_user_settings() {
|
fn apply_requirements_value_overrides_user_settings() {
|
||||||
let raw_config: toml::Value = toml::from_str(
|
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();
|
.unwrap();
|
||||||
let mut cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
|
let mut cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
|
||||||
cfg.default_yolo_mode = true;
|
cfg.default_yolo_mode = true;
|
||||||
let requirements: toml::Value = toml::from_str(
|
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();
|
.unwrap();
|
||||||
let source = RequirementSource::Requirements {
|
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
|
Some("https://managed-proxy.example/v1"), cfg.endpoints.coding_api_base_url
|
||||||
.as_deref()
|
.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!(
|
assert_eq!(
|
||||||
Some("https://managed-models.example/v1"), cfg.endpoints.models_base_url
|
Some("https://managed-models.example/v1"), cfg.endpoints.models_base_url
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ impl std::fmt::Display for Scope {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct InspectReport {
|
pub struct InspectReport {
|
||||||
pub grok_version: String,
|
pub kigi_version: String,
|
||||||
pub channel: String,
|
pub channel: String,
|
||||||
pub cwd: String,
|
pub cwd: String,
|
||||||
pub project_root: Option<String>,
|
pub project_root: Option<String>,
|
||||||
@@ -370,7 +370,7 @@ async fn build_report(cwd: &Path) -> InspectReport {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
InspectReport {
|
InspectReport {
|
||||||
grok_version: kigi_version::VERSION.to_string(),
|
kigi_version: kigi_version::VERSION.to_string(),
|
||||||
channel: crate::util::config::channel_name_from_cache()
|
channel: crate::util::config::channel_name_from_cache()
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -1224,7 +1224,7 @@ fn render_harness_compatibility(report: &ExternalCompatReport) -> String {
|
|||||||
fn print_human(r: &InspectReport) {
|
fn print_human(r: &InspectReport) {
|
||||||
println!();
|
println!();
|
||||||
println!(" Environment");
|
println!(" Environment");
|
||||||
println!(" {TREE} Version: {} [{}]", r.grok_version, r.channel);
|
println!(" {TREE} Version: {} [{}]", r.kigi_version, r.channel);
|
||||||
println!(" {TREE} CWD: {}", r.cwd);
|
println!(" {TREE} CWD: {}", r.cwd);
|
||||||
if let Some(ref root) = r.project_root {
|
if let Some(ref root) = r.project_root {
|
||||||
println!(" {TREE} Git root: {}", root);
|
println!(" {TREE} Git root: {}", root);
|
||||||
|
|||||||
@@ -1113,7 +1113,7 @@ async fn evict_leader(conn: LeaderConnection, lock: &LeaderLock) {
|
|||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `client_type` - Identifier for the client type (e.g., "grok-tui", "vscode")
|
/// * `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
|
/// * `capabilities` - Client capabilities (e.g., yolo_mode) to register with the leader
|
||||||
pub async fn connect_or_spawn(
|
pub async fn connect_or_spawn(
|
||||||
client_type: &str,
|
client_type: &str,
|
||||||
|
|||||||
@@ -108,9 +108,6 @@ impl Default for ClientId {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ClientMode {
|
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.
|
/// Stdio mode (grok agent stdio, grok -p) - uses local IPC.
|
||||||
/// Client sends/receives ACP messages directly via IPC.
|
/// Client sends/receives ACP messages directly via IPC.
|
||||||
Stdio,
|
Stdio,
|
||||||
@@ -178,8 +175,6 @@ pub struct LeaderCapabilities {
|
|||||||
pub runtime_cpu_profile: bool,
|
pub runtime_cpu_profile: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub profile_formats: Vec<ProfileArtifactFormat>,
|
pub profile_formats: Vec<ProfileArtifactFormat>,
|
||||||
#[serde(default)]
|
|
||||||
pub workspace_exposure: bool,
|
|
||||||
/// Whether the leader supports [`ControlCommand::RelaunchForUpdate`] — a
|
/// Whether the leader supports [`ControlCommand::RelaunchForUpdate`] — a
|
||||||
/// disruptive, bounded-grace relaunch onto a freshly-installed binary
|
/// disruptive, bounded-grace relaunch onto a freshly-installed binary
|
||||||
/// (driven by `grok update`). Old leaders default to `false`, so a new
|
/// (driven by `grok update`). Old leaders default to `false`, so a new
|
||||||
@@ -200,15 +195,6 @@ pub enum ControlCommand {
|
|||||||
frequency_hz: Option<i32>,
|
frequency_hz: Option<i32>,
|
||||||
},
|
},
|
||||||
StopCpuProfile,
|
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
|
/// Ask the leader to relaunch onto a freshly-installed binary (driven by
|
||||||
/// `grok update`). The leader stops admitting new turns, waits a bounded
|
/// `grok update`). The leader stops admitting new turns, waits a bounded
|
||||||
/// grace period for in-flight turns to finish, flushes session state, then
|
/// grace period for in-flight turns to finish, flushes session state, then
|
||||||
@@ -260,18 +246,6 @@ pub enum ControlPayload {
|
|||||||
started_at: String,
|
started_at: String,
|
||||||
stopped_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
|
/// Ack for [`ControlCommand::RelaunchForUpdate`]: the leader accepted the
|
||||||
/// request and will exit after a bounded grace period of `grace_ms`.
|
/// request and will exit after a bounded grace period of `grace_ms`.
|
||||||
Relaunching {
|
Relaunching {
|
||||||
@@ -531,7 +505,6 @@ mod tests {
|
|||||||
control_v1: true,
|
control_v1: true,
|
||||||
runtime_cpu_profile: true,
|
runtime_cpu_profile: true,
|
||||||
profile_formats: vec![ProfileArtifactFormat::Svg],
|
profile_formats: vec![ProfileArtifactFormat::Svg],
|
||||||
workspace_exposure: true,
|
|
||||||
relaunch_v1: true,
|
relaunch_v1: true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -549,7 +522,6 @@ mod tests {
|
|||||||
control_v1: true,
|
control_v1: true,
|
||||||
runtime_cpu_profile: true,
|
runtime_cpu_profile: true,
|
||||||
profile_formats,
|
profile_formats,
|
||||||
workspace_exposure: true,
|
|
||||||
relaunch_v1: true,
|
relaunch_v1: true,
|
||||||
}),
|
}),
|
||||||
} if profile_formats == vec![ProfileArtifactFormat::Svg]
|
} 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]
|
#[test]
|
||||||
fn client_id_is_unique() {
|
fn client_id_is_unique() {
|
||||||
let ids: Vec<_> = (0..100).map(|_| ClientId::new()).collect();
|
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 agent_client_protocol::AGENT_METHOD_NAMES;
|
||||||
use kanal::{AsyncReceiver, AsyncSender};
|
use kanal::{AsyncReceiver, AsyncSender};
|
||||||
use kigi_computer_hub_sdk::{AuthCredential, AuthIdentity, AuthProvider};
|
|
||||||
use kigi_workspace::WorkspaceHandle;
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
@@ -129,105 +127,24 @@ pub struct LeaderServerMetadata {
|
|||||||
pub struct LeaderServerControlState {
|
pub struct LeaderServerControlState {
|
||||||
pub metadata: LeaderServerMetadata,
|
pub metadata: LeaderServerMetadata,
|
||||||
pub cpu_profile: Arc<Mutex<CpuProfileManager>>,
|
pub cpu_profile: Arc<Mutex<CpuProfileManager>>,
|
||||||
pub workspace: Arc<WorkspaceControl>,
|
|
||||||
}
|
}
|
||||||
impl LeaderServerControlState {
|
impl LeaderServerControlState {
|
||||||
pub fn new(metadata: LeaderServerMetadata) -> Self {
|
pub fn new(metadata: LeaderServerMetadata) -> Self {
|
||||||
Self {
|
Self {
|
||||||
metadata,
|
metadata,
|
||||||
cpu_profile: Arc::new(Mutex::new(CpuProfileManager::new())),
|
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 {
|
fn leader_capabilities(&self) -> LeaderCapabilities {
|
||||||
let manager = self.cpu_profile.lock();
|
let manager = self.cpu_profile.lock();
|
||||||
LeaderCapabilities {
|
LeaderCapabilities {
|
||||||
control_v1: true,
|
control_v1: true,
|
||||||
runtime_cpu_profile: manager.runtime_cpu_profile(),
|
runtime_cpu_profile: manager.runtime_cpu_profile(),
|
||||||
profile_formats: manager.profile_formats().to_vec(),
|
profile_formats: manager.profile_formats().to_vec(),
|
||||||
workspace_exposure: true,
|
|
||||||
relaunch_v1: 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
|
/// Rewrite JSON-RPC request ID **in place** by prefixing with client ID to
|
||||||
/// avoid collisions.
|
/// avoid collisions.
|
||||||
///
|
///
|
||||||
@@ -934,233 +851,6 @@ fn leader_info_payload(control_state: &LeaderServerControlState) -> ControlPaylo
|
|||||||
profile_formats: manager.profile_formats().to_vec(),
|
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(
|
fn handle_control_command(
|
||||||
control_state: &LeaderServerControlState,
|
control_state: &LeaderServerControlState,
|
||||||
command: ControlCommand,
|
command: ControlCommand,
|
||||||
@@ -1208,13 +898,6 @@ fn handle_control_command(
|
|||||||
ControlCommand::StopCpuProfile => {
|
ControlCommand::StopCpuProfile => {
|
||||||
unreachable!("StopCpuProfile must be handled asynchronously")
|
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 { .. } => {
|
ControlCommand::RelaunchForUpdate { .. } => {
|
||||||
unreachable!("RelaunchForUpdate must be handled asynchronously")
|
unreachable!("RelaunchForUpdate must be handled asynchronously")
|
||||||
}
|
}
|
||||||
@@ -1564,13 +1247,6 @@ pub async fn run_leader_server(
|
|||||||
agent_activity = agent_activity.clone(); let relaunching = relaunching
|
agent_activity = agent_activity.clone(); let relaunching = relaunching
|
||||||
.clone(); tokio::spawn(async move { let result = match command {
|
.clone(); tokio::spawn(async move { let result = match command {
|
||||||
ControlCommand::StopCpuProfile => { handle_stop_cpu_profile(control_state).
|
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 } => {
|
await } ControlCommand::RelaunchForUpdate { to_version } => {
|
||||||
decide_relaunch_for_update(& control_state, to_version, & relaunching,) }
|
decide_relaunch_for_update(& control_state, to_version, & relaunching,) }
|
||||||
other => handle_control_command(& control_state, other), }; let arm_relaunch
|
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"); } }
|
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;
|
finalize_cpu_profile_on_shutdown(control_state).await;
|
||||||
let _ = std::fs::remove_file(&socket_path);
|
let _ = std::fs::remove_file(&socket_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2214,48 +1889,6 @@ mod tests {
|
|||||||
Ok(ControlPayload::RelaunchDeclined { .. })
|
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(
|
async fn setup_test_server(
|
||||||
temp: &TempDir,
|
temp: &TempDir,
|
||||||
) -> (PathBuf, CancellationToken, mpsc::UnboundedReceiver<String>) {
|
) -> (PathBuf, CancellationToken, mpsc::UnboundedReceiver<String>) {
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ pub(crate) fn fake_caps(control_v1: bool, relaunch_v1: bool) -> LeaderCapabiliti
|
|||||||
control_v1,
|
control_v1,
|
||||||
runtime_cpu_profile: false,
|
runtime_cpu_profile: false,
|
||||||
profile_formats: Vec::new(),
|
profile_formats: Vec::new(),
|
||||||
workspace_exposure: false,
|
|
||||||
relaunch_v1,
|
relaunch_v1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -928,9 +928,6 @@ pub(crate) struct SessionActor {
|
|||||||
/// Centralized event tracking: event log, turn-end guard, active tool,
|
/// Centralized event tracking: event log, turn-end guard, active tool,
|
||||||
/// doom loop terminate flag. All event-related state lives here.
|
/// doom loop terminate flag. All event-related state lives here.
|
||||||
pub(crate) events: crate::session::events::EventTracker,
|
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
|
/// Turn number captured at the start of each turn (before prompt index
|
||||||
/// increment). Used by `ToolCallStarted` bridge emissions so they
|
/// increment). Used by `ToolCallStarted` bridge emissions so they
|
||||||
/// report the same turn number as `TurnStarted` / `TurnEnded`.
|
/// 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"]
|
#[path = "acp_session_tests/interjection_actor_tests.rs"]
|
||||||
mod interjection_actor_tests;
|
mod interjection_actor_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "acp_session_tests/observability_bridge_mapping_tests.rs"]
|
|
||||||
mod observability_bridge_mapping_tests;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "acp_session_tests/permission_auto_mode_tests.rs"]
|
#[path = "acp_session_tests/permission_auto_mode_tests.rs"]
|
||||||
mod permission_auto_mode_tests;
|
mod permission_auto_mode_tests;
|
||||||
|
|||||||
@@ -251,34 +251,8 @@ pub(crate) async fn spawn_session_actor(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(kigi_workspace::permission::resolution::deny_read_globs_from_config)
|
.map(kigi_workspace::permission::resolution::deny_read_globs_from_config)
|
||||||
.unwrap_or_default();
|
.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) =
|
let (permissions, _permission_events_rx) =
|
||||||
kigi_workspace::permission::spawn_permission_manager_with_hub(
|
kigi_workspace::permission::spawn_permission_manager(
|
||||||
session_info.id.clone(),
|
session_info.id.clone(),
|
||||||
gateway.clone(),
|
gateway.clone(),
|
||||||
tool_context.cwd.clone(),
|
tool_context.cwd.clone(),
|
||||||
@@ -289,7 +263,6 @@ pub(crate) async fn spawn_session_actor(
|
|||||||
session_yolo_mode,
|
session_yolo_mode,
|
||||||
session_client_identifier.clone(),
|
session_client_identifier.clone(),
|
||||||
crate::util::config::remember_tool_approvals_from_disk(),
|
crate::util::config::remember_tool_approvals_from_disk(),
|
||||||
hub_permission,
|
|
||||||
);
|
);
|
||||||
if crate::util::config::auto_mode_session_active(
|
if crate::util::config::auto_mode_session_active(
|
||||||
crate::util::config::auto_permission_mode_enabled_from_disk(),
|
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::<
|
let (goal_update_tx, goal_update_rx) = tokio::sync::mpsc::unbounded_channel::<
|
||||||
kigi_tools::implementations::grok_build::update_goal::UpdateGoalEnvelope,
|
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()
|
let mut effective_config = crate::config::load_effective_config()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|raw| crate::agent::config::Config::new_from_toml_cfg(&raw).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(
|
events: crate::session::events::EventTracker::new(
|
||||||
&crate::session::persistence::session_dir(&session_info),
|
&crate::session::persistence::session_dir(&session_info),
|
||||||
),
|
),
|
||||||
observability_bridge: obs_bridge,
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
recap_in_flight: std::cell::Cell::new(false),
|
||||||
|
|||||||
@@ -323,15 +323,6 @@ impl SessionActor {
|
|||||||
self.emit_event(crate::session::events::Event::ToolStarted {
|
self.emit_event(crate::session::events::Event::ToolStarted {
|
||||||
tool_name: call.function.name.clone(),
|
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();
|
let call_name = call.function.name.clone();
|
||||||
match self
|
match self
|
||||||
.prepare_tool_call(call, &mut deferred_followups)
|
.prepare_tool_call(call, &mut deferred_followups)
|
||||||
@@ -653,16 +644,6 @@ impl SessionActor {
|
|||||||
duration_ms,
|
duration_ms,
|
||||||
outcome: tool_outcome,
|
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!(
|
tracing::info_span!(
|
||||||
"tool.execution", tool_name = % prepared.tool_name, tool_use_id = %
|
"tool.execution", tool_name = % prepared.tool_name, tool_use_id = %
|
||||||
prepared.call_id, tool_input_size_bytes = prepared.raw_arguments.len() as
|
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(),
|
schema_version: crate::session::events::EVENT_SCHEMA_VERSION.into(),
|
||||||
redirect_kind,
|
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 {
|
self.send_before_turn_event(kigi_tool_protocol::turn_hook::BeforeTurnPayload {
|
||||||
turn_number: self.chat_state_handle.get_prompt_index().await as u64,
|
turn_number: self.chat_state_handle.get_prompt_index().await as u64,
|
||||||
model_id: model_id.clone(),
|
model_id: model_id.clone(),
|
||||||
@@ -733,15 +724,6 @@ impl SessionActor {
|
|||||||
);
|
);
|
||||||
let turn_tool_count = self.events.tool_count_this_turn();
|
let turn_tool_count = self.events.tool_count_this_turn();
|
||||||
let bridge_outcome = turn_result_to_hook_outcome(&result);
|
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 {
|
match &result {
|
||||||
Ok(TurnOutcome::Completed { .. }) => {
|
Ok(TurnOutcome::Completed { .. }) => {
|
||||||
self.emit_turn_ended(
|
self.emit_turn_ended(
|
||||||
@@ -1678,13 +1660,6 @@ impl SessionActor {
|
|||||||
self.emit_event(crate::session::events::Event::PhaseChanged {
|
self.emit_event(crate::session::events::Event::PhaseChanged {
|
||||||
phase: crate::session::events::Phase::WaitingForModel,
|
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(
|
kigi_log::unified_log::info(
|
||||||
"shell.turn.inference_start",
|
"shell.turn.inference_start",
|
||||||
Some(self.session_info.id.0.as_ref()),
|
Some(self.session_info.id.0.as_ref()),
|
||||||
@@ -1989,13 +1964,6 @@ impl SessionActor {
|
|||||||
self.emit_event(crate::session::events::Event::PhaseChanged {
|
self.emit_event(crate::session::events::Event::PhaseChanged {
|
||||||
phase: crate::session::events::Phase::ToolExecution,
|
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;
|
let execute_tool_calls_result = self.execute_tool_calls(tool_call_responses).await;
|
||||||
match execute_tool_calls_result {
|
match execute_tool_calls_result {
|
||||||
Ok(ToolLoop::PermissionReject { tool_name, reason }) => {
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
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(),
|
video_gen_config: Default::default(),
|
||||||
app_builder_deployer_config: Default::default(),
|
app_builder_deployer_config: Default::default(),
|
||||||
api_key_provider: None,
|
api_key_provider: None,
|
||||||
auth_provider: None,
|
|
||||||
attribution_callback: None,
|
attribution_callback: None,
|
||||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
recap_in_flight: std::cell::Cell::new(false),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ fn install_real_permissions(actor: &mut SessionActor) {
|
|||||||
vec![],
|
vec![],
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
actor.permissions = handle;
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
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 =
|
pub(crate) const PLAN_SEED_TODOS_PHRASE: &str =
|
||||||
"Seed todos from the plan's acceptance criteria via";
|
"Seed todos from the plan's acceptance criteria via";
|
||||||
#[cfg(test)]
|
#[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)]
|
#[cfg(test)]
|
||||||
pub(crate) async fn test_agent_default() -> kigi_agent::Agent {
|
pub(crate) async fn test_agent_default() -> kigi_agent::Agent {
|
||||||
test_agent_with_tools(vec![]).await
|
test_agent_with_tools(vec![]).await
|
||||||
@@ -99,7 +93,6 @@ async fn test_agent_from_config(
|
|||||||
video_gen_config: Default::default(),
|
video_gen_config: Default::default(),
|
||||||
app_builder_deployer_config: Default::default(),
|
app_builder_deployer_config: Default::default(),
|
||||||
api_key_provider: None,
|
api_key_provider: None,
|
||||||
auth_provider: None,
|
|
||||||
attribution_callback: None,
|
attribution_callback: None,
|
||||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
recap_in_flight: std::cell::Cell::new(false),
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ async fn web_search_errors_when_disabled() {
|
|||||||
video_gen_config: Default::default(),
|
video_gen_config: Default::default(),
|
||||||
app_builder_deployer_config: Default::default(),
|
app_builder_deployer_config: Default::default(),
|
||||||
api_key_provider: None,
|
api_key_provider: None,
|
||||||
auth_provider: None,
|
|
||||||
attribution_callback: None,
|
attribution_callback: None,
|
||||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
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: std::cell::RefCell::new(None),
|
||||||
plugin_registry_handle: None,
|
plugin_registry_handle: None,
|
||||||
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
|
||||||
observability_bridge: noop_observability_bridge(),
|
|
||||||
current_turn_number: std::cell::Cell::new(0),
|
current_turn_number: std::cell::Cell::new(0),
|
||||||
last_recap_main_turn: std::cell::Cell::new(0),
|
last_recap_main_turn: std::cell::Cell::new(0),
|
||||||
recap_in_flight: std::cell::Cell::new(false),
|
recap_in_flight: std::cell::Cell::new(false),
|
||||||
|
|||||||
@@ -1052,7 +1052,7 @@ async fn test_load_prompts_only_large_session() {
|
|||||||
info.id.clone(),
|
info.id.clone(),
|
||||||
acp::SessionUpdate::UserMessageChunk(
|
acp::SessionUpdate::UserMessageChunk(
|
||||||
acp::ContentChunk::new(
|
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 {
|
unsafe {
|
||||||
std::env::set_var("KIGI_SHARE_DIR", kigi_home.path());
|
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_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("XAI_API_KEY", "test-key-for-ci");
|
||||||
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
||||||
std::env::set_var("KIGI_FEEDBACK_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");
|
||||||
std::env::set_var("KIGI_INSTRUMENTATION_LOG", &instr_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_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("XAI_API_KEY", "test-key-for-ci");
|
||||||
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
||||||
std::env::set_var("KIGI_FEEDBACK_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#"
|
r#"
|
||||||
[endpoints]
|
[endpoints]
|
||||||
deployment_key = "test-deployment-key"
|
deployment_key = "test-deployment-key"
|
||||||
xai_api_base_url = "{url}"
|
api_base_url = "{url}"
|
||||||
|
|
||||||
[model.kigi-build]
|
[model.kigi-build]
|
||||||
api_backend = "responses"
|
api_backend = "responses"
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
|
|||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("KIGI_SHARE_DIR", kigi_home.path());
|
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_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("XAI_API_KEY", "test-key-for-ci");
|
||||||
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
|
||||||
std::env::set_var("KIGI_FEEDBACK_ENABLED", "false");
|
std::env::set_var("KIGI_FEEDBACK_ENABLED", "false");
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use kigi_workspace::permission::types::{
|
|||||||
};
|
};
|
||||||
use kigi_workspace::permission::{
|
use kigi_workspace::permission::{
|
||||||
AccessKind, ClientType, Decision, PermissionCommand, PermissionHandle, PermissionState,
|
AccessKind, ClientType, Decision, PermissionCommand, PermissionHandle, PermissionState,
|
||||||
spawn_permission_manager, spawn_permission_manager_with_hub,
|
spawn_permission_manager,
|
||||||
};
|
};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
@@ -221,6 +221,7 @@ async fn run_actor_test_full<F, Fut>(
|
|||||||
vec![],
|
vec![],
|
||||||
initial_yolo,
|
initial_yolo,
|
||||||
None,
|
None,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
body(handle, gw, cwd).await;
|
body(handle, gw, cwd).await;
|
||||||
})
|
})
|
||||||
@@ -357,7 +358,7 @@ async fn policy_ask_suppresses_mcp_tool_allowlist() {
|
|||||||
|
|
||||||
let (gw, _gw_task) = fake_gateway();
|
let (gw, _gw_task) = fake_gateway();
|
||||||
// Gate OFF so the `ask` rule stays a hard floor over the grant.
|
// 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(),
|
make_session_id(),
|
||||||
gw.sender.clone(),
|
gw.sender.clone(),
|
||||||
cwd.clone(),
|
cwd.clone(),
|
||||||
@@ -368,7 +369,6 @@ async fn policy_ask_suppresses_mcp_tool_allowlist() {
|
|||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
false, // remember_tool_approvals
|
false, // remember_tool_approvals
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Script an outright reject so we can confirm the prompt fires.
|
// 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();
|
let (gw, _gw_task) = fake_gateway();
|
||||||
// Gate OFF so the `ask` rule stays a hard floor over the grant.
|
// 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(),
|
make_session_id(),
|
||||||
gw.sender.clone(),
|
gw.sender.clone(),
|
||||||
cwd.clone(),
|
cwd.clone(),
|
||||||
@@ -419,7 +419,6 @@ async fn policy_ask_suppresses_mcp_server_allowlist() {
|
|||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
false, // remember_tool_approvals
|
false, // remember_tool_approvals
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
gw.expected.send(("reject-once".to_string(), None)).unwrap();
|
gw.expected.send(("reject-once".to_string(), None)).unwrap();
|
||||||
@@ -463,6 +462,7 @@ async fn policy_deny_takes_precedence_over_mcp_allowlist() {
|
|||||||
vec![],
|
vec![],
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Do NOT script a response: a policy Deny must short-circuit
|
// Do NOT script a response: a policy Deny must short-circuit
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tokio-tungstenite = { workspace = true }
|
|
||||||
tokio-util = { workspace = true, features = ["compat"] }
|
tokio-util = { workspace = true, features = ["compat"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true, features = ["fmt"] }
|
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`.
|
// Mirrors `leader.rs` and the pty-harness `env_for_pager`.
|
||||||
.env("KIGI_SHARE_DIR", home.join(".kigi"))
|
.env("KIGI_SHARE_DIR", home.join(".kigi"))
|
||||||
.env("KIGI_CODE_BASE_URL", mock_url)
|
.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("XAI_API_KEY", "test-key-for-ci")
|
||||||
.env("KIGI_TELEMETRY_ENABLED", "false")
|
.env("KIGI_TELEMETRY_ENABLED", "false")
|
||||||
.env("KIGI_FEEDBACK_ENABLED", "false")
|
.env("KIGI_FEEDBACK_ENABLED", "false")
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ impl LeaderStdioClient {
|
|||||||
// (re-)elected leader binds the same sandboxed path.
|
// (re-)elected leader binds the same sandboxed path.
|
||||||
.env("KIGI_LEADER_SOCKET", home.join(".kigi").join("leader.sock"))
|
.env("KIGI_LEADER_SOCKET", home.join(".kigi").join("leader.sock"))
|
||||||
.env("KIGI_CODE_BASE_URL", server.url())
|
.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("XAI_API_KEY", "test-key-for-ci")
|
||||||
.env("KIGI_TELEMETRY_ENABLED", "false")
|
.env("KIGI_TELEMETRY_ENABLED", "false")
|
||||||
.env("KIGI_FEEDBACK_ENABLED", "false")
|
.env("KIGI_FEEDBACK_ENABLED", "false")
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ fn main() {
|
|||||||
".", // match every message & enum
|
".", // match every message & enum
|
||||||
"#[derive(serde::Serialize, serde::Deserialize)]",
|
"#[derive(serde::Serialize, serde::Deserialize)]",
|
||||||
)
|
)
|
||||||
// ToolConfigEntry is embedded in external JSON contracts (Computer Hub
|
// ToolConfigEntry is embedded in external JSON contracts
|
||||||
// `session.bind` metadata and agent-config JSON) where sparse payloads
|
// (agent-config JSON) where sparse payloads must deserialize. Defaults are applied per optional field (not
|
||||||
// must deserialize. Defaults are applied per optional field (not
|
|
||||||
// type-level) so the required `id` field still fails deserialization
|
// type-level) so the required `id` field still fails deserialization
|
||||||
// when missing instead of silently becoming "". See tests/wire_shape.rs.
|
// when missing instead of silently becoming "". See tests/wire_shape.rs.
|
||||||
.field_attribute(
|
.field_attribute(
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ serde_path_to_error = { workspace = true }
|
|||||||
kigi-tool-runtime = { workspace = true }
|
kigi-tool-runtime = { workspace = true }
|
||||||
kigi-tool-types = { workspace = true }
|
kigi-tool-types = { workspace = true }
|
||||||
kigi-tool-protocol = { workspace = true }
|
kigi-tool-protocol = { workspace = true }
|
||||||
kigi-computer-hub-core = { workspace = true }
|
|
||||||
kigi-computer-hub-sdk = { workspace = true }
|
|
||||||
|
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
similar = { workspace = true }
|
similar = { workspace = true }
|
||||||
strum = { version = "0.26", features = ["derive"] }
|
strum = { version = "0.26", features = ["derive"] }
|
||||||
|
|||||||
@@ -2093,11 +2093,11 @@ mod tests {
|
|||||||
assert!(validate_anchor(&padded, &lines, &scheme).is_err());
|
assert!(validate_anchor(&padded, &lines, &scheme).is_err());
|
||||||
|
|
||||||
// Stale anchor with arrow reports the stripped anchor in error metadata.
|
// 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();
|
let err = validate_anchor(&stale, &lines, &scheme).unwrap_err();
|
||||||
assert_eq!(err.requested_anchor.as_deref(), Some("2:zzz:zzz"));
|
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();
|
let err = validate_anchor(&stale_ascii, &lines, &scheme).unwrap_err();
|
||||||
assert_eq!(err.requested_anchor.as_deref(), Some("2:zzz:zzz"));
|
assert_eq!(err.requested_anchor.as_deref(), Some("2:zzz:zzz"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ pub fn build_server_reminder(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut text = format!("Connected MCP servers:\n",);
|
let mut text = "Connected MCP servers:\n".to_string();
|
||||||
for server in servers {
|
for server in servers {
|
||||||
text.push_str(&format_server_line(server));
|
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.
|
/// instead of using the key baked into their config at construction time.
|
||||||
/// Prevents 401 failures when a session outlives the initial token lifetime.
|
/// Prevents 401 failures when a session outlives the initial token lifetime.
|
||||||
pub api_key_provider: Option<crate::types::SharedApiKeyProvider>,
|
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
|
/// Optional 401-attribution callback for tool HTTP clients. When
|
||||||
/// set, a 401 from `image_gen` / `video_gen` / `web_search`
|
/// set, a 401 from `image_gen` / `video_gen` / `web_search`
|
||||||
/// emits an `auth_401_attribution` event via this hook. Hosts can
|
/// emits an `auth_401_attribution` event via this hook. Hosts can
|
||||||
@@ -351,7 +345,7 @@ type OutputConverter =
|
|||||||
/// `.await`.
|
/// `.await`.
|
||||||
struct DispatchParts {
|
struct DispatchParts {
|
||||||
/// Resolved `LocalRegistry` handle to dispatch through.
|
/// 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,
|
/// Runtime context built for the call (resources, renderer, cwd,
|
||||||
/// behavior version, inner-dispatch).
|
/// behavior version, inner-dispatch).
|
||||||
ctx: kigi_tool_runtime::ToolCallContext,
|
ctx: kigi_tool_runtime::ToolCallContext,
|
||||||
@@ -394,7 +388,7 @@ struct ToolEntry {
|
|||||||
>,
|
>,
|
||||||
/// Registers this tool into a `LocalRegistry` using the concrete type.
|
/// Registers this tool into a `LocalRegistry` using the concrete type.
|
||||||
/// Captured at `register::<T>()` time when T is known.
|
/// 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.
|
/// Per-reminder metadata stored in the builder.
|
||||||
struct ReminderEntry {
|
struct ReminderEntry {
|
||||||
@@ -450,7 +444,7 @@ pub struct FinalizedToolset {
|
|||||||
scheduler_cancel: Option<tokio_util::sync::CancellationToken>,
|
scheduler_cancel: Option<tokio_util::sync::CancellationToken>,
|
||||||
/// Shared local registry for in-process dispatch.
|
/// Shared local registry for in-process dispatch.
|
||||||
/// Contains only config-enabled tools. Can be shared with ToolHarness.
|
/// 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.
|
/// Lock-free access to the template renderer for tool name/param resolution.
|
||||||
/// Cloned into `ToolCallContext::extensions` on each `call()` so tools
|
/// Cloned into `ToolCallContext::extensions` on each `call()` so tools
|
||||||
/// can resolve names without acquiring the `resources` mutex.
|
/// can resolve names without acquiring the `resources` mutex.
|
||||||
@@ -519,7 +513,7 @@ impl RequirementError {
|
|||||||
pub struct ToolRegistryBuilder {
|
pub struct ToolRegistryBuilder {
|
||||||
tools: HashMap<String, ToolEntry>,
|
tools: HashMap<String, ToolEntry>,
|
||||||
reminders: Vec<ReminderEntry>,
|
reminders: Vec<ReminderEntry>,
|
||||||
shared_local_registry: Option<kigi_computer_hub_sdk::LocalRegistry>,
|
shared_local_registry: Option<kigi_tool_runtime::LocalRegistry>,
|
||||||
}
|
}
|
||||||
impl Default for ToolRegistryBuilder {
|
impl Default for ToolRegistryBuilder {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -613,7 +607,7 @@ impl ToolRegistryBuilder {
|
|||||||
let typed = serde_json::from_value::<T::Args>(json)?;
|
let typed = serde_json::from_value::<T::Args>(json)?;
|
||||||
Ok(typed.into())
|
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());
|
lr.register(T::default());
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -744,7 +738,7 @@ impl ToolRegistryBuilder {
|
|||||||
}
|
}
|
||||||
b
|
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.shared_local_registry = Some(registry);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -990,9 +984,6 @@ impl ToolRegistryBuilder {
|
|||||||
if let Some(memory_backend) = ctx.memory_backend {
|
if let Some(memory_backend) = ctx.memory_backend {
|
||||||
resources.insert(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(
|
if let Ok(client) = crate::implementations::web_search::client::WebSearchClient::new(
|
||||||
&ctx.web_search_config,
|
&ctx.web_search_config,
|
||||||
ctx.api_key_provider.clone(),
|
ctx.api_key_provider.clone(),
|
||||||
@@ -1265,7 +1256,7 @@ impl FinalizedToolset {
|
|||||||
)),
|
)),
|
||||||
resources_persistence: Arc::new(ResourcesPersistence::noop()),
|
resources_persistence: Arc::new(ResourcesPersistence::noop()),
|
||||||
scheduler_cancel: None,
|
scheduler_cancel: None,
|
||||||
local_registry: kigi_computer_hub_sdk::LocalRegistry::new(),
|
local_registry: kigi_tool_runtime::LocalRegistry::new(),
|
||||||
renderer: Arc::new(TemplateRenderer::new(
|
renderer: Arc::new(TemplateRenderer::new(
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
@@ -1274,9 +1265,6 @@ impl FinalizedToolset {
|
|||||||
workspace_viewer_ctx: None,
|
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.
|
/// Get all tool definitions to send to the client.
|
||||||
pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
|
pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
|
||||||
self.tools
|
self.tools
|
||||||
@@ -2018,7 +2006,6 @@ mod tests {
|
|||||||
app_builder_deployer_config:
|
app_builder_deployer_config:
|
||||||
crate::implementations::grok_build::deploy_app::AppBuilderDeployerConfig::default(),
|
crate::implementations::grok_build::deploy_app::AppBuilderDeployerConfig::default(),
|
||||||
api_key_provider: None,
|
api_key_provider: None,
|
||||||
auth_provider: None,
|
|
||||||
attribution_callback: None,
|
attribution_callback: None,
|
||||||
system_reminder_tag: crate::reminders::DEFAULT_REMINDER_TAG,
|
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 {
|
if let Some(effort) = flags.reasoning_effort_override {
|
||||||
agent_config.reasoning_effort_override = Some(effort);
|
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() {
|
if !flags.permission_rules.is_empty() {
|
||||||
agent_config.cli_agent_overrides.permission_rules = flags.permission_rules.clone();
|
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
|
/// Manage git worktrees
|
||||||
Worktree(crate::worktree_cmd::WorktreeArgs),
|
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.
|
/// Open the Agent Dashboard view at startup.
|
||||||
///
|
///
|
||||||
/// Centralised, agent-native overview of every session (top-level and
|
/// Centralised, agent-native overview of every session (top-level and
|
||||||
@@ -138,7 +132,7 @@ pub struct WrapArgs {
|
|||||||
)]
|
)]
|
||||||
pub command: Vec<String>,
|
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)]
|
#[derive(Debug, clap::Args, Clone, Default)]
|
||||||
pub struct LeaderTargetArgs {
|
pub struct LeaderTargetArgs {
|
||||||
/// Leader process ID from `kigi leader list`.
|
/// Leader process ID from `kigi leader list`.
|
||||||
@@ -169,69 +163,6 @@ pub enum LeaderMgmtCommand {
|
|||||||
/// Stop all running leader processes
|
/// Stop all running leader processes
|
||||||
Kill,
|
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.
|
/// Arguments for the `agent` subcommand.
|
||||||
#[derive(Debug, clap::Args, Clone)]
|
#[derive(Debug, clap::Args, Clone)]
|
||||||
pub struct AgentArgs {
|
pub struct AgentArgs {
|
||||||
@@ -276,9 +207,9 @@ pub struct AgentArgs {
|
|||||||
/// Override the CLI chat proxy base URL.
|
/// Override the CLI chat proxy base URL.
|
||||||
#[arg(long = "coding-api-base-url")]
|
#[arg(long = "coding-api-base-url")]
|
||||||
pub coding_api_base_url: Option<String>,
|
pub coding_api_base_url: Option<String>,
|
||||||
/// Override the public xAI API base URL.
|
/// Override the direct (BYOK / external-API-key) API base URL.
|
||||||
#[arg(long = "xai-api-base-url")]
|
#[arg(long = "api-base-url")]
|
||||||
pub xai_api_base_url: Option<String>,
|
pub api_base_url: Option<String>,
|
||||||
/// Agent runtime mode. Optional: bare `kigi agent` (and the `kigi acp`
|
/// Agent runtime mode. Optional: bare `kigi agent` (and the `kigi acp`
|
||||||
/// alias) default to stdio.
|
/// alias) default to stdio.
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|||||||
@@ -1619,7 +1619,7 @@ fn sanitize_user_error_strips_auth_prefixes() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sanitize_user_error_collapses_disk_full() {
|
fn sanitize_user_error_collapses_disk_full() {
|
||||||
assert_eq!(
|
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."
|
"Out of disk space."
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ impl PagerLeaderCluster {
|
|||||||
let env = vec![
|
let env = vec![
|
||||||
crate::test_util::EnvVarGuard::set("KIGI_SHARE_DIR", kigi_home.path()),
|
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_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("XAI_API_KEY", "test-key-for-ci"),
|
||||||
crate::test_util::EnvVarGuard::set("KIGI_TELEMETRY_ENABLED", "false"),
|
crate::test_util::EnvVarGuard::set("KIGI_TELEMETRY_ENABLED", "false"),
|
||||||
crate::test_util::EnvVarGuard::set("KIGI_FEEDBACK_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,
|
AgentArgs, AgentCmd, Command, LeaderArgs, LeaderMgmtArgs, LeaderMgmtCommand, LeaderTargetArgs,
|
||||||
OutputFormat, PagerArgs, ServeArgs, WrapArgs,
|
OutputFormat, PagerArgs, ServeArgs, WrapArgs,
|
||||||
};
|
};
|
||||||
pub use cli::{WorkspaceMgmtArgs, WorkspaceMgmtCommand, WorkspaceStartArgs};
|
|
||||||
use crossterm::cursor::{self, SetCursorStyle};
|
use crossterm::cursor::{self, SetCursorStyle};
|
||||||
use crossterm::event;
|
use crossterm::event;
|
||||||
use crossterm::execute;
|
use crossterm::execute;
|
||||||
|
|||||||
@@ -888,9 +888,6 @@ pub async fn run_single_turn(
|
|||||||
None,
|
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);
|
apply_agent_flag(&options.agent, &mut agent_config);
|
||||||
|
|
||||||
if let Some(ref json) = options.agents_json {
|
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 {
|
let metadata = ExportMetadata {
|
||||||
session_id: session_id.to_owned(),
|
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(),
|
os: std::env::consts::OS.to_owned(),
|
||||||
arch: std::env::consts::ARCH.to_owned(),
|
arch: std::env::consts::ARCH.to_owned(),
|
||||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
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)]
|
#[derive(serde::Serialize)]
|
||||||
struct ExportMetadata {
|
struct ExportMetadata {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
grok_version: String,
|
kigi_version: String,
|
||||||
os: String,
|
os: String,
|
||||||
arch: String,
|
arch: String,
|
||||||
exported_at: String,
|
exported_at: String,
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ description = "Core host-local workspace library (FS, VCS, execution, discovery)
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
arc-swap = { 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 }
|
dunce = { workspace = true }
|
||||||
kigi-version = { workspace = true }
|
kigi-version = { workspace = true }
|
||||||
async-stream = { workspace = true }
|
async-stream = { workspace = true }
|
||||||
@@ -41,7 +38,6 @@ uuid = { workspace = true, features = ["v4", "v5", "v7"] }
|
|||||||
kigi-agent = { path = "../kigi-agent" }
|
kigi-agent = { path = "../kigi-agent" }
|
||||||
kigi-tools = { path = "../kigi-tools" }
|
kigi-tools = { path = "../kigi-tools" }
|
||||||
kigi-tools-api = { path = "../kigi-tools-api" }
|
kigi-tools-api = { path = "../kigi-tools-api" }
|
||||||
kigi-workspace-client = { path = "../kigi-workspace-client" }
|
|
||||||
kigi-workspace-types = { path = "../kigi-workspace-types" }
|
kigi-workspace-types = { path = "../kigi-workspace-types" }
|
||||||
kigi-config = { workspace = true }
|
kigi-config = { workspace = true }
|
||||||
# Leaf config value types (RemoteSettings, BoolFlag) for the folder-trust decision.
|
# 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-sandbox = { path = "../kigi-sandbox", default-features = false }
|
||||||
kigi-hooks = { path = "../kigi-hooks" }
|
kigi-hooks = { path = "../kigi-hooks" }
|
||||||
kigi-hunk-tracker = { path = "../kigi-hunk-tracker" }
|
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-mcp = { path = "../kigi-mcp" }
|
||||||
kigi-file-utils = { path = "../kigi-file-utils" }
|
kigi-file-utils = { path = "../kigi-file-utils" }
|
||||||
kigi-auth = { path = "../kigi-auth" }
|
kigi-auth = { path = "../kigi-auth" }
|
||||||
kigi-log = { workspace = true }
|
kigi-log = { workspace = true }
|
||||||
kigi-tty-utils = { workspace = true }
|
kigi-tty-utils = { workspace = true }
|
||||||
kigi-sqlite-journal = { workspace = true }
|
kigi-sqlite-journal = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
|
||||||
kigi-tool-protocol = { workspace = true }
|
kigi-tool-protocol = { workspace = true }
|
||||||
kigi-tool-runtime = { workspace = true }
|
kigi-tool-runtime = { workspace = true }
|
||||||
kigi-tool-types = { workspace = true }
|
kigi-tool-types = { workspace = true }
|
||||||
tokio-util = { workspace = true }
|
tokio-util = { workspace = true }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
kigi-fast-worktree = { path = "../kigi-fast-worktree", features = ["metadata"] }
|
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" }
|
kigi-fsnotify = { path = "../kigi-fsnotify" }
|
||||||
clap = { workspace = true }
|
|
||||||
tracing-subscriber = { 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 }
|
tempfile = { workspace = true }
|
||||||
zstd = { 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]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = { workspace = true }
|
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]
|
[features]
|
||||||
default = ["sandbox-enforce"]
|
default = ["sandbox-enforce"]
|
||||||
compression = []
|
compression = []
|
||||||
@@ -137,12 +108,6 @@ filetime = { workspace = true }
|
|||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["test-util"] }
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
kigi-test-utils = { path = "../../common/kigi-test-utils" }
|
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]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Workspace and session configuration types.
|
//! Workspace and session configuration types.
|
||||||
use crate::capability::CapabilityMode;
|
use crate::capability::CapabilityMode;
|
||||||
use crate::hub::HubConfig;
|
|
||||||
use kigi_tools::registry::types::{SessionContext, ToolRegistryBuilder, ToolServerConfig};
|
use kigi_tools::registry::types::{SessionContext, ToolRegistryBuilder, ToolServerConfig};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -97,605 +96,6 @@ pub trait SessionContextFactory: Send + Sync {
|
|||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub struct MemoryConfig {}
|
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`].
|
/// Top-level config required to construct a [`crate::handle::WorkspaceHandle`].
|
||||||
///
|
///
|
||||||
/// `#[non_exhaustive]` so future fields are non-breaking.
|
/// `#[non_exhaustive]` so future fields are non-breaking.
|
||||||
@@ -726,18 +126,6 @@ pub struct WorkspaceConfig {
|
|||||||
/// and disabled/enabled lists. Stored on `WorkspaceShared` for
|
/// and disabled/enabled lists. Stored on `WorkspaceShared` for
|
||||||
/// `discover_plugins` calls. Defaults to empty.
|
/// `discover_plugins` calls. Defaults to empty.
|
||||||
pub plugin_discovery_config: crate::discovery::PluginDiscoveryConfig,
|
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.
|
/// Runtime-tunable timing/threshold config for the tool server.
|
||||||
pub status_config: crate::status_config::StatusConfig,
|
pub status_config: crate::status_config::StatusConfig,
|
||||||
/// Folder-trust verdict for repo-local (project-scoped) LSP servers from
|
/// 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
|
/// shell caller resolves the verdict and threads it in; callers without a
|
||||||
/// folder-trust decision pass `true`.
|
/// folder-trust decision pass `true`.
|
||||||
pub project_lsp_trusted: bool,
|
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
|
/// Confine `x.ai/fs/*` / `workspace.fs_*` resolution to the workspace root
|
||||||
/// (reject `..`, absolute-outside-root, symlink escapes). Default `false`
|
/// (reject `..`, absolute-outside-root, symlink escapes). Default `false`
|
||||||
/// (unconfined) — set to `true` only by the workspace server on a remote
|
/// (unconfined) — set to `true` only by the workspace server on a remote
|
||||||
/// sandbox, where the root is a real tenant boundary.
|
/// sandbox, where the root is a real tenant boundary.
|
||||||
pub confine_fs_to_workspace_root: bool,
|
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.
|
/// Configuration for spawning a subagent session within a workspace.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
@@ -928,158 +220,3 @@ pub enum IsolationMode {
|
|||||||
/// Run the subagent inside a sandbox/container.
|
/// Run the subagent inside a sandbox/container.
|
||||||
Sandbox,
|
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}")]
|
#[error("hunk action failed: {0}")]
|
||||||
HunkActionFailed(String),
|
HunkActionFailed(String),
|
||||||
|
|
||||||
/// An error from the server connection or tool server.
|
/// An internal workspace error.
|
||||||
#[error("hub error: {0}")]
|
#[error("workspace error: {0}")]
|
||||||
HubError(String),
|
Internal(String),
|
||||||
|
|
||||||
/// Deploy-service error tagged with its gRPC status class; see
|
/// Deploy-service error tagged with its gRPC status class; see
|
||||||
/// [`DeployError`] for how the class crosses the workspace RPC boundary.
|
/// [`DeployError`] for how the class crosses the workspace RPC boundary.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ impl WorkspaceOp for FsListReq {
|
|||||||
let req = self.clone();
|
let req = self.clone();
|
||||||
tokio::task::spawn_blocking(move || list(&abs, &req, confine_root))
|
tokio::task::spawn_blocking(move || list(&abs, &req, confine_root))
|
||||||
.await
|
.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 {
|
if !ranged {
|
||||||
let bytes = tokio::fs::read(&abs)
|
let bytes = tokio::fs::read(&abs)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||||
return Ok(build_file_entry(&bytes));
|
return Ok(build_file_entry(&bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,9 +106,9 @@ impl WorkspaceOp for FsReadFileReq {
|
|||||||
// chunk is `[offset, offset + min(length, max_bytes, cap))`.
|
// chunk is `[offset, offset + min(length, max_bytes, cap))`.
|
||||||
let md = tokio::fs::metadata(&abs)
|
let md = tokio::fs::metadata(&abs)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||||
if md.is_dir() {
|
if md.is_dir() {
|
||||||
return Err(WorkspaceError::HubError(format!(
|
return Err(WorkspaceError::Internal(format!(
|
||||||
"not a file: {}",
|
"not a file: {}",
|
||||||
self.path
|
self.path
|
||||||
)));
|
)));
|
||||||
@@ -120,7 +120,7 @@ impl WorkspaceOp for FsReadFileReq {
|
|||||||
let length = super::walk::clamp_read_length(self.length, self.max_bytes);
|
let length = super::walk::clamp_read_length(self.length, self.max_bytes);
|
||||||
let chunk = super::walk::read_range(&abs, offset, length)
|
let chunk = super::walk::read_range(&abs, offset, length)
|
||||||
.await
|
.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))
|
Ok(build_ranged_entry(chunk, size, self.encoding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,8 +143,8 @@ impl WorkspaceOp for FsWriteFileReq {
|
|||||||
std::fs::write(&abs, content.as_bytes())
|
std::fs::write(&abs, content.as_bytes())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))?
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@ impl WorkspaceOp for FsDeleteFileReq {
|
|||||||
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
|
let (abs, _) = ws.confine_to_workspace_root(&abs_unconfined).await?;
|
||||||
tokio::fs::remove_file(&abs)
|
tokio::fs::remove_file(&abs)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,8 @@ pub use ext_fs::{
|
|||||||
FsReadFileReq, FsWriteFileReq,
|
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,
|
// 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;
|
mod walk;
|
||||||
pub use walk::{
|
pub use walk::{
|
||||||
ChunkPayload, ListOptions, ListPage, ListedEntry, MAX_LIST_COLLECT, MAX_READ_BYTES,
|
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 capability;
|
||||||
pub mod channel;
|
pub mod channel;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod daemonize;
|
|
||||||
pub mod diag_server;
|
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod envrc;
|
pub mod envrc;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -20,17 +18,8 @@ pub mod folder_trust;
|
|||||||
pub mod foreign_sessions;
|
pub mod foreign_sessions;
|
||||||
pub mod fs_notify;
|
pub mod fs_notify;
|
||||||
pub mod handle;
|
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 permission;
|
||||||
pub mod preview_supervisor;
|
|
||||||
pub mod project_config;
|
pub mod project_config;
|
||||||
pub mod recovery;
|
|
||||||
pub mod rpc_envelope;
|
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod status_config;
|
pub mod status_config;
|
||||||
pub use status_config::StatusConfig;
|
pub use status_config::StatusConfig;
|
||||||
@@ -46,25 +35,18 @@ pub use config::{
|
|||||||
};
|
};
|
||||||
pub use error::{WorkspaceError, WorkspaceResult};
|
pub use error::{WorkspaceError, WorkspaceResult};
|
||||||
pub use file_system::*;
|
pub use file_system::*;
|
||||||
pub use handle::{
|
pub use handle::WorkspaceHandle;
|
||||||
DrainOutcome, DrainReason, WorkspaceHandle, connect_local_workspace, resolve_workspace_home,
|
|
||||||
termination_grace_from_env,
|
|
||||||
};
|
|
||||||
pub use hub::HubConfig;
|
|
||||||
pub use kigi_hunk_tracker::HunkTrackerHandle;
|
pub use kigi_hunk_tracker::HunkTrackerHandle;
|
||||||
pub use kigi_workspace_client::WorkspaceClient;
|
|
||||||
pub use kigi_workspace_types::WorkspaceEvent;
|
pub use kigi_workspace_types::WorkspaceEvent;
|
||||||
pub use permission::*;
|
pub use permission::*;
|
||||||
pub use session::{WorkspaceSession, WorkspaceShared};
|
pub use session::{WorkspaceSession, WorkspaceShared};
|
||||||
pub use session::{file_state, git, jj};
|
pub use session::{file_state, git, jj};
|
||||||
pub use workspace_ops::{WorkspaceOp, WorkspaceOps};
|
pub use workspace_ops::{WorkspaceOp, WorkspaceOps};
|
||||||
/// Zero-init every workspace metric family so idle panels render a `0` baseline
|
/// 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() {
|
pub fn init_metrics() {
|
||||||
handle::init_metrics();
|
handle::init_metrics();
|
||||||
session::swap_policy::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
|
/// Crate-wide lock serializing every test that mutates the process-global
|
||||||
/// environment (`KIGI_SHARE_DIR`, `HOME`, …). nextest isolates each test in its own
|
/// 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(
|
assert!(has(
|
||||||
"grok_workspace_toolset_swap_rejected_total",
|
"grok_workspace_toolset_swap_rejected_total",
|
||||||
&[("reason", "turn_active"), ("trigger", "update_tool_config")]
|
&[("reason", "turn_active"), ("trigger", "update_tool_config")]
|
||||||
));
|
));
|
||||||
|
assert!(has(
|
||||||
|
"grok_workspace_rewind_checkpoint_capture_total",
|
||||||
|
&[("domain", "fs"), ("outcome", "completed")]
|
||||||
|
));
|
||||||
assert!(
|
assert!(
|
||||||
families
|
families
|
||||||
.iter()
|
.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
|
/// Spawns the permission manager actor, returning a handle and the telemetry
|
||||||
/// event receiver.
|
/// 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(
|
pub fn spawn_permission_manager(
|
||||||
session_id: acp::SessionId,
|
session_id: acp::SessionId,
|
||||||
gateway: GatewaySender,
|
gateway: GatewaySender,
|
||||||
@@ -833,45 +837,7 @@ pub fn spawn_permission_manager(
|
|||||||
web_fetch_allowed_domains: Vec<String>,
|
web_fetch_allowed_domains: Vec<String>,
|
||||||
initial_yolo: bool,
|
initial_yolo: bool,
|
||||||
client_identifier: Option<String>,
|
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,
|
remember_tool_approvals: bool,
|
||||||
hub_permission: Option<Arc<dyn crate::permission::PermissionHookTransport>>,
|
|
||||||
) -> (PermissionHandle, mpsc::UnboundedReceiver<PermissionEvent>) {
|
) -> (PermissionHandle, mpsc::UnboundedReceiver<PermissionEvent>) {
|
||||||
// Read the pin ONCE (file I/O) and cache it; never re-read per tool-call.
|
// 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.
|
// Every yolo ingestion path funnels through construction or SetYoloMode.
|
||||||
@@ -887,7 +853,6 @@ pub fn spawn_permission_manager_with_hub(
|
|||||||
client_identifier,
|
client_identifier,
|
||||||
remember_tool_approvals,
|
remember_tool_approvals,
|
||||||
crate::permission::resolution::yolo_disabled_by_policy(),
|
crate::permission::resolution::yolo_disabled_by_policy(),
|
||||||
hub_permission,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,7 +870,6 @@ fn spawn_permission_manager_with_pin(
|
|||||||
client_identifier: Option<String>,
|
client_identifier: Option<String>,
|
||||||
remember_tool_approvals: bool,
|
remember_tool_approvals: bool,
|
||||||
yolo_pin: Option<&'static str>,
|
yolo_pin: Option<&'static str>,
|
||||||
hub_permission: Option<Arc<dyn crate::permission::PermissionHookTransport>>,
|
|
||||||
) -> (PermissionHandle, mpsc::UnboundedReceiver<PermissionEvent>) {
|
) -> (PermissionHandle, mpsc::UnboundedReceiver<PermissionEvent>) {
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel::<PermissionCommand>();
|
let (tx, mut rx) = mpsc::unbounded_channel::<PermissionCommand>();
|
||||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<PermissionEvent>();
|
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)
|
let prompter = AcpPrompter::new(session_id.clone(), gateway.clone(), client_type)
|
||||||
.with_hub_permission(hub_permission)
|
|
||||||
.with_remember_tool_approvals(remember_tool_approvals);
|
.with_remember_tool_approvals(remember_tool_approvals);
|
||||||
let mut yolo_mode = initial_yolo;
|
let mut yolo_mode = initial_yolo;
|
||||||
let mut auto_mode = seed_auto;
|
let mut auto_mode = seed_auto;
|
||||||
@@ -1849,7 +1812,6 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
true,
|
true,
|
||||||
yolo_pin,
|
yolo_pin,
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1871,7 +1833,6 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1939,187 +1900,6 @@ mod tests {
|
|||||||
.await;
|
.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
|
/// 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
|
/// the unconditional auto-allow. With no responder wired, that surfaces as a
|
||||||
/// non-`Allow` decision; a non-ask read still auto-allows.
|
/// non-`Allow` decision; a non-ask read still auto-allows.
|
||||||
@@ -2484,7 +2264,6 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
handle.deny_read_globs(),
|
handle.deny_read_globs(),
|
||||||
@@ -2654,7 +2433,6 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
remember_tool_approvals,
|
remember_tool_approvals,
|
||||||
None,
|
None,
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3386,7 +3164,6 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
let PermissionHandle::Actor { ref cmd_tx, .. } = mgr else {
|
let PermissionHandle::Actor { ref cmd_tx, .. } = mgr else {
|
||||||
panic!("manager must be actor-backed");
|
panic!("manager must be actor-backed");
|
||||||
@@ -3577,7 +3354,6 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Request A parks in the gated prompt; B then arrives and overlaps it.
|
// Request A parks in the gated prompt; B then arrives and overlaps it.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
pub mod auto_mode;
|
pub mod auto_mode;
|
||||||
pub mod claude_settings;
|
pub mod claude_settings;
|
||||||
mod hub_permission;
|
|
||||||
mod manager;
|
mod manager;
|
||||||
mod policy;
|
mod policy;
|
||||||
mod prompter;
|
mod prompter;
|
||||||
@@ -19,19 +18,7 @@ pub use auto_mode::{
|
|||||||
classifier_output_json_schema, default_auto_mode_classifier, is_auto_mode_allowlisted_access,
|
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,
|
is_auto_mode_allowlisted_tool_name, parse_classifier_model_text, permission_decision_args,
|
||||||
};
|
};
|
||||||
pub use hub_permission::{
|
pub use manager::{PermissionHandle, default_always_allow_scope, spawn_permission_manager};
|
||||||
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 policy::CompiledPolicy;
|
pub use policy::CompiledPolicy;
|
||||||
pub use prompter::{
|
pub use prompter::{
|
||||||
ALLOW_EDITS_SESSION_OPTION_ID, AcpPrompter, BashCommandPermission, BashCommandSelectedTerms,
|
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
|
/// at decision-time through it. `EventWriter::noop()` when events recording
|
||||||
/// is disabled (the default for the permission scaffolding's own tests).
|
/// is disabled (the default for the permission scaffolding's own tests).
|
||||||
event_writer: EventWriter,
|
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
|
/// When `false` (default, fail-safe), the per-tool "Always allow …" options
|
||||||
/// are stripped (see [`REMEMBER_TOOL_APPROVALS_GATED_IDS`]).
|
/// are stripped (see [`REMEMBER_TOOL_APPROVALS_GATED_IDS`]).
|
||||||
remember_tool_approvals: bool,
|
remember_tool_approvals: bool,
|
||||||
@@ -496,7 +493,6 @@ impl AcpPrompter {
|
|||||||
// must NOT double-emit. A workspace-server-side caller that owns the
|
// must NOT double-emit. A workspace-server-side caller that owns the
|
||||||
// per-session `events.jsonl` opts in via [`with_event_writer`].
|
// per-session `events.jsonl` opts in via [`with_event_writer`].
|
||||||
event_writer: EventWriter::noop(),
|
event_writer: EventWriter::noop(),
|
||||||
hub_permission: None,
|
|
||||||
// Fail-safe default; opt in via `with_remember_tool_approvals`.
|
// Fail-safe default; opt in via `with_remember_tool_approvals`.
|
||||||
remember_tool_approvals: false,
|
remember_tool_approvals: false,
|
||||||
}
|
}
|
||||||
@@ -509,16 +505,6 @@ impl AcpPrompter {
|
|||||||
self
|
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)
|
/// Attach a per-session `events.jsonl` writer so [`request`](Self::request)
|
||||||
/// records `PermissionRequested` / `PermissionResolved`. Used by the
|
/// records `PermissionRequested` / `PermissionResolved`. Used by the
|
||||||
/// workspace-server permission path (which owns the session log); the shell
|
/// workspace-server permission path (which owns the session log); the shell
|
||||||
@@ -737,41 +723,29 @@ impl AcpPrompter {
|
|||||||
prompt_start,
|
prompt_start,
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome = match &self.hub_permission {
|
let outcome = {
|
||||||
// Route the prompt to chat over the server (see
|
let permission_options = self.build_options(access);
|
||||||
// `ToolServerPermissionTransport` for the await/release contract).
|
let req = acp::RequestPermissionRequest::new(
|
||||||
Some(transport) => {
|
self.session_id.clone(),
|
||||||
crate::permission::hub_permission::request_permission_via_hub(
|
tool_call_update.clone(),
|
||||||
transport.as_ref(),
|
permission_options.values().cloned().collect(),
|
||||||
access,
|
)
|
||||||
tool_call_update.tool_call_id.0.as_ref(),
|
.meta(self.bash_selection_meta(access));
|
||||||
)
|
match self.gateway.request_permission(req).await {
|
||||||
.await
|
Ok(resp) => match resp.outcome {
|
||||||
}
|
acp::RequestPermissionOutcome::Cancelled => PromptOutcome::Cancelled,
|
||||||
None => {
|
acp::RequestPermissionOutcome::Selected(selected) => map_selected_outcome(
|
||||||
let permission_options = self.build_options(access);
|
&permission_options,
|
||||||
let req = acp::RequestPermissionRequest::new(
|
&selected.option_id,
|
||||||
self.session_id.clone(),
|
resp.meta.as_ref(),
|
||||||
tool_call_update.clone(),
|
access,
|
||||||
permission_options.values().cloned().collect(),
|
),
|
||||||
)
|
// TODO(acp-0.10): `RequestPermissionOutcome` is #[non_exhaustive].
|
||||||
.meta(self.bash_selection_meta(access));
|
_ => PromptOutcome::Error("unknown permission outcome".to_owned()),
|
||||||
match self.gateway.request_permission(req).await {
|
},
|
||||||
Ok(resp) => match resp.outcome {
|
Err(e) => {
|
||||||
acp::RequestPermissionOutcome::Cancelled => PromptOutcome::Cancelled,
|
tracing::error!(?e, "failed to request permission");
|
||||||
acp::RequestPermissionOutcome::Selected(selected) => map_selected_outcome(
|
PromptOutcome::Error("failed to request permission".to_owned())
|
||||||
&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::capability::CapabilityMode;
|
||||||
use crate::config::{MemoryConfig, SessionContextFactory};
|
use crate::config::{MemoryConfig, SessionContextFactory};
|
||||||
use crate::file_system::{AsyncFsWrapper, LocalFs};
|
use crate::file_system::{AsyncFsWrapper, LocalFs};
|
||||||
use crate::hub::{HubConfig, HubHandle};
|
|
||||||
use crate::session::file_state::FileStateTracker;
|
use crate::session::file_state::FileStateTracker;
|
||||||
use kigi_computer_hub_mcp_adapter::McpBridgeHandle;
|
|
||||||
use kigi_hunk_tracker::HunkTrackerHandle;
|
use kigi_hunk_tracker::HunkTrackerHandle;
|
||||||
use kigi_mcp::servers::McpState;
|
|
||||||
use kigi_tool_protocol::ToolId;
|
|
||||||
use kigi_tool_runtime::WorkspaceViewerContext;
|
use kigi_tool_runtime::WorkspaceViewerContext;
|
||||||
use kigi_tools::notification::types::{ToolNotification, ToolNotificationHandle};
|
use kigi_tools::notification::types::{ToolNotification, ToolNotificationHandle};
|
||||||
use kigi_tools::registry::types::{FinalizedToolset, ToolConfig, ToolServerConfig};
|
use kigi_tools::registry::types::{FinalizedToolset, ToolConfig, ToolServerConfig};
|
||||||
@@ -76,12 +72,6 @@ pub struct WorkspaceSession {
|
|||||||
inner: RwLock<WorkspaceSessionInner>,
|
inner: RwLock<WorkspaceSessionInner>,
|
||||||
/// Per-session lock that serialises `update_tool_config` calls.
|
/// Per-session lock that serialises `update_tool_config` calls.
|
||||||
pub(crate) update_lock: tokio::sync::Mutex<()>,
|
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
|
/// Per-user feature-flag bag resolved at session-bind time, frozen for
|
||||||
/// the session lifetime. `None` → tools use their safe defaults.
|
/// the session lifetime. `None` → tools use their safe defaults.
|
||||||
pub(crate) viewer_ctx: Option<WorkspaceViewerContext>,
|
pub(crate) viewer_ctx: Option<WorkspaceViewerContext>,
|
||||||
@@ -107,8 +97,7 @@ pub struct WorkspaceSession {
|
|||||||
/// created (or last rebound) with. `None` when the session was resolved
|
/// created (or last rebound) with. `None` when the session was resolved
|
||||||
/// from the workspace default (no explicit toolset in the bind metadata).
|
/// from the workspace default (no explicit toolset in the bind metadata).
|
||||||
/// Lets a rebind detect a config change and re-resolve instead of silently
|
/// 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
|
/// reusing a stale toolset.
|
||||||
/// hub revive bind that a config-carrying client rebind must correct).
|
|
||||||
bind_tool_config_fingerprint: std::sync::Mutex<Option<serde_json::Value>>,
|
bind_tool_config_fingerprint: std::sync::Mutex<Option<serde_json::Value>>,
|
||||||
/// The last snapshot-driven rebuild failed and kept a stale toolset;
|
/// The last snapshot-driven rebuild failed and kept a stale toolset;
|
||||||
/// cleared by any successful install. While set, an identical-config
|
/// cleared by any successful install. While set, an identical-config
|
||||||
@@ -190,9 +179,6 @@ impl WorkspaceSession {
|
|||||||
update_lock: tokio::sync::Mutex::new(()),
|
update_lock: tokio::sync::Mutex::new(()),
|
||||||
bind_tool_config_fingerprint: std::sync::Mutex::new(None),
|
bind_tool_config_fingerprint: std::sync::Mutex::new(None),
|
||||||
stale_resolve: std::sync::atomic::AtomicBool::new(false),
|
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,
|
viewer_ctx,
|
||||||
yolo_mode: std::sync::atomic::AtomicBool::new(false),
|
yolo_mode: std::sync::atomic::AtomicBool::new(false),
|
||||||
system_notifications,
|
system_notifications,
|
||||||
@@ -440,9 +426,6 @@ pub type ClientExtSink = std::sync::Arc<dyn Fn(String, serde_json::Value) + Send
|
|||||||
/// Workspace-wide shared state.
|
/// Workspace-wide shared state.
|
||||||
pub struct WorkspaceShared {
|
pub struct WorkspaceShared {
|
||||||
pub(crate) default_tool_config: ToolServerConfig,
|
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`].
|
/// See [`crate::config::WorkspaceConfig::confine_fs_to_workspace_root`].
|
||||||
/// Default `false`; enabled only for remote-sandbox workspace servers.
|
/// Default `false`; enabled only for remote-sandbox workspace servers.
|
||||||
pub(crate) confine_fs_to_workspace_root: bool,
|
pub(crate) confine_fs_to_workspace_root: bool,
|
||||||
@@ -464,38 +447,16 @@ pub struct WorkspaceShared {
|
|||||||
/// disabled/enabled lists). Used by `discover_plugins` via the
|
/// disabled/enabled lists). Used by `discover_plugins` via the
|
||||||
/// `discovery` module.
|
/// `discovery` module.
|
||||||
pub(crate) plugin_discovery_config: crate::discovery::PluginDiscoveryConfig,
|
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.
|
/// Sink for workspace-originated ext-notifications to the client (e.g.
|
||||||
/// `x.ai/search/fuzzy/status`). Mode-agnostic: the shell wires it to the
|
/// `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
|
/// 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).
|
/// 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) 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>,
|
pub(crate) activity_tracker: std::sync::Arc<crate::activity::ActivityTracker>,
|
||||||
/// Runtime-tunable timing/threshold config for the tool server.
|
/// Runtime-tunable timing/threshold config for the tool server.
|
||||||
/// Read by the status publisher task and at shutdown.
|
/// Read by the status publisher task and at shutdown.
|
||||||
pub(crate) status_config: crate::status_config::StatusConfig,
|
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
|
/// Workspace-level fuzzy search manager. Separate from the shell's
|
||||||
/// own `FuzzySearchManager` — this instance serves remote (hub/RPC)
|
/// own `FuzzySearchManager` — this instance serves remote (hub/RPC)
|
||||||
/// clients.
|
/// clients.
|
||||||
@@ -533,7 +494,6 @@ pub struct WorkspaceShared {
|
|||||||
/// turn start inside the check→install window deterministically.
|
/// turn start inside the check→install window deterministically.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) post_resolve_test_hook: parking_lot::Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
|
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 {
|
impl WorkspaceShared {
|
||||||
/// Workspace root directory.
|
/// Workspace root directory.
|
||||||
@@ -578,40 +538,6 @@ impl WorkspaceShared {
|
|||||||
.get(session_id)
|
.get(session_id)
|
||||||
.map(|w| w.value().clone())
|
.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 {
|
pub fn default_tool_config(&self) -> &ToolServerConfig {
|
||||||
&self.default_tool_config
|
&self.default_tool_config
|
||||||
}
|
}
|
||||||
@@ -624,49 +550,6 @@ impl WorkspaceShared {
|
|||||||
pub fn mcp_tools_snapshot(&self) -> Arc<Vec<ToolConfig>> {
|
pub fn mcp_tools_snapshot(&self) -> Arc<Vec<ToolConfig>> {
|
||||||
self.mcp_tools_snapshot.load_full()
|
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> {
|
pub fn activity_tracker(&self) -> &std::sync::Arc<crate::activity::ActivityTracker> {
|
||||||
&self.activity_tracker
|
&self.activity_tracker
|
||||||
}
|
}
|
||||||
@@ -717,7 +600,6 @@ impl WorkspaceShared {
|
|||||||
};
|
};
|
||||||
let trigger = SwapTrigger::from_rebuild_source(source);
|
let trigger = SwapTrigger::from_rebuild_source(source);
|
||||||
let mcp_snap = self.mcp_tools_snapshot.load_full();
|
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 sessions: Vec<(String, Arc<WorkspaceSession>)> = {
|
||||||
let guard = self.sessions.read();
|
let guard = self.sessions.read();
|
||||||
guard
|
guard
|
||||||
@@ -779,7 +661,6 @@ impl WorkspaceShared {
|
|||||||
baseline,
|
baseline,
|
||||||
session.capability_mode(),
|
session.capability_mode(),
|
||||||
&mcp_snap,
|
&mcp_snap,
|
||||||
&hub_snap,
|
|
||||||
session.cwd().to_path_buf(),
|
session.cwd().to_path_buf(),
|
||||||
session.session_env().clone(),
|
session.session_env().clone(),
|
||||||
&sid,
|
&sid,
|
||||||
@@ -787,7 +668,7 @@ impl WorkspaceShared {
|
|||||||
Some(self.local_registry.clone()),
|
Some(self.local_registry.clone()),
|
||||||
self.lsp.clone(),
|
self.lsp.clone(),
|
||||||
session.viewer_ctx().cloned(),
|
session.viewer_ctx().cloned(),
|
||||||
self.compose_session_notification_handle(session.system_notify_handle()),
|
session.system_notify_handle(),
|
||||||
session.terminal_backend().clone(),
|
session.terminal_backend().clone(),
|
||||||
) {
|
) {
|
||||||
Ok((effective, toolset)) => {
|
Ok((effective, toolset)) => {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
//! Tool config resolution pipeline.
|
//! 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())`
|
//! 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())`
|
//! 2. `merged = merge_mcp_tools(effective_tool_config, shared.mcp_servers.snapshot())`
|
||||||
//! 3. `merged = merge_hub_tools(merged, shared.hub_tools_snapshot())`
|
//! 3. `filtered = config.capability_mode.filter(merged)`
|
||||||
//! 4. `filtered = config.capability_mode.filter(merged)`
|
//! 4. `toolset = build_finalized_toolset(filtered, &session.cwd, &session.session_env, ...)`
|
||||||
//! 5. `toolset = build_finalized_toolset(filtered, &session.cwd, &session.session_env, ...)`
|
|
||||||
use crate::capability::{CapabilityMode, kind_allowed};
|
use crate::capability::{CapabilityMode, kind_allowed};
|
||||||
use crate::config::SessionContextFactory;
|
use crate::config::SessionContextFactory;
|
||||||
use crate::error::{WorkspaceError, WorkspaceResult};
|
use crate::error::{WorkspaceError, WorkspaceResult};
|
||||||
@@ -19,19 +18,16 @@ use std::sync::Arc;
|
|||||||
/// Create-shaped entry of the resolution pipeline: run
|
/// Create-shaped entry of the resolution pipeline: run
|
||||||
/// [`resolve_session_toolset_rebuild`] around a FRESH factory-built
|
/// [`resolve_session_toolset_rebuild`] around a FRESH factory-built
|
||||||
/// session-lifetime terminal backend, and return that backend so the caller
|
/// session-lifetime terminal backend, and return that backend so the caller
|
||||||
/// can store it on the session it is creating. Session-less resolves (the
|
/// can store it on the session it is creating.
|
||||||
/// `__template__` catalog resolve in `connect_hub`) also use this entry and
|
|
||||||
/// simply drop the returned backend with the toolset.
|
|
||||||
pub(crate) fn resolve_session_toolset(
|
pub(crate) fn resolve_session_toolset(
|
||||||
effective_tool_config: ToolServerConfig,
|
effective_tool_config: ToolServerConfig,
|
||||||
capability_mode: CapabilityMode,
|
capability_mode: CapabilityMode,
|
||||||
mcp_snapshot: &[ToolConfig],
|
mcp_snapshot: &[ToolConfig],
|
||||||
hub_snapshot: &[ToolConfig],
|
|
||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
session_env: Arc<HashMap<String, String>>,
|
session_env: Arc<HashMap<String, String>>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
factory: &dyn SessionContextFactory,
|
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>>,
|
lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
|
||||||
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
||||||
notification_handle: Option<kigi_tools::notification::types::ToolNotificationHandle>,
|
notification_handle: Option<kigi_tools::notification::types::ToolNotificationHandle>,
|
||||||
@@ -45,7 +41,6 @@ pub(crate) fn resolve_session_toolset(
|
|||||||
effective_tool_config,
|
effective_tool_config,
|
||||||
capability_mode,
|
capability_mode,
|
||||||
mcp_snapshot,
|
mcp_snapshot,
|
||||||
hub_snapshot,
|
|
||||||
cwd,
|
cwd,
|
||||||
session_env,
|
session_env,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -66,24 +61,23 @@ pub(crate) fn resolve_session_toolset(
|
|||||||
///
|
///
|
||||||
/// Returns the *unmodified* `effective_tool_config` (step-1 baseline) so
|
/// Returns the *unmodified* `effective_tool_config` (step-1 baseline) so
|
||||||
/// the caller can store it on the session. The FinalizedToolset reflects
|
/// 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
|
/// **MCP-origin `kind: None` tools are dropped under every non-`All`
|
||||||
/// every non-`All` mode.** Baseline `kind: None` tools are always kept —
|
/// mode.** Baseline `kind: None` tools are always kept — but before
|
||||||
/// but before filtering, kind-less baseline entries whose id the binary's
|
/// filtering, kind-less baseline entries whose id the binary's registry
|
||||||
/// registry knows get their [`ToolKind`] backfilled (see
|
/// knows get their [`ToolKind`] backfilled (see [`backfill_tool_kinds`]),
|
||||||
/// [`backfill_tool_kinds`]), so the capability filter applies to pinned
|
/// so the capability filter applies to pinned toolsets whose wire entries
|
||||||
/// server-bind toolsets whose wire entries cannot carry a kind.
|
/// cannot carry a kind.
|
||||||
pub(crate) fn resolve_session_toolset_rebuild(
|
pub(crate) fn resolve_session_toolset_rebuild(
|
||||||
effective_tool_config: ToolServerConfig,
|
effective_tool_config: ToolServerConfig,
|
||||||
capability_mode: CapabilityMode,
|
capability_mode: CapabilityMode,
|
||||||
mcp_snapshot: &[ToolConfig],
|
mcp_snapshot: &[ToolConfig],
|
||||||
hub_snapshot: &[ToolConfig],
|
|
||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
session_env: Arc<HashMap<String, String>>,
|
session_env: Arc<HashMap<String, String>>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
factory: &dyn SessionContextFactory,
|
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>>,
|
lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
|
||||||
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
||||||
notification_handle: Option<kigi_tools::notification::types::ToolNotificationHandle>,
|
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);
|
builder = builder.with_local_registry(lr);
|
||||||
}
|
}
|
||||||
let baseline = backfill_tool_kinds(&effective_tool_config, &builder.known_tool_kinds());
|
let baseline = backfill_tool_kinds(&effective_tool_config, &builder.known_tool_kinds());
|
||||||
let filtered = merge_and_filter(
|
let finalize_config = merge_and_filter(&baseline, mcp_snapshot, capability_mode, session_id);
|
||||||
&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 mut ctx = factory.build_session_context(session_id, cwd, session_env, terminal_backend);
|
let mut ctx = factory.build_session_context(session_id, cwd, session_env, terminal_backend);
|
||||||
if let Some(lsp_handle) = lsp {
|
if let Some(lsp_handle) = lsp {
|
||||||
ctx.lsp = Some(lsp_handle);
|
ctx.lsp = Some(lsp_handle);
|
||||||
@@ -157,22 +134,20 @@ fn backfill_tool_kinds(
|
|||||||
behavior_preset: config.behavior_preset.clone(),
|
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 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 3** -- Capability filter: drop tools whose `kind` is not allowed by the mode.
|
||||||
/// - **Step 4** -- Capability filter: drop tools whose `kind` is not allowed by the mode.
|
/// External (MCP) `kind: None` tools are only kept under `CapabilityMode::All`.
|
||||||
/// External (MCP/hub) `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(
|
pub(crate) fn merge_and_filter(
|
||||||
baseline: &ToolServerConfig,
|
baseline: &ToolServerConfig,
|
||||||
mcp_snapshot: &[ToolConfig],
|
mcp_snapshot: &[ToolConfig],
|
||||||
hub_snapshot: &[ToolConfig],
|
|
||||||
mode: CapabilityMode,
|
mode: CapabilityMode,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> ToolServerConfig {
|
) -> ToolServerConfig {
|
||||||
if mcp_snapshot.is_empty() && hub_snapshot.is_empty() {
|
if mcp_snapshot.is_empty() {
|
||||||
return mode.filter(baseline);
|
return mode.filter(baseline);
|
||||||
}
|
}
|
||||||
let baseline_ids: std::collections::HashSet<&str> =
|
let baseline_ids: std::collections::HashSet<&str> =
|
||||||
@@ -187,7 +162,6 @@ pub(crate) fn merge_and_filter(
|
|||||||
.collect();
|
.collect();
|
||||||
let mut tagged: Vec<(ToolConfig, bool)> =
|
let mut tagged: Vec<(ToolConfig, bool)> =
|
||||||
baseline.tools.iter().cloned().map(|t| (t, false)).collect();
|
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 {
|
for mcp_tool in mcp_snapshot {
|
||||||
if baseline_ids.contains(mcp_tool.id.as_str()) {
|
if baseline_ids.contains(mcp_tool.id.as_str()) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -205,35 +179,8 @@ pub(crate) fn merge_and_filter(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
mcp_tool_ids.insert(mcp_tool.id.as_str());
|
|
||||||
tagged.push((mcp_tool.clone(), true));
|
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
|
let kept: Vec<ToolConfig> = tagged
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(tool, is_external)| match tool.kind {
|
.filter(|(tool, is_external)| match tool.kind {
|
||||||
@@ -250,19 +197,13 @@ pub(crate) fn merge_and_filter(
|
|||||||
}
|
}
|
||||||
/// Alias for backward compatibility.
|
/// Alias for backward compatibility.
|
||||||
pub type NoopSessionContextFactory = WorkspaceSessionContextFactory;
|
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
|
/// Sanitize a `session_id` into a single safe filesystem path segment: chars
|
||||||
/// outside `[A-Za-z0-9_-]` become `_`, empty becomes `anon`. When any
|
/// 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
|
/// replacement happened, an 8-hex digest of the ORIGINAL id is appended so the
|
||||||
/// mapping stays injective — plain substitution would collide distinct ids
|
/// mapping stays injective — plain substitution would collide distinct ids
|
||||||
/// (`sess/1` and `sess_1`) into one directory, cross-contaminating
|
/// (`sess/1` and `sess_1`) into one directory, cross-contaminating
|
||||||
/// persistence, rehydration, and [`crate::recovery::cleanup_stale_sessions`].
|
/// persistence and rehydration. Already-safe ids (the common UUID case)
|
||||||
/// Already-safe ids (the common UUID case) map to themselves.
|
/// map to themselves.
|
||||||
fn sanitize_session_id(session_id: &str) -> String {
|
fn sanitize_session_id(session_id: &str) -> String {
|
||||||
let mut safe = String::with_capacity(session_id.len());
|
let mut safe = String::with_capacity(session_id.len());
|
||||||
let mut modified = false;
|
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).
|
/// hazard is the global `environ` array, not the variable's value).
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use crate::ENV_TEST_LOCK as TOOL_STATE_ENV_LOCK;
|
pub(crate) use crate::ENV_TEST_LOCK as TOOL_STATE_ENV_LOCK;
|
||||||
/// [`SessionContextFactory`] for workspace server sessions.
|
/// [`SessionContextFactory`] for workspace 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).
|
|
||||||
///
|
///
|
||||||
/// [`SessionContext::session_folder`] is `/tmp/sessions/<sanitized_id>/`
|
/// [`SessionContext::session_folder`] is `/tmp/sessions/<sanitized_id>/`
|
||||||
/// (terminal logs and other tool artifacts — not the project `cwd`).
|
/// (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_terminal_backend`]: crate::config::SessionContextFactory::build_terminal_backend
|
||||||
/// [`build_session_context`]: crate::config::SessionContextFactory::build_session_context
|
/// [`build_session_context`]: crate::config::SessionContextFactory::build_session_context
|
||||||
/// [`LocalTerminalBackend`]: kigi_tools::computer::local::LocalTerminalBackend
|
/// [`LocalTerminalBackend`]: kigi_tools::computer::local::LocalTerminalBackend
|
||||||
pub struct WorkspaceSessionContextFactory {
|
#[derive(Default)]
|
||||||
auth: Option<kigi_computer_hub_sdk::SharedAuthProvider>,
|
pub struct WorkspaceSessionContextFactory;
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl WorkspaceSessionContextFactory {
|
impl WorkspaceSessionContextFactory {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
/// `/tmp/sessions/<sanitized_id>/` for terminal logs and other tool artifacts.
|
/// `/tmp/sessions/<sanitized_id>/` for terminal logs and other tool artifacts.
|
||||||
fn resolve_session_folder(session_id: &str) -> PathBuf {
|
fn resolve_session_folder(session_id: &str) -> PathBuf {
|
||||||
@@ -397,59 +276,9 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
|||||||
session_env: Arc<HashMap<String, String>>,
|
session_env: Arc<HashMap<String, String>>,
|
||||||
backend: Arc<dyn kigi_tools::computer::types::TerminalBackend>,
|
backend: Arc<dyn kigi_tools::computer::types::TerminalBackend>,
|
||||||
) -> kigi_tools::registry::types::SessionContext {
|
) -> 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)
|
let fs = Arc::new(kigi_tools::computer::local::LocalFs)
|
||||||
as Arc<dyn kigi_tools::computer::types::AsyncFileSystem>;
|
as Arc<dyn kigi_tools::computer::types::AsyncFileSystem>;
|
||||||
let notification_handle = kigi_tools::notification::ToolNotificationHandle::noop();
|
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 {
|
kigi_tools::registry::types::SessionContext {
|
||||||
backend,
|
backend,
|
||||||
fs,
|
fs,
|
||||||
@@ -460,16 +289,18 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
|||||||
owner_session_id: None,
|
owner_session_id: None,
|
||||||
parent_scheduler_handle: None,
|
parent_scheduler_handle: None,
|
||||||
skills: vec![],
|
skills: vec![],
|
||||||
state_path: self.resolve_state_path(session_id),
|
state_path: PathBuf::new(),
|
||||||
memory_backend: None,
|
memory_backend: None,
|
||||||
web_search_config,
|
web_search_config: kigi_tools::implementations::web_search::WebSearchConfig::default(),
|
||||||
web_fetch_config: build_web_fetch_config(),
|
web_fetch_config: build_web_fetch_config(),
|
||||||
lsp: None,
|
lsp: None,
|
||||||
image_gen_config,
|
image_gen_config:
|
||||||
video_gen_config,
|
kigi_tools::implementations::grok_build::image_gen::ImageGenConfig::default(),
|
||||||
app_builder_deployer_config,
|
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,
|
api_key_provider: None,
|
||||||
auth_provider: self.auth.clone(),
|
|
||||||
attribution_callback: None,
|
attribution_callback: None,
|
||||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
||||||
}
|
}
|
||||||
@@ -488,18 +319,6 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
|||||||
IDS.clone()
|
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
|
/// Build web fetch config. Enabled with default params unless
|
||||||
/// `KIGI_DISABLE_WEB_FETCH=1` is set.
|
/// `KIGI_DISABLE_WEB_FETCH=1` is set.
|
||||||
fn build_web_fetch_config() -> kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig {
|
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(),
|
video_gen_config: Default::default(),
|
||||||
app_builder_deployer_config: Default::default(),
|
app_builder_deployer_config: Default::default(),
|
||||||
api_key_provider: None,
|
api_key_provider: None,
|
||||||
auth_provider: None,
|
|
||||||
attribution_callback: None,
|
attribution_callback: None,
|
||||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
||||||
}
|
}
|
||||||
@@ -635,7 +453,6 @@ mod tests {
|
|||||||
baseline,
|
baseline,
|
||||||
CapabilityMode::ReadWrite,
|
CapabilityMode::ReadWrite,
|
||||||
&[],
|
&[],
|
||||||
&[],
|
|
||||||
cwd,
|
cwd,
|
||||||
empty_env(),
|
empty_env(),
|
||||||
"main",
|
"main",
|
||||||
@@ -672,7 +489,6 @@ mod tests {
|
|||||||
baseline,
|
baseline,
|
||||||
CapabilityMode::ReadWrite,
|
CapabilityMode::ReadWrite,
|
||||||
&snapshot,
|
&snapshot,
|
||||||
&[],
|
|
||||||
PathBuf::from("/tmp"),
|
PathBuf::from("/tmp"),
|
||||||
empty_env(),
|
empty_env(),
|
||||||
"main",
|
"main",
|
||||||
@@ -752,7 +568,6 @@ mod tests {
|
|||||||
baseline,
|
baseline,
|
||||||
CapabilityMode::ReadOnly,
|
CapabilityMode::ReadOnly,
|
||||||
&[],
|
&[],
|
||||||
&[],
|
|
||||||
PathBuf::from("/tmp"),
|
PathBuf::from("/tmp"),
|
||||||
empty_env(),
|
empty_env(),
|
||||||
"main",
|
"main",
|
||||||
@@ -792,13 +607,7 @@ mod tests {
|
|||||||
behavior_preset: None,
|
behavior_preset: None,
|
||||||
};
|
};
|
||||||
let mcp_edit = test_support::tc("mcp.editor", Some(ToolKind::Edit));
|
let mcp_edit = test_support::tc("mcp.editor", Some(ToolKind::Edit));
|
||||||
let filtered = merge_and_filter(
|
let filtered = merge_and_filter(&baseline, &[mcp_edit], CapabilityMode::ReadOnly, "test");
|
||||||
&baseline,
|
|
||||||
&[mcp_edit],
|
|
||||||
&[],
|
|
||||||
CapabilityMode::ReadOnly,
|
|
||||||
"test",
|
|
||||||
);
|
|
||||||
assert!(!filtered.tools.iter().any(|t| t.id == "mcp.editor"));
|
assert!(!filtered.tools.iter().any(|t| t.id == "mcp.editor"));
|
||||||
}
|
}
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -812,13 +621,7 @@ mod tests {
|
|||||||
behavior_preset: None,
|
behavior_preset: None,
|
||||||
};
|
};
|
||||||
let mcp = vec![test_support::tc("mcp.opaque", None)];
|
let mcp = vec![test_support::tc("mcp.opaque", None)];
|
||||||
let filtered = merge_and_filter(
|
let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::ReadOnly, "test_session");
|
||||||
&baseline,
|
|
||||||
&mcp,
|
|
||||||
&[],
|
|
||||||
CapabilityMode::ReadOnly,
|
|
||||||
"test_session",
|
|
||||||
);
|
|
||||||
let kept_ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
let kept_ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
||||||
assert!(
|
assert!(
|
||||||
kept_ids.contains(&"baseline.opaque"),
|
kept_ids.contains(&"baseline.opaque"),
|
||||||
@@ -841,7 +644,7 @@ mod tests {
|
|||||||
behavior_preset: None,
|
behavior_preset: None,
|
||||||
};
|
};
|
||||||
let mcp = vec![test_support::tc("mcp.opaque", 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();
|
let kept_ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
||||||
assert!(
|
assert!(
|
||||||
kept_ids.contains(&"mcp.opaque"),
|
kept_ids.contains(&"mcp.opaque"),
|
||||||
@@ -859,13 +662,7 @@ mod tests {
|
|||||||
let mut mcp_b = test_support::tc("mcp.tool_b", Some(ToolKind::Read));
|
let mut mcp_b = test_support::tc("mcp.tool_b", Some(ToolKind::Read));
|
||||||
mcp_b.name_override = Some("shared_name".into());
|
mcp_b.name_override = Some("shared_name".into());
|
||||||
let mcp = vec![mcp_a, mcp_b];
|
let mcp = vec![mcp_a, mcp_b];
|
||||||
let filtered = merge_and_filter(
|
let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::ReadOnly, "test_session");
|
||||||
&baseline,
|
|
||||||
&mcp,
|
|
||||||
&[],
|
|
||||||
CapabilityMode::ReadOnly,
|
|
||||||
"test_session",
|
|
||||||
);
|
|
||||||
let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
||||||
assert!(ids.contains(&"mcp.tool_a"), "first wins: {ids:?}");
|
assert!(ids.contains(&"mcp.tool_a"), "first wins: {ids:?}");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -874,144 +671,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn factory_session_folder_is_tmp_sessions_not_project_cwd() {
|
||||||
let cwd = PathBuf::from("/workspace");
|
let cwd = PathBuf::from("/workspace");
|
||||||
let folder = WorkspaceSessionContextFactory::resolve_session_folder("sess-1");
|
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
|
/// A hostile `session_id` (`../../etc`) is sanitized to a single safe
|
||||||
/// segment and cannot traverse outside `<home>/sessions/`.
|
/// segment and cannot traverse outside `<home>/sessions/`.
|
||||||
#[test]
|
#[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 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 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!(
|
assert!(
|
||||||
p.starts_with(&sessions),
|
session_dir.starts_with(&sessions),
|
||||||
"state path escaped sessions/: {}",
|
"session dir escaped sessions/: {}",
|
||||||
p.display()
|
session_dir.display()
|
||||||
);
|
);
|
||||||
let session_dir = p.parent().expect("state path has a parent dir");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session_dir.parent(),
|
session_dir.parent(),
|
||||||
Some(sessions.as_path()),
|
Some(sessions.as_path()),
|
||||||
@@ -1123,7 +780,6 @@ mod tests {
|
|||||||
test_support::baseline_config(),
|
test_support::baseline_config(),
|
||||||
CapabilityMode::ReadWrite,
|
CapabilityMode::ReadWrite,
|
||||||
&[],
|
&[],
|
||||||
&[],
|
|
||||||
cwd.clone(),
|
cwd.clone(),
|
||||||
empty_env(),
|
empty_env(),
|
||||||
"sess-A",
|
"sess-A",
|
||||||
@@ -1145,7 +801,6 @@ mod tests {
|
|||||||
test_support::baseline_config(),
|
test_support::baseline_config(),
|
||||||
CapabilityMode::ReadWrite,
|
CapabilityMode::ReadWrite,
|
||||||
&[],
|
&[],
|
||||||
&[],
|
|
||||||
cwd.clone(),
|
cwd.clone(),
|
||||||
empty_env(),
|
empty_env(),
|
||||||
"sess-A",
|
"sess-A",
|
||||||
@@ -1170,7 +825,6 @@ mod tests {
|
|||||||
test_support::baseline_config(),
|
test_support::baseline_config(),
|
||||||
CapabilityMode::ReadWrite,
|
CapabilityMode::ReadWrite,
|
||||||
&[],
|
&[],
|
||||||
&[],
|
|
||||||
cwd,
|
cwd,
|
||||||
empty_env(),
|
empty_env(),
|
||||||
"sess-B",
|
"sess-B",
|
||||||
|
|||||||
@@ -1,30 +1,22 @@
|
|||||||
//! [`WorkspaceOps`] — dual-mode workspace operations handle.
|
//! [`WorkspaceOps`] — workspace operations handle.
|
||||||
//!
|
//!
|
||||||
//! Two modes:
|
//! Extensions dispatch through [`WorkspaceHandle`]; tool calls dispatch
|
||||||
//!
|
//! through the workspace session's [`FinalizedToolset`]. The toolset is
|
||||||
//! - **`Local`** — extensions dispatch through [`WorkspaceHandle`]; tool
|
//! installed via [`WorkspaceOps::bind_local_session`] after the agent is
|
||||||
//! calls dispatch through the workspace session's [`FinalizedToolset`].
|
//! built.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! ## Type safety
|
//! ## 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
|
//! [`WorkspaceRpc`]. The struct carries a `METHOD` constant and derives
|
||||||
//! `Serialize + Deserialize`. Both the proxy client (`WorkspaceOps`) and
|
//! `Serialize + Deserialize`, so op identity and payload shape live in
|
||||||
//! the server (`WorkspaceRpcHandler::dispatch`) use the same struct —
|
//! one place.
|
||||||
//! add/rename a field and the compiler catches both sides.
|
|
||||||
use crate::error::{WorkspaceError, WorkspaceResult};
|
use crate::error::{WorkspaceError, WorkspaceResult};
|
||||||
use crate::file_system::ContentSearchRequest;
|
use crate::file_system::ContentSearchRequest;
|
||||||
use crate::handle::WorkspaceHandle;
|
use crate::handle::WorkspaceHandle;
|
||||||
use crate::worktree::{ApplyWorktreeRequest, CreateWorktreeRequest, RemoveWorktreeRequest};
|
use crate::worktree::{ApplyWorktreeRequest, CreateWorktreeRequest, RemoveWorktreeRequest};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use kigi_computer_hub_sdk::ToolHarness;
|
|
||||||
use kigi_tools::types::output::ToolRunResult;
|
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::WorkspaceRpc;
|
||||||
pub use kigi_workspace_types::rpc::agents_md::DiscoverAgentsMdReq;
|
pub use kigi_workspace_types::rpc::agents_md::DiscoverAgentsMdReq;
|
||||||
pub use kigi_workspace_types::rpc::code_nav::{
|
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,
|
CodeIndexStats, CodeIndexStatusReq, CodeIndexStatusResponse, CodeNavLocation, CodeNavResponse,
|
||||||
};
|
};
|
||||||
pub use kigi_workspace_types::rpc::fs::{
|
pub use kigi_workspace_types::rpc::fs::{
|
||||||
ClientFsListNode, ClientFsListReq, ClientFsListRes, ClientFsReadFileReq, ClientFsReadFileRes,
|
GetFileEntry, GetFileResult, GetFilesReq, GetFilesRes, PutFileEntry, PutFileResult,
|
||||||
ClientFsStatReq, ClientFsStatRes, GetFileEntry, GetFileResult, GetFilesReq, GetFilesRes,
|
PutFilesReq, PutFilesRes,
|
||||||
PutFileEntry, PutFileResult, PutFilesReq, PutFilesRes,
|
|
||||||
};
|
};
|
||||||
pub use kigi_workspace_types::rpc::git::{
|
pub use kigi_workspace_types::rpc::git::{
|
||||||
BinaryFileInfoData, CheckoutCommitResponse, CommitWithPatchData, DetectVcsKindReq,
|
BinaryFileInfoData, CheckoutCommitResponse, CommitWithPatchData, DetectVcsKindReq,
|
||||||
@@ -69,7 +60,6 @@ use serde::de::DeserializeOwned;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::AtomicBool;
|
|
||||||
/// Implements [`WorkspaceRpc`] for request types whose responses
|
/// Implements [`WorkspaceRpc`] for request types whose responses
|
||||||
/// reference crate-internal types and so cannot live in the types crate.
|
/// reference crate-internal types and so cannot live in the types crate.
|
||||||
macro_rules! workspace_rpc {
|
macro_rules! workspace_rpc {
|
||||||
@@ -214,7 +204,7 @@ fn session_tracker(
|
|||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
) -> WorkspaceResult<kigi_hunk_tracker::HunkTrackerHandle> {
|
) -> WorkspaceResult<kigi_hunk_tracker::HunkTrackerHandle> {
|
||||||
let sid = session_id
|
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
|
let session = ws
|
||||||
.session(sid)
|
.session(sid)
|
||||||
.ok_or_else(|| WorkspaceError::SessionNotFound(sid.to_owned()))?;
|
.ok_or_else(|| WorkspaceError::SessionNotFound(sid.to_owned()))?;
|
||||||
@@ -251,13 +241,13 @@ impl WorkspaceOp for GitStatusExtReq {
|
|||||||
self.include_patches,
|
self.include_patches,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||||
Ok(GitStatusExtResponse::structured(data))
|
Ok(GitStatusExtResponse::structured(data))
|
||||||
}
|
}
|
||||||
GitStatusFormat::Prompt => {
|
GitStatusFormat::Prompt => {
|
||||||
let result = crate::file_system::git_status(cwd)
|
let result = crate::file_system::git_status(cwd)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))?;
|
||||||
Ok(GitStatusExtResponse::prompt(result))
|
Ok(GitStatusExtResponse::prompt(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +263,7 @@ impl WorkspaceOp for GitFilesReq {
|
|||||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||||
crate::session::git::read_files(&cwd, &self.paths, &self.version)
|
crate::session::git::read_files(&cwd, &self.paths, &self.version)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -294,7 +284,7 @@ impl WorkspaceOp for GitDiffReq {
|
|||||||
self.merge_base,
|
self.merge_base,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -307,7 +297,7 @@ impl WorkspaceOp for GitStageReq {
|
|||||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||||
crate::session::git::stage(&cwd, self.paths.clone())
|
crate::session::git::stage(&cwd, self.paths.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -320,7 +310,7 @@ impl WorkspaceOp for GitStageContentReq {
|
|||||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||||
crate::session::git::stage_content(&cwd, &self.path, &self.content)
|
crate::session::git::stage_content(&cwd, &self.path, &self.content)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -333,7 +323,7 @@ impl WorkspaceOp for GitUnstageReq {
|
|||||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||||
crate::session::git::unstage(&cwd, self.paths.clone())
|
crate::session::git::unstage(&cwd, self.paths.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -346,7 +336,7 @@ impl WorkspaceOp for GitDiscardReq {
|
|||||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||||
crate::session::git::discard(&cwd, self.paths.clone(), self.scope, self.include_untracked)
|
crate::session::git::discard(&cwd, self.paths.clone(), self.scope, self.include_untracked)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -366,7 +356,7 @@ impl WorkspaceOp for GitCommitReq {
|
|||||||
self.sync,
|
self.sync,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -379,7 +369,7 @@ impl WorkspaceOp for GitCheckoutReq {
|
|||||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||||
crate::session::git::checkout_branch(&cwd, &self.branch, self.create)
|
crate::session::git::checkout_branch(&cwd, &self.branch, self.create)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -392,7 +382,7 @@ impl WorkspaceOp for GitStashReq {
|
|||||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||||
crate::session::git::stash(&cwd, self.include_untracked)
|
crate::session::git::stash(&cwd, self.include_untracked)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -405,7 +395,7 @@ impl WorkspaceOp for GitInfoReq {
|
|||||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||||
crate::session::git::git_info(&cwd)
|
crate::session::git::git_info(&cwd)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -418,7 +408,7 @@ impl WorkspaceOp for GitBranchesReq {
|
|||||||
let cwd = git_op_cwd(ws, &self.git_root)?;
|
let cwd = git_op_cwd(ws, &self.git_root)?;
|
||||||
crate::session::git::list_branches(&cwd)
|
crate::session::git::list_branches(&cwd)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -429,7 +419,7 @@ impl WorkspaceOp for GitCollectChangesReq {
|
|||||||
_session_id: Option<&str>,
|
_session_id: Option<&str>,
|
||||||
) -> WorkspaceResult<Self::Response> {
|
) -> WorkspaceResult<Self::Response> {
|
||||||
{
|
{
|
||||||
return Err(WorkspaceError::HubError(
|
return Err(WorkspaceError::Internal(
|
||||||
"git collect changes is unavailable in this build".to_string(),
|
"git collect changes is unavailable in this build".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -550,7 +540,7 @@ impl WorkspaceOp for PrepareWorktreeFromWorktreeReq {
|
|||||||
spawn_task: result.spawn_task,
|
spawn_task: result.spawn_task,
|
||||||
response: Some(
|
response: Some(
|
||||||
serde_json::to_value(&resp)
|
serde_json::to_value(&resp)
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?,
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))?,
|
||||||
),
|
),
|
||||||
error: None,
|
error: None,
|
||||||
}),
|
}),
|
||||||
@@ -572,7 +562,7 @@ impl WorkspaceOp for CreateWorktreeFromWorktreeSyncReq {
|
|||||||
let req = crate::worktree::CreateWorktreeFromWorktreeRequest::from(self.inner.clone());
|
let req = crate::worktree::CreateWorktreeFromWorktreeRequest::from(self.inner.clone());
|
||||||
crate::worktree::create_worktree_from_worktree_sync(&req)
|
crate::worktree::create_worktree_from_worktree_sync(&req)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -583,8 +573,8 @@ impl WorkspaceOp for WorktreeDbRebuildReq {
|
|||||||
_session_id: Option<&str>,
|
_session_id: Option<&str>,
|
||||||
) -> WorkspaceResult<Self::Response> {
|
) -> WorkspaceResult<Self::Response> {
|
||||||
let report = crate::worktree::worktree_db_rebuild()
|
let report = crate::worktree::worktree_db_rebuild()
|
||||||
.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::HubError(e.to_string()))
|
serde_json::to_value(report).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -595,7 +585,7 @@ impl WorkspaceOp for WorktreeDbPathReq {
|
|||||||
_session_id: Option<&str>,
|
_session_id: Option<&str>,
|
||||||
) -> WorkspaceResult<Self::Response> {
|
) -> WorkspaceResult<Self::Response> {
|
||||||
let path = crate::worktree::worktree_db_path()
|
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 {
|
Ok(WorktreeDbPathResponse {
|
||||||
path: Some(path.display().to_string()),
|
path: Some(path.display().to_string()),
|
||||||
})
|
})
|
||||||
@@ -864,8 +854,8 @@ fn hook_registry_to_wire(
|
|||||||
registry: &kigi_hooks::discovery::HookRegistry,
|
registry: &kigi_hooks::discovery::HookRegistry,
|
||||||
) -> WorkspaceResult<HookRegistryWire> {
|
) -> WorkspaceResult<HookRegistryWire> {
|
||||||
let value =
|
let value =
|
||||||
serde_json::to_value(registry).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::HubError(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
|
/// Inverse of [`hook_registry_to_wire`]. The compiled `matcher` is absent from
|
||||||
/// the wire (and from this result); callers recompile it via
|
/// the wire (and from this result); callers recompile it via
|
||||||
@@ -873,8 +863,8 @@ fn hook_registry_to_wire(
|
|||||||
fn wire_to_hook_registry(
|
fn wire_to_hook_registry(
|
||||||
wire: &HookRegistryWire,
|
wire: &HookRegistryWire,
|
||||||
) -> WorkspaceResult<kigi_hooks::discovery::HookRegistry> {
|
) -> WorkspaceResult<kigi_hooks::discovery::HookRegistry> {
|
||||||
let value = serde_json::to_value(wire).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::HubError(e.to_string()))
|
serde_json::from_value(value).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl WorkspaceOp for HookRegistryReq {
|
impl WorkspaceOp for HookRegistryReq {
|
||||||
@@ -886,56 +876,6 @@ impl WorkspaceOp for HookRegistryReq {
|
|||||||
hook_registry_to_wire(&ws.hook_registry())
|
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
|
/// 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.
|
/// `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
|
/// Without this, code nav in a non-primary window would query the launch
|
||||||
@@ -972,7 +912,7 @@ impl WorkspaceOp for CodeGotoDefinitionReq {
|
|||||||
let result = handle
|
let result = handle
|
||||||
.goto_definition(std::path::PathBuf::from(&self.file), self.line, self.col)
|
.goto_definition(std::path::PathBuf::from(&self.file), self.line, self.col)
|
||||||
.await
|
.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))
|
Ok(query_result_to_response(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -992,7 +932,7 @@ impl WorkspaceOp for CodeGotoReferencesReq {
|
|||||||
self.include_definition,
|
self.include_definition,
|
||||||
)
|
)
|
||||||
.await
|
.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))
|
Ok(query_result_to_response(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1010,7 +950,7 @@ impl WorkspaceOp for CodeFindDefinitionsReq {
|
|||||||
self.context_file.as_ref().map(std::path::PathBuf::from),
|
self.context_file.as_ref().map(std::path::PathBuf::from),
|
||||||
)
|
)
|
||||||
.await
|
.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))
|
Ok(symbol_locations_to_response(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1028,7 +968,7 @@ impl WorkspaceOp for CodeFindReferencesReq {
|
|||||||
self.context_file.as_ref().map(std::path::PathBuf::from),
|
self.context_file.as_ref().map(std::path::PathBuf::from),
|
||||||
)
|
)
|
||||||
.await
|
.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))
|
Ok(symbol_locations_to_response(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1105,9 +1045,9 @@ impl WorkspaceOp for CreateWorktreeRequest {
|
|||||||
let result = crate::worktree::prepare_worktree_creation(self).await;
|
let result = crate::worktree::prepare_worktree_creation(self).await;
|
||||||
match result.response {
|
match result.response {
|
||||||
Ok(resp) => {
|
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 copy_ctx = crate::worktree::BackgroundCopyContext::new();
|
||||||
let result = crate::worktree::remove_worktree(self, ©_ctx)
|
let result = crate::worktree::remove_worktree(self, ©_ctx)
|
||||||
.await
|
.await
|
||||||
.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::HubError(e.to_string()))
|
serde_json::to_value(result).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -1134,8 +1074,8 @@ impl WorkspaceOp for ApplyWorktreeRequest {
|
|||||||
) -> WorkspaceResult<Self::Response> {
|
) -> WorkspaceResult<Self::Response> {
|
||||||
let result = crate::worktree::apply_worktree(self)
|
let result = crate::worktree::apply_worktree(self)
|
||||||
.await
|
.await
|
||||||
.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::HubError(e.to_string()))
|
serde_json::to_value(result).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -1147,8 +1087,8 @@ impl WorkspaceOp for WorktreeListReq {
|
|||||||
) -> WorkspaceResult<Self::Response> {
|
) -> WorkspaceResult<Self::Response> {
|
||||||
let records =
|
let records =
|
||||||
crate::worktree::list_worktrees(self.repo.as_deref(), &self.types, self.include_all)
|
crate::worktree::list_worktrees(self.repo.as_deref(), &self.types, self.include_all)
|
||||||
.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::HubError(e.to_string()))
|
serde_json::to_value(records).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -1159,8 +1099,8 @@ impl WorkspaceOp for WorktreeShowReq {
|
|||||||
_session_id: Option<&str>,
|
_session_id: Option<&str>,
|
||||||
) -> WorkspaceResult<Self::Response> {
|
) -> WorkspaceResult<Self::Response> {
|
||||||
let record = crate::worktree::show_worktree(&self.id_or_path)
|
let record = crate::worktree::show_worktree(&self.id_or_path)
|
||||||
.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::HubError(e.to_string()))
|
serde_json::to_value(record).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -1175,9 +1115,9 @@ impl WorkspaceOp for WorktreeGcReq {
|
|||||||
crate::worktree::gc_worktrees_mgmt(dry_run, max_age_secs, force)
|
crate::worktree::gc_worktrees_mgmt(dry_run, max_age_secs, force)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkspaceError::HubError(e.to_string()))?
|
.map_err(|e| WorkspaceError::Internal(e.to_string()))?
|
||||||
.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::HubError(e.to_string()))
|
serde_json::to_value(report).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -1188,27 +1128,22 @@ impl WorkspaceOp for WorktreeDbStatsReq {
|
|||||||
_session_id: Option<&str>,
|
_session_id: Option<&str>,
|
||||||
) -> WorkspaceResult<Self::Response> {
|
) -> WorkspaceResult<Self::Response> {
|
||||||
let stats = crate::worktree::worktree_db_stats()
|
let stats = crate::worktree::worktree_db_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::HubError(e.to_string()))
|
serde_json::to_value(stats).map_err(|e| WorkspaceError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Dual-mode workspace operations handle.
|
/// Dual-mode workspace operations handle.
|
||||||
///
|
///
|
||||||
/// - **`Local`** — wraps a [`WorkspaceHandle`]. Extensions dispatch
|
/// Wraps a [`WorkspaceHandle`]. Extensions dispatch through the handle;
|
||||||
/// through the handle; tool calls dispatch through the workspace
|
/// tool calls dispatch through the workspace session's
|
||||||
/// session's [`FinalizedToolset`](kigi_tools::registry::types::FinalizedToolset).
|
/// [`FinalizedToolset`](kigi_tools::registry::types::FinalizedToolset).
|
||||||
/// Call [`bind_local_session`](Self::bind_local_session) after building
|
/// Call [`bind_local_session`](Self::bind_local_session) after building
|
||||||
/// the agent to install the toolset on the workspace session.
|
/// 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.
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum WorkspaceOps {
|
pub enum WorkspaceOps {
|
||||||
/// Local in-process mode — extensions through the handle, tool calls
|
/// Local in-process mode — extensions through the handle, tool calls
|
||||||
/// through the workspace session's toolset.
|
/// through the workspace session's toolset.
|
||||||
Local { handle: WorkspaceHandle },
|
Local { handle: WorkspaceHandle },
|
||||||
/// Proxy mode — routes through hub RPC.
|
|
||||||
Proxy { client: WorkspaceClient },
|
|
||||||
}
|
}
|
||||||
impl WorkspaceOps {
|
impl WorkspaceOps {
|
||||||
/// Construct a local-mode ops handle.
|
/// Construct a local-mode ops handle.
|
||||||
@@ -1219,36 +1154,10 @@ impl WorkspaceOps {
|
|||||||
pub fn local(handle: WorkspaceHandle) -> Self {
|
pub fn local(handle: WorkspaceHandle) -> Self {
|
||||||
Self::Local { handle }
|
Self::Local { handle }
|
||||||
}
|
}
|
||||||
/// Construct a proxy-mode ops handle.
|
/// Access the underlying workspace 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).
|
|
||||||
pub fn workspace_handle(&self) -> Option<&WorkspaceHandle> {
|
pub fn workspace_handle(&self) -> Option<&WorkspaceHandle> {
|
||||||
match self {
|
match self {
|
||||||
Self::Local { handle } => Some(handle),
|
Self::Local { handle } => Some(handle),
|
||||||
Self::Proxy { .. } => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Create the workspace session and bind the agent's toolset for local mode.
|
/// 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>,
|
toolset: Arc<kigi_tools::registry::types::FinalizedToolset>,
|
||||||
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
viewer_ctx: Option<kigi_tool_runtime::WorkspaceViewerContext>,
|
||||||
) -> WorkspaceResult<()> {
|
) -> WorkspaceResult<()> {
|
||||||
let Self::Local { handle } = self else {
|
let Self::Local { handle } = self;
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
if handle.session(session_id).is_none() {
|
if handle.session(session_id).is_none() {
|
||||||
handle.create_session_with_tracker_and_viewer_ctx(
|
handle.create_session_with_tracker_and_viewer_ctx(
|
||||||
session_id,
|
session_id,
|
||||||
@@ -1296,9 +1203,7 @@ impl WorkspaceOps {
|
|||||||
}
|
}
|
||||||
/// Release the workspace session. No-op in proxy mode.
|
/// Release the workspace session. No-op in proxy mode.
|
||||||
pub fn end_local_session(&self, session_id: &str) {
|
pub fn end_local_session(&self, session_id: &str) {
|
||||||
let Self::Local { handle } = self else {
|
let Self::Local { handle } = self;
|
||||||
return;
|
|
||||||
};
|
|
||||||
handle.on_session_ended(session_id);
|
handle.on_session_ended(session_id);
|
||||||
if let Err(e) = handle.drop_session(session_id, session_id) {
|
if let Err(e) = handle.drop_session(session_id, session_id) {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -1312,82 +1217,26 @@ impl WorkspaceOps {
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
payload: &kigi_tool_protocol::turn_hook::BeforeTurnPayload,
|
payload: &kigi_tool_protocol::turn_hook::BeforeTurnPayload,
|
||||||
) {
|
) {
|
||||||
match self {
|
let Self::Local { handle } = self;
|
||||||
Self::Local { handle } => {
|
handle.on_before_turn(session_id, payload).await;
|
||||||
handle.on_before_turn(session_id, payload).await;
|
|
||||||
}
|
|
||||||
Self::Proxy { .. } => {
|
|
||||||
tracing::debug!("on_before_turn called on Proxy WorkspaceOps (no-op)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pub async fn on_after_turn(
|
pub async fn on_after_turn(
|
||||||
&self,
|
&self,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
payload: &kigi_tool_protocol::turn_hook::AfterTurnPayload,
|
payload: &kigi_tool_protocol::turn_hook::AfterTurnPayload,
|
||||||
) {
|
) {
|
||||||
match self {
|
let Self::Local { handle } = self;
|
||||||
Self::Local { handle } => {
|
handle.on_after_turn(session_id, payload).await;
|
||||||
handle.on_after_turn(session_id, payload).await;
|
|
||||||
}
|
|
||||||
Self::Proxy { .. } => {
|
|
||||||
tracing::debug!("on_after_turn called on Proxy WorkspaceOps (no-op)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pub async fn rpc_raw(&self, method: &str, params: Value) -> WorkspaceResult<Value> {
|
/// Dispatch a typed operation: calls `op.execute(handle, session_id)`.
|
||||||
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()`.
|
|
||||||
pub async fn dispatch<Op: WorkspaceOp>(
|
pub async fn dispatch<Op: WorkspaceOp>(
|
||||||
&self,
|
&self,
|
||||||
op: &Op,
|
op: &Op,
|
||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
) -> WorkspaceResult<Op::Response> {
|
) -> WorkspaceResult<Op::Response> {
|
||||||
let mode = match self {
|
tracing::debug!(method = Op::METHOD, "WorkspaceOps::dispatch");
|
||||||
Self::Local { .. } => "local",
|
let Self::Local { handle } = self;
|
||||||
Self::Proxy { .. } => "proxy",
|
op.execute(handle, session_id).await
|
||||||
};
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
/// Get git status with configurable output format.
|
/// Get git status with configurable output format.
|
||||||
///
|
///
|
||||||
@@ -1408,52 +1257,9 @@ impl WorkspaceOps {
|
|||||||
let wire = self.dispatch(&HookRegistryReq {}, None).await?;
|
let wire = self.dispatch(&HookRegistryReq {}, None).await?;
|
||||||
wire_to_hook_registry(&wire)
|
wire_to_hook_registry(&wire)
|
||||||
}
|
}
|
||||||
pub async fn begin_prompt(&self, session_id: &str, prompt_index: usize) -> WorkspaceResult<()> {
|
/// Dispatch a tool call through the workspace session's
|
||||||
self.rpc(&BeginPromptReq {
|
/// [`FinalizedToolset`](kigi_tools::registry::types::FinalizedToolset)
|
||||||
session_id: session_id.to_owned(),
|
/// (in-process). Requires `session_id` to look up the session.
|
||||||
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).
|
|
||||||
pub async fn call_tool(
|
pub async fn call_tool(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -1461,57 +1267,23 @@ impl WorkspaceOps {
|
|||||||
call_id: &str,
|
call_id: &str,
|
||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
) -> Result<ToolRunResult, kigi_tool_runtime::ToolError> {
|
) -> Result<ToolRunResult, kigi_tool_runtime::ToolError> {
|
||||||
match self {
|
let Self::Local { handle } = self;
|
||||||
Self::Local { handle } => {
|
let session_id = session_id.ok_or_else(|| {
|
||||||
let session_id = session_id.ok_or_else(|| {
|
kigi_tool_runtime::ToolError::custom(
|
||||||
kigi_tool_runtime::ToolError::custom(
|
"missing_session",
|
||||||
"missing_session",
|
"session_id required for local tool dispatch",
|
||||||
"session_id required for local tool dispatch",
|
)
|
||||||
)
|
})?;
|
||||||
})?;
|
let session = handle.session(session_id).ok_or_else(|| {
|
||||||
let session = handle.session(session_id).ok_or_else(|| {
|
kigi_tool_runtime::ToolError::custom(
|
||||||
kigi_tool_runtime::ToolError::custom(
|
"session_not_found",
|
||||||
"session_not_found",
|
format!(
|
||||||
format!(
|
"workspace session not found: {session_id} \
|
||||||
"workspace session not found: {session_id} \
|
— call bind_local_session() first"
|
||||||
— call bind_local_session() first"
|
),
|
||||||
),
|
)
|
||||||
)
|
})?;
|
||||||
})?;
|
session.toolset().call(name, args, call_id, None).await
|
||||||
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}"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(any(test, feature = "test-support"))]
|
#[cfg(any(test, feature = "test-support"))]
|
||||||
@@ -1569,9 +1341,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn git_op_cwd_uses_explicit_git_root_per_window() {
|
fn git_op_cwd_uses_explicit_git_root_per_window() {
|
||||||
let ops = WorkspaceOps::for_test();
|
let ops = WorkspaceOps::for_test();
|
||||||
let WorkspaceOps::Local { handle } = &ops else {
|
let WorkspaceOps::Local { handle } = &ops;
|
||||||
unreachable!("for_test builds a local handle");
|
|
||||||
};
|
|
||||||
let workspace_root = handle.root_cwd().unwrap();
|
let workspace_root = handle.root_cwd().unwrap();
|
||||||
let window_a = std::path::PathBuf::from("/repos/xai-main");
|
let window_a = std::path::PathBuf::from("/repos/xai-main");
|
||||||
let window_b = std::path::PathBuf::from("/repos/xai-main-2");
|
let window_b = std::path::PathBuf::from("/repos/xai-main-2");
|
||||||
@@ -1594,9 +1364,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn end_local_session_drops_bound_toolset() {
|
async fn end_local_session_drops_bound_toolset() {
|
||||||
let ops = WorkspaceOps::for_test();
|
let ops = WorkspaceOps::for_test();
|
||||||
let WorkspaceOps::Local { handle } = &ops else {
|
let WorkspaceOps::Local { handle } = &ops;
|
||||||
unreachable!("for_test builds a local handle");
|
|
||||||
};
|
|
||||||
let sid = "sess-teardown";
|
let sid = "sess-teardown";
|
||||||
let toolset =
|
let toolset =
|
||||||
std::sync::Arc::new(kigi_tools::registry::types::FinalizedToolset::empty_for_test());
|
std::sync::Arc::new(kigi_tools::registry::types::FinalizedToolset::empty_for_test());
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ license = "Apache-2.0"
|
|||||||
name = "kigi-tool-protocol"
|
name = "kigi-tool-protocol"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
description = "Wire-protocol types for the xAI Computer Hub"
|
description = "Tool wire-protocol types"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kigi-tool-types = { workspace = true }
|
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,
|
//! Identifier newtypes, registration payloads, capabilities, hook events,
|
||||||
//! handshake messages, the JSON-RPC 2.0 envelope and method catalog, the
|
//! handshake messages, the JSON-RPC 2.0 envelope and method catalog, the
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ license = "Apache-2.0"
|
|||||||
name = "kigi-tool-runtime"
|
name = "kigi-tool-runtime"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.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]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
indexmap = { workspace = true }
|
||||||
|
parking_lot = { workspace = true }
|
||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio-util = { workspace = true }
|
tokio-util = { workspace = true }
|
||||||
kigi-tools-api = { workspace = true }
|
|
||||||
kigi-tool-protocol = { workspace = true }
|
kigi-tool-protocol = { workspace = true }
|
||||||
kigi-tool-types = { workspace = true }
|
kigi-tool-types = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -152,185 +152,3 @@ pub struct WorkspaceViewerContext {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stream_tool_progress: bool,
|
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`,
|
//! Single home for the `Tool` trait, `ToolDispatch`, `ToolError`,
|
||||||
//! `ToolNotification`, `ToolSearchIndex`, `ToolCallContext`, `ToolStream`,
|
//! `ToolNotification`, `ToolSearchIndex`, `ToolCallContext`, `ToolStream`,
|
||||||
//! and the helper constructors that build well-formed streams. Adapters
|
//! the in-process `LocalRegistry`, and the helper constructors that build
|
||||||
//! for individual tool sources re-export from here so every tool author
|
//! well-formed streams. Adapters for individual tool sources re-export
|
||||||
//! sees the same surface.
|
//! from here so every tool author sees the same surface.
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod dispatch;
|
pub mod dispatch;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod local_registry;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
@@ -19,10 +20,11 @@ pub mod tool;
|
|||||||
|
|
||||||
pub use context::{
|
pub use context::{
|
||||||
BehaviorVersion, Cancellation, Cwd, ListToolsContext, SessionContext, ToolCallContext,
|
BehaviorVersion, Cancellation, Cwd, ListToolsContext, SessionContext, ToolCallContext,
|
||||||
TraceContext, TypedExtensions, WorkspaceBindMetadata, WorkspaceViewerContext,
|
TraceContext, TypedExtensions, WorkspaceViewerContext,
|
||||||
};
|
};
|
||||||
pub use dispatch::ToolDispatch;
|
pub use dispatch::ToolDispatch;
|
||||||
pub use error::{ToolError, ToolErrorKind};
|
pub use error::{ToolError, ToolErrorKind};
|
||||||
|
pub use local_registry::LocalRegistry;
|
||||||
pub use notification::{
|
pub use notification::{
|
||||||
BashExecutionBackgrounded, BashExecutionComplete, BashExecutionFailed, BashExecutionTimeout,
|
BashExecutionBackgrounded, BashExecutionComplete, BashExecutionFailed, BashExecutionTimeout,
|
||||||
BashNotificationBase, BashOutputChunk, FileRead, FileWritten, LspServerCrashed,
|
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"
|
name = "kigi-tool-types"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
description = "Canonical tool-description types for the xAI platform"
|
description = "Canonical tool-description types"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Enables `BuiltinSubagent::render_prompt` (MiniJinja rendering of the
|
# 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