mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-08 09:04:26 +08:00
Compute version strings from git describe in openshell-core's build.rs using the guess-next-dev scheme (e.g. 0.0.4-dev.6+g2bf9969). All binary crates and the TUI splash screen now use the shared openshell_core::VERSION constant instead of CARGO_PKG_VERSION. In Docker/CI builds where .git is absent, falls back to CARGO_PKG_VERSION which is already set correctly by the sed-patch pipeline. Also adds OPENSHELL_CARGO_VERSION support to the cluster image and fast-deploy supervisor builds for parity with the gateway.
4602 lines
161 KiB
Rust
4602 lines
161 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! gRPC service implementation.
|
|
|
|
#![allow(clippy::ignored_unit_patterns)] // Tokio select! macro generates unit patterns
|
|
|
|
use crate::persistence::{
|
|
DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, generate_name,
|
|
};
|
|
use futures::future;
|
|
use openshell_core::proto::{
|
|
ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest,
|
|
ApproveDraftChunkResponse, ClearDraftChunksRequest, ClearDraftChunksResponse,
|
|
CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse,
|
|
DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse,
|
|
DraftHistoryEntry, EditDraftChunkRequest, EditDraftChunkResponse, ExecSandboxEvent,
|
|
ExecSandboxExit, ExecSandboxRequest, ExecSandboxStderr, ExecSandboxStdout,
|
|
GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse,
|
|
GetProviderRequest, GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyRequest,
|
|
GetSandboxPolicyResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse,
|
|
GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest,
|
|
HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse,
|
|
ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, ListSandboxesRequest,
|
|
ListSandboxesResponse, PolicyChunk, PolicyStatus, Provider, ProviderResponse,
|
|
PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest,
|
|
RejectDraftChunkResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse,
|
|
RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxLogLine, SandboxPolicyRevision,
|
|
SandboxResponse, SandboxStreamEvent, ServiceStatus, SshSession, SubmitPolicyAnalysisRequest,
|
|
SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, UndoDraftChunkResponse,
|
|
UpdateProviderRequest, UpdateSandboxPolicyRequest, UpdateSandboxPolicyResponse,
|
|
WatchSandboxRequest, open_shell_server::OpenShell,
|
|
};
|
|
use openshell_core::proto::{
|
|
Sandbox, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, SandboxTemplate,
|
|
};
|
|
use prost::Message;
|
|
use sha2::{Digest, Sha256};
|
|
use std::sync::Arc;
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
use tokio::sync::mpsc;
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
use tonic::{Request, Response, Status};
|
|
use tracing::{debug, info, warn};
|
|
|
|
use russh::ChannelMsg;
|
|
use russh::client::AuthResult;
|
|
|
|
use crate::ServerState;
|
|
|
|
/// Maximum number of records a single list RPC may return.
|
|
///
|
|
/// Client-provided `limit` values are clamped to this ceiling to prevent
|
|
/// unbounded memory allocation from an excessively large page request.
|
|
pub const MAX_PAGE_SIZE: u32 = 1000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Field-level size limits
|
|
//
|
|
// Named constants for easy tuning. Each limit is chosen to be generous
|
|
// enough for legitimate payloads while capping resource-exhaustion vectors.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Maximum length for a sandbox or provider name (Kubernetes name limit).
|
|
const MAX_NAME_LEN: usize = 253;
|
|
|
|
/// Maximum number of providers that can be attached to a sandbox.
|
|
const MAX_PROVIDERS: usize = 32;
|
|
|
|
/// Maximum length for the `log_level` field.
|
|
const MAX_LOG_LEVEL_LEN: usize = 32;
|
|
|
|
/// Maximum number of entries in `spec.environment`.
|
|
const MAX_ENVIRONMENT_ENTRIES: usize = 128;
|
|
|
|
/// Maximum length for an environment map key (bytes).
|
|
const MAX_MAP_KEY_LEN: usize = 256;
|
|
|
|
/// Maximum length for an environment map value (bytes).
|
|
const MAX_MAP_VALUE_LEN: usize = 8192;
|
|
|
|
/// Maximum length for template string fields (`image`, `runtime_class_name`, `agent_socket`).
|
|
const MAX_TEMPLATE_STRING_LEN: usize = 1024;
|
|
|
|
/// Maximum number of entries in template map fields (`labels`, `annotations`, `environment`).
|
|
const MAX_TEMPLATE_MAP_ENTRIES: usize = 128;
|
|
|
|
/// Maximum serialized size (bytes) for template Struct fields (`resources`, `pod_template`,
|
|
/// `volume_claim_templates`).
|
|
const MAX_TEMPLATE_STRUCT_SIZE: usize = 65_536;
|
|
|
|
/// Maximum serialized size (bytes) for the policy field.
|
|
const MAX_POLICY_SIZE: usize = 262_144;
|
|
|
|
/// Maximum length for a provider type slug.
|
|
const MAX_PROVIDER_TYPE_LEN: usize = 64;
|
|
|
|
/// Maximum number of entries in the provider `credentials` map.
|
|
const MAX_PROVIDER_CREDENTIALS_ENTRIES: usize = 32;
|
|
|
|
/// Maximum number of entries in the provider `config` map.
|
|
const MAX_PROVIDER_CONFIG_ENTRIES: usize = 64;
|
|
|
|
/// Clamp a client-provided page `limit`.
|
|
///
|
|
/// Returns `default` when `raw` is 0 (the protobuf zero-value convention),
|
|
/// otherwise returns the smaller of `raw` and `max`.
|
|
pub fn clamp_limit(raw: u32, default: u32, max: u32) -> u32 {
|
|
if raw == 0 { default } else { raw.min(max) }
|
|
}
|
|
|
|
/// OpenShell gRPC service implementation.
|
|
#[derive(Debug, Clone)]
|
|
pub struct OpenShellService {
|
|
state: Arc<ServerState>,
|
|
}
|
|
|
|
impl OpenShellService {
|
|
/// Create a new OpenShell service.
|
|
#[must_use]
|
|
#[allow(clippy::missing_const_for_fn)]
|
|
pub fn new(state: Arc<ServerState>) -> Self {
|
|
Self { state }
|
|
}
|
|
}
|
|
|
|
#[tonic::async_trait]
|
|
impl OpenShell for OpenShellService {
|
|
async fn health(
|
|
&self,
|
|
_request: Request<HealthRequest>,
|
|
) -> Result<Response<HealthResponse>, Status> {
|
|
Ok(Response::new(HealthResponse {
|
|
status: ServiceStatus::Healthy.into(),
|
|
version: openshell_core::VERSION.to_string(),
|
|
}))
|
|
}
|
|
|
|
async fn create_sandbox(
|
|
&self,
|
|
request: Request<CreateSandboxRequest>,
|
|
) -> Result<Response<SandboxResponse>, Status> {
|
|
let request = request.into_inner();
|
|
let spec = request
|
|
.spec
|
|
.ok_or_else(|| Status::invalid_argument("spec is required"))?;
|
|
|
|
// Validate field sizes before any I/O (fail fast on oversized payloads).
|
|
validate_sandbox_spec(&request.name, &spec)?;
|
|
|
|
// Validate provider names exist (fail fast). Credentials are fetched at
|
|
// runtime by the sandbox supervisor via GetSandboxProviderEnvironment.
|
|
for name in &spec.providers {
|
|
self.state
|
|
.store
|
|
.get_message_by_name::<Provider>(name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch provider failed: {e}")))?
|
|
.ok_or_else(|| {
|
|
Status::failed_precondition(format!("provider '{name}' not found"))
|
|
})?;
|
|
}
|
|
|
|
// Ensure the template always carries the resolved image so clients
|
|
// (CLI, TUI, etc.) can read the actual image from the stored sandbox.
|
|
let mut spec = spec;
|
|
let template = spec.template.get_or_insert_with(SandboxTemplate::default);
|
|
if template.image.is_empty() {
|
|
template.image = self.state.sandbox_client.default_image().to_string();
|
|
}
|
|
|
|
if spec.gpu {
|
|
self.state
|
|
.sandbox_client
|
|
.validate_gpu_support()
|
|
.await
|
|
.map_err(|status| {
|
|
warn!(error = %status, "Rejecting GPU sandbox request");
|
|
status
|
|
})?;
|
|
}
|
|
|
|
// Ensure process identity defaults to "sandbox" when missing or
|
|
// empty, then validate policy safety before persisting.
|
|
if let Some(ref mut policy) = spec.policy {
|
|
openshell_policy::ensure_sandbox_process_identity(policy);
|
|
validate_policy_safety(policy)?;
|
|
}
|
|
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let name = if request.name.is_empty() {
|
|
petname::petname(2, "-").unwrap_or_else(generate_name)
|
|
} else {
|
|
request.name.clone()
|
|
};
|
|
let namespace = self.state.config.sandbox_namespace.clone();
|
|
|
|
let sandbox = Sandbox {
|
|
id: id.clone(),
|
|
name: name.clone(),
|
|
namespace,
|
|
spec: Some(spec),
|
|
status: None,
|
|
phase: SandboxPhase::Provisioning as i32,
|
|
..Default::default()
|
|
};
|
|
|
|
// Persist to the store FIRST so the sandbox watcher always finds
|
|
// the record with `spec` populated. If we created the k8s
|
|
// resource first, the watcher could race us and write a fallback
|
|
// record with `spec: None`, causing the supervisor to fail with
|
|
// "sandbox has no spec".
|
|
self.state.sandbox_index.update_from_sandbox(&sandbox);
|
|
|
|
self.state
|
|
.store
|
|
.put_message(&sandbox)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist sandbox failed: {e}")))?;
|
|
|
|
// Now create the Kubernetes resource. If this fails, clean up
|
|
// the store entry to avoid orphans.
|
|
match self.state.sandbox_client.create(&sandbox).await {
|
|
Ok(_) => {}
|
|
Err(kube::Error::Api(err)) if err.code == 409 => {
|
|
// Clean up the store entry we just wrote.
|
|
let _ = self.state.store.delete("sandbox", &id).await;
|
|
self.state.sandbox_index.remove_sandbox(&id);
|
|
warn!(
|
|
sandbox_id = %id,
|
|
sandbox_name = %name,
|
|
"Sandbox already exists in Kubernetes"
|
|
);
|
|
return Err(Status::already_exists("sandbox already exists"));
|
|
}
|
|
Err(err) => {
|
|
// Clean up the store entry we just wrote.
|
|
let _ = self.state.store.delete("sandbox", &id).await;
|
|
self.state.sandbox_index.remove_sandbox(&id);
|
|
warn!(
|
|
sandbox_id = %id,
|
|
sandbox_name = %name,
|
|
error = %err,
|
|
"CreateSandbox request failed"
|
|
);
|
|
return Err(Status::internal(format!(
|
|
"create sandbox in kubernetes failed: {err}"
|
|
)));
|
|
}
|
|
}
|
|
|
|
self.state.sandbox_watch_bus.notify(&id);
|
|
|
|
info!(
|
|
sandbox_id = %id,
|
|
sandbox_name = %name,
|
|
"CreateSandbox request completed successfully"
|
|
);
|
|
Ok(Response::new(SandboxResponse {
|
|
sandbox: Some(sandbox),
|
|
}))
|
|
}
|
|
|
|
type WatchSandboxStream = ReceiverStream<Result<SandboxStreamEvent, Status>>;
|
|
type ExecSandboxStream = ReceiverStream<Result<ExecSandboxEvent, Status>>;
|
|
|
|
async fn watch_sandbox(
|
|
&self,
|
|
request: Request<WatchSandboxRequest>,
|
|
) -> Result<Response<Self::WatchSandboxStream>, Status> {
|
|
let req = request.into_inner();
|
|
if req.id.is_empty() {
|
|
return Err(Status::invalid_argument("id is required"));
|
|
}
|
|
let sandbox_id = req.id.clone();
|
|
|
|
let follow_status = req.follow_status;
|
|
let follow_logs = req.follow_logs;
|
|
let follow_events = req.follow_events;
|
|
let log_tail = if req.log_tail_lines == 0 {
|
|
200
|
|
} else {
|
|
req.log_tail_lines
|
|
};
|
|
let stop_on_terminal = req.stop_on_terminal;
|
|
let log_since_ms = req.log_since_ms;
|
|
let log_sources = req.log_sources;
|
|
let log_min_level = req.log_min_level;
|
|
|
|
let (tx, rx) = mpsc::channel::<Result<SandboxStreamEvent, Status>>(256);
|
|
let state = self.state.clone();
|
|
|
|
// Spawn producer task.
|
|
tokio::spawn(async move {
|
|
// Validate that the sandbox exists BEFORE subscribing to any buses.
|
|
// This prevents creating bus entries for non-existent sandbox IDs.
|
|
match state.store.get_message::<Sandbox>(&sandbox_id).await {
|
|
Ok(Some(_)) => {} // sandbox exists, proceed
|
|
Ok(None) => {
|
|
let _ = tx.send(Err(Status::not_found("sandbox not found"))).await;
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
let _ = tx
|
|
.send(Err(Status::internal(format!("fetch sandbox failed: {e}"))))
|
|
.await;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Subscribe to all buses BEFORE reading the snapshot to avoid
|
|
// missing notifications that fire between the snapshot read and subscribe.
|
|
let mut status_rx = if follow_status {
|
|
Some(state.sandbox_watch_bus.subscribe(&sandbox_id))
|
|
} else {
|
|
None
|
|
};
|
|
let mut log_rx = if follow_logs {
|
|
Some(state.tracing_log_bus.subscribe(&sandbox_id))
|
|
} else {
|
|
None
|
|
};
|
|
let mut platform_rx = if follow_events {
|
|
Some(
|
|
state
|
|
.tracing_log_bus
|
|
.platform_event_bus
|
|
.subscribe(&sandbox_id),
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Re-read the snapshot now that we have subscriptions active
|
|
// (avoids missing notifications between validate and subscribe).
|
|
match state.store.get_message::<Sandbox>(&sandbox_id).await {
|
|
Ok(Some(sandbox)) => {
|
|
state.sandbox_index.update_from_sandbox(&sandbox);
|
|
let _ = tx
|
|
.send(Ok(SandboxStreamEvent {
|
|
payload: Some(
|
|
openshell_core::proto::sandbox_stream_event::Payload::Sandbox(
|
|
sandbox.clone(),
|
|
),
|
|
),
|
|
}))
|
|
.await;
|
|
|
|
if stop_on_terminal {
|
|
let phase =
|
|
SandboxPhase::try_from(sandbox.phase).unwrap_or(SandboxPhase::Unknown);
|
|
// Only stop on Ready - Error phase may be transient (e.g., ReconcilerError)
|
|
// and the sandbox may recover. Let the client decide how to handle errors.
|
|
if phase == SandboxPhase::Ready {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
// Sandbox was deleted between validate and subscribe — end stream.
|
|
let _ = tx.send(Err(Status::not_found("sandbox not found"))).await;
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
let _ = tx
|
|
.send(Err(Status::internal(format!("fetch sandbox failed: {e}"))))
|
|
.await;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Replay tail logs (best-effort), filtered by log_since_ms and log_sources.
|
|
if follow_logs {
|
|
for evt in state.tracing_log_bus.tail(&sandbox_id, log_tail as usize) {
|
|
if let Some(openshell_core::proto::sandbox_stream_event::Payload::Log(
|
|
ref log,
|
|
)) = evt.payload
|
|
{
|
|
if log_since_ms > 0 && log.timestamp_ms < log_since_ms {
|
|
continue;
|
|
}
|
|
if !log_sources.is_empty() && !source_matches(&log.source, &log_sources) {
|
|
continue;
|
|
}
|
|
if !level_matches(&log.level, &log_min_level) {
|
|
continue;
|
|
}
|
|
}
|
|
if tx.send(Ok(evt)).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Replay buffered platform events (best-effort) so late subscribers
|
|
// see Kubernetes events (Scheduled, Pulling, etc.) that already fired.
|
|
if follow_events {
|
|
for evt in state
|
|
.tracing_log_bus
|
|
.platform_event_bus
|
|
.tail(&sandbox_id, 50)
|
|
{
|
|
if tx.send(Ok(evt)).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
loop {
|
|
tokio::select! {
|
|
res = async {
|
|
match status_rx.as_mut() {
|
|
Some(rx) => rx.recv().await,
|
|
None => future::pending().await,
|
|
}
|
|
} => {
|
|
match res {
|
|
Ok(()) => {
|
|
match state.store.get_message::<Sandbox>(&sandbox_id).await {
|
|
Ok(Some(sandbox)) => {
|
|
state.sandbox_index.update_from_sandbox(&sandbox);
|
|
if tx.send(Ok(SandboxStreamEvent { payload: Some(openshell_core::proto::sandbox_stream_event::Payload::Sandbox(sandbox.clone()))})).await.is_err() {
|
|
return;
|
|
}
|
|
if stop_on_terminal {
|
|
let phase = SandboxPhase::try_from(sandbox.phase).unwrap_or(SandboxPhase::Unknown);
|
|
// Only stop on Ready - Error phase may be transient (e.g., ReconcilerError)
|
|
// and the sandbox may recover. Let the client decide how to handle errors.
|
|
if phase == SandboxPhase::Ready {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
// Deleted; end stream.
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
let _ = tx.send(Err(Status::internal(format!("fetch sandbox failed: {e}")))).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let _ = tx.send(Err(crate::sandbox_watch::broadcast_to_status(err))).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
res = async {
|
|
match log_rx.as_mut() {
|
|
Some(rx) => rx.recv().await,
|
|
None => future::pending().await,
|
|
}
|
|
} => {
|
|
match res {
|
|
Ok(evt) => {
|
|
// Apply source + level filter on live log events.
|
|
if let Some(openshell_core::proto::sandbox_stream_event::Payload::Log(ref log)) = evt.payload {
|
|
if !log_sources.is_empty() && !source_matches(&log.source, &log_sources) {
|
|
continue;
|
|
}
|
|
if !level_matches(&log.level, &log_min_level) {
|
|
continue;
|
|
}
|
|
}
|
|
if tx.send(Ok(evt)).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let _ = tx.send(Err(crate::sandbox_watch::broadcast_to_status(err))).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
res = async {
|
|
match platform_rx.as_mut() {
|
|
Some(rx) => rx.recv().await,
|
|
None => future::pending().await,
|
|
}
|
|
} => {
|
|
match res {
|
|
Ok(evt) => {
|
|
if tx.send(Ok(evt)).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let _ = tx.send(Err(crate::sandbox_watch::broadcast_to_status(err))).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(Response::new(ReceiverStream::new(rx)))
|
|
}
|
|
|
|
async fn get_sandbox(
|
|
&self,
|
|
request: Request<GetSandboxRequest>,
|
|
) -> Result<Response<SandboxResponse>, Status> {
|
|
let name = request.into_inner().name;
|
|
if name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?;
|
|
|
|
let sandbox = sandbox.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
Ok(Response::new(SandboxResponse {
|
|
sandbox: Some(sandbox),
|
|
}))
|
|
}
|
|
|
|
async fn list_sandboxes(
|
|
&self,
|
|
request: Request<ListSandboxesRequest>,
|
|
) -> Result<Response<ListSandboxesResponse>, Status> {
|
|
let request = request.into_inner();
|
|
let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE);
|
|
let records = self
|
|
.state
|
|
.store
|
|
.list(Sandbox::object_type(), limit, request.offset)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list sandboxes failed: {e}")))?;
|
|
|
|
let mut sandboxes = Vec::with_capacity(records.len());
|
|
for record in records {
|
|
let mut sandbox = Sandbox::decode(record.payload.as_slice())
|
|
.map_err(|e| Status::internal(format!("decode sandbox failed: {e}")))?;
|
|
sandbox.created_at_ms = record.created_at_ms;
|
|
sandboxes.push(sandbox);
|
|
}
|
|
|
|
Ok(Response::new(ListSandboxesResponse { sandboxes }))
|
|
}
|
|
|
|
async fn delete_sandbox(
|
|
&self,
|
|
request: Request<DeleteSandboxRequest>,
|
|
) -> Result<Response<DeleteSandboxResponse>, Status> {
|
|
let name = request.into_inner().name;
|
|
if name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?;
|
|
|
|
let Some(mut sandbox) = sandbox else {
|
|
return Err(Status::not_found("sandbox not found"));
|
|
};
|
|
|
|
let id = sandbox.id.clone();
|
|
|
|
sandbox.phase = SandboxPhase::Deleting as i32;
|
|
self.state
|
|
.store
|
|
.put_message(&sandbox)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist sandbox failed: {e}")))?;
|
|
|
|
self.state.sandbox_index.update_from_sandbox(&sandbox);
|
|
self.state.sandbox_watch_bus.notify(&id);
|
|
|
|
// Clean up SSH sessions associated with this sandbox.
|
|
if let Ok(records) = self
|
|
.state
|
|
.store
|
|
.list(SshSession::object_type(), 1000, 0)
|
|
.await
|
|
{
|
|
for record in records {
|
|
if let Ok(session) = SshSession::decode(record.payload.as_slice())
|
|
&& session.sandbox_id == id
|
|
&& let Err(e) = self
|
|
.state
|
|
.store
|
|
.delete(SshSession::object_type(), &session.id)
|
|
.await
|
|
{
|
|
warn!(
|
|
session_id = %session.id,
|
|
error = %e,
|
|
"Failed to delete SSH session during sandbox cleanup"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let deleted = match self.state.sandbox_client.delete(&sandbox.name).await {
|
|
Ok(deleted) => deleted,
|
|
Err(err) => {
|
|
warn!(
|
|
sandbox_id = %id,
|
|
sandbox_name = %sandbox.name,
|
|
error = %err,
|
|
"DeleteSandbox request failed"
|
|
);
|
|
return Err(Status::internal(format!(
|
|
"delete sandbox in kubernetes failed: {err}"
|
|
)));
|
|
}
|
|
};
|
|
|
|
if !deleted && let Err(e) = self.state.store.delete(Sandbox::object_type(), &id).await {
|
|
warn!(sandbox_id = %id, error = %e, "Failed to clean up store after delete");
|
|
}
|
|
|
|
// Clean up bus entries to prevent unbounded memory growth.
|
|
self.state.tracing_log_bus.remove(&id);
|
|
self.state.tracing_log_bus.platform_event_bus.remove(&id);
|
|
self.state.sandbox_watch_bus.remove(&id);
|
|
|
|
info!(
|
|
sandbox_id = %id,
|
|
sandbox_name = %sandbox.name,
|
|
"DeleteSandbox request completed successfully"
|
|
);
|
|
Ok(Response::new(DeleteSandboxResponse { deleted }))
|
|
}
|
|
|
|
async fn create_provider(
|
|
&self,
|
|
request: Request<CreateProviderRequest>,
|
|
) -> Result<Response<ProviderResponse>, Status> {
|
|
let req = request.into_inner();
|
|
let provider = req
|
|
.provider
|
|
.ok_or_else(|| Status::invalid_argument("provider is required"))?;
|
|
let provider = create_provider_record(self.state.store.as_ref(), provider).await?;
|
|
|
|
Ok(Response::new(ProviderResponse {
|
|
provider: Some(provider),
|
|
}))
|
|
}
|
|
|
|
async fn get_provider(
|
|
&self,
|
|
request: Request<GetProviderRequest>,
|
|
) -> Result<Response<ProviderResponse>, Status> {
|
|
let name = request.into_inner().name;
|
|
let provider = get_provider_record(self.state.store.as_ref(), &name).await?;
|
|
|
|
Ok(Response::new(ProviderResponse {
|
|
provider: Some(provider),
|
|
}))
|
|
}
|
|
|
|
async fn list_providers(
|
|
&self,
|
|
request: Request<ListProvidersRequest>,
|
|
) -> Result<Response<ListProvidersResponse>, Status> {
|
|
let request = request.into_inner();
|
|
let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE);
|
|
let providers =
|
|
list_provider_records(self.state.store.as_ref(), limit, request.offset).await?;
|
|
|
|
Ok(Response::new(ListProvidersResponse { providers }))
|
|
}
|
|
|
|
async fn update_provider(
|
|
&self,
|
|
request: Request<UpdateProviderRequest>,
|
|
) -> Result<Response<ProviderResponse>, Status> {
|
|
let req = request.into_inner();
|
|
let provider = req
|
|
.provider
|
|
.ok_or_else(|| Status::invalid_argument("provider is required"))?;
|
|
let provider = update_provider_record(self.state.store.as_ref(), provider).await?;
|
|
|
|
Ok(Response::new(ProviderResponse {
|
|
provider: Some(provider),
|
|
}))
|
|
}
|
|
|
|
async fn delete_provider(
|
|
&self,
|
|
request: Request<DeleteProviderRequest>,
|
|
) -> Result<Response<DeleteProviderResponse>, Status> {
|
|
let name = request.into_inner().name;
|
|
let deleted = delete_provider_record(self.state.store.as_ref(), &name).await?;
|
|
|
|
Ok(Response::new(DeleteProviderResponse { deleted }))
|
|
}
|
|
|
|
async fn get_sandbox_policy(
|
|
&self,
|
|
request: Request<GetSandboxPolicyRequest>,
|
|
) -> Result<Response<GetSandboxPolicyResponse>, Status> {
|
|
let sandbox_id = request.into_inner().sandbox_id;
|
|
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message::<Sandbox>(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
|
|
// Try to get the latest policy from the policy history table.
|
|
let latest = self
|
|
.state
|
|
.store
|
|
.get_latest_policy(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch policy history failed: {e}")))?;
|
|
|
|
if let Some(record) = latest {
|
|
let policy = ProtoSandboxPolicy::decode(record.policy_payload.as_slice())
|
|
.map_err(|e| Status::internal(format!("decode policy failed: {e}")))?;
|
|
debug!(
|
|
sandbox_id = %sandbox_id,
|
|
version = record.version,
|
|
"GetSandboxPolicy served from policy history"
|
|
);
|
|
return Ok(Response::new(GetSandboxPolicyResponse {
|
|
policy: Some(policy),
|
|
version: u32::try_from(record.version).unwrap_or(0),
|
|
policy_hash: record.policy_hash,
|
|
}));
|
|
}
|
|
|
|
// Lazy backfill: no policy history exists yet.
|
|
let spec = sandbox
|
|
.spec
|
|
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
|
|
|
|
// If spec.policy is None, the sandbox was created without a policy.
|
|
// Return an empty response so the sandbox can discover policy from disk
|
|
// or fall back to its restrictive default.
|
|
let Some(policy) = spec.policy else {
|
|
debug!(
|
|
sandbox_id = %sandbox_id,
|
|
"GetSandboxPolicy: no policy configured, returning empty response"
|
|
);
|
|
return Ok(Response::new(GetSandboxPolicyResponse {
|
|
policy: None,
|
|
version: 0,
|
|
policy_hash: String::new(),
|
|
}));
|
|
};
|
|
|
|
// Create version 1 from spec.policy.
|
|
let payload = policy.encode_to_vec();
|
|
let hash = deterministic_policy_hash(&policy);
|
|
let policy_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
// Best-effort backfill: if it fails (e.g., concurrent backfill race), we still
|
|
// return the policy from spec.
|
|
if let Err(e) = self
|
|
.state
|
|
.store
|
|
.put_policy_revision(&policy_id, &sandbox_id, 1, &payload, &hash)
|
|
.await
|
|
{
|
|
warn!(sandbox_id = %sandbox_id, error = %e, "Failed to backfill policy version 1");
|
|
} else if let Err(e) = self
|
|
.state
|
|
.store
|
|
.update_policy_status(&sandbox_id, 1, "loaded", None, None)
|
|
.await
|
|
{
|
|
warn!(sandbox_id = %sandbox_id, error = %e, "Failed to mark backfilled policy as loaded");
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
"GetSandboxPolicy served from spec (backfilled version 1)"
|
|
);
|
|
|
|
Ok(Response::new(GetSandboxPolicyResponse {
|
|
policy: Some(policy),
|
|
version: 1,
|
|
policy_hash: hash,
|
|
}))
|
|
}
|
|
|
|
async fn get_sandbox_provider_environment(
|
|
&self,
|
|
request: Request<GetSandboxProviderEnvironmentRequest>,
|
|
) -> Result<Response<GetSandboxProviderEnvironmentResponse>, Status> {
|
|
let sandbox_id = request.into_inner().sandbox_id;
|
|
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message::<Sandbox>(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
|
|
let spec = sandbox
|
|
.spec
|
|
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
|
|
|
|
let environment =
|
|
resolve_provider_environment(self.state.store.as_ref(), &spec.providers).await?;
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
provider_count = spec.providers.len(),
|
|
env_count = environment.len(),
|
|
"GetSandboxProviderEnvironment request completed successfully"
|
|
);
|
|
|
|
Ok(Response::new(GetSandboxProviderEnvironmentResponse {
|
|
environment,
|
|
}))
|
|
}
|
|
|
|
async fn create_ssh_session(
|
|
&self,
|
|
request: Request<CreateSshSessionRequest>,
|
|
) -> Result<Response<CreateSshSessionResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.sandbox_id.is_empty() {
|
|
return Err(Status::invalid_argument("sandbox_id is required"));
|
|
}
|
|
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message::<Sandbox>(&req.sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
|
|
if SandboxPhase::try_from(sandbox.phase).ok() != Some(SandboxPhase::Ready) {
|
|
return Err(Status::failed_precondition("sandbox is not ready"));
|
|
}
|
|
|
|
let token = uuid::Uuid::new_v4().to_string();
|
|
let now_ms = current_time_ms()
|
|
.map_err(|e| Status::internal(format!("timestamp generation failed: {e}")))?;
|
|
let expires_at_ms = if self.state.config.ssh_session_ttl_secs > 0 {
|
|
now_ms + (self.state.config.ssh_session_ttl_secs as i64 * 1000)
|
|
} else {
|
|
0
|
|
};
|
|
let session = SshSession {
|
|
id: token.clone(),
|
|
sandbox_id: req.sandbox_id.clone(),
|
|
token: token.clone(),
|
|
created_at_ms: now_ms,
|
|
revoked: false,
|
|
name: generate_name(),
|
|
expires_at_ms,
|
|
};
|
|
|
|
self.state
|
|
.store
|
|
.put_message(&session)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist ssh session failed: {e}")))?;
|
|
|
|
let (gateway_host, gateway_port) = resolve_gateway(&self.state.config);
|
|
let scheme = if self.state.config.tls.is_some() {
|
|
"https"
|
|
} else {
|
|
"http"
|
|
};
|
|
|
|
Ok(Response::new(CreateSshSessionResponse {
|
|
sandbox_id: req.sandbox_id,
|
|
token,
|
|
gateway_host,
|
|
gateway_port: gateway_port.into(),
|
|
gateway_scheme: scheme.to_string(),
|
|
connect_path: self.state.config.ssh_connect_path.clone(),
|
|
host_key_fingerprint: String::new(),
|
|
expires_at_ms,
|
|
}))
|
|
}
|
|
|
|
async fn exec_sandbox(
|
|
&self,
|
|
request: Request<ExecSandboxRequest>,
|
|
) -> Result<Response<Self::ExecSandboxStream>, Status> {
|
|
let req = request.into_inner();
|
|
if req.sandbox_id.is_empty() {
|
|
return Err(Status::invalid_argument("sandbox_id is required"));
|
|
}
|
|
if req.command.is_empty() {
|
|
return Err(Status::invalid_argument("command is required"));
|
|
}
|
|
if req.environment.keys().any(|key| !is_valid_env_key(key)) {
|
|
return Err(Status::invalid_argument(
|
|
"environment keys must match ^[A-Za-z_][A-Za-z0-9_]*$",
|
|
));
|
|
}
|
|
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message::<Sandbox>(&req.sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
|
|
if SandboxPhase::try_from(sandbox.phase).ok() != Some(SandboxPhase::Ready) {
|
|
return Err(Status::failed_precondition("sandbox is not ready"));
|
|
}
|
|
|
|
let (target_host, target_port) = resolve_sandbox_exec_target(&self.state, &sandbox).await?;
|
|
let command_str = build_remote_exec_command(&req);
|
|
let stdin_payload = req.stdin;
|
|
let timeout_seconds = req.timeout_seconds;
|
|
let sandbox_id = sandbox.id;
|
|
let handshake_secret = self.state.config.ssh_handshake_secret.clone();
|
|
|
|
let (tx, rx) = mpsc::channel::<Result<ExecSandboxEvent, Status>>(256);
|
|
tokio::spawn(async move {
|
|
if let Err(err) = stream_exec_over_ssh(
|
|
tx.clone(),
|
|
&sandbox_id,
|
|
&target_host,
|
|
target_port,
|
|
&command_str,
|
|
stdin_payload,
|
|
timeout_seconds,
|
|
&handshake_secret,
|
|
)
|
|
.await
|
|
{
|
|
warn!(sandbox_id = %sandbox_id, error = %err, "ExecSandbox failed");
|
|
let _ = tx.send(Err(err)).await;
|
|
}
|
|
});
|
|
|
|
Ok(Response::new(ReceiverStream::new(rx)))
|
|
}
|
|
|
|
async fn revoke_ssh_session(
|
|
&self,
|
|
request: Request<RevokeSshSessionRequest>,
|
|
) -> Result<Response<RevokeSshSessionResponse>, Status> {
|
|
let token = request.into_inner().token;
|
|
if token.is_empty() {
|
|
return Err(Status::invalid_argument("token is required"));
|
|
}
|
|
|
|
let session = self
|
|
.state
|
|
.store
|
|
.get_message::<SshSession>(&token)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch ssh session failed: {e}")))?;
|
|
|
|
let Some(mut session) = session else {
|
|
return Ok(Response::new(RevokeSshSessionResponse { revoked: false }));
|
|
};
|
|
|
|
session.revoked = true;
|
|
self.state
|
|
.store
|
|
.put_message(&session)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist ssh session failed: {e}")))?;
|
|
|
|
Ok(Response::new(RevokeSshSessionResponse { revoked: true }))
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Policy update handlers
|
|
// -------------------------------------------------------------------
|
|
|
|
async fn update_sandbox_policy(
|
|
&self,
|
|
request: Request<UpdateSandboxPolicyRequest>,
|
|
) -> Result<Response<UpdateSandboxPolicyResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
let mut new_policy = req
|
|
.policy
|
|
.ok_or_else(|| Status::invalid_argument("policy is required"))?;
|
|
|
|
// Resolve sandbox by name.
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
|
|
let sandbox_id = sandbox.id.clone();
|
|
|
|
// Get the baseline (version 1) policy for static field validation.
|
|
let spec = sandbox
|
|
.spec
|
|
.as_ref()
|
|
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
|
|
|
|
// Ensure process identity defaults to "sandbox" when missing or empty.
|
|
openshell_policy::ensure_sandbox_process_identity(&mut new_policy);
|
|
|
|
if let Some(baseline_policy) = spec.policy.as_ref() {
|
|
// Validate static fields haven't changed.
|
|
validate_static_fields_unchanged(baseline_policy, &new_policy)?;
|
|
|
|
// Validate network mode hasn't changed (Block ↔ Proxy).
|
|
validate_network_mode_unchanged(baseline_policy, &new_policy)?;
|
|
|
|
// Validate policy safety (no root, no path traversal, etc.).
|
|
validate_policy_safety(&new_policy)?;
|
|
} else {
|
|
// No baseline policy exists (sandbox created without one). The
|
|
// sandbox is syncing a locally-discovered or restrictive-default
|
|
// policy. Backfill spec.policy so future updates can validate
|
|
// against it.
|
|
let mut sandbox = sandbox;
|
|
if let Some(ref mut spec) = sandbox.spec {
|
|
spec.policy = Some(new_policy.clone());
|
|
}
|
|
self.state
|
|
.store
|
|
.put_message(&sandbox)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("backfill spec.policy failed: {e}")))?;
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
"UpdateSandboxPolicy: backfilled spec.policy from sandbox-discovered policy"
|
|
);
|
|
}
|
|
|
|
// Determine next version number.
|
|
let latest = self
|
|
.state
|
|
.store
|
|
.get_latest_policy(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?;
|
|
|
|
// Compute hash and check if the policy actually changed.
|
|
let payload = new_policy.encode_to_vec();
|
|
let hash = deterministic_policy_hash(&new_policy);
|
|
|
|
if let Some(ref current) = latest
|
|
&& current.policy_hash == hash
|
|
{
|
|
return Ok(Response::new(UpdateSandboxPolicyResponse {
|
|
version: u32::try_from(current.version).unwrap_or(0),
|
|
policy_hash: hash,
|
|
}));
|
|
}
|
|
|
|
let next_version = latest.map_or(1, |r| r.version + 1);
|
|
let policy_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
self.state
|
|
.store
|
|
.put_policy_revision(&policy_id, &sandbox_id, next_version, &payload, &hash)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist policy revision failed: {e}")))?;
|
|
|
|
// Supersede older pending revisions.
|
|
let _ = self
|
|
.state
|
|
.store
|
|
.supersede_older_policies(&sandbox_id, next_version)
|
|
.await;
|
|
|
|
// Notify watchers (unblocks CLI --wait polling).
|
|
self.state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
version = next_version,
|
|
policy_hash = %hash,
|
|
"UpdateSandboxPolicy: new policy version persisted"
|
|
);
|
|
|
|
Ok(Response::new(UpdateSandboxPolicyResponse {
|
|
version: u32::try_from(next_version).unwrap_or(0),
|
|
policy_hash: hash,
|
|
}))
|
|
}
|
|
|
|
async fn get_sandbox_policy_status(
|
|
&self,
|
|
request: Request<GetSandboxPolicyStatusRequest>,
|
|
) -> Result<Response<GetSandboxPolicyStatusResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
|
|
let sandbox_id = sandbox.id;
|
|
|
|
let record = if req.version == 0 {
|
|
self.state
|
|
.store
|
|
.get_latest_policy(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch policy failed: {e}")))?
|
|
} else {
|
|
self.state
|
|
.store
|
|
.get_policy_by_version(&sandbox_id, i64::from(req.version))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch policy failed: {e}")))?
|
|
};
|
|
|
|
let record =
|
|
record.ok_or_else(|| Status::not_found("no policy revision found for this sandbox"))?;
|
|
|
|
let active_version = sandbox.current_policy_version;
|
|
|
|
Ok(Response::new(GetSandboxPolicyStatusResponse {
|
|
revision: Some(policy_record_to_revision(&record, true)),
|
|
active_version,
|
|
}))
|
|
}
|
|
|
|
async fn list_sandbox_policies(
|
|
&self,
|
|
request: Request<ListSandboxPoliciesRequest>,
|
|
) -> Result<Response<ListSandboxPoliciesResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
|
|
let limit = clamp_limit(req.limit, 50, MAX_PAGE_SIZE);
|
|
let records = self
|
|
.state
|
|
.store
|
|
.list_policies(&sandbox.id, limit, req.offset)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list policies failed: {e}")))?;
|
|
|
|
let revisions = records
|
|
.iter()
|
|
.map(|r| policy_record_to_revision(r, false))
|
|
.collect();
|
|
|
|
Ok(Response::new(ListSandboxPoliciesResponse { revisions }))
|
|
}
|
|
|
|
async fn report_policy_status(
|
|
&self,
|
|
request: Request<ReportPolicyStatusRequest>,
|
|
) -> Result<Response<ReportPolicyStatusResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.sandbox_id.is_empty() {
|
|
return Err(Status::invalid_argument("sandbox_id is required"));
|
|
}
|
|
if req.version == 0 {
|
|
return Err(Status::invalid_argument("version is required"));
|
|
}
|
|
|
|
let version = i64::from(req.version);
|
|
let status_str = match PolicyStatus::try_from(req.status) {
|
|
Ok(PolicyStatus::Loaded) => "loaded",
|
|
Ok(PolicyStatus::Failed) => "failed",
|
|
_ => return Err(Status::invalid_argument("status must be LOADED or FAILED")),
|
|
};
|
|
|
|
let loaded_at_ms = if status_str == "loaded" {
|
|
Some(current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let load_error = if status_str == "failed" && !req.load_error.is_empty() {
|
|
Some(req.load_error.as_str())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let updated = self
|
|
.state
|
|
.store
|
|
.update_policy_status(
|
|
&req.sandbox_id,
|
|
version,
|
|
status_str,
|
|
load_error,
|
|
loaded_at_ms,
|
|
)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update policy status failed: {e}")))?;
|
|
|
|
if !updated {
|
|
return Err(Status::not_found("policy revision not found"));
|
|
}
|
|
|
|
// If loaded, update the sandbox's current_policy_version and
|
|
// supersede all older versions.
|
|
if status_str == "loaded" {
|
|
let _ = self
|
|
.state
|
|
.store
|
|
.supersede_older_policies(&req.sandbox_id, version)
|
|
.await;
|
|
if let Ok(Some(mut sandbox)) = self
|
|
.state
|
|
.store
|
|
.get_message::<Sandbox>(&req.sandbox_id)
|
|
.await
|
|
{
|
|
sandbox.current_policy_version = req.version;
|
|
let _ = self.state.store.put_message(&sandbox).await;
|
|
}
|
|
// Notify watchers so CLI --wait can detect the status change.
|
|
self.state.sandbox_watch_bus.notify(&req.sandbox_id);
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %req.sandbox_id,
|
|
version = req.version,
|
|
status = %status_str,
|
|
"ReportPolicyStatus: sandbox reported policy load result"
|
|
);
|
|
|
|
Ok(Response::new(ReportPolicyStatusResponse {}))
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Sandbox logs handler
|
|
// -------------------------------------------------------------------
|
|
|
|
async fn get_sandbox_logs(
|
|
&self,
|
|
request: Request<GetSandboxLogsRequest>,
|
|
) -> Result<Response<GetSandboxLogsResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.sandbox_id.is_empty() {
|
|
return Err(Status::invalid_argument("sandbox_id is required"));
|
|
}
|
|
|
|
let lines = if req.lines == 0 { 2000 } else { req.lines };
|
|
let tail = self
|
|
.state
|
|
.tracing_log_bus
|
|
.tail(&req.sandbox_id, lines as usize);
|
|
|
|
let buffer_total = tail.len() as u32;
|
|
|
|
// Extract SandboxLogLine and apply time + source filters.
|
|
let logs: Vec<SandboxLogLine> = tail
|
|
.into_iter()
|
|
.filter_map(|evt| {
|
|
if let Some(openshell_core::proto::sandbox_stream_event::Payload::Log(log)) =
|
|
evt.payload
|
|
{
|
|
if req.since_ms > 0 && log.timestamp_ms < req.since_ms {
|
|
return None;
|
|
}
|
|
if !req.sources.is_empty() && !source_matches(&log.source, &req.sources) {
|
|
return None;
|
|
}
|
|
if !level_matches(&log.level, &req.min_level) {
|
|
return None;
|
|
}
|
|
Some(log)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(Response::new(GetSandboxLogsResponse { logs, buffer_total }))
|
|
}
|
|
|
|
async fn push_sandbox_logs(
|
|
&self,
|
|
request: Request<tonic::Streaming<PushSandboxLogsRequest>>,
|
|
) -> Result<Response<PushSandboxLogsResponse>, Status> {
|
|
let mut stream = request.into_inner();
|
|
let mut validated = false;
|
|
|
|
while let Some(batch) = stream
|
|
.message()
|
|
.await
|
|
.map_err(|e| Status::internal(format!("stream error: {e}")))?
|
|
{
|
|
if batch.sandbox_id.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Validate sandbox existence once at stream open (first batch).
|
|
// Subsequent batches trust the validated sandbox_id. If the sandbox
|
|
// is deleted mid-stream, bus remove() drops the sender and publish
|
|
// silently discards via `let _ = tx.send(...)`.
|
|
if !validated {
|
|
self.state
|
|
.store
|
|
.get_message::<Sandbox>(&batch.sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
validated = true;
|
|
}
|
|
|
|
// Cap lines per batch to prevent abuse.
|
|
for log in batch.logs.into_iter().take(100) {
|
|
let mut log = log;
|
|
// Force source to "sandbox" — the sandbox cannot claim to be the gateway.
|
|
log.source = "sandbox".to_string();
|
|
// Force sandbox_id to match the batch envelope.
|
|
log.sandbox_id.clone_from(&batch.sandbox_id);
|
|
self.state.tracing_log_bus.publish_external(log);
|
|
}
|
|
}
|
|
|
|
Ok(Response::new(PushSandboxLogsResponse {}))
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Draft policy recommendation handlers
|
|
// -----------------------------------------------------------------------
|
|
|
|
async fn submit_policy_analysis(
|
|
&self,
|
|
request: Request<SubmitPolicyAnalysisRequest>,
|
|
) -> Result<Response<SubmitPolicyAnalysisResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
// Resolve sandbox by name.
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let sandbox_id = sandbox.id.clone();
|
|
|
|
// Get the next draft version.
|
|
let current_version = self
|
|
.state
|
|
.store
|
|
.get_draft_version(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("get draft version failed: {e}")))?;
|
|
let draft_version = current_version + 1;
|
|
|
|
// Validate and persist proposed chunks from the sandbox.
|
|
// The sandbox runs the mechanistic mapper (or future LLM advisor)
|
|
// and sends pre-formed chunks. The gateway is a thin persistence +
|
|
// validation layer — it never generates proposals itself.
|
|
//
|
|
// Dedup is handled at the DB level: the unique partial index on
|
|
// (sandbox_id, host, port, binary) triggers an upsert that
|
|
// increments hit_count + updates last_seen_ms.
|
|
let mut accepted: u32 = 0;
|
|
let mut rejected: u32 = 0;
|
|
let mut rejection_reasons: Vec<String> = Vec::new();
|
|
|
|
for chunk in &req.proposed_chunks {
|
|
// Basic validation: rule_name and proposed_rule are required.
|
|
if chunk.rule_name.is_empty() {
|
|
rejected += 1;
|
|
rejection_reasons.push("chunk missing rule_name".to_string());
|
|
continue;
|
|
}
|
|
if chunk.proposed_rule.is_none() {
|
|
rejected += 1;
|
|
rejection_reasons
|
|
.push(format!("chunk '{}' missing proposed_rule", chunk.rule_name));
|
|
continue;
|
|
}
|
|
|
|
let chunk_id = uuid::Uuid::new_v4().to_string();
|
|
let now_ms =
|
|
current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
|
|
let proposed_rule_bytes = chunk
|
|
.proposed_rule
|
|
.as_ref()
|
|
.map(|r| r.encode_to_vec())
|
|
.unwrap_or_default();
|
|
|
|
// Extract host:port and binary from the proposed rule for denormalized columns.
|
|
let rule_ref = chunk.proposed_rule.as_ref();
|
|
let (ep_host, ep_port) = rule_ref
|
|
.and_then(|r| r.endpoints.first())
|
|
.map(|ep| (ep.host.to_lowercase(), ep.port as i32))
|
|
.unwrap_or_default();
|
|
let ep_binary = rule_ref
|
|
.and_then(|r| r.binaries.first())
|
|
.map(|b| b.path.clone())
|
|
.unwrap_or_default();
|
|
|
|
let record = DraftChunkRecord {
|
|
id: chunk_id,
|
|
sandbox_id: sandbox_id.clone(),
|
|
draft_version,
|
|
status: "pending".to_string(),
|
|
rule_name: chunk.rule_name.clone(),
|
|
proposed_rule: proposed_rule_bytes,
|
|
rationale: chunk.rationale.clone(),
|
|
security_notes: chunk.security_notes.clone(),
|
|
confidence: f64::from(chunk.confidence),
|
|
created_at_ms: now_ms,
|
|
decided_at_ms: None,
|
|
host: ep_host,
|
|
port: ep_port,
|
|
binary: ep_binary,
|
|
hit_count: if chunk.hit_count > 0 {
|
|
chunk.hit_count
|
|
} else {
|
|
1
|
|
},
|
|
first_seen_ms: if chunk.first_seen_ms > 0 {
|
|
chunk.first_seen_ms
|
|
} else {
|
|
now_ms
|
|
},
|
|
last_seen_ms: if chunk.last_seen_ms > 0 {
|
|
chunk.last_seen_ms
|
|
} else {
|
|
now_ms
|
|
},
|
|
};
|
|
self.state
|
|
.store
|
|
.put_draft_chunk(&record)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist draft chunk failed: {e}")))?;
|
|
accepted += 1;
|
|
}
|
|
|
|
// Notify watchers that new draft chunks are available.
|
|
self.state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
accepted = accepted,
|
|
rejected = rejected,
|
|
draft_version = draft_version,
|
|
summaries = req.summaries.len(),
|
|
"SubmitPolicyAnalysis: persisted draft chunks"
|
|
);
|
|
|
|
Ok(Response::new(SubmitPolicyAnalysisResponse {
|
|
accepted_chunks: accepted,
|
|
rejected_chunks: rejected,
|
|
rejection_reasons,
|
|
}))
|
|
}
|
|
|
|
async fn get_draft_policy(
|
|
&self,
|
|
request: Request<GetDraftPolicyRequest>,
|
|
) -> Result<Response<GetDraftPolicyResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
// Resolve sandbox by name.
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let sandbox_id = sandbox.id.clone();
|
|
|
|
let status_filter = if req.status_filter.is_empty() {
|
|
None
|
|
} else {
|
|
Some(req.status_filter.as_str())
|
|
};
|
|
|
|
let records = self
|
|
.state
|
|
.store
|
|
.list_draft_chunks(&sandbox_id, status_filter)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list draft chunks failed: {e}")))?;
|
|
|
|
let draft_version = self
|
|
.state
|
|
.store
|
|
.get_draft_version(&sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("get draft version failed: {e}")))?;
|
|
|
|
let chunks: Vec<PolicyChunk> = records
|
|
.into_iter()
|
|
.map(|r| draft_chunk_record_to_proto(&r))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
// Determine last_analyzed_at_ms from the most recent chunk.
|
|
let last_analyzed_at_ms = chunks.iter().map(|c| c.created_at_ms).max().unwrap_or(0);
|
|
|
|
debug!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_count = chunks.len(),
|
|
draft_version = draft_version,
|
|
"GetDraftPolicy: served draft chunks"
|
|
);
|
|
|
|
Ok(Response::new(GetDraftPolicyResponse {
|
|
chunks,
|
|
rolling_summary: String::new(),
|
|
draft_version: u64::try_from(draft_version).unwrap_or(0),
|
|
last_analyzed_at_ms,
|
|
}))
|
|
}
|
|
|
|
async fn approve_draft_chunk(
|
|
&self,
|
|
request: Request<ApproveDraftChunkRequest>,
|
|
) -> Result<Response<ApproveDraftChunkResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
if req.chunk_id.is_empty() {
|
|
return Err(Status::invalid_argument("chunk_id is required"));
|
|
}
|
|
|
|
// Resolve sandbox.
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let sandbox_id = sandbox.id.clone();
|
|
|
|
// Fetch the chunk — accept pending or rejected (re-approve toggle).
|
|
let chunk = self
|
|
.state
|
|
.store
|
|
.get_draft_chunk(&req.chunk_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("chunk not found"))?;
|
|
|
|
if chunk.status != "pending" && chunk.status != "rejected" {
|
|
return Err(Status::failed_precondition(format!(
|
|
"chunk status is '{}', expected 'pending' or 'rejected'",
|
|
chunk.status
|
|
)));
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %req.chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
host = %chunk.host,
|
|
port = chunk.port,
|
|
hit_count = chunk.hit_count,
|
|
prev_status = %chunk.status,
|
|
"ApproveDraftChunk: merging rule into active policy"
|
|
);
|
|
|
|
// Merge the approved rule into the current policy (with optimistic retry).
|
|
let (version, hash) = merge_chunk_into_policy(&self.state, &sandbox_id, &chunk).await?;
|
|
|
|
// Mark chunk as approved.
|
|
let now_ms =
|
|
current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
|
|
self.state
|
|
.store
|
|
.update_draft_chunk_status(&req.chunk_id, "approved", Some(now_ms))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
|
|
|
|
// Notify watchers.
|
|
self.state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %req.chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
version = version,
|
|
policy_hash = %hash,
|
|
"ApproveDraftChunk: rule merged successfully"
|
|
);
|
|
|
|
Ok(Response::new(ApproveDraftChunkResponse {
|
|
policy_version: u32::try_from(version).unwrap_or(0),
|
|
policy_hash: hash,
|
|
}))
|
|
}
|
|
|
|
async fn reject_draft_chunk(
|
|
&self,
|
|
request: Request<RejectDraftChunkRequest>,
|
|
) -> Result<Response<RejectDraftChunkResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
if req.chunk_id.is_empty() {
|
|
return Err(Status::invalid_argument("chunk_id is required"));
|
|
}
|
|
|
|
// Resolve sandbox.
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let sandbox_id = sandbox.id.clone();
|
|
|
|
// Fetch the chunk — accept pending or approved (revoke toggle).
|
|
let chunk = self
|
|
.state
|
|
.store
|
|
.get_draft_chunk(&req.chunk_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("chunk not found"))?;
|
|
|
|
if chunk.status != "pending" && chunk.status != "approved" {
|
|
return Err(Status::failed_precondition(format!(
|
|
"chunk status is '{}', expected 'pending' or 'approved'",
|
|
chunk.status
|
|
)));
|
|
}
|
|
|
|
let was_approved = chunk.status == "approved";
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %req.chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
host = %chunk.host,
|
|
port = chunk.port,
|
|
reason = %req.reason,
|
|
prev_status = %chunk.status,
|
|
"RejectDraftChunk: rejecting chunk"
|
|
);
|
|
|
|
// If the chunk was approved, remove its rule from the active policy.
|
|
if was_approved {
|
|
remove_chunk_from_policy(&self.state, &sandbox_id, &chunk).await?;
|
|
}
|
|
|
|
// Mark chunk as rejected.
|
|
let now_ms =
|
|
current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
|
|
self.state
|
|
.store
|
|
.update_draft_chunk_status(&req.chunk_id, "rejected", Some(now_ms))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
|
|
|
|
// Notify watchers.
|
|
self.state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
Ok(Response::new(RejectDraftChunkResponse {}))
|
|
}
|
|
|
|
async fn approve_all_draft_chunks(
|
|
&self,
|
|
request: Request<ApproveAllDraftChunksRequest>,
|
|
) -> Result<Response<ApproveAllDraftChunksResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
// Resolve sandbox.
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let sandbox_id = sandbox.id.clone();
|
|
|
|
// List all pending chunks.
|
|
let pending_chunks = self
|
|
.state
|
|
.store
|
|
.list_draft_chunks(&sandbox_id, Some("pending"))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list draft chunks failed: {e}")))?;
|
|
|
|
if pending_chunks.is_empty() {
|
|
return Err(Status::failed_precondition("no pending chunks to approve"));
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
pending_count = pending_chunks.len(),
|
|
include_security_flagged = req.include_security_flagged,
|
|
"ApproveAllDraftChunks: starting bulk approval"
|
|
);
|
|
|
|
let mut chunks_approved: u32 = 0;
|
|
let mut chunks_skipped: u32 = 0;
|
|
let mut last_version: i64 = 0;
|
|
let mut last_hash = String::new();
|
|
|
|
for chunk in &pending_chunks {
|
|
// Skip security-flagged chunks unless explicitly included.
|
|
if !req.include_security_flagged && !chunk.security_notes.is_empty() {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %chunk.id,
|
|
rule_name = %chunk.rule_name,
|
|
security_notes = %chunk.security_notes,
|
|
"ApproveAllDraftChunks: skipping security-flagged chunk"
|
|
);
|
|
chunks_skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %chunk.id,
|
|
rule_name = %chunk.rule_name,
|
|
host = %chunk.host,
|
|
port = chunk.port,
|
|
"ApproveAllDraftChunks: merging chunk"
|
|
);
|
|
|
|
// Merge each chunk into the policy (with optimistic retry).
|
|
let (version, hash) = merge_chunk_into_policy(&self.state, &sandbox_id, chunk).await?;
|
|
last_version = version;
|
|
last_hash = hash;
|
|
|
|
// Mark chunk as approved.
|
|
let now_ms =
|
|
current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?;
|
|
self.state
|
|
.store
|
|
.update_draft_chunk_status(&chunk.id, "approved", Some(now_ms))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
|
|
|
|
chunks_approved += 1;
|
|
}
|
|
|
|
// Notify watchers.
|
|
self.state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunks_approved = chunks_approved,
|
|
chunks_skipped = chunks_skipped,
|
|
version = last_version,
|
|
policy_hash = %last_hash,
|
|
"ApproveAllDraftChunks: bulk approval complete"
|
|
);
|
|
|
|
Ok(Response::new(ApproveAllDraftChunksResponse {
|
|
policy_version: u32::try_from(last_version).unwrap_or(0),
|
|
policy_hash: last_hash,
|
|
chunks_approved,
|
|
chunks_skipped,
|
|
}))
|
|
}
|
|
|
|
async fn edit_draft_chunk(
|
|
&self,
|
|
request: Request<EditDraftChunkRequest>,
|
|
) -> Result<Response<EditDraftChunkResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
if req.chunk_id.is_empty() {
|
|
return Err(Status::invalid_argument("chunk_id is required"));
|
|
}
|
|
let proposed_rule = req
|
|
.proposed_rule
|
|
.ok_or_else(|| Status::invalid_argument("proposed_rule is required"))?;
|
|
|
|
// Resolve sandbox (validates name exists).
|
|
let _sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
|
|
// Fetch the chunk and validate it's pending.
|
|
let chunk = self
|
|
.state
|
|
.store
|
|
.get_draft_chunk(&req.chunk_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("chunk not found"))?;
|
|
|
|
if chunk.status != "pending" {
|
|
return Err(Status::failed_precondition(format!(
|
|
"chunk status is '{}', expected 'pending'",
|
|
chunk.status
|
|
)));
|
|
}
|
|
|
|
// Update the proposed rule.
|
|
let rule_bytes = proposed_rule.encode_to_vec();
|
|
self.state
|
|
.store
|
|
.update_draft_chunk_rule(&req.chunk_id, &rule_bytes)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update chunk rule failed: {e}")))?;
|
|
|
|
info!(
|
|
chunk_id = %req.chunk_id,
|
|
"EditDraftChunk: proposed rule updated"
|
|
);
|
|
|
|
Ok(Response::new(EditDraftChunkResponse {}))
|
|
}
|
|
|
|
async fn undo_draft_chunk(
|
|
&self,
|
|
request: Request<UndoDraftChunkRequest>,
|
|
) -> Result<Response<UndoDraftChunkResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
if req.chunk_id.is_empty() {
|
|
return Err(Status::invalid_argument("chunk_id is required"));
|
|
}
|
|
|
|
// Resolve sandbox.
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let sandbox_id = sandbox.id.clone();
|
|
|
|
// Fetch the chunk and validate it's approved.
|
|
let chunk = self
|
|
.state
|
|
.store
|
|
.get_draft_chunk(&req.chunk_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch chunk failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("chunk not found"))?;
|
|
|
|
if chunk.status != "approved" {
|
|
return Err(Status::failed_precondition(format!(
|
|
"chunk status is '{}', expected 'approved'",
|
|
chunk.status
|
|
)));
|
|
}
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %req.chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
host = %chunk.host,
|
|
port = chunk.port,
|
|
"UndoDraftChunk: removing rule from active policy"
|
|
);
|
|
|
|
// Remove the rule from the current policy (with optimistic retry).
|
|
let (version, hash) = remove_chunk_from_policy(&self.state, &sandbox_id, &chunk).await?;
|
|
|
|
// Mark chunk back to pending.
|
|
self.state
|
|
.store
|
|
.update_draft_chunk_status(&req.chunk_id, "pending", None)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?;
|
|
|
|
// Notify watchers.
|
|
self.state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunk_id = %req.chunk_id,
|
|
rule_name = %chunk.rule_name,
|
|
version = version,
|
|
policy_hash = %hash,
|
|
"UndoDraftChunk: rule removed, chunk reverted to pending"
|
|
);
|
|
|
|
Ok(Response::new(UndoDraftChunkResponse {
|
|
policy_version: u32::try_from(version).unwrap_or(0),
|
|
policy_hash: hash,
|
|
}))
|
|
}
|
|
|
|
async fn clear_draft_chunks(
|
|
&self,
|
|
request: Request<ClearDraftChunksRequest>,
|
|
) -> Result<Response<ClearDraftChunksResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
// Resolve sandbox.
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let sandbox_id = sandbox.id.clone();
|
|
|
|
let deleted = self
|
|
.state
|
|
.store
|
|
.delete_draft_chunks(&sandbox_id, "pending")
|
|
.await
|
|
.map_err(|e| Status::internal(format!("delete draft chunks failed: {e}")))?;
|
|
|
|
// Notify watchers.
|
|
self.state.sandbox_watch_bus.notify(&sandbox_id);
|
|
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
chunks_cleared = deleted,
|
|
"ClearDraftChunks: pending chunks cleared"
|
|
);
|
|
|
|
Ok(Response::new(ClearDraftChunksResponse {
|
|
chunks_cleared: u32::try_from(deleted).unwrap_or(0),
|
|
}))
|
|
}
|
|
|
|
async fn get_draft_history(
|
|
&self,
|
|
request: Request<GetDraftHistoryRequest>,
|
|
) -> Result<Response<GetDraftHistoryResponse>, Status> {
|
|
let req = request.into_inner();
|
|
if req.name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
// Resolve sandbox by name.
|
|
let sandbox = self
|
|
.state
|
|
.store
|
|
.get_message_by_name::<Sandbox>(&req.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("sandbox not found"))?;
|
|
let sandbox_id = sandbox.id.clone();
|
|
|
|
// Build history from all draft chunks (across all versions).
|
|
let all_chunks = self
|
|
.state
|
|
.store
|
|
.list_draft_chunks(&sandbox_id, None)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list draft chunks failed: {e}")))?;
|
|
|
|
let mut entries: Vec<DraftHistoryEntry> = Vec::new();
|
|
|
|
for chunk in &all_chunks {
|
|
// Creation event.
|
|
entries.push(DraftHistoryEntry {
|
|
timestamp_ms: chunk.created_at_ms,
|
|
event_type: "proposed".to_string(),
|
|
description: format!(
|
|
"Rule '{}' proposed (confidence: {:.0}%)",
|
|
chunk.rule_name,
|
|
chunk.confidence * 100.0
|
|
),
|
|
chunk_id: chunk.id.clone(),
|
|
});
|
|
|
|
// Decision event (if decided).
|
|
if let Some(decided_at) = chunk.decided_at_ms {
|
|
entries.push(DraftHistoryEntry {
|
|
timestamp_ms: decided_at,
|
|
event_type: chunk.status.clone(),
|
|
description: format!("Rule '{}' {}", chunk.rule_name, chunk.status),
|
|
chunk_id: chunk.id.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by timestamp ascending.
|
|
entries.sort_by_key(|e| e.timestamp_ms);
|
|
|
|
debug!(
|
|
sandbox_id = %sandbox_id,
|
|
entry_count = entries.len(),
|
|
"GetDraftHistory: served draft history"
|
|
);
|
|
|
|
Ok(Response::new(GetDraftHistoryResponse { entries }))
|
|
}
|
|
}
|
|
|
|
/// Convert a `DraftChunkRecord` from the persistence layer into a `PolicyChunk`
|
|
/// proto message.
|
|
fn draft_chunk_record_to_proto(record: &DraftChunkRecord) -> Result<PolicyChunk, Status> {
|
|
use openshell_core::proto::NetworkPolicyRule;
|
|
|
|
let proposed_rule = if record.proposed_rule.is_empty() {
|
|
None
|
|
} else {
|
|
Some(
|
|
NetworkPolicyRule::decode(record.proposed_rule.as_slice())
|
|
.map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?,
|
|
)
|
|
};
|
|
|
|
Ok(PolicyChunk {
|
|
id: record.id.clone(),
|
|
status: record.status.clone(),
|
|
rule_name: record.rule_name.clone(),
|
|
proposed_rule,
|
|
rationale: record.rationale.clone(),
|
|
security_notes: record.security_notes.clone(),
|
|
confidence: record.confidence as f32,
|
|
created_at_ms: record.created_at_ms,
|
|
decided_at_ms: record.decided_at_ms.unwrap_or(0),
|
|
hit_count: record.hit_count,
|
|
first_seen_ms: record.first_seen_ms,
|
|
last_seen_ms: record.last_seen_ms,
|
|
binary: record.binary.clone(),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
/// Merge a draft chunk's proposed rule into the current active sandbox policy.
|
|
///
|
|
/// Returns `(new_version, policy_hash)`. This reuses the same persistence
|
|
/// pattern as `update_sandbox_policy`: compute hash, check for no-op,
|
|
/// persist a new revision, supersede older versions, and notify watchers.
|
|
/// Maximum number of optimistic retry attempts for policy version conflicts.
|
|
const MERGE_RETRY_LIMIT: usize = 5;
|
|
|
|
async fn merge_chunk_into_policy(
|
|
state: &ServerState,
|
|
sandbox_id: &str,
|
|
chunk: &DraftChunkRecord,
|
|
) -> Result<(i64, String), Status> {
|
|
use openshell_core::proto::NetworkPolicyRule;
|
|
|
|
// Decode the proposed rule once — it doesn't change between retries.
|
|
let rule = NetworkPolicyRule::decode(chunk.proposed_rule.as_slice())
|
|
.map_err(|e| Status::internal(format!("decode proposed_rule failed: {e}")))?;
|
|
|
|
for attempt in 1..=MERGE_RETRY_LIMIT {
|
|
// Get the current active policy (re-read on each attempt).
|
|
let latest = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?;
|
|
|
|
let mut policy = if let Some(ref record) = latest {
|
|
ProtoSandboxPolicy::decode(record.policy_payload.as_slice())
|
|
.map_err(|e| Status::internal(format!("decode current policy failed: {e}")))?
|
|
} else {
|
|
ProtoSandboxPolicy::default()
|
|
};
|
|
|
|
let base_version = latest.as_ref().map_or(0, |r| r.version);
|
|
|
|
// Merge: if a rule for this endpoint already exists, add the binary
|
|
// to its binaries list. Otherwise insert the whole proposed rule.
|
|
if let Some(existing) = policy.network_policies.get_mut(&chunk.rule_name) {
|
|
// Add the chunk's binary if not already present.
|
|
for b in &rule.binaries {
|
|
if !existing.binaries.iter().any(|eb| eb.path == b.path) {
|
|
existing.binaries.push(b.clone());
|
|
}
|
|
}
|
|
// Also merge endpoints and L7 rules in case they differ.
|
|
for ep in &rule.endpoints {
|
|
if !existing
|
|
.endpoints
|
|
.iter()
|
|
.any(|e| e.host == ep.host && e.port == ep.port)
|
|
{
|
|
existing.endpoints.push(ep.clone());
|
|
}
|
|
}
|
|
} else {
|
|
policy
|
|
.network_policies
|
|
.insert(chunk.rule_name.clone(), rule.clone());
|
|
}
|
|
|
|
// Persist as a new version.
|
|
let payload = policy.encode_to_vec();
|
|
let hash = deterministic_policy_hash(&policy);
|
|
let next_version = base_version + 1;
|
|
let policy_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
match state
|
|
.store
|
|
.put_policy_revision(&policy_id, sandbox_id, next_version, &payload, &hash)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
let _ = state
|
|
.store
|
|
.supersede_older_policies(sandbox_id, next_version)
|
|
.await;
|
|
|
|
if attempt > 1 {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
rule_name = %chunk.rule_name,
|
|
attempt,
|
|
version = next_version,
|
|
"merge_chunk_into_policy: succeeded after version conflict retry"
|
|
);
|
|
}
|
|
|
|
return Ok((next_version, hash));
|
|
}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
// Check for UNIQUE constraint violation (version conflict).
|
|
if msg.contains("UNIQUE") || msg.contains("unique") || msg.contains("duplicate") {
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
rule_name = %chunk.rule_name,
|
|
attempt,
|
|
conflicting_version = next_version,
|
|
"merge_chunk_into_policy: version conflict, retrying"
|
|
);
|
|
// Brief yield to let the winning write settle.
|
|
tokio::task::yield_now().await;
|
|
continue;
|
|
}
|
|
return Err(Status::internal(format!(
|
|
"persist policy revision failed: {e}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(Status::aborted(format!(
|
|
"merge_chunk_into_policy: gave up after {} version conflict retries for rule '{}'",
|
|
MERGE_RETRY_LIMIT, chunk.rule_name
|
|
)))
|
|
}
|
|
|
|
/// Remove a previously-approved draft chunk's rule from the current active
|
|
/// sandbox policy.
|
|
///
|
|
/// Returns `(new_version, policy_hash)`.
|
|
async fn remove_chunk_from_policy(
|
|
state: &ServerState,
|
|
sandbox_id: &str,
|
|
chunk: &DraftChunkRecord,
|
|
) -> Result<(i64, String), Status> {
|
|
for attempt in 1..=MERGE_RETRY_LIMIT {
|
|
// Get the current active policy (re-read on each attempt).
|
|
let latest = state
|
|
.store
|
|
.get_latest_policy(sandbox_id)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch latest policy failed: {e}")))?
|
|
.ok_or_else(|| Status::internal("no active policy to undo from"))?;
|
|
|
|
let mut policy = ProtoSandboxPolicy::decode(latest.policy_payload.as_slice())
|
|
.map_err(|e| Status::internal(format!("decode current policy failed: {e}")))?;
|
|
|
|
// Remove this chunk's binary from the rule. If no binaries remain,
|
|
// remove the entire rule.
|
|
let should_remove =
|
|
if let Some(existing) = policy.network_policies.get_mut(&chunk.rule_name) {
|
|
existing.binaries.retain(|b| b.path != chunk.binary);
|
|
existing.binaries.is_empty()
|
|
} else {
|
|
false
|
|
};
|
|
if should_remove {
|
|
policy.network_policies.remove(&chunk.rule_name);
|
|
}
|
|
|
|
// Persist as a new version.
|
|
let payload = policy.encode_to_vec();
|
|
let hash = deterministic_policy_hash(&policy);
|
|
let next_version = latest.version + 1;
|
|
let policy_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
match state
|
|
.store
|
|
.put_policy_revision(&policy_id, sandbox_id, next_version, &payload, &hash)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
let _ = state
|
|
.store
|
|
.supersede_older_policies(sandbox_id, next_version)
|
|
.await;
|
|
|
|
if attempt > 1 {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
rule_name = %chunk.rule_name,
|
|
attempt,
|
|
version = next_version,
|
|
"remove_chunk_from_policy: succeeded after version conflict retry"
|
|
);
|
|
}
|
|
|
|
return Ok((next_version, hash));
|
|
}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
if msg.contains("UNIQUE") || msg.contains("unique") || msg.contains("duplicate") {
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
rule_name = %chunk.rule_name,
|
|
attempt,
|
|
conflicting_version = next_version,
|
|
"remove_chunk_from_policy: version conflict, retrying"
|
|
);
|
|
tokio::task::yield_now().await;
|
|
continue;
|
|
}
|
|
return Err(Status::internal(format!(
|
|
"persist policy revision failed: {e}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(Status::aborted(format!(
|
|
"remove_chunk_from_policy: gave up after {} version conflict retries for rule '{}'",
|
|
MERGE_RETRY_LIMIT, chunk.rule_name
|
|
)))
|
|
}
|
|
|
|
/// Compute a deterministic SHA-256 hash of a `SandboxPolicy`.
|
|
///
|
|
/// Protobuf `map` fields use `HashMap` which has randomized iteration order,
|
|
/// so `encode_to_vec()` is non-deterministic. This function hashes each field
|
|
/// individually with map entries sorted by key.
|
|
fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(policy.version.to_le_bytes());
|
|
if let Some(fs) = &policy.filesystem {
|
|
hasher.update(fs.encode_to_vec());
|
|
}
|
|
if let Some(ll) = &policy.landlock {
|
|
hasher.update(ll.encode_to_vec());
|
|
}
|
|
if let Some(p) = &policy.process {
|
|
hasher.update(p.encode_to_vec());
|
|
}
|
|
// Sort network_policies by key for deterministic ordering.
|
|
let mut entries: Vec<_> = policy.network_policies.iter().collect();
|
|
entries.sort_by_key(|(k, _)| k.as_str());
|
|
for (key, value) in entries {
|
|
hasher.update(key.as_bytes());
|
|
hasher.update(value.encode_to_vec());
|
|
}
|
|
hex::encode(hasher.finalize())
|
|
}
|
|
|
|
/// Check if a log line's source matches the filter list.
|
|
/// Empty source is treated as "gateway" for backward compatibility.
|
|
fn source_matches(log_source: &str, filters: &[String]) -> bool {
|
|
let effective = if log_source.is_empty() {
|
|
"gateway"
|
|
} else {
|
|
log_source
|
|
};
|
|
filters.iter().any(|f| f == effective)
|
|
}
|
|
|
|
/// Check if a log line's level meets the minimum level threshold.
|
|
/// Empty `min_level` means no filtering (all levels pass).
|
|
fn level_matches(log_level: &str, min_level: &str) -> bool {
|
|
if min_level.is_empty() {
|
|
return true;
|
|
}
|
|
let to_num = |s: &str| match s.to_uppercase().as_str() {
|
|
"ERROR" => 0,
|
|
"WARN" => 1,
|
|
"INFO" => 2,
|
|
"DEBUG" => 3,
|
|
"TRACE" => 4,
|
|
_ => 5, // unknown levels always pass
|
|
};
|
|
to_num(log_level) <= to_num(min_level)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Policy helper functions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sandbox spec validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Validate field sizes on a `CreateSandboxRequest` before persisting.
|
|
///
|
|
/// Returns `INVALID_ARGUMENT` on the first field that exceeds its limit.
|
|
fn validate_sandbox_spec(
|
|
name: &str,
|
|
spec: &openshell_core::proto::SandboxSpec,
|
|
) -> Result<(), Status> {
|
|
// --- request.name ---
|
|
if name.len() > MAX_NAME_LEN {
|
|
return Err(Status::invalid_argument(format!(
|
|
"name exceeds maximum length ({} > {MAX_NAME_LEN})",
|
|
name.len()
|
|
)));
|
|
}
|
|
|
|
// --- spec.providers ---
|
|
if spec.providers.len() > MAX_PROVIDERS {
|
|
return Err(Status::invalid_argument(format!(
|
|
"providers list exceeds maximum ({} > {MAX_PROVIDERS})",
|
|
spec.providers.len()
|
|
)));
|
|
}
|
|
|
|
// --- spec.log_level ---
|
|
if spec.log_level.len() > MAX_LOG_LEVEL_LEN {
|
|
return Err(Status::invalid_argument(format!(
|
|
"log_level exceeds maximum length ({} > {MAX_LOG_LEVEL_LEN})",
|
|
spec.log_level.len()
|
|
)));
|
|
}
|
|
|
|
// --- spec.environment ---
|
|
validate_string_map(
|
|
&spec.environment,
|
|
MAX_ENVIRONMENT_ENTRIES,
|
|
MAX_MAP_KEY_LEN,
|
|
MAX_MAP_VALUE_LEN,
|
|
"spec.environment",
|
|
)?;
|
|
|
|
// --- spec.template ---
|
|
if let Some(ref tmpl) = spec.template {
|
|
validate_sandbox_template(tmpl)?;
|
|
}
|
|
|
|
// --- spec.policy serialized size ---
|
|
if let Some(ref policy) = spec.policy {
|
|
let size = policy.encoded_len();
|
|
if size > MAX_POLICY_SIZE {
|
|
return Err(Status::invalid_argument(format!(
|
|
"policy serialized size exceeds maximum ({size} > {MAX_POLICY_SIZE})"
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate template-level field sizes.
|
|
fn validate_sandbox_template(tmpl: &SandboxTemplate) -> Result<(), Status> {
|
|
// String fields.
|
|
for (field, value) in [
|
|
("template.image", &tmpl.image),
|
|
("template.runtime_class_name", &tmpl.runtime_class_name),
|
|
("template.agent_socket", &tmpl.agent_socket),
|
|
] {
|
|
if value.len() > MAX_TEMPLATE_STRING_LEN {
|
|
return Err(Status::invalid_argument(format!(
|
|
"{field} exceeds maximum length ({} > {MAX_TEMPLATE_STRING_LEN})",
|
|
value.len()
|
|
)));
|
|
}
|
|
}
|
|
|
|
// Map fields.
|
|
validate_string_map(
|
|
&tmpl.labels,
|
|
MAX_TEMPLATE_MAP_ENTRIES,
|
|
MAX_MAP_KEY_LEN,
|
|
MAX_MAP_VALUE_LEN,
|
|
"template.labels",
|
|
)?;
|
|
validate_string_map(
|
|
&tmpl.annotations,
|
|
MAX_TEMPLATE_MAP_ENTRIES,
|
|
MAX_MAP_KEY_LEN,
|
|
MAX_MAP_VALUE_LEN,
|
|
"template.annotations",
|
|
)?;
|
|
validate_string_map(
|
|
&tmpl.environment,
|
|
MAX_TEMPLATE_MAP_ENTRIES,
|
|
MAX_MAP_KEY_LEN,
|
|
MAX_MAP_VALUE_LEN,
|
|
"template.environment",
|
|
)?;
|
|
|
|
// Struct fields (serialized size).
|
|
if let Some(ref s) = tmpl.resources {
|
|
let size = s.encoded_len();
|
|
if size > MAX_TEMPLATE_STRUCT_SIZE {
|
|
return Err(Status::invalid_argument(format!(
|
|
"template.resources serialized size exceeds maximum ({size} > {MAX_TEMPLATE_STRUCT_SIZE})"
|
|
)));
|
|
}
|
|
}
|
|
if let Some(ref s) = tmpl.pod_template {
|
|
let size = s.encoded_len();
|
|
if size > MAX_TEMPLATE_STRUCT_SIZE {
|
|
return Err(Status::invalid_argument(format!(
|
|
"template.pod_template serialized size exceeds maximum ({size} > {MAX_TEMPLATE_STRUCT_SIZE})"
|
|
)));
|
|
}
|
|
}
|
|
if let Some(ref s) = tmpl.volume_claim_templates {
|
|
let size = s.encoded_len();
|
|
if size > MAX_TEMPLATE_STRUCT_SIZE {
|
|
return Err(Status::invalid_argument(format!(
|
|
"template.volume_claim_templates serialized size exceeds maximum ({size} > {MAX_TEMPLATE_STRUCT_SIZE})"
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate a `map<string, string>` field: entry count, key length, value length.
|
|
fn validate_string_map(
|
|
map: &std::collections::HashMap<String, String>,
|
|
max_entries: usize,
|
|
max_key_len: usize,
|
|
max_value_len: usize,
|
|
field_name: &str,
|
|
) -> Result<(), Status> {
|
|
if map.len() > max_entries {
|
|
return Err(Status::invalid_argument(format!(
|
|
"{field_name} exceeds maximum entries ({} > {max_entries})",
|
|
map.len()
|
|
)));
|
|
}
|
|
for (key, value) in map {
|
|
if key.len() > max_key_len {
|
|
return Err(Status::invalid_argument(format!(
|
|
"{field_name} key exceeds maximum length ({} > {max_key_len})",
|
|
key.len()
|
|
)));
|
|
}
|
|
if value.len() > max_value_len {
|
|
return Err(Status::invalid_argument(format!(
|
|
"{field_name} value exceeds maximum length ({} > {max_value_len})",
|
|
value.len()
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider field validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Validate field sizes on a `Provider` before persisting.
|
|
fn validate_provider_fields(provider: &Provider) -> Result<(), Status> {
|
|
if provider.name.len() > MAX_NAME_LEN {
|
|
return Err(Status::invalid_argument(format!(
|
|
"provider.name exceeds maximum length ({} > {MAX_NAME_LEN})",
|
|
provider.name.len()
|
|
)));
|
|
}
|
|
if provider.r#type.len() > MAX_PROVIDER_TYPE_LEN {
|
|
return Err(Status::invalid_argument(format!(
|
|
"provider.type exceeds maximum length ({} > {MAX_PROVIDER_TYPE_LEN})",
|
|
provider.r#type.len()
|
|
)));
|
|
}
|
|
validate_string_map(
|
|
&provider.credentials,
|
|
MAX_PROVIDER_CREDENTIALS_ENTRIES,
|
|
MAX_MAP_KEY_LEN,
|
|
MAX_MAP_VALUE_LEN,
|
|
"provider.credentials",
|
|
)?;
|
|
validate_string_map(
|
|
&provider.config,
|
|
MAX_PROVIDER_CONFIG_ENTRIES,
|
|
MAX_MAP_KEY_LEN,
|
|
MAX_MAP_VALUE_LEN,
|
|
"provider.config",
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate that a policy does not contain unsafe content.
|
|
///
|
|
/// Delegates to [`openshell_policy::validate_sandbox_policy`] and converts
|
|
/// violations into a gRPC `INVALID_ARGUMENT` status.
|
|
fn validate_policy_safety(policy: &ProtoSandboxPolicy) -> Result<(), Status> {
|
|
if let Err(violations) = openshell_policy::validate_sandbox_policy(policy) {
|
|
let messages: Vec<String> = violations.iter().map(ToString::to_string).collect();
|
|
return Err(Status::invalid_argument(format!(
|
|
"policy contains unsafe content: {}",
|
|
messages.join("; ")
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate that static policy fields (filesystem, landlock, process) haven't changed
|
|
/// from the baseline (version 1) policy.
|
|
fn validate_static_fields_unchanged(
|
|
baseline: &ProtoSandboxPolicy,
|
|
new: &ProtoSandboxPolicy,
|
|
) -> Result<(), Status> {
|
|
// Filesystem: allow additive changes (new paths can be added, but
|
|
// existing paths cannot be removed and include_workdir cannot change).
|
|
// This supports the supervisor's baseline path enrichment at startup.
|
|
// Note: Landlock is a one-way door — adding paths to the stored policy
|
|
// has no effect on a running child process; the enriched paths only
|
|
// take effect on the next restart.
|
|
validate_filesystem_additive(baseline.filesystem.as_ref(), new.filesystem.as_ref())?;
|
|
|
|
if baseline.landlock != new.landlock {
|
|
return Err(Status::invalid_argument(
|
|
"landlock policy cannot be changed on a live sandbox (applied at startup)",
|
|
));
|
|
}
|
|
if baseline.process != new.process {
|
|
return Err(Status::invalid_argument(
|
|
"process policy cannot be changed on a live sandbox (applied at startup)",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate that a filesystem policy update is purely additive: all baseline
|
|
/// paths must still be present, `include_workdir` must not change, but new
|
|
/// paths may be added.
|
|
fn validate_filesystem_additive(
|
|
baseline: Option<&openshell_core::proto::FilesystemPolicy>,
|
|
new: Option<&openshell_core::proto::FilesystemPolicy>,
|
|
) -> Result<(), Status> {
|
|
match (baseline, new) {
|
|
(Some(base), Some(upd)) => {
|
|
if base.include_workdir != upd.include_workdir {
|
|
return Err(Status::invalid_argument(
|
|
"filesystem include_workdir cannot be changed on a live sandbox",
|
|
));
|
|
}
|
|
for path in &base.read_only {
|
|
if !upd.read_only.contains(path) {
|
|
return Err(Status::invalid_argument(format!(
|
|
"filesystem read_only path '{path}' cannot be removed on a live sandbox"
|
|
)));
|
|
}
|
|
}
|
|
for path in &base.read_write {
|
|
if !upd.read_write.contains(path) {
|
|
return Err(Status::invalid_argument(format!(
|
|
"filesystem read_write path '{path}' cannot be removed on a live sandbox"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
(None, Some(_)) => {
|
|
// Baseline had no filesystem policy, new one adds it — allowed
|
|
// (enrichment from empty).
|
|
}
|
|
(Some(_), None) => {
|
|
return Err(Status::invalid_argument(
|
|
"filesystem policy cannot be removed on a live sandbox",
|
|
));
|
|
}
|
|
(None, None) => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate that network mode hasn't changed (Block ↔ Proxy).
|
|
/// Adding `network_policies` when none existed (or removing all) changes the mode.
|
|
fn validate_network_mode_unchanged(
|
|
baseline: &ProtoSandboxPolicy,
|
|
new: &ProtoSandboxPolicy,
|
|
) -> Result<(), Status> {
|
|
let baseline_has_policies = !baseline.network_policies.is_empty();
|
|
let new_has_policies = !new.network_policies.is_empty();
|
|
if baseline_has_policies != new_has_policies {
|
|
let msg = if new_has_policies {
|
|
"cannot add network policies to a sandbox created without them (Block → Proxy mode change requires restart)"
|
|
} else {
|
|
"cannot remove all network policies from a sandbox created with them (Proxy → Block mode change requires restart)"
|
|
};
|
|
return Err(Status::invalid_argument(msg));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Convert a `PolicyRecord` to a `SandboxPolicyRevision` proto message.
|
|
fn policy_record_to_revision(record: &PolicyRecord, include_policy: bool) -> SandboxPolicyRevision {
|
|
let status = match record.status.as_str() {
|
|
"pending" => PolicyStatus::Pending,
|
|
"loaded" => PolicyStatus::Loaded,
|
|
"failed" => PolicyStatus::Failed,
|
|
"superseded" => PolicyStatus::Superseded,
|
|
_ => PolicyStatus::Unspecified,
|
|
};
|
|
|
|
let policy = if include_policy {
|
|
ProtoSandboxPolicy::decode(record.policy_payload.as_slice()).ok()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
SandboxPolicyRevision {
|
|
version: u32::try_from(record.version).unwrap_or(0),
|
|
policy_hash: record.policy_hash.clone(),
|
|
status: status.into(),
|
|
load_error: record.load_error.clone().unwrap_or_default(),
|
|
created_at_ms: record.created_at_ms,
|
|
loaded_at_ms: record.loaded_at_ms.unwrap_or(0),
|
|
policy,
|
|
}
|
|
}
|
|
|
|
fn current_time_ms() -> Result<i64, std::time::SystemTimeError> {
|
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
|
|
Ok(i64::try_from(now.as_millis()).unwrap_or(i64::MAX))
|
|
}
|
|
|
|
fn resolve_gateway(config: &openshell_core::Config) -> (String, u16) {
|
|
let host = if config.ssh_gateway_host.is_empty() {
|
|
config.bind_address.ip().to_string()
|
|
} else {
|
|
config.ssh_gateway_host.clone()
|
|
};
|
|
let port = if config.ssh_gateway_port == 0 {
|
|
config.bind_address.port()
|
|
} else {
|
|
config.ssh_gateway_port
|
|
};
|
|
(host, port)
|
|
}
|
|
|
|
async fn resolve_sandbox_exec_target(
|
|
state: &ServerState,
|
|
sandbox: &Sandbox,
|
|
) -> Result<(String, u16), Status> {
|
|
if let Some(status) = sandbox.status.as_ref()
|
|
&& !status.agent_pod.is_empty()
|
|
{
|
|
match state.sandbox_client.agent_pod_ip(&status.agent_pod).await {
|
|
Ok(Some(ip)) => {
|
|
return Ok((ip.to_string(), state.config.sandbox_ssh_port));
|
|
}
|
|
Ok(None) => {
|
|
return Err(Status::failed_precondition(
|
|
"sandbox agent pod IP is not available",
|
|
));
|
|
}
|
|
Err(err) => {
|
|
return Err(Status::internal(format!(
|
|
"failed to resolve agent pod IP: {err}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
if sandbox.name.is_empty() {
|
|
return Err(Status::failed_precondition("sandbox has no name"));
|
|
}
|
|
|
|
Ok((
|
|
format!(
|
|
"{}.{}.svc.cluster.local",
|
|
sandbox.name, state.config.sandbox_namespace
|
|
),
|
|
state.config.sandbox_ssh_port,
|
|
))
|
|
}
|
|
|
|
fn shell_escape(value: &str) -> String {
|
|
if value.is_empty() {
|
|
return "''".to_string();
|
|
}
|
|
let safe = value
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'-' | b'_'));
|
|
if safe {
|
|
return value.to_string();
|
|
}
|
|
let escaped = value.replace('\'', "'\"'\"'");
|
|
format!("'{escaped}'")
|
|
}
|
|
|
|
fn build_remote_exec_command(req: &ExecSandboxRequest) -> String {
|
|
let mut parts = Vec::new();
|
|
let mut env_entries = req.environment.iter().collect::<Vec<_>>();
|
|
env_entries.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
for (key, value) in env_entries {
|
|
parts.push(format!("{key}={}", shell_escape(value)));
|
|
}
|
|
parts.extend(req.command.iter().map(|arg| shell_escape(arg)));
|
|
let command = parts.join(" ");
|
|
if req.workdir.is_empty() {
|
|
command
|
|
} else {
|
|
format!("cd {} && {command}", shell_escape(&req.workdir))
|
|
}
|
|
}
|
|
|
|
/// Resolve provider credentials into environment variables.
|
|
///
|
|
/// For each provider name in the list, fetches the provider from the store and
|
|
/// collects credential key-value pairs. Returns a map of environment variables
|
|
/// to inject into the sandbox. When duplicate keys appear across providers, the
|
|
/// first provider's value wins.
|
|
async fn resolve_provider_environment(
|
|
store: &crate::persistence::Store,
|
|
provider_names: &[String],
|
|
) -> Result<std::collections::HashMap<String, String>, Status> {
|
|
if provider_names.is_empty() {
|
|
return Ok(std::collections::HashMap::new());
|
|
}
|
|
|
|
let mut env = std::collections::HashMap::new();
|
|
|
|
for name in provider_names {
|
|
let provider = store
|
|
.get_message_by_name::<Provider>(name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))?
|
|
.ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?;
|
|
|
|
for (key, value) in &provider.credentials {
|
|
if is_valid_env_key(key) {
|
|
env.entry(key.clone()).or_insert_with(|| value.clone());
|
|
} else {
|
|
warn!(
|
|
provider_name = %name,
|
|
key = %key,
|
|
"skipping credential with invalid env var key"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(env)
|
|
}
|
|
|
|
fn is_valid_env_key(key: &str) -> bool {
|
|
let mut bytes = key.bytes();
|
|
let Some(first) = bytes.next() else {
|
|
return false;
|
|
};
|
|
if !(first == b'_' || first.is_ascii_alphabetic()) {
|
|
return false;
|
|
}
|
|
bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric())
|
|
}
|
|
|
|
/// Maximum number of attempts when establishing the SSH transport to a sandbox.
|
|
/// The sandbox SSH server may not be listening yet when the pod is marked Ready,
|
|
/// so we retry transient connection failures with exponential backoff.
|
|
const SSH_CONNECT_MAX_ATTEMPTS: u32 = 6;
|
|
|
|
/// Initial backoff duration between SSH connection retries (doubles each attempt).
|
|
const SSH_CONNECT_INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
|
|
|
|
/// Maximum backoff duration between SSH connection retries (caps exponential growth).
|
|
const SSH_CONNECT_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
|
|
|
|
/// Returns `true` if the gRPC status represents a transient SSH connection error
|
|
/// that is worth retrying (e.g. the sandbox SSH server is not yet listening).
|
|
fn is_retryable_ssh_error(status: &Status) -> bool {
|
|
if status.code() != tonic::Code::Internal {
|
|
return false;
|
|
}
|
|
let msg = status.message();
|
|
msg.contains("Connection reset by peer")
|
|
|| msg.contains("Connection refused")
|
|
|| msg.contains("failed to establish ssh transport")
|
|
|| msg.contains("failed to connect to ssh proxy")
|
|
|| msg.contains("failed to start ssh proxy")
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn stream_exec_over_ssh(
|
|
tx: mpsc::Sender<Result<ExecSandboxEvent, Status>>,
|
|
sandbox_id: &str,
|
|
target_host: &str,
|
|
target_port: u16,
|
|
command: &str,
|
|
stdin_payload: Vec<u8>,
|
|
timeout_seconds: u32,
|
|
handshake_secret: &str,
|
|
) -> Result<(), Status> {
|
|
info!(
|
|
sandbox_id = %sandbox_id,
|
|
target_host = %target_host,
|
|
target_port,
|
|
"ExecSandbox command started"
|
|
);
|
|
|
|
// Retry loop: the sandbox SSH server may not be accepting connections yet
|
|
// even though the pod is marked Ready by Kubernetes. We retry transient
|
|
// connection errors with exponential backoff.
|
|
let (exit_code, proxy_task) = {
|
|
let mut last_err: Option<Status> = None;
|
|
|
|
let mut result = None;
|
|
for attempt in 0..SSH_CONNECT_MAX_ATTEMPTS {
|
|
if attempt > 0 {
|
|
let backoff = (SSH_CONNECT_INITIAL_BACKOFF * 2u32.pow(attempt - 1))
|
|
.min(SSH_CONNECT_MAX_BACKOFF);
|
|
warn!(
|
|
sandbox_id = %sandbox_id,
|
|
attempt = attempt + 1,
|
|
backoff_ms = %backoff.as_millis(),
|
|
error = %last_err.as_ref().unwrap(),
|
|
"Retrying SSH transport establishment"
|
|
);
|
|
tokio::time::sleep(backoff).await;
|
|
}
|
|
|
|
let (local_proxy_port, proxy_task) = match start_single_use_ssh_proxy(
|
|
target_host,
|
|
target_port,
|
|
handshake_secret,
|
|
)
|
|
.await
|
|
{
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
last_err = Some(Status::internal(format!("failed to start ssh proxy: {e}")));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let exec =
|
|
run_exec_with_russh(local_proxy_port, command, stdin_payload.clone(), tx.clone());
|
|
|
|
let exec_result = if timeout_seconds == 0 {
|
|
exec.await
|
|
} else if let Ok(r) = tokio::time::timeout(
|
|
std::time::Duration::from_secs(u64::from(timeout_seconds)),
|
|
exec,
|
|
)
|
|
.await
|
|
{
|
|
r
|
|
} else {
|
|
// Timed out — not retryable, report timeout exit code immediately.
|
|
let _ = tx
|
|
.send(Ok(ExecSandboxEvent {
|
|
payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Exit(
|
|
ExecSandboxExit { exit_code: 124 },
|
|
)),
|
|
}))
|
|
.await;
|
|
let _ = proxy_task.await;
|
|
return Ok(());
|
|
};
|
|
|
|
match exec_result {
|
|
Ok(exit_code) => {
|
|
result = Some((exit_code, proxy_task));
|
|
break;
|
|
}
|
|
Err(status) => {
|
|
let _ = proxy_task.await;
|
|
if is_retryable_ssh_error(&status) && attempt + 1 < SSH_CONNECT_MAX_ATTEMPTS {
|
|
last_err = Some(status);
|
|
continue;
|
|
}
|
|
return Err(status);
|
|
}
|
|
}
|
|
}
|
|
|
|
result.ok_or_else(|| {
|
|
last_err.unwrap_or_else(|| {
|
|
Status::internal("ssh connection failed after exhausting retries")
|
|
})
|
|
})?
|
|
};
|
|
|
|
let _ = proxy_task.await;
|
|
|
|
let _ = tx
|
|
.send(Ok(ExecSandboxEvent {
|
|
payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Exit(
|
|
ExecSandboxExit { exit_code },
|
|
)),
|
|
}))
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct SandboxSshClientHandler;
|
|
|
|
impl russh::client::Handler for SandboxSshClientHandler {
|
|
type Error = russh::Error;
|
|
|
|
async fn check_server_key(
|
|
&mut self,
|
|
_server_public_key: &russh::keys::PublicKey,
|
|
) -> Result<bool, Self::Error> {
|
|
Ok(true)
|
|
}
|
|
}
|
|
|
|
async fn run_exec_with_russh(
|
|
local_proxy_port: u16,
|
|
command: &str,
|
|
stdin_payload: Vec<u8>,
|
|
tx: mpsc::Sender<Result<ExecSandboxEvent, Status>>,
|
|
) -> Result<i32, Status> {
|
|
let stream = TcpStream::connect(("127.0.0.1", local_proxy_port))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("failed to connect to ssh proxy: {e}")))?;
|
|
|
|
let config = Arc::new(russh::client::Config::default());
|
|
let mut client = russh::client::connect_stream(config, stream, SandboxSshClientHandler)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("failed to establish ssh transport: {e}")))?;
|
|
|
|
match client
|
|
.authenticate_none("sandbox")
|
|
.await
|
|
.map_err(|e| Status::internal(format!("failed to authenticate ssh session: {e}")))?
|
|
{
|
|
AuthResult::Success => {}
|
|
AuthResult::Failure { .. } => {
|
|
return Err(Status::permission_denied(
|
|
"ssh authentication rejected by sandbox",
|
|
));
|
|
}
|
|
}
|
|
|
|
let mut channel = client
|
|
.channel_open_session()
|
|
.await
|
|
.map_err(|e| Status::internal(format!("failed to open ssh channel: {e}")))?;
|
|
|
|
channel
|
|
.exec(true, command.as_bytes())
|
|
.await
|
|
.map_err(|e| Status::internal(format!("failed to execute command over ssh: {e}")))?;
|
|
|
|
if !stdin_payload.is_empty() {
|
|
channel
|
|
.data(std::io::Cursor::new(stdin_payload))
|
|
.await
|
|
.map_err(|e| Status::internal(format!("failed to send ssh stdin payload: {e}")))?;
|
|
}
|
|
|
|
channel
|
|
.eof()
|
|
.await
|
|
.map_err(|e| Status::internal(format!("failed to close ssh stdin: {e}")))?;
|
|
|
|
let mut exit_code: Option<i32> = None;
|
|
while let Some(msg) = channel.wait().await {
|
|
match msg {
|
|
ChannelMsg::Data { data } => {
|
|
let _ = tx
|
|
.send(Ok(ExecSandboxEvent {
|
|
payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Stdout(
|
|
ExecSandboxStdout {
|
|
data: data.to_vec(),
|
|
},
|
|
)),
|
|
}))
|
|
.await;
|
|
}
|
|
ChannelMsg::ExtendedData { data, .. } => {
|
|
let _ = tx
|
|
.send(Ok(ExecSandboxEvent {
|
|
payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Stderr(
|
|
ExecSandboxStderr {
|
|
data: data.to_vec(),
|
|
},
|
|
)),
|
|
}))
|
|
.await;
|
|
}
|
|
ChannelMsg::ExitStatus { exit_status } => {
|
|
let converted = i32::try_from(exit_status).unwrap_or(i32::MAX);
|
|
exit_code = Some(converted);
|
|
}
|
|
ChannelMsg::Close => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let _ = channel.close().await;
|
|
let _ = client
|
|
.disconnect(russh::Disconnect::ByApplication, "exec complete", "en")
|
|
.await;
|
|
|
|
Ok(exit_code.unwrap_or(1))
|
|
}
|
|
|
|
async fn start_single_use_ssh_proxy(
|
|
target_host: &str,
|
|
target_port: u16,
|
|
handshake_secret: &str,
|
|
) -> Result<(u16, tokio::task::JoinHandle<()>), Box<dyn std::error::Error + Send + Sync>> {
|
|
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
|
|
let port = listener.local_addr()?.port();
|
|
let target_host = target_host.to_string();
|
|
let handshake_secret = handshake_secret.to_string();
|
|
|
|
let task = tokio::spawn(async move {
|
|
let Ok((mut client_conn, _)) = listener.accept().await else {
|
|
warn!("SSH proxy: failed to accept local connection");
|
|
return;
|
|
};
|
|
let Ok(mut sandbox_conn) = TcpStream::connect((target_host.as_str(), target_port)).await
|
|
else {
|
|
warn!(target_host = %target_host, target_port, "SSH proxy: failed to connect to sandbox");
|
|
return;
|
|
};
|
|
let Ok(preface) = build_preface(&uuid::Uuid::new_v4().to_string(), &handshake_secret)
|
|
else {
|
|
warn!("SSH proxy: failed to build handshake preface");
|
|
return;
|
|
};
|
|
if let Err(e) = sandbox_conn.write_all(preface.as_bytes()).await {
|
|
warn!(error = %e, "SSH proxy: failed to send handshake preface");
|
|
return;
|
|
}
|
|
let mut response = String::new();
|
|
if let Err(e) = read_line(&mut sandbox_conn, &mut response).await {
|
|
warn!(error = %e, "SSH proxy: failed to read handshake response");
|
|
return;
|
|
}
|
|
if response.trim() != "OK" {
|
|
warn!(response = %response.trim(), "SSH proxy: handshake rejected by sandbox");
|
|
return;
|
|
}
|
|
let _ = tokio::io::copy_bidirectional(&mut client_conn, &mut sandbox_conn).await;
|
|
});
|
|
|
|
Ok((port, task))
|
|
}
|
|
|
|
fn build_preface(
|
|
token: &str,
|
|
secret: &str,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
let timestamp = i64::try_from(
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_err(|_| "time error")?
|
|
.as_secs(),
|
|
)
|
|
.map_err(|_| "time error")?;
|
|
let nonce = uuid::Uuid::new_v4().to_string();
|
|
let payload = format!("{token}|{timestamp}|{nonce}");
|
|
let signature = hmac_sha256(secret.as_bytes(), payload.as_bytes());
|
|
Ok(format!("NSSH1 {token} {timestamp} {nonce} {signature}\n"))
|
|
}
|
|
|
|
async fn read_line(
|
|
stream: &mut TcpStream,
|
|
buf: &mut String,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut bytes = Vec::new();
|
|
loop {
|
|
let mut byte = [0_u8; 1];
|
|
let n = stream.read(&mut byte).await?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
if byte[0] == b'\n' {
|
|
break;
|
|
}
|
|
bytes.push(byte[0]);
|
|
if bytes.len() > 1024 {
|
|
break;
|
|
}
|
|
}
|
|
*buf = String::from_utf8_lossy(&bytes).to_string();
|
|
Ok(())
|
|
}
|
|
|
|
fn hmac_sha256(key: &[u8], data: &[u8]) -> String {
|
|
use hmac::{Hmac, Mac};
|
|
use sha2::Sha256;
|
|
|
|
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("hmac key");
|
|
mac.update(data);
|
|
let result = mac.finalize().into_bytes();
|
|
hex::encode(result)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn create_provider_record(
|
|
store: &crate::persistence::Store,
|
|
mut provider: Provider,
|
|
) -> Result<Provider, Status> {
|
|
if provider.name.is_empty() {
|
|
provider.name = generate_name();
|
|
}
|
|
if provider.r#type.trim().is_empty() {
|
|
return Err(Status::invalid_argument("provider.type is required"));
|
|
}
|
|
if provider.credentials.is_empty() {
|
|
return Err(Status::invalid_argument(
|
|
"provider.credentials must not be empty",
|
|
));
|
|
}
|
|
|
|
// Validate field sizes before any I/O.
|
|
validate_provider_fields(&provider)?;
|
|
|
|
let existing = store
|
|
.get_message_by_name::<Provider>(&provider.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch provider failed: {e}")))?;
|
|
|
|
if existing.is_some() {
|
|
return Err(Status::already_exists("provider already exists"));
|
|
}
|
|
|
|
provider.id = uuid::Uuid::new_v4().to_string();
|
|
|
|
store
|
|
.put_message(&provider)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist provider failed: {e}")))?;
|
|
|
|
Ok(provider)
|
|
}
|
|
|
|
async fn get_provider_record(
|
|
store: &crate::persistence::Store,
|
|
name: &str,
|
|
) -> Result<Provider, Status> {
|
|
if name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
store
|
|
.get_message_by_name::<Provider>(name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch provider failed: {e}")))?
|
|
.ok_or_else(|| Status::not_found("provider not found"))
|
|
}
|
|
|
|
async fn list_provider_records(
|
|
store: &crate::persistence::Store,
|
|
limit: u32,
|
|
offset: u32,
|
|
) -> Result<Vec<Provider>, Status> {
|
|
let records = store
|
|
.list(Provider::object_type(), limit, offset)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("list providers failed: {e}")))?;
|
|
|
|
let mut providers = Vec::with_capacity(records.len());
|
|
for record in records {
|
|
let provider = Provider::decode(record.payload.as_slice())
|
|
.map_err(|e| Status::internal(format!("decode provider failed: {e}")))?;
|
|
providers.push(provider);
|
|
}
|
|
|
|
Ok(providers)
|
|
}
|
|
|
|
/// Merge an incoming map into an existing map.
|
|
///
|
|
/// - If `incoming` is empty, return `existing` unchanged (no-op).
|
|
/// - Otherwise, upsert all incoming entries into `existing`.
|
|
/// - Entries with an empty-string value are removed (delete semantics).
|
|
fn merge_map(
|
|
mut existing: std::collections::HashMap<String, String>,
|
|
incoming: std::collections::HashMap<String, String>,
|
|
) -> std::collections::HashMap<String, String> {
|
|
if incoming.is_empty() {
|
|
return existing;
|
|
}
|
|
for (key, value) in incoming {
|
|
if value.is_empty() {
|
|
existing.remove(&key);
|
|
} else {
|
|
existing.insert(key, value);
|
|
}
|
|
}
|
|
existing
|
|
}
|
|
|
|
async fn update_provider_record(
|
|
store: &crate::persistence::Store,
|
|
provider: Provider,
|
|
) -> Result<Provider, Status> {
|
|
if provider.name.is_empty() {
|
|
return Err(Status::invalid_argument("provider.name is required"));
|
|
}
|
|
|
|
let existing = store
|
|
.get_message_by_name::<Provider>(&provider.name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("fetch provider failed: {e}")))?;
|
|
|
|
let Some(existing) = existing else {
|
|
return Err(Status::not_found("provider not found"));
|
|
};
|
|
|
|
// Provider type is immutable after creation. Reject if the caller
|
|
// sends a non-empty type that differs from the existing one.
|
|
let incoming_type = provider.r#type.trim();
|
|
if !incoming_type.is_empty() && !incoming_type.eq_ignore_ascii_case(existing.r#type.trim()) {
|
|
return Err(Status::invalid_argument(
|
|
"provider type cannot be changed; delete and recreate the provider",
|
|
));
|
|
}
|
|
|
|
let updated = Provider {
|
|
id: existing.id,
|
|
name: existing.name,
|
|
r#type: existing.r#type,
|
|
credentials: merge_map(existing.credentials, provider.credentials),
|
|
config: merge_map(existing.config, provider.config),
|
|
};
|
|
|
|
validate_provider_fields(&updated)?;
|
|
|
|
store
|
|
.put_message(&updated)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("persist provider failed: {e}")))?;
|
|
|
|
Ok(updated)
|
|
}
|
|
|
|
async fn delete_provider_record(
|
|
store: &crate::persistence::Store,
|
|
name: &str,
|
|
) -> Result<bool, Status> {
|
|
if name.is_empty() {
|
|
return Err(Status::invalid_argument("name is required"));
|
|
}
|
|
|
|
store
|
|
.delete_by_name(Provider::object_type(), name)
|
|
.await
|
|
.map_err(|e| Status::internal(format!("delete provider failed: {e}")))
|
|
}
|
|
|
|
impl ObjectType for Provider {
|
|
fn object_type() -> &'static str {
|
|
"provider"
|
|
}
|
|
}
|
|
|
|
impl ObjectId for Provider {
|
|
fn object_id(&self) -> &str {
|
|
&self.id
|
|
}
|
|
}
|
|
|
|
impl ObjectName for Provider {
|
|
fn object_name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
MAX_ENVIRONMENT_ENTRIES, MAX_LOG_LEVEL_LEN, MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN,
|
|
MAX_NAME_LEN, MAX_PAGE_SIZE, MAX_POLICY_SIZE, MAX_PROVIDER_CONFIG_ENTRIES,
|
|
MAX_PROVIDER_CREDENTIALS_ENTRIES, MAX_PROVIDER_TYPE_LEN, MAX_PROVIDERS,
|
|
MAX_TEMPLATE_MAP_ENTRIES, MAX_TEMPLATE_STRING_LEN, MAX_TEMPLATE_STRUCT_SIZE, clamp_limit,
|
|
create_provider_record, delete_provider_record, get_provider_record, is_valid_env_key,
|
|
list_provider_records, resolve_provider_environment, update_provider_record,
|
|
validate_provider_fields, validate_sandbox_spec,
|
|
};
|
|
use crate::persistence::Store;
|
|
use openshell_core::proto::{Provider, SandboxSpec, SandboxTemplate};
|
|
use std::collections::HashMap;
|
|
use tonic::Code;
|
|
|
|
#[test]
|
|
fn env_key_validation_accepts_valid_keys() {
|
|
assert!(is_valid_env_key("PATH"));
|
|
assert!(is_valid_env_key("PYTHONPATH"));
|
|
assert!(is_valid_env_key("_OPENSHELL_VALUE_1"));
|
|
}
|
|
|
|
#[test]
|
|
fn env_key_validation_rejects_invalid_keys() {
|
|
assert!(!is_valid_env_key(""));
|
|
assert!(!is_valid_env_key("1PATH"));
|
|
assert!(!is_valid_env_key("BAD-KEY"));
|
|
assert!(!is_valid_env_key("BAD KEY"));
|
|
assert!(!is_valid_env_key("X=Y"));
|
|
assert!(!is_valid_env_key("X;rm -rf /"));
|
|
}
|
|
|
|
// ---- clamp_limit tests ----
|
|
|
|
#[test]
|
|
fn clamp_limit_zero_returns_default() {
|
|
assert_eq!(clamp_limit(0, 100, MAX_PAGE_SIZE), 100);
|
|
assert_eq!(clamp_limit(0, 50, MAX_PAGE_SIZE), 50);
|
|
}
|
|
|
|
#[test]
|
|
fn clamp_limit_within_range_passes_through() {
|
|
assert_eq!(clamp_limit(1, 100, MAX_PAGE_SIZE), 1);
|
|
assert_eq!(clamp_limit(500, 100, MAX_PAGE_SIZE), 500);
|
|
assert_eq!(
|
|
clamp_limit(MAX_PAGE_SIZE, 100, MAX_PAGE_SIZE),
|
|
MAX_PAGE_SIZE
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clamp_limit_exceeding_max_is_capped() {
|
|
assert_eq!(
|
|
clamp_limit(MAX_PAGE_SIZE + 1, 100, MAX_PAGE_SIZE),
|
|
MAX_PAGE_SIZE
|
|
);
|
|
assert_eq!(clamp_limit(u32::MAX, 100, MAX_PAGE_SIZE), MAX_PAGE_SIZE);
|
|
}
|
|
|
|
fn provider_with_values(name: &str, provider_type: &str) -> Provider {
|
|
Provider {
|
|
id: String::new(),
|
|
name: name.to_string(),
|
|
r#type: provider_type.to_string(),
|
|
credentials: [
|
|
("API_TOKEN".to_string(), "token-123".to_string()),
|
|
("SECONDARY".to_string(), "secondary-token".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
config: [
|
|
("endpoint".to_string(), "https://example.com".to_string()),
|
|
("region".to_string(), "us-west".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_crud_round_trip_and_semantics() {
|
|
let store = Store::connect("sqlite::memory:?cache=shared")
|
|
.await
|
|
.unwrap();
|
|
|
|
let created = provider_with_values("gitlab-local", "gitlab");
|
|
let persisted = create_provider_record(&store, created.clone())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(persisted.name, "gitlab-local");
|
|
assert_eq!(persisted.r#type, "gitlab");
|
|
assert!(!persisted.id.is_empty());
|
|
let provider_id = persisted.id.clone();
|
|
|
|
let duplicate_err = create_provider_record(&store, created).await.unwrap_err();
|
|
assert_eq!(duplicate_err.code(), Code::AlreadyExists);
|
|
|
|
let loaded = get_provider_record(&store, "gitlab-local").await.unwrap();
|
|
assert_eq!(loaded.id, provider_id);
|
|
|
|
let listed = list_provider_records(&store, 100, 0).await.unwrap();
|
|
assert_eq!(listed.len(), 1);
|
|
assert_eq!(listed[0].name, "gitlab-local");
|
|
|
|
let updated = update_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "gitlab-local".to_string(),
|
|
r#type: "gitlab".to_string(),
|
|
credentials: std::iter::once((
|
|
"API_TOKEN".to_string(),
|
|
"rotated-token".to_string(),
|
|
))
|
|
.collect(),
|
|
config: std::iter::once(("endpoint".to_string(), "https://gitlab.com".to_string()))
|
|
.collect(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(updated.id, provider_id);
|
|
// Updated credential has new value.
|
|
assert_eq!(
|
|
updated.credentials.get("API_TOKEN"),
|
|
Some(&"rotated-token".to_string())
|
|
);
|
|
// Non-updated credential is preserved (not clobbered).
|
|
assert_eq!(
|
|
updated.credentials.get("SECONDARY"),
|
|
Some(&"secondary-token".to_string())
|
|
);
|
|
// Updated config has new value.
|
|
assert_eq!(
|
|
updated.config.get("endpoint"),
|
|
Some(&"https://gitlab.com".to_string())
|
|
);
|
|
// Non-updated config is preserved (not clobbered).
|
|
assert_eq!(updated.config.get("region"), Some(&"us-west".to_string()));
|
|
|
|
let deleted = delete_provider_record(&store, "gitlab-local")
|
|
.await
|
|
.unwrap();
|
|
assert!(deleted);
|
|
|
|
let deleted_again = delete_provider_record(&store, "gitlab-local")
|
|
.await
|
|
.unwrap();
|
|
assert!(!deleted_again);
|
|
|
|
let missing = get_provider_record(&store, "gitlab-local")
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(missing.code(), Code::NotFound);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_validation_errors() {
|
|
let store = Store::connect("sqlite::memory:?cache=shared")
|
|
.await
|
|
.unwrap();
|
|
|
|
let create_missing_type = create_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "bad-provider".to_string(),
|
|
r#type: String::new(),
|
|
credentials: HashMap::new(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(create_missing_type.code(), Code::InvalidArgument);
|
|
|
|
let get_err = get_provider_record(&store, "").await.unwrap_err();
|
|
assert_eq!(get_err.code(), Code::InvalidArgument);
|
|
|
|
let delete_err = delete_provider_record(&store, "").await.unwrap_err();
|
|
assert_eq!(delete_err.code(), Code::InvalidArgument);
|
|
|
|
let update_missing_err = update_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "missing".to_string(),
|
|
r#type: String::new(),
|
|
credentials: HashMap::new(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(update_missing_err.code(), Code::NotFound);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_provider_empty_maps_is_noop() {
|
|
let store = Store::connect("sqlite::memory:?cache=shared")
|
|
.await
|
|
.unwrap();
|
|
|
|
let created = provider_with_values("noop-test", "nvidia");
|
|
let persisted = create_provider_record(&store, created).await.unwrap();
|
|
|
|
// Update with empty type, empty credentials, empty config = no changes.
|
|
let updated = update_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "noop-test".to_string(),
|
|
r#type: String::new(),
|
|
credentials: HashMap::new(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(updated.id, persisted.id);
|
|
assert_eq!(updated.r#type, "nvidia");
|
|
assert_eq!(updated.credentials.len(), 2);
|
|
assert_eq!(
|
|
updated.credentials.get("API_TOKEN"),
|
|
Some(&"token-123".to_string())
|
|
);
|
|
assert_eq!(
|
|
updated.credentials.get("SECONDARY"),
|
|
Some(&"secondary-token".to_string())
|
|
);
|
|
assert_eq!(updated.config.len(), 2);
|
|
assert_eq!(
|
|
updated.config.get("endpoint"),
|
|
Some(&"https://example.com".to_string())
|
|
);
|
|
assert_eq!(updated.config.get("region"), Some(&"us-west".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_provider_empty_value_deletes_key() {
|
|
let store = Store::connect("sqlite::memory:?cache=shared")
|
|
.await
|
|
.unwrap();
|
|
|
|
let created = provider_with_values("delete-key-test", "openai");
|
|
create_provider_record(&store, created).await.unwrap();
|
|
|
|
// Send SECONDARY with empty value to delete it; API_TOKEN untouched.
|
|
let updated = update_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "delete-key-test".to_string(),
|
|
r#type: String::new(),
|
|
credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(),
|
|
config: std::iter::once(("region".to_string(), String::new())).collect(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(updated.credentials.len(), 1);
|
|
assert_eq!(
|
|
updated.credentials.get("API_TOKEN"),
|
|
Some(&"token-123".to_string())
|
|
);
|
|
assert!(updated.credentials.get("SECONDARY").is_none());
|
|
assert_eq!(updated.config.len(), 1);
|
|
assert_eq!(
|
|
updated.config.get("endpoint"),
|
|
Some(&"https://example.com".to_string())
|
|
);
|
|
assert!(updated.config.get("region").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_provider_empty_type_preserves_existing() {
|
|
let store = Store::connect("sqlite::memory:?cache=shared")
|
|
.await
|
|
.unwrap();
|
|
|
|
let created = provider_with_values("type-preserve-test", "anthropic");
|
|
create_provider_record(&store, created).await.unwrap();
|
|
|
|
let updated = update_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "type-preserve-test".to_string(),
|
|
r#type: String::new(),
|
|
credentials: HashMap::new(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(updated.r#type, "anthropic");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_provider_rejects_type_change() {
|
|
let store = Store::connect("sqlite::memory:?cache=shared")
|
|
.await
|
|
.unwrap();
|
|
|
|
let created = provider_with_values("type-change-test", "nvidia");
|
|
create_provider_record(&store, created).await.unwrap();
|
|
|
|
let err = update_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "type-change-test".to_string(),
|
|
r#type: "openai".to_string(),
|
|
credentials: HashMap::new(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("type cannot be changed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_provider_validates_merged_result() {
|
|
let store = Store::connect("sqlite::memory:?cache=shared")
|
|
.await
|
|
.unwrap();
|
|
|
|
let created = provider_with_values("validate-merge-test", "gitlab");
|
|
create_provider_record(&store, created).await.unwrap();
|
|
|
|
// Add credentials that exceed the max key length to trigger validation.
|
|
let oversized_key = "K".repeat(MAX_MAP_KEY_LEN + 1);
|
|
let err = update_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "validate-merge-test".to_string(),
|
|
r#type: String::new(),
|
|
credentials: std::iter::once((oversized_key, "value".to_string())).collect(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_provider_env_empty_list_returns_empty() {
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
let result = resolve_provider_environment(&store, &[]).await.unwrap();
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_provider_env_injects_credentials() {
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
let provider = Provider {
|
|
id: String::new(),
|
|
name: "claude-local".to_string(),
|
|
r#type: "claude".to_string(),
|
|
credentials: [
|
|
("ANTHROPIC_API_KEY".to_string(), "sk-abc".to_string()),
|
|
("CLAUDE_API_KEY".to_string(), "sk-abc".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
config: std::iter::once((
|
|
"endpoint".to_string(),
|
|
"https://api.anthropic.com".to_string(),
|
|
))
|
|
.collect(),
|
|
};
|
|
create_provider_record(&store, provider).await.unwrap();
|
|
|
|
let result = resolve_provider_environment(&store, &["claude-local".to_string()])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string()));
|
|
assert_eq!(result.get("CLAUDE_API_KEY"), Some(&"sk-abc".to_string()));
|
|
// Config values should NOT be injected.
|
|
assert!(!result.contains_key("endpoint"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_provider_env_unknown_name_returns_error() {
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
let err = resolve_provider_environment(&store, &["nonexistent".to_string()])
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), Code::FailedPrecondition);
|
|
assert!(err.message().contains("nonexistent"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_provider_env_skips_invalid_credential_keys() {
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
let provider = Provider {
|
|
id: String::new(),
|
|
name: "test-provider".to_string(),
|
|
r#type: "test".to_string(),
|
|
credentials: [
|
|
("VALID_KEY".to_string(), "value".to_string()),
|
|
("nested.api_key".to_string(), "should-skip".to_string()),
|
|
("bad-key".to_string(), "should-skip".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
config: HashMap::new(),
|
|
};
|
|
create_provider_record(&store, provider).await.unwrap();
|
|
|
|
let result = resolve_provider_environment(&store, &["test-provider".to_string()])
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.get("VALID_KEY"), Some(&"value".to_string()));
|
|
assert!(!result.contains_key("nested.api_key"));
|
|
assert!(!result.contains_key("bad-key"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_provider_env_multiple_providers_merge() {
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
create_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "claude-local".to_string(),
|
|
r#type: "claude".to_string(),
|
|
credentials: std::iter::once((
|
|
"ANTHROPIC_API_KEY".to_string(),
|
|
"sk-abc".to_string(),
|
|
))
|
|
.collect(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
create_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "gitlab-local".to_string(),
|
|
r#type: "gitlab".to_string(),
|
|
credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string()))
|
|
.collect(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = resolve_provider_environment(
|
|
&store,
|
|
&["claude-local".to_string(), "gitlab-local".to_string()],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string()));
|
|
assert_eq!(result.get("GITLAB_TOKEN"), Some(&"glpat-xyz".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_provider_env_first_credential_wins_on_duplicate_key() {
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
create_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "provider-a".to_string(),
|
|
r#type: "claude".to_string(),
|
|
credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string()))
|
|
.collect(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
create_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "provider-b".to_string(),
|
|
r#type: "gitlab".to_string(),
|
|
credentials: std::iter::once((
|
|
"SHARED_KEY".to_string(),
|
|
"second-value".to_string(),
|
|
))
|
|
.collect(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let result = resolve_provider_environment(
|
|
&store,
|
|
&["provider-a".to_string(), "provider-b".to_string()],
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.get("SHARED_KEY"), Some(&"first-value".to_string()));
|
|
}
|
|
|
|
/// Simulates the handler flow: persist a sandbox with providers, then resolve
|
|
/// provider environment from the sandbox's spec.providers list.
|
|
#[tokio::test]
|
|
async fn handler_flow_resolves_credentials_from_sandbox_providers() {
|
|
use openshell_core::proto::{Sandbox, SandboxPhase, SandboxSpec};
|
|
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
|
|
// Create providers.
|
|
create_provider_record(
|
|
&store,
|
|
Provider {
|
|
id: String::new(),
|
|
name: "my-claude".to_string(),
|
|
r#type: "claude".to_string(),
|
|
credentials: std::iter::once((
|
|
"ANTHROPIC_API_KEY".to_string(),
|
|
"sk-test".to_string(),
|
|
))
|
|
.collect(),
|
|
config: HashMap::new(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Persist a sandbox with providers field set.
|
|
let sandbox = Sandbox {
|
|
id: "sandbox-001".to_string(),
|
|
name: "test-sandbox".to_string(),
|
|
namespace: "default".to_string(),
|
|
spec: Some(SandboxSpec {
|
|
providers: vec!["my-claude".to_string()],
|
|
..SandboxSpec::default()
|
|
}),
|
|
status: None,
|
|
phase: SandboxPhase::Ready as i32,
|
|
..Default::default()
|
|
};
|
|
store.put_message(&sandbox).await.unwrap();
|
|
|
|
// Simulate the handler: fetch sandbox, read spec.providers, resolve.
|
|
let loaded = store
|
|
.get_message::<Sandbox>("sandbox-001")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let spec = loaded.spec.unwrap();
|
|
let env = resolve_provider_environment(&store, &spec.providers)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(env.get("ANTHROPIC_API_KEY"), Some(&"sk-test".to_string()));
|
|
}
|
|
|
|
/// Handler flow returns empty map when sandbox has no providers.
|
|
#[tokio::test]
|
|
async fn handler_flow_returns_empty_when_no_providers() {
|
|
use openshell_core::proto::{Sandbox, SandboxPhase, SandboxSpec};
|
|
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
|
|
let sandbox = Sandbox {
|
|
id: "sandbox-002".to_string(),
|
|
name: "empty-sandbox".to_string(),
|
|
namespace: "default".to_string(),
|
|
spec: Some(SandboxSpec::default()),
|
|
status: None,
|
|
phase: SandboxPhase::Ready as i32,
|
|
..Default::default()
|
|
};
|
|
store.put_message(&sandbox).await.unwrap();
|
|
|
|
let loaded = store
|
|
.get_message::<Sandbox>("sandbox-002")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let spec = loaded.spec.unwrap();
|
|
let env = resolve_provider_environment(&store, &spec.providers)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(env.is_empty());
|
|
}
|
|
|
|
/// Handler returns not-found when sandbox doesn't exist.
|
|
#[tokio::test]
|
|
async fn handler_flow_returns_none_for_unknown_sandbox() {
|
|
use openshell_core::proto::Sandbox;
|
|
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
let result = store.get_message::<Sandbox>("nonexistent").await.unwrap();
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
// ---- Policy safety validation tests ----
|
|
|
|
#[test]
|
|
fn validate_policy_safety_rejects_root_user() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, ProcessPolicy, SandboxPolicy as ProtoSandboxPolicy,
|
|
};
|
|
|
|
let policy = ProtoSandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
include_workdir: true,
|
|
read_only: vec!["/usr".into()],
|
|
read_write: vec!["/tmp".into()],
|
|
}),
|
|
process: Some(ProcessPolicy {
|
|
run_as_user: "root".into(),
|
|
run_as_group: "sandbox".into(),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let err = super::validate_policy_safety(&policy).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("root"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_safety_rejects_path_traversal() {
|
|
use openshell_core::proto::{FilesystemPolicy, SandboxPolicy as ProtoSandboxPolicy};
|
|
|
|
let policy = ProtoSandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
include_workdir: true,
|
|
read_only: vec!["/usr/../etc/shadow".into()],
|
|
read_write: vec!["/tmp".into()],
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let err = super::validate_policy_safety(&policy).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("traversal"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_safety_rejects_overly_broad_path() {
|
|
use openshell_core::proto::{FilesystemPolicy, SandboxPolicy as ProtoSandboxPolicy};
|
|
|
|
let policy = ProtoSandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
include_workdir: true,
|
|
read_only: vec!["/usr".into()],
|
|
read_write: vec!["/".into()],
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let err = super::validate_policy_safety(&policy).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("broad"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_safety_accepts_valid_policy() {
|
|
let policy = openshell_policy::restrictive_default_policy();
|
|
assert!(super::validate_policy_safety(&policy).is_ok());
|
|
}
|
|
|
|
// ---- Static field validation tests ----
|
|
|
|
#[test]
|
|
fn validate_static_fields_allows_unchanged() {
|
|
use super::{validate_network_mode_unchanged, validate_static_fields_unchanged};
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, LandlockPolicy, ProcessPolicy, SandboxPolicy as ProtoSandboxPolicy,
|
|
};
|
|
|
|
let policy = ProtoSandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
include_workdir: true,
|
|
read_only: vec!["/usr".into()],
|
|
read_write: vec!["/tmp".into()],
|
|
}),
|
|
landlock: Some(LandlockPolicy {
|
|
compatibility: "best_effort".into(),
|
|
}),
|
|
process: Some(ProcessPolicy {
|
|
run_as_user: "sandbox".into(),
|
|
run_as_group: "sandbox".into(),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
assert!(validate_static_fields_unchanged(&policy, &policy).is_ok());
|
|
assert!(validate_network_mode_unchanged(&policy, &policy).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_static_fields_allows_additive_filesystem() {
|
|
use super::validate_static_fields_unchanged;
|
|
use openshell_core::proto::{FilesystemPolicy, SandboxPolicy as ProtoSandboxPolicy};
|
|
|
|
let baseline = ProtoSandboxPolicy {
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_only: vec!["/usr".into()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
// Adding /lib is purely additive — should be allowed.
|
|
let additive = ProtoSandboxPolicy {
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_only: vec!["/usr".into(), "/lib".into()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
assert!(validate_static_fields_unchanged(&baseline, &additive).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_static_fields_rejects_filesystem_removal() {
|
|
use super::validate_static_fields_unchanged;
|
|
use openshell_core::proto::{FilesystemPolicy, SandboxPolicy as ProtoSandboxPolicy};
|
|
|
|
let baseline = ProtoSandboxPolicy {
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_only: vec!["/usr".into(), "/lib".into()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
// Removing /lib should be rejected.
|
|
let removed = ProtoSandboxPolicy {
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_only: vec!["/usr".into()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let result = validate_static_fields_unchanged(&baseline, &removed);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().message().contains("/lib"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_static_fields_rejects_filesystem_deletion() {
|
|
use super::validate_static_fields_unchanged;
|
|
use openshell_core::proto::{FilesystemPolicy, SandboxPolicy as ProtoSandboxPolicy};
|
|
|
|
let baseline = ProtoSandboxPolicy {
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_only: vec!["/usr".into()],
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
// Removing filesystem entirely should be rejected.
|
|
let deleted = ProtoSandboxPolicy {
|
|
filesystem: None,
|
|
..Default::default()
|
|
};
|
|
let result = validate_static_fields_unchanged(&baseline, &deleted);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().message().contains("removed"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_static_fields_allows_filesystem_enrichment_from_none() {
|
|
use super::validate_static_fields_unchanged;
|
|
use openshell_core::proto::{FilesystemPolicy, SandboxPolicy as ProtoSandboxPolicy};
|
|
|
|
let baseline = ProtoSandboxPolicy {
|
|
filesystem: None,
|
|
..Default::default()
|
|
};
|
|
// Adding filesystem when baseline had none — enrichment, allowed.
|
|
let enriched = ProtoSandboxPolicy {
|
|
filesystem: Some(FilesystemPolicy {
|
|
read_only: vec!["/usr".into(), "/lib".into(), "/etc".into()],
|
|
read_write: vec!["/sandbox".into(), "/tmp".into()],
|
|
include_workdir: true,
|
|
}),
|
|
..Default::default()
|
|
};
|
|
assert!(validate_static_fields_unchanged(&baseline, &enriched).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_static_fields_rejects_include_workdir_change() {
|
|
use super::validate_static_fields_unchanged;
|
|
use openshell_core::proto::{FilesystemPolicy, SandboxPolicy as ProtoSandboxPolicy};
|
|
|
|
let baseline = ProtoSandboxPolicy {
|
|
filesystem: Some(FilesystemPolicy {
|
|
include_workdir: true,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let changed = ProtoSandboxPolicy {
|
|
filesystem: Some(FilesystemPolicy {
|
|
include_workdir: false,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let result = validate_static_fields_unchanged(&baseline, &changed);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().message().contains("include_workdir"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_network_mode_rejects_block_to_proxy() {
|
|
use super::validate_network_mode_unchanged;
|
|
use openshell_core::proto::{NetworkPolicyRule, SandboxPolicy as ProtoSandboxPolicy};
|
|
|
|
let baseline = ProtoSandboxPolicy::default(); // no network policies = Block
|
|
let mut changed = ProtoSandboxPolicy::default();
|
|
changed.network_policies.insert(
|
|
"test".into(),
|
|
NetworkPolicyRule {
|
|
name: "test".into(),
|
|
..Default::default()
|
|
},
|
|
);
|
|
assert!(validate_network_mode_unchanged(&baseline, &changed).is_err());
|
|
}
|
|
|
|
// ---- Sandbox creation without policy ----
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_without_policy_stores_successfully() {
|
|
use openshell_core::proto::{Sandbox, SandboxPhase, SandboxSpec};
|
|
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
|
|
let sandbox = Sandbox {
|
|
id: "sb-no-policy".to_string(),
|
|
name: "no-policy-sandbox".to_string(),
|
|
namespace: "default".to_string(),
|
|
spec: Some(SandboxSpec {
|
|
policy: None, // no policy
|
|
..Default::default()
|
|
}),
|
|
phase: SandboxPhase::Provisioning as i32,
|
|
..Default::default()
|
|
};
|
|
store.put_message(&sandbox).await.unwrap();
|
|
|
|
let loaded = store
|
|
.get_message::<Sandbox>("sb-no-policy")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(loaded.spec.unwrap().policy.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sandbox_policy_backfill_on_update_when_no_baseline() {
|
|
use openshell_core::proto::{
|
|
FilesystemPolicy, LandlockPolicy, ProcessPolicy, Sandbox, SandboxPhase,
|
|
SandboxPolicy as ProtoSandboxPolicy, SandboxSpec,
|
|
};
|
|
|
|
let store = Store::connect("sqlite::memory:").await.unwrap();
|
|
|
|
// Create sandbox without policy.
|
|
let sandbox = Sandbox {
|
|
id: "sb-backfill".to_string(),
|
|
name: "backfill-sandbox".to_string(),
|
|
namespace: "default".to_string(),
|
|
spec: Some(SandboxSpec {
|
|
policy: None,
|
|
..Default::default()
|
|
}),
|
|
phase: SandboxPhase::Provisioning as i32,
|
|
..Default::default()
|
|
};
|
|
store.put_message(&sandbox).await.unwrap();
|
|
|
|
// Simulate what update_sandbox_policy does when spec.policy is None:
|
|
// backfill spec.policy with the new policy.
|
|
let new_policy = ProtoSandboxPolicy {
|
|
version: 1,
|
|
filesystem: Some(FilesystemPolicy {
|
|
include_workdir: true,
|
|
read_only: vec!["/usr".into()],
|
|
read_write: vec!["/tmp".into()],
|
|
}),
|
|
landlock: Some(LandlockPolicy {
|
|
compatibility: "best_effort".into(),
|
|
}),
|
|
process: Some(ProcessPolicy {
|
|
run_as_user: "sandbox".into(),
|
|
run_as_group: "sandbox".into(),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let mut sandbox = store
|
|
.get_message::<Sandbox>("sb-backfill")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
if let Some(ref mut spec) = sandbox.spec {
|
|
spec.policy = Some(new_policy.clone());
|
|
}
|
|
store.put_message(&sandbox).await.unwrap();
|
|
|
|
// Verify backfill succeeded.
|
|
let loaded = store
|
|
.get_message::<Sandbox>("sb-backfill")
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
let policy = loaded.spec.unwrap().policy.unwrap();
|
|
assert_eq!(policy.version, 1);
|
|
assert!(policy.filesystem.is_some());
|
|
assert_eq!(policy.process.unwrap().run_as_user, "sandbox");
|
|
}
|
|
|
|
// ── petname default name generation ───────────────────────────────
|
|
|
|
/// Verify that `petname::petname(2, "-")` produces names matching the
|
|
/// expected two-word, hyphen-separated, lowercase pattern.
|
|
#[test]
|
|
fn sandbox_name_defaults_to_petname_format() {
|
|
for _ in 0..50 {
|
|
let name = petname::petname(2, "-").expect("petname should produce a name");
|
|
let parts: Vec<&str> = name.split('-').collect();
|
|
assert_eq!(
|
|
parts.len(),
|
|
2,
|
|
"expected two hyphen-separated words, got: {name}"
|
|
);
|
|
for part in &parts {
|
|
assert!(
|
|
!part.is_empty() && part.chars().all(|c| c.is_ascii_lowercase()),
|
|
"each word should be non-empty lowercase ascii: {name}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The `generate_name` fallback is still a valid 6-char lowercase name.
|
|
#[test]
|
|
fn generate_name_fallback_is_valid() {
|
|
use crate::persistence::generate_name;
|
|
for _ in 0..50 {
|
|
let name = generate_name();
|
|
assert_eq!(name.len(), 6, "unexpected length for fallback name: {name}");
|
|
assert!(
|
|
name.chars().all(|c| c.is_ascii_lowercase()),
|
|
"fallback name should be all lowercase: {name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---- Field-level size limit tests ----
|
|
|
|
fn default_spec() -> SandboxSpec {
|
|
SandboxSpec::default()
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_accepts_gpu_flag() {
|
|
let spec = SandboxSpec {
|
|
gpu: true,
|
|
..Default::default()
|
|
};
|
|
|
|
assert!(validate_sandbox_spec("gpu-sandbox", &spec).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_accepts_empty_defaults() {
|
|
assert!(validate_sandbox_spec("", &default_spec()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_accepts_at_limit_name() {
|
|
let name = "a".repeat(MAX_NAME_LEN);
|
|
assert!(validate_sandbox_spec(&name, &default_spec()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_rejects_over_limit_name() {
|
|
let name = "a".repeat(MAX_NAME_LEN + 1);
|
|
let err = validate_sandbox_spec(&name, &default_spec()).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("name"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_accepts_at_limit_providers() {
|
|
let spec = SandboxSpec {
|
|
providers: (0..MAX_PROVIDERS).map(|i| format!("p-{i}")).collect(),
|
|
..Default::default()
|
|
};
|
|
assert!(validate_sandbox_spec("ok", &spec).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_rejects_over_limit_providers() {
|
|
let spec = SandboxSpec {
|
|
providers: (0..=MAX_PROVIDERS).map(|i| format!("p-{i}")).collect(),
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_spec("ok", &spec).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("providers"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_rejects_over_limit_log_level() {
|
|
let spec = SandboxSpec {
|
|
log_level: "x".repeat(MAX_LOG_LEVEL_LEN + 1),
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_spec("ok", &spec).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("log_level"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_rejects_too_many_env_entries() {
|
|
let env: HashMap<String, String> = (0..=MAX_ENVIRONMENT_ENTRIES)
|
|
.map(|i| (format!("K{i}"), "v".to_string()))
|
|
.collect();
|
|
let spec = SandboxSpec {
|
|
environment: env,
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_spec("ok", &spec).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("environment"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_rejects_oversized_env_key() {
|
|
let mut env = HashMap::new();
|
|
env.insert("k".repeat(MAX_MAP_KEY_LEN + 1), "v".to_string());
|
|
let spec = SandboxSpec {
|
|
environment: env,
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_spec("ok", &spec).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("key"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_rejects_oversized_env_value() {
|
|
let mut env = HashMap::new();
|
|
env.insert("KEY".to_string(), "v".repeat(MAX_MAP_VALUE_LEN + 1));
|
|
let spec = SandboxSpec {
|
|
environment: env,
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_spec("ok", &spec).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("value"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_rejects_oversized_template_image() {
|
|
let spec = SandboxSpec {
|
|
template: Some(SandboxTemplate {
|
|
image: "x".repeat(MAX_TEMPLATE_STRING_LEN + 1),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_spec("ok", &spec).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("template.image"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_rejects_too_many_template_labels() {
|
|
let labels: HashMap<String, String> = (0..=MAX_TEMPLATE_MAP_ENTRIES)
|
|
.map(|i| (format!("k{i}"), "v".to_string()))
|
|
.collect();
|
|
let spec = SandboxSpec {
|
|
template: Some(SandboxTemplate {
|
|
labels,
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_spec("ok", &spec).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("template.labels"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_rejects_oversized_template_struct() {
|
|
use prost_types::{Struct, Value, value::Kind};
|
|
|
|
// Build a Struct with enough data to exceed MAX_TEMPLATE_STRUCT_SIZE.
|
|
let mut fields = std::collections::BTreeMap::new();
|
|
let big_str = "x".repeat(MAX_TEMPLATE_STRUCT_SIZE);
|
|
fields.insert(
|
|
"big".to_string(),
|
|
Value {
|
|
kind: Some(Kind::StringValue(big_str)),
|
|
},
|
|
);
|
|
let big_struct = Struct { fields };
|
|
let spec = SandboxSpec {
|
|
template: Some(SandboxTemplate {
|
|
resources: Some(big_struct),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_spec("ok", &spec).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("template.resources"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_rejects_oversized_policy() {
|
|
use openshell_core::proto::NetworkPolicyRule;
|
|
use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy;
|
|
|
|
// Build a policy large enough to exceed MAX_POLICY_SIZE.
|
|
let mut policy = ProtoSandboxPolicy::default();
|
|
let big_name = "x".repeat(MAX_POLICY_SIZE);
|
|
policy
|
|
.network_policies
|
|
.insert(big_name, NetworkPolicyRule::default());
|
|
let spec = SandboxSpec {
|
|
policy: Some(policy),
|
|
..Default::default()
|
|
};
|
|
let err = validate_sandbox_spec("ok", &spec).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("policy"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_sandbox_spec_accepts_valid_spec() {
|
|
let spec = SandboxSpec {
|
|
log_level: "debug".to_string(),
|
|
providers: vec!["p1".to_string()],
|
|
environment: std::iter::once(("KEY".to_string(), "val".to_string())).collect(),
|
|
template: Some(SandboxTemplate {
|
|
image: "nvcr.io/test:latest".to_string(),
|
|
runtime_class_name: "kata".to_string(),
|
|
labels: std::iter::once(("app".to_string(), "test".to_string())).collect(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
assert!(validate_sandbox_spec("my-sandbox", &spec).is_ok());
|
|
}
|
|
|
|
// ---- Provider field limit tests ----
|
|
|
|
/// Helper: a single-entry credentials map for test providers.
|
|
fn one_credential() -> HashMap<String, String> {
|
|
std::iter::once(("KEY".to_string(), "val".to_string())).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn validate_provider_fields_accepts_valid() {
|
|
let provider = Provider {
|
|
id: String::new(),
|
|
name: "my-provider".to_string(),
|
|
r#type: "claude".to_string(),
|
|
credentials: one_credential(),
|
|
config: std::iter::once(("endpoint".to_string(), "https://example.com".to_string()))
|
|
.collect(),
|
|
};
|
|
assert!(validate_provider_fields(&provider).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_provider_fields_rejects_over_limit_name() {
|
|
let provider = Provider {
|
|
id: String::new(),
|
|
name: "a".repeat(MAX_NAME_LEN + 1),
|
|
r#type: "claude".to_string(),
|
|
credentials: one_credential(),
|
|
config: HashMap::new(),
|
|
};
|
|
let err = validate_provider_fields(&provider).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("provider.name"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_provider_fields_rejects_over_limit_type() {
|
|
let provider = Provider {
|
|
id: String::new(),
|
|
name: "ok".to_string(),
|
|
r#type: "x".repeat(MAX_PROVIDER_TYPE_LEN + 1),
|
|
credentials: one_credential(),
|
|
config: HashMap::new(),
|
|
};
|
|
let err = validate_provider_fields(&provider).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("provider.type"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_provider_fields_rejects_too_many_credentials() {
|
|
let creds: HashMap<String, String> = (0..=MAX_PROVIDER_CREDENTIALS_ENTRIES)
|
|
.map(|i| (format!("K{i}"), "v".to_string()))
|
|
.collect();
|
|
let provider = Provider {
|
|
id: String::new(),
|
|
name: "ok".to_string(),
|
|
r#type: "claude".to_string(),
|
|
credentials: creds,
|
|
config: HashMap::new(),
|
|
};
|
|
let err = validate_provider_fields(&provider).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("provider.credentials"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_provider_fields_rejects_too_many_config() {
|
|
let config: HashMap<String, String> = (0..=MAX_PROVIDER_CONFIG_ENTRIES)
|
|
.map(|i| (format!("K{i}"), "v".to_string()))
|
|
.collect();
|
|
let provider = Provider {
|
|
id: String::new(),
|
|
name: "ok".to_string(),
|
|
r#type: "claude".to_string(),
|
|
credentials: one_credential(),
|
|
config,
|
|
};
|
|
let err = validate_provider_fields(&provider).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("provider.config"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_provider_fields_at_limit_name_accepted() {
|
|
let provider = Provider {
|
|
id: String::new(),
|
|
name: "a".repeat(MAX_NAME_LEN),
|
|
r#type: "claude".to_string(),
|
|
credentials: one_credential(),
|
|
config: HashMap::new(),
|
|
};
|
|
assert!(validate_provider_fields(&provider).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_provider_fields_rejects_oversized_credential_key() {
|
|
let mut creds = HashMap::new();
|
|
creds.insert("k".repeat(MAX_MAP_KEY_LEN + 1), "v".to_string());
|
|
let provider = Provider {
|
|
id: String::new(),
|
|
name: "ok".to_string(),
|
|
r#type: "claude".to_string(),
|
|
credentials: creds,
|
|
config: HashMap::new(),
|
|
};
|
|
let err = validate_provider_fields(&provider).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("key"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_provider_fields_rejects_oversized_config_value() {
|
|
let mut config = HashMap::new();
|
|
config.insert("k".to_string(), "v".repeat(MAX_MAP_VALUE_LEN + 1));
|
|
let provider = Provider {
|
|
id: String::new(),
|
|
name: "ok".to_string(),
|
|
r#type: "claude".to_string(),
|
|
credentials: one_credential(),
|
|
config,
|
|
};
|
|
let err = validate_provider_fields(&provider).unwrap_err();
|
|
assert_eq!(err.code(), Code::InvalidArgument);
|
|
assert!(err.message().contains("value"));
|
|
}
|
|
}
|