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