M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
license = "Apache-2.0"
name = "prod-mc-cli-chat-proxy-types"
version.workspace = true
edition.workspace = true
description = "Lightweight request/response types for cli-chat-proxy API"
[features]
default-bazel = []
[dependencies]
chrono = { workspace = true, features = ["serde"] }
serde = { workspace = true }
serde_json.workspace = true
# The canonical requirements-TOML fail_closed parse, shared by the signer
# (cli-chat-proxy) and the client (kigi-config) so they can't drift.
toml.workspace = true
[lints]
workspace = true
@@ -0,0 +1,55 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ClientMetric {
pub metric: String,
pub value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
// Dedup key: server-side / downstream may use to drop replays.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
}
impl ClientMetric {
pub fn new(metric: impl Into<String>, value: f64) -> Self {
Self {
metric: metric.into(),
value,
timestamp: None,
idempotency_key: None,
}
}
pub fn with_timestamp(mut self, ts: chrono::DateTime<chrono::Utc>) -> Self {
self.timestamp = Some(ts);
self
}
pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = Some(key.into());
self
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ClientMetricsBatch {
pub events: Vec<ClientMetric>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub process_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arch: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ClientMetricsResponse {
pub accepted: usize,
}
@@ -0,0 +1,105 @@
//! Signed deployment-config envelope: the wire contract between the
//! cli-chat-proxy signer and the client verifier. Shared so a field rename
//! breaks at compile time on both sides instead of silently failing verification.
use serde::{Deserialize, Serialize};
/// The payload format version the server currently signs. Bump when the payload
/// gains semantics (e.g. an anti-replay counter or a key-fingerprint binding) so
/// verifiers can distinguish generations; `0` means a pre-versioned payload.
pub const SIGNED_PAYLOAD_VERSION: u32 = 1;
/// The exact bytes the server signs: the served policy, the principal it is
/// bound to, and an expiry. Serialized once on the server and shipped verbatim
/// as `signed_payload`, so the client verifies the received bytes directly
/// instead of re-canonicalizing (no cross-language serialization drift).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignedPayload {
/// Payload format version ([`SIGNED_PAYLOAD_VERSION`]); `default` 0 so
/// pre-versioned sidecars parse and verify unchanged.
#[serde(default)]
pub version: u32,
#[serde(default)]
pub deployment_id: Option<String>,
#[serde(default)]
pub team_id: Option<String>,
#[serde(default)]
pub managed_config: Option<String>,
#[serde(default)]
pub requirements: Option<String>,
/// Strict (fail-closed) opt-in, carried in the SIGNED bytes so a local actor can't
/// flip enforcement. `default` false so an older/unsigned payload stays lenient.
#[serde(default)]
pub fail_closed: bool,
/// Unix seconds after which the signature is no longer trusted.
pub expires_at: u64,
/// Identifies the signing key, so a rotation can be distinguished.
pub key_id: String,
}
/// One signed envelope carried alongside the legacy policy fields in the
/// deployment-config response (additive: old clients ignore it). Also the
/// shape the client persists as its on-disk signature sidecar.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignatureEnvelope {
/// The exact JSON string that was signed (a serialized [`SignedPayload`]).
pub signed_payload: String,
/// Base64 (standard) Ed25519 signature over `signed_payload`'s UTF-8 bytes.
pub signature: String,
/// Untrusted (outside the signed bytes): a hint for picking among multiple
/// envelopes, never for selecting the verifying key — only the signed
/// payload's `key_id` is authoritative.
#[serde(default)]
pub key_id: String,
}
/// Unix seconds now (saturating to 0 on a pre-epoch clock).
pub fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// The `requirements.toml` opt-in key for strict (fail-closed) enforcement.
pub const FAIL_CLOSED_KEY: &str = "fail_closed";
/// Read the `fail_closed` opt-in from a requirements-TOML string — THE canonical parse,
/// shared by the cli-chat-proxy signer and the client so the two sides can't drift.
/// Invalid TOML or a non-bool value → `false`.
pub fn fail_closed_flag_from_str(requirements: &str) -> bool {
toml::from_str::<toml::Value>(requirements)
.ok()
.and_then(|v| v.get(FAIL_CLOSED_KEY).and_then(toml::Value::as_bool))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
/// The version field round-trips, and a pre-versioned payload (no `version`
/// key) defaults to 0 — old sidecars keep parsing.
#[test]
fn signed_payload_version_round_trips_and_defaults() {
let versioned = SignedPayload {
version: SIGNED_PAYLOAD_VERSION,
deployment_id: None,
team_id: Some("team-007".into()),
managed_config: None,
requirements: None,
fail_closed: false,
expires_at: 4_000_000_000,
key_id: "v1".into(),
};
let json = serde_json::to_string(&versioned).unwrap();
assert_eq!(
serde_json::from_str::<SignedPayload>(&json).unwrap(),
versioned
);
let legacy: SignedPayload =
serde_json::from_str(r#"{"expires_at": 1, "key_id": "v1"}"#).unwrap();
assert_eq!(legacy.version, 0, "pre-versioned payloads default to 0");
}
}
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
//! Lightweight request/response types for the cli-chat-proxy sandbox API.
//!
//! This crate contains only the API types with minimal dependencies (just serde),
//! suitable for use by clients that don't need the full cli-chat-proxy crate.
pub mod client_metrics_types;
pub mod deployment_config_types;
pub mod feedback_types;
pub mod metadata_types;
mod sandbox_types;
pub mod serde_helpers;
pub mod session_types;
pub mod storage_types;
pub mod subagent_bundle;
pub use client_metrics_types::*;
pub use deployment_config_types::*;
pub use feedback_types::*;
pub use metadata_types::*;
pub use sandbox_types::*;
pub use session_types::*;
pub use storage_types::*;
pub use subagent_bundle::*;
@@ -0,0 +1,219 @@
//! Prompt metadata types shared between the CLI client and the cli-chat-proxy server.
//!
//! The CLI client serializes `PromptMetadata` and uploads it as `metadata.json` to GCS
//! via the `/v1/storage` endpoint. The server deserializes it to inject authenticated
//! user identity fields (`user_id`, `user_email`) before forwarding to GCS.
use serde::{Deserialize, Serialize};
/// Schema version for the GCS metadata format.
/// Increment this when making breaking changes to PromptMetadata structure.
/// v1.2: Added `signals` and `turn_delta` fields to turn_result.json.
/// v1.3: Added `user_query` field to metadata.json.
/// v1.4: Renamed `user_query` to `prompt` (required), renamed `prompt` to `full_prompt` (optional).
/// v1.5: Added `prompt_has_image` field.
/// v1.6: Added `prompt_was_truncated` flag.
/// v1.7: Added `truncated_prompt_local_path`: local disk path embedded in truncated message for search-replace against GCS path.
/// v1.8: Added A/B fork provenance: `ab_root_session_id`, `ab_root_turn_number`, `ab_comparison_id`, `ab_experiment_type`, `ab_experiment_name`.
/// v1.9: Added `cwd` field (current working directory).
/// v1.10: `ab_root_turn_number` now uses the monotonic GCS trace counter
/// (same as `turn_number` and GCS paths) instead of the signal-based prompt count.
/// v1.11: Added `auto_model_hash` for auto-mode model assignment.
/// v1.12: Removed `auto_model_hash` (auto-mode feature was removed).
/// v1.13: Removed `ab_*` fields after the A/B experimentation feature was discontinued.
/// v1.14: Added `prompt_verbatim` field.
/// v1.15: Added `agent_type` field.
/// v1.16: Added `team_id` field (OAuth team identity).
/// v1.17: Added `input_tokens`, `cached_input_tokens`, `output_tokens` to
/// TurnResultMetadata for per-component token attribution.
/// v1.18: Added `shell_version`: the grok-shell agent binary version, distinct
/// from `client_version` (the UI client's version). They coincide for the
/// TUI but differ for embedding clients like grok-desktop.
/// v1.19: Added `workspace_type`: classifies the working directory as "git",
/// "project" (non-git project dir), or "non_project" (system/temp/home).
/// v1.20: Added `sandbox`: resolved OS sandbox profile and whether enforcement is active.
/// v1.21: Added an optional session-metadata field.
/// v1.22: Added `reasoning_effort`: the reasoning effort the turn was sampled
/// with (e.g. "low"/"medium"/"high"/"xhigh"). Omitted when the session
/// has no configured effort.
/// v1.23: Removed `prompt`, `full_prompt`, and `truncated_prompt_local_path`
/// from metadata.json (prompt content is no longer uploaded in metadata).
pub const GCS_SCHEMA_VERSION: &str = "v1.23";
/// OS-level sandbox state for a trace turn (local `kigi-sandbox`, not cloud sandbox).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LocalSandboxTelemetry {
/// Resolved profile at process startup (e.g. "off", "workspace", "strict").
pub profile: String,
/// Whether kernel-level enforcement is active for this process.
pub applied: bool,
}
/// Metadata about a prompt turn, uploaded as JSON for tracing/debugging.
///
/// Path format: `{session_id}/turn_{N}/metadata.json`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptMetadata {
/// Schema version for this metadata format
pub schema_version: String,
/// Session id (UUIDv7) for this trace
pub session_id: String,
/// Monotonic turn number within the session
pub turn_number: u64,
/// Request id for this prompt (uuid v4 we generate per prompt)
pub request_id: String,
/// Timestamp at the start of prompt handling (UTC RFC3339)
pub turn_started_at: String,
/// Git repo root (if the session cwd is inside a git repository).
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_root: Option<String>,
/// Git remote URL (origin) for the repository.
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_url: Option<String>,
/// How workspace files were collected: "git", "project", or "non_project".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_type: Option<String>,
/// User ID from authentication
pub user_id: Option<String>,
/// User email from authentication (may be None)
pub user_email: Option<String>,
/// Team ID from OAuth authentication (may be None for personal accounts)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
/// Client source identifier.
/// Pulled from InitializeRequest.meta (prefers clientSource, falls back to clientType, then clientIdentifier).
#[serde(skip_serializing_if = "Option::is_none")]
pub client_source: Option<String>,
/// Client (TUI) version string, e.g., "0.1.70 (c28a985a1f1)"
/// This is sent by the TUI in InitializeRequest.meta.clientVersion
#[serde(skip_serializing_if = "Option::is_none")]
pub client_version: Option<String>,
/// The model being used for this session
pub model: String,
/// Reasoning effort the turn was sampled with (e.g. "low", "medium",
/// "high", "xhigh"). Omitted when the session has no configured effort
/// (the model then uses its server-side default).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
/// Experiment ID when the model was overridden via experiment routing. Currently unused.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub experiment_id: Option<String>,
/// Host OS where the agent is running (e.g., "macos", "linux")
pub host_os: String,
/// Host architecture (e.g., "x86_64", "aarch64")
pub host_arch: String,
/// Whether the user's prompt contains at least one image attachment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_has_image: Option<bool>,
/// Whether the prompt was truncated. When `Some(true)`, the full text is at `full_prompt.txt`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_was_truncated: Option<bool>,
/// Whether the prompt was sent in verbatim mode (skipping `<user_query>` wrapping).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_verbatim: Option<bool>,
/// Current working directory of the session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
/// The agent type / harness name for this session (e.g. "grok-build", "codex").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
/// Version of the grok-shell agent binary that handled this turn
/// (`kigi_version::VERSION`). Self-reported by the agent, so it reflects
/// the binary actually running. Distinct from `client_version`, which is the
/// UI client's version — for the TUI these coincide, but for embedding clients
/// like grok-desktop the bundled shell differs from the app version.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell_version: Option<String>,
/// Resolved OS sandbox profile and whether enforcement is active.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<LocalSandboxTelemetry>,
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal JSON matching the pre-prompt-content schema fields.
fn minimal_json() -> &'static str {
r#"{
"schema_version": "v1.23",
"session_id": "abc",
"turn_number": 1,
"request_id": "req-1",
"turn_started_at": "2025-01-01T00:00:00Z",
"user_id": null,
"user_email": null,
"model": "grok-3",
"host_os": "linux",
"host_arch": "x86_64"
}"#
}
#[test]
fn missing_fields_deserialize_to_none_not_false() {
let meta: PromptMetadata = serde_json::from_str(minimal_json()).unwrap();
assert_eq!(meta.prompt_has_image, None);
assert_eq!(meta.prompt_was_truncated, None);
assert_eq!(meta.cwd, None);
assert_eq!(meta.team_id, None);
}
#[test]
fn explicit_false_deserializes_to_some_false() {
let json = r#"{
"schema_version": "v1.23",
"session_id": "abc",
"turn_number": 1,
"request_id": "req-1",
"turn_started_at": "2025-01-01T00:00:00Z",
"user_id": null,
"user_email": null,
"model": "grok-3",
"host_os": "linux",
"host_arch": "x86_64",
"prompt_has_image": false,
"prompt_was_truncated": false
}"#;
let meta: PromptMetadata = serde_json::from_str(json).unwrap();
assert_eq!(meta.prompt_has_image, Some(false));
assert_eq!(meta.prompt_was_truncated, Some(false));
}
#[test]
fn none_fields_are_omitted_from_serialization() {
let meta: PromptMetadata = serde_json::from_str(minimal_json()).unwrap();
let serialized = serde_json::to_string(&meta).unwrap();
assert!(!serialized.contains("prompt_has_image"));
assert!(!serialized.contains("prompt_was_truncated"));
assert!(!serialized.contains("cwd"));
assert!(!serialized.contains("team_id"));
assert!(!serialized.contains("\"prompt\""));
assert!(!serialized.contains("full_prompt"));
assert!(!serialized.contains("truncated_prompt_local_path"));
}
#[test]
fn some_fields_are_included_in_serialization() {
let mut meta: PromptMetadata = serde_json::from_str(minimal_json()).unwrap();
meta.prompt_has_image = Some(false);
meta.prompt_was_truncated = Some(true);
let serialized = serde_json::to_string(&meta).unwrap();
assert!(serialized.contains("\"prompt_has_image\":false"));
assert!(serialized.contains("\"prompt_was_truncated\":true"));
}
#[test]
fn cwd_round_trips() {
let mut meta: PromptMetadata = serde_json::from_str(minimal_json()).unwrap();
meta.cwd = Some("/root/code/xai".into());
let json = serde_json::to_string(&meta).unwrap();
let deserialized: PromptMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.cwd.as_deref(), Some("/root/code/xai"));
}
#[test]
fn sandbox_round_trips() {
let mut meta: PromptMetadata = serde_json::from_str(minimal_json()).unwrap();
meta.sandbox = Some(LocalSandboxTelemetry {
profile: "strict".into(),
applied: true,
});
let json = serde_json::to_string(&meta).unwrap();
let deserialized: PromptMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized.sandbox,
Some(LocalSandboxTelemetry {
profile: "strict".into(),
applied: true,
})
);
}
}
@@ -0,0 +1,715 @@
//! Sandbox API request and response types.
//!
//! These types are shared between the server and clients that use the sandbox API.
//! All types use `camelCase` serialization to match the proto3 canonical JSON encoding
//! used on the wire.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Request body for forking a sandbox session.
/// POST /v1/sandbox/sessions/fork
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxForkRequest {
/// The source sandbox ID to fork from
pub source_sandbox_id: String,
/// Number of copies to create (defaults to 1)
#[serde(default)]
pub copies: Option<u32>,
/// Snapshot bucket to use.
///
/// SECURITY (CWE-284): This field is accepted for backwards compatibility
/// but MUST NOT be forwarded to backend services. The server always uses the
/// configured default bucket and enforces this server-side.
#[serde(default)]
pub snapshot_bucket: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
/// Verify that a user-supplied snapshotBucket is deserialized but
/// the handler is expected to ignore it. This test documents the security
/// invariant: snapshot_bucket from user input must never control GCS access.
#[test]
fn test_fork_request_snapshot_bucket_is_ignored_by_convention() {
// User sends a malicious bucket name
let json = r#"{
"sourceSandboxId": "session-123",
"copies": 2,
"snapshotBucket": "attacker-controlled-bucket"
}"#;
let req: SandboxForkRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.source_sandbox_id, "session-123");
assert_eq!(req.copies, Some(2));
// Field is deserialized for backwards compat, but the handler MUST NOT use it.
assert_eq!(
req.snapshot_bucket,
Some("attacker-controlled-bucket".to_string())
);
}
/// Verify fork request works without snapshot_bucket (the expected path).
#[test]
fn test_fork_request_without_snapshot_bucket() {
let json = r#"{"sourceSandboxId": "session-456"}"#;
let req: SandboxForkRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.source_sandbox_id, "session-456");
assert_eq!(req.copies, None);
assert_eq!(req.snapshot_bucket, None);
}
// ====================================================================
// SandboxMode enum serde
// ====================================================================
#[test]
fn test_sandbox_mode_serializes_as_proto3_string() {
assert_eq!(
serde_json::to_string(&SandboxMode::Agent).unwrap(),
r#""SANDBOX_MODE_AGENT""#
);
assert_eq!(
serde_json::to_string(&SandboxMode::WorkspaceServer).unwrap(),
r#""SANDBOX_MODE_WORKSPACE_SERVER""#
);
assert_eq!(
serde_json::to_string(&SandboxMode::Bare).unwrap(),
r#""SANDBOX_MODE_BARE""#
);
assert_eq!(
serde_json::to_string(&SandboxMode::Invalid).unwrap(),
r#""SANDBOX_MODE_INVALID""#
);
}
#[test]
fn test_sandbox_mode_roundtrip() {
for mode in [
SandboxMode::Invalid,
SandboxMode::Agent,
SandboxMode::WorkspaceServer,
SandboxMode::Bare,
] {
let json = serde_json::to_string(&mode).unwrap();
let back: SandboxMode = serde_json::from_str(&json).unwrap();
assert_eq!(back, mode);
}
}
#[test]
fn test_sandbox_mode_default_is_invalid() {
assert_eq!(SandboxMode::default(), SandboxMode::Invalid);
}
// ====================================================================
// SandboxStartResponse deserialization from realistic proto3 JSON
// ====================================================================
#[test]
fn test_start_response_from_proto3_json() {
// Realistic JSON using proto3 canonical JSON encoding.
// uint64 values like memoryLimitBytes are encoded as strings.
let json = r#"{
"sandboxId": "sb-abc123",
"sessionId": "sess-xyz789",
"websocketUrl": "wss://sandbox.example.com/ws",
"environment": {
"environment": {
"environmentId": "env-001",
"name": "test-env",
"repository": "org/repo",
"requestedMemoryBytes": "17179869184",
"requestedCpus": 4,
"cachingEnabled": true,
"preinstalledPackages": {"python": "3.11"}
},
"environmentVariables": [
{"key": "FOO", "value": "bar"}
],
"secrets": [],
"userRole": "ROLE_OWNER"
},
"directUrls": {"6013": "http://direct.example.com:6013"},
"cloudflareUrls": {"443": "https://cf.example.com"},
"mode": "SANDBOX_MODE_AGENT"
}"#;
let resp: SandboxStartResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.sandbox_id, "sb-abc123");
assert_eq!(resp.session_id, "sess-xyz789");
assert_eq!(resp.websocket_url, "wss://sandbox.example.com/ws");
assert_eq!(resp.mode, Some(SandboxMode::Agent));
// Verify direct_urls / cloudflare_urls maps
assert_eq!(
resp.direct_urls.get("6013").map(|s| s.as_str()),
Some("http://direct.example.com:6013")
);
assert_eq!(
resp.cloudflare_urls.get("443").map(|s| s.as_str()),
Some("https://cf.example.com")
);
// Verify nested environment
let env_meta = resp.environment.as_ref().unwrap();
let env = env_meta.environment.as_ref().unwrap();
assert_eq!(env.environment_id.as_deref(), Some("env-001"));
assert_eq!(env.name.as_deref(), Some("test-env"));
assert_eq!(env.requested_memory_bytes.as_deref(), Some("17179869184"));
assert_eq!(env.requested_cpus, Some(4));
assert_eq!(env.caching_enabled, Some(true));
assert_eq!(
env.preinstalled_packages.get("python").map(|s| s.as_str()),
Some("3.11")
);
// Verify environment variables
assert_eq!(env_meta.environment_variables.len(), 1);
assert_eq!(
env_meta.environment_variables[0].key.as_deref(),
Some("FOO")
);
assert_eq!(env_meta.user_role.as_deref(), Some("ROLE_OWNER"));
}
/// Verify SandboxStartResponse handles missing optional fields gracefully.
#[test]
fn test_start_response_minimal_json() {
let json = r#"{
"sandboxId": "sb-min",
"sessionId": "sess-min",
"websocketUrl": "wss://example.com"
}"#;
let resp: SandboxStartResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.sandbox_id, "sb-min");
assert!(resp.environment.is_none());
assert!(resp.direct_urls.is_empty());
assert!(resp.cloudflare_urls.is_empty());
assert!(resp.mode.is_none());
}
// ====================================================================
// SandboxEnvironmentResponse roundtrip
// ====================================================================
#[test]
fn test_environment_response_roundtrip() {
let resp = SandboxEnvironmentResponse {
environment: Some(SandboxEnvironmentWithMetadata {
environment: Some(SandboxEnvironment {
environment_id: Some("env-rt".into()),
name: Some("roundtrip".into()),
caching_enabled: Some(false),
preinstalled_packages: HashMap::from([("node".into(), "20".into())]),
..Default::default()
}),
environment_variables: vec![SandboxEnvironmentVariable {
key: Some("KEY".into()),
value: Some("VAL".into()),
}],
secrets: vec![],
user_role: Some("ROLE_EDITOR".into()),
}),
};
let json = serde_json::to_string(&resp).unwrap();
let back: SandboxEnvironmentResponse = serde_json::from_str(&json).unwrap();
let env = back
.environment
.as_ref()
.unwrap()
.environment
.as_ref()
.unwrap();
assert_eq!(env.environment_id.as_deref(), Some("env-rt"));
assert_eq!(env.name.as_deref(), Some("roundtrip"));
assert_eq!(
env.preinstalled_packages.get("node").map(|s| s.as_str()),
Some("20")
);
}
}
/// Information about a single forked session.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxForkedSession {
/// The provider sandbox ID
pub sandbox_id: String,
/// WebSocket URL for connecting to the sandbox
pub websocket_url: String,
/// JWT token for authenticating the WebSocket connection
pub jwt_token: String,
}
/// Response from forking a sandbox session.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxForkResponse {
/// List of created sandbox IDs
pub sandbox_ids: Vec<String>,
/// Detailed information about each forked session
pub sessions: Vec<SandboxForkedSession>,
}
/// Request body/query for terminating a sandbox session.
/// DELETE /v1/sandbox/sessions/{sandbox_id}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxTerminateRequest {
/// Environment ID (defaults to "universal")
#[serde(default)]
pub environment_id: Option<String>,
}
// ============================================================================
// Session Lifecycle Types
// ============================================================================
/// Sandbox operating mode.
///
/// Proto3 enum serialized as its string name on the wire
/// (e.g. `"SANDBOX_MODE_AGENT"`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SandboxMode {
#[default]
#[serde(rename = "SANDBOX_MODE_INVALID")]
Invalid,
#[serde(rename = "SANDBOX_MODE_AGENT")]
Agent,
#[serde(rename = "SANDBOX_MODE_WORKSPACE_SERVER")]
WorkspaceServer,
#[serde(rename = "SANDBOX_MODE_BARE")]
Bare,
}
/// Request body for starting a sandbox session (non-TUI).
/// POST /v1/sandbox/sessions/start
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxStartRequest {
/// Environment ID to use (defaults to "universal").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment_id: Option<String>,
/// Optional session ID to resume or associate.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
/// Repository to clone (e.g. "owner/repo" or full git URL).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
/// Branch to checkout.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
/// Memory limit in bytes. Proto3 uint64, serialized as a JSON string.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_limit_bytes: Option<String>,
/// Number of CPUs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpus: Option<u32>,
/// Session timeout in seconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_timeout_seconds: Option<u32>,
/// Additional environment variables.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env_vars: HashMap<String, String>,
/// Disk size in bytes. Proto3 uint64, serialized as a JSON string.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk_bytes: Option<String>,
/// Number of GPUs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpus: Option<u32>,
/// GPU type (e.g. "A100", "H100").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu_type: Option<String>,
/// Sandbox operating mode.
pub mode: SandboxMode,
}
/// Response from starting a sandbox session.
/// Returned by POST /v1/sandbox/sessions/start.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxStartResponse {
/// Provider sandbox ID.
#[serde(default)]
pub sandbox_id: String,
/// Session ID for persistence and reconnection.
#[serde(default)]
pub session_id: String,
/// WebSocket URL for connecting to the sandbox.
#[serde(default)]
pub websocket_url: String,
/// Environment configuration returned by the sandbox service.
#[serde(default)]
pub environment: Option<SandboxEnvironmentWithMetadata>,
/// Port-to-URL mapping for direct access.
#[serde(default)]
pub direct_urls: HashMap<String, String>,
/// Port-to-URL mapping for Cloudflare-proxied access.
#[serde(default)]
pub cloudflare_urls: HashMap<String, String>,
/// Which mode was actually started.
#[serde(default)]
pub mode: Option<SandboxMode>,
}
/// Response from getting sandbox session status.
/// GET /v1/sandbox/sessions/{id}/status
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxStatusResponse {
/// Status string (e.g. "STARTING", "SETUP", "READY", "ERROR").
#[serde(default)]
pub status: String,
/// Human-readable status message.
#[serde(default)]
pub message: String,
/// Additional metadata (e.g. repository size).
#[serde(default)]
pub metadata: HashMap<String, String>,
/// ISO 8601 timestamp.
#[serde(default)]
pub timestamp: Option<String>,
}
/// Exit codes for sandbox log commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxLogsExitCodes {
/// Exit code of environment variables echo command.
#[serde(default)]
pub env: Option<i32>,
/// Exit code of direct mode logs.
#[serde(default)]
pub direct_mode: Option<i32>,
/// Exit code of git fetch logs.
#[serde(default)]
pub fetch: Option<i32>,
}
/// Response from getting sandbox session logs.
/// GET /v1/sandbox/sessions/{id}/logs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxLogsResponse {
/// Combined environment variables echo stdout/stderr.
#[serde(default)]
pub env_vars: String,
/// Combined direct mode logs stdout/stderr.
#[serde(default)]
pub direct_mode_logs: String,
/// Combined fetch/clone logs stdout/stderr.
#[serde(default)]
pub fetch_logs: String,
/// Exit codes for each command.
#[serde(default)]
pub exit_codes: Option<SandboxLogsExitCodes>,
}
/// Response from hibernating a sandbox session.
/// POST /v1/sandbox/sessions/{id}/hibernate
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxHibernateResponse {
/// GCS path where the snapshot was stored.
#[serde(default)]
pub snapshot_path: String,
}
/// Request body for restoring a hibernated sandbox session.
/// POST /v1/sandbox/sessions/{id}/restore
///
/// The `session_id` is provided as a path parameter, not in the body.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxRestoreRequest {
/// Server key for the restored session's direct-mode agent.
pub server_key: String,
}
/// Response from restoring a hibernated sandbox session.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxRestoreResponse {
/// Provider sandbox ID of the newly created restored sandbox.
#[serde(default)]
pub sandbox_id: String,
/// GCS path of the snapshot that was restored.
#[serde(default)]
pub snapshot_path: String,
/// WebSocket URL for the restored session.
#[serde(default)]
pub websocket_url: String,
}
// ============================================================================
// Environment Types
// ============================================================================
/// A sandbox environment configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxEnvironment {
#[serde(default)]
pub environment_id: Option<String>,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub team_id: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub default_branch: Option<String>,
#[serde(default)]
pub workspace_directory: Option<String>,
#[serde(default)]
pub container_image: Option<String>,
#[serde(default)]
pub setup_script: Option<String>,
#[serde(default)]
pub maintenance_script: Option<String>,
#[serde(default)]
pub caching_enabled: Option<bool>,
#[serde(default)]
pub internet_enabled: Option<bool>,
#[serde(default)]
pub domain_allowlist_preset: Option<String>,
#[serde(default)]
pub additional_domains: Option<String>,
#[serde(default)]
pub allowed_http_methods: Option<String>,
#[serde(default)]
pub preinstalled_packages: HashMap<String, String>,
/// ISO 8601 timestamp.
#[serde(default)]
pub create_time: Option<String>,
/// ISO 8601 timestamp.
#[serde(default)]
pub modify_time: Option<String>,
#[serde(default)]
pub cached_commit_sha: Option<String>,
#[serde(default)]
pub provider_id: Option<String>,
#[serde(default)]
pub requested_cpus: Option<u32>,
/// Proto3 uint64, serialized as a JSON string.
#[serde(default)]
pub requested_memory_bytes: Option<String>,
/// Proto3 uint64, serialized as a JSON string.
#[serde(default)]
pub requested_disk_bytes: Option<String>,
#[serde(default)]
pub requested_gpus: Option<u32>,
}
/// An environment variable key-value pair.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxEnvironmentVariable {
#[serde(default)]
pub key: Option<String>,
#[serde(default)]
pub value: Option<String>,
}
/// A secret input key-value pair for environment creation/update.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxSecretInput {
#[serde(default)]
pub key: Option<String>,
#[serde(default)]
pub value: Option<String>,
}
/// A sandbox environment with its associated metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxEnvironmentWithMetadata {
/// The environment configuration.
#[serde(default)]
pub environment: Option<SandboxEnvironment>,
/// Non-secret environment variables.
#[serde(default)]
pub environment_variables: Vec<SandboxEnvironmentVariable>,
/// Secret environment variables (values may be redacted).
#[serde(default)]
pub secrets: Vec<SandboxEnvironmentVariable>,
/// The requesting user's role for this environment (proto enum as string).
#[serde(default)]
pub user_role: Option<String>,
}
/// Query parameters for listing sandbox environments.
/// Used with GET /v1/sandbox/environments.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxListEnvironmentsRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_size: Option<i32>,
}
/// Response from listing sandbox environments.
/// GET /v1/sandbox/environments
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxListEnvironmentsResponse {
#[serde(default)]
pub environments: Vec<SandboxEnvironmentWithMetadata>,
#[serde(default)]
pub page: Option<i32>,
#[serde(default)]
pub page_size: Option<i32>,
#[serde(default)]
pub has_more: Option<bool>,
}
/// Request body for creating a sandbox environment.
/// POST /v1/sandbox/environments
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxCreateEnvironmentRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup_script: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maintenance_script: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caching_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub internet_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain_allowlist_preset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_domains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_http_methods: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment_variables: Option<Vec<SandboxEnvironmentVariable>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<Vec<SandboxSecretInput>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub preinstalled_packages: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_cpus: Option<u32>,
/// Proto3 uint64, serialized as a JSON string.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_memory_bytes: Option<String>,
/// Proto3 uint64, serialized as a JSON string.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_disk_bytes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_gpus: Option<u32>,
}
/// Response wrapping a single environment with metadata.
///
/// Shared by the create, get, and update environment endpoints since they all
/// return the same shape: `{ "environment": SandboxEnvironmentWithMetadata }`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxEnvironmentResponse {
#[serde(default)]
pub environment: Option<SandboxEnvironmentWithMetadata>,
}
/// Request body for updating a sandbox environment.
/// PUT /v1/sandbox/environments/{environment_id}
///
/// The `environment_id` is provided as a path parameter, not in the body.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxUpdateEnvironmentRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup_script: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maintenance_script: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caching_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub internet_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain_allowlist_preset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_domains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_http_methods: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment_variables: Option<Vec<SandboxEnvironmentVariable>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<Vec<SandboxSecretInput>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub preinstalled_packages: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_cpus: Option<u32>,
/// Proto3 uint64, serialized as a JSON string.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_memory_bytes: Option<String>,
/// Proto3 uint64, serialized as a JSON string.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_disk_bytes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_gpus: Option<u32>,
}
/// A preinstalled package available for sandbox environments.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxPreinstalledPackage {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub versions: Vec<String>,
#[serde(default)]
pub default_version: Option<String>,
}
/// Response from listing preinstalled packages.
/// GET /v1/sandbox/environments/preinstalled-packages
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxListPreinstalledPackagesResponse {
#[serde(default)]
pub packages: Vec<SandboxPreinstalledPackage>,
}
@@ -0,0 +1,9 @@
use serde::{Deserialize, Deserializer};
pub fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<String>::deserialize(deserializer)?;
Ok(opt.filter(|s| !s.is_empty()))
}
@@ -0,0 +1,116 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegisterSessionRequest {
pub session_id: String,
pub cwd: String,
/// Ignored; server derives this from `session_id`. Kept for wire-compat.
#[serde(default)]
pub gcs_trace_prefix: Option<String>,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default)]
pub repo_remote_url: Option<String>,
#[serde(default)]
pub repo_branch: Option<String>,
#[serde(default)]
pub repo_head_at_start: Option<String>,
/// Ignored; server uses its own bucket constant. Kept for wire-compat.
#[serde(default)]
pub gcs_bucket: Option<String>,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub parent_session_id: Option<String>,
/// Opaque per-machine device id (`deviceId` on the wire). Sent by the CLI
/// at register; optional for backward-compat with older clients.
#[serde(default)]
pub device_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSessionRequest {
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub first_prompt: Option<String>,
#[serde(default)]
pub last_turn_number: Option<i32>,
#[serde(default)]
pub repo_head_at_end: Option<String>,
/// Latest turn whose restore artifacts are confirmed durable.
/// `None` = leave unchanged. Written separately from `last_turn_number`
/// once session-state upload is confirmed.
#[serde(default)]
pub restorable_turn_number: Option<i32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchSessionsQuery {
#[serde(default)]
pub query: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default = "default_limit")]
pub limit: i64,
}
fn default_limit() -> i64 {
20
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionReplicaResponse {
pub session_id: String,
pub summary: String,
pub first_prompt: Option<String>,
pub model_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub last_turn_number: i32,
/// See `UpdateSessionRequest.restorable_turn_number`. Optional in the wire
/// type so newer CLI builds can parse responses from older servers gracefully.
pub restorable_turn_number: Option<i32>,
pub cwd: String,
pub repo_remote_url: Option<String>,
pub repo_branch: Option<String>,
pub repo_head_at_start: Option<String>,
pub repo_head_at_end: Option<String>,
pub gcs_trace_prefix: String,
pub gcs_bucket: String,
pub hostname: Option<String>,
pub parent_session_id: Option<String>,
pub status: String,
pub last_active_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchSessionsResponse {
pub sessions: Vec<SessionReplicaResponse>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadSessionQuery {
pub file: String,
#[serde(default)]
pub turn: Option<i32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadSessionResponse {
pub download_url: String,
pub expires_in_seconds: u64,
pub file: String,
pub turn: i32,
}
@@ -0,0 +1,226 @@
//! Signed upload URL types shared between cli-chat-proxy (server) and grok-shell (client).
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct BatchExistsRequest {
pub paths: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct BatchExistsResponse {
pub exists: Vec<String>,
pub missing: Vec<String>,
}
/// Response from the signed upload URL endpoint.
/// `POST /v1/storage/signed-upload-url`
///
/// The client uses the returned `signed_url` to PUT the object directly to GCS,
/// completely bypassing the proxy for the data transfer. This avoids nginx /
/// Cloudflare body-size limits that would otherwise cause 413 errors on large
/// payloads (e.g. session share data).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignedUploadUrlResponse {
/// Pre-signed GCS PUT URL. Upload the object body here with a simple PUT.
pub signed_url: String,
/// GCS bucket where the object will be stored.
pub bucket: String,
/// Object path within the bucket.
pub path: String,
/// Content-Type that was baked into the signed URL.
/// The PUT request **must** use this exact Content-Type header.
pub content_type: String,
/// Validity window in seconds.
pub expires_in_secs: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchUploadResult {
pub path: String,
pub status: BatchUploadStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub bucket: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generation: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BatchUploadStatus {
Ok,
Error,
Skipped,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchUploadResponse {
pub results: Vec<BatchUploadResult>,
}
/// JSON request body for `POST /v1/storage/batch_upload_json`.
///
/// Each file's content is base64-encoded. The request is typically sent with
/// `Content-Encoding: zstd` so the JSON body is compressed on the wire.
#[derive(Debug, Deserialize, Serialize)]
pub struct BatchUploadRequest {
pub files: Vec<BatchUploadFile>,
}
/// A single file entry in a [`BatchUploadRequest`].
///
/// All three fields are required on the wire. The server treats an empty
/// `content_type` as `"application/octet-stream"`, but the field itself
/// must be present in the JSON object.
#[derive(Debug, Deserialize, Serialize)]
pub struct BatchUploadFile {
/// GCS destination path.
pub path: String,
/// MIME type of the file content. Required on the wire; the server
/// defaults empty values to `"application/octet-stream"`.
pub content_type: String,
/// Base64-encoded file content (standard alphabet, with padding).
pub data: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_upload_status_serde_round_trip() {
for (variant, expected_json) in [
(BatchUploadStatus::Ok, "\"ok\""),
(BatchUploadStatus::Error, "\"error\""),
(BatchUploadStatus::Skipped, "\"skipped\""),
] {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected_json);
let deserialized: BatchUploadStatus = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, variant);
}
}
#[test]
fn batch_upload_response_serializes_ok_result_with_metadata() {
let resp = BatchUploadResponse {
results: vec![BatchUploadResult {
path: "data/file.txt".to_string(),
status: BatchUploadStatus::Ok,
bucket: Some("my-bucket".to_string()),
size: Some(1024),
generation: Some(42),
error: None,
}],
};
let json: serde_json::Value = serde_json::to_value(&resp).unwrap();
let result = &json["results"][0];
assert_eq!(result["path"], "data/file.txt");
assert_eq!(result["status"], "ok");
assert_eq!(result["bucket"], "my-bucket");
assert_eq!(result["size"], 1024);
assert_eq!(result["generation"], 42);
assert!(result.get("error").is_none(), "None fields must be omitted");
}
#[test]
fn batch_upload_response_serializes_error_result_without_metadata() {
let resp = BatchUploadResponse {
results: vec![BatchUploadResult {
path: "data/fail.txt".to_string(),
status: BatchUploadStatus::Error,
bucket: None,
size: None,
generation: None,
error: Some("upload failed".to_string()),
}],
};
let json: serde_json::Value = serde_json::to_value(&resp).unwrap();
let result = &json["results"][0];
assert_eq!(result["status"], "error");
assert_eq!(result["error"], "upload failed");
assert!(result.get("bucket").is_none());
assert!(result.get("size").is_none());
}
#[test]
fn batch_upload_response_round_trips_mixed_results() {
let original = BatchUploadResponse {
results: vec![
BatchUploadResult {
path: "ok.bin".to_string(),
status: BatchUploadStatus::Ok,
bucket: Some("b".to_string()),
size: Some(100),
generation: Some(1),
error: None,
},
BatchUploadResult {
path: "err.bin".to_string(),
status: BatchUploadStatus::Error,
bucket: None,
size: None,
generation: None,
error: Some("boom".to_string()),
},
BatchUploadResult {
path: "skip.bin".to_string(),
status: BatchUploadStatus::Skipped,
bucket: Some("b".to_string()),
size: Some(200),
generation: Some(5),
error: None,
},
],
};
let json = serde_json::to_string(&original).unwrap();
let deserialized: BatchUploadResponse = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.results.len(), 3);
assert_eq!(deserialized.results[0].status, BatchUploadStatus::Ok);
assert_eq!(deserialized.results[1].status, BatchUploadStatus::Error);
assert_eq!(deserialized.results[1].error.as_deref(), Some("boom"));
assert_eq!(deserialized.results[2].status, BatchUploadStatus::Skipped);
assert_eq!(deserialized.results[2].size, Some(200));
}
#[test]
fn batch_upload_request_serializes_round_trip() {
let req = BatchUploadRequest {
files: vec![
BatchUploadFile {
path: "a.txt".to_string(),
content_type: "text/plain".to_string(),
data: "SGVsbG8=".to_string(), // "Hello" in base64
},
BatchUploadFile {
path: "b.bin".to_string(),
content_type: "application/octet-stream".to_string(),
data: "AAEC/w==".to_string(),
},
],
};
let json = serde_json::to_string(&req).unwrap();
let deserialized: BatchUploadRequest = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.files.len(), 2);
assert_eq!(deserialized.files[0].path, "a.txt");
assert_eq!(deserialized.files[0].data, "SGVsbG8=");
assert_eq!(deserialized.files[1].path, "b.bin");
assert_eq!(
deserialized.files[1].content_type,
"application/octet-stream"
);
}
#[test]
fn batch_upload_request_empty_files_round_trip() {
let req = BatchUploadRequest { files: vec![] };
let json = serde_json::to_string(&req).unwrap();
let parsed: BatchUploadRequest = serde_json::from_str(&json).unwrap();
assert!(parsed.files.is_empty());
}
}
@@ -0,0 +1,101 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Shared bundle payload for subagent persona, role, and agent definitions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubagentBundle {
pub version: String,
pub personas: HashMap<String, String>,
pub roles: HashMap<String, String>,
pub agents: HashMap<String, String>,
#[serde(default)]
pub skills: HashMap<String, String>,
}
impl SubagentBundle {
pub fn empty(version: impl Into<String>) -> Self {
Self {
version: version.into(),
personas: HashMap::new(),
roles: HashMap::new(),
agents: HashMap::new(),
skills: HashMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::SubagentBundle;
use std::collections::HashMap;
#[test]
fn serializes_expected_shape() {
let bundle = SubagentBundle {
version: "bundle-v1".to_owned(),
personas: HashMap::from([("researcher".to_owned(), "persona body".to_owned())]),
roles: HashMap::from([("reviewer".to_owned(), "role body".to_owned())]),
agents: HashMap::from([("default".to_owned(), "agent body".to_owned())]),
skills: HashMap::from([("commit".to_owned(), "skill body".to_owned())]),
};
let actual = serde_json::to_value(bundle).unwrap();
let expected = serde_json::json!({
"version": "bundle-v1",
"personas": {
"researcher": "persona body"
},
"roles": {
"reviewer": "role body"
},
"agents": {
"default": "agent body"
},
"skills": {
"commit": "skill body"
}
});
assert_eq!(expected, actual);
}
#[test]
fn deserializes_without_skills_field() {
let json = serde_json::json!({
"version": "bundle-v1",
"personas": {},
"roles": {},
"agents": {}
});
let bundle: SubagentBundle = serde_json::from_value(json).unwrap();
assert_eq!(bundle.version, "bundle-v1");
assert!(bundle.skills.is_empty());
assert!(SubagentBundle::empty("v1").skills.is_empty());
}
#[test]
fn round_trips_with_skills() {
let bundle = SubagentBundle {
version: "v2".to_owned(),
personas: HashMap::new(),
roles: HashMap::new(),
agents: HashMap::new(),
skills: HashMap::from([
(
"commit".to_owned(),
"---\nname: commit\n---\n# Commit".to_owned(),
),
(
"review".to_owned(),
"---\nname: review\n---\n# Review".to_owned(),
),
]),
};
let json = serde_json::to_string(&bundle).unwrap();
let deserialized: SubagentBundle = serde_json::from_str(&json).unwrap();
assert_eq!(bundle, deserialized);
}
}