Quay, splits, QoL
This commit is contained in:
parent
1d408f0734
commit
3faa1e5712
11 changed files with 1120 additions and 101 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1724,6 +1724,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"ed25519-dalek",
|
||||
"hmac 0.12.1",
|
||||
"mtp",
|
||||
"regex",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ mtp = { git = "https://git@git.methanium.net/methanium/mtp.git", features = [
|
|||
] }
|
||||
tokio = "1"
|
||||
async-trait = "0.1"
|
||||
ed25519-dalek = "2"
|
||||
url = "2"
|
||||
regex = "1"
|
||||
hmac = "0.12"
|
||||
|
|
|
|||
219
src/compute.rs
Normal file
219
src/compute.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Administrator-defined weighted compute admission cost.
|
||||
///
|
||||
/// Units are deliberately not tied to a physical quantity such as CPU cores
|
||||
/// or GPU count. They are a relative scheduling weight configured for a
|
||||
/// worker and its workloads.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Units(pub u32);
|
||||
|
||||
impl Units {
|
||||
pub const ZERO: Self = Self(0);
|
||||
|
||||
pub const fn new(value: u32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub const fn get(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Tokio's weighted semaphore API accepts a `u32` permit count. Keeping
|
||||
/// this conversion explicit prevents callers from silently truncating a
|
||||
/// wider configuration value before admission.
|
||||
pub const fn semaphore_permits(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn checked_add(self, other: Self) -> Option<Self> {
|
||||
self.0.checked_add(other.0).map(Self)
|
||||
}
|
||||
|
||||
pub fn checked_sub(self, other: Self) -> Option<Self> {
|
||||
self.0.checked_sub(other.0).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Units> for u32 {
|
||||
fn from(units: Units) -> Self {
|
||||
units.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u64> for Units {
|
||||
type Error = std::num::TryFromIntError;
|
||||
|
||||
fn try_from(value: u64) -> Result<Self, Self::Error> {
|
||||
u32::try_from(value).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A worker's current weighted capacity and work-slot snapshot.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CapacitySnapshot {
|
||||
pub total_units: Units,
|
||||
pub available_units: Units,
|
||||
pub active_work: u32,
|
||||
pub max_work: u32,
|
||||
}
|
||||
|
||||
impl CapacitySnapshot {
|
||||
pub const fn new(total_units: Units, max_work: u32) -> Self {
|
||||
Self {
|
||||
total_units,
|
||||
available_units: total_units,
|
||||
active_work: 0,
|
||||
max_work,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether both independent admission guards have room for work.
|
||||
pub fn can_admit(&self, required_units: Units) -> bool {
|
||||
self.available_units >= required_units && self.active_work < self.max_work
|
||||
}
|
||||
|
||||
/// Capacity snapshots are external observations, so reject impossible
|
||||
/// values before they reach scheduling code.
|
||||
pub fn validate(&self) -> Result<(), CapacitySnapshotError> {
|
||||
if self.available_units > self.total_units {
|
||||
return Err(CapacitySnapshotError::AvailableExceedsTotal);
|
||||
}
|
||||
if self.active_work > self.max_work {
|
||||
return Err(CapacitySnapshotError::ActiveWorkExceedsMaximum);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CapacitySnapshotError {
|
||||
#[error("available Units exceed total Units")]
|
||||
AvailableExceedsTotal,
|
||||
#[error("active work exceeds the configured maximum")]
|
||||
ActiveWorkExceedsMaximum,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PodHardwareSnapshot {
|
||||
pub gpu_count: u32,
|
||||
pub gpu_memory_total_mb: Option<u64>,
|
||||
pub gpu_memory_free_mb: Option<u64>,
|
||||
pub system_memory_total_mb: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuayHardwareSnapshot {
|
||||
pub logical_cpu_count: Option<u32>,
|
||||
pub system_memory_total_mb: Option<u64>,
|
||||
pub scratch_total_bytes: Option<u64>,
|
||||
pub scratch_free_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
/// Hard scheduling requirements. This is separate from task execution
|
||||
/// containment limits such as timeout and memory rlimits.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ComputeRequirement {
|
||||
pub units: Units,
|
||||
pub min_memory_mb: Option<u64>,
|
||||
pub min_gpu_memory_mb: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PodCapacityConfig {
|
||||
pub total_units: Units,
|
||||
pub max_agent_sessions: u32,
|
||||
}
|
||||
|
||||
impl Default for PodCapacityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
total_units: Units::ZERO,
|
||||
max_agent_sessions: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PodCapacityConfig {
|
||||
pub const fn new(total_units: Units, max_agent_sessions: u32) -> Self {
|
||||
Self {
|
||||
total_units,
|
||||
max_agent_sessions,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn snapshot(self) -> CapacitySnapshot {
|
||||
CapacitySnapshot::new(self.total_units, self.max_agent_sessions)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuayCapacityConfig {
|
||||
pub total_units: Units,
|
||||
pub max_executions: u32,
|
||||
}
|
||||
|
||||
impl Default for QuayCapacityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
total_units: Units::ZERO,
|
||||
max_executions: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QuayCapacityConfig {
|
||||
pub const fn new(total_units: Units, max_executions: u32) -> Self {
|
||||
Self {
|
||||
total_units,
|
||||
max_executions,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn snapshot(self) -> CapacitySnapshot {
|
||||
CapacitySnapshot::new(self.total_units, self.max_executions)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn units_are_transparent_and_checked() {
|
||||
let units = Units::new(7);
|
||||
let encoded = serde_json::to_value(units).expect("Units should serialize");
|
||||
assert_eq!(encoded, serde_json::json!(7));
|
||||
assert_eq!(Units::try_from(7_u64), Ok(units));
|
||||
assert!(Units::try_from(u64::from(u32::MAX) + 1).is_err());
|
||||
assert_eq!(units.checked_add(Units::new(2)), Some(Units::new(9)));
|
||||
assert_eq!(Units::new(2).checked_sub(units), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_uses_units_and_work_as_separate_guards() {
|
||||
let mut snapshot = CapacitySnapshot::new(Units::new(4), 2);
|
||||
assert!(snapshot.can_admit(Units::new(4)));
|
||||
snapshot.active_work = 1;
|
||||
snapshot.available_units = Units::new(0);
|
||||
assert!(!snapshot.can_admit(Units::new(1)));
|
||||
snapshot.available_units = Units::new(4);
|
||||
snapshot.active_work = 2;
|
||||
assert!(!snapshot.can_admit(Units::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_snapshot_rejects_inconsistent_observations() {
|
||||
let snapshot = CapacitySnapshot {
|
||||
total_units: Units::new(1),
|
||||
available_units: Units::new(2),
|
||||
active_work: 0,
|
||||
max_work: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
snapshot.validate(),
|
||||
Err(CapacitySnapshotError::AvailableExceedsTotal)
|
||||
);
|
||||
}
|
||||
}
|
||||
19
src/enums.rs
19
src/enums.rs
|
|
@ -1,6 +1,25 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InfrastructureKind {
|
||||
Pod,
|
||||
Quay,
|
||||
Reservoir,
|
||||
}
|
||||
|
||||
impl fmt::Display for InfrastructureKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let value = match self {
|
||||
Self::Pod => "pod",
|
||||
Self::Quay => "quay",
|
||||
Self::Reservoir => "reservoir",
|
||||
};
|
||||
f.write_str(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToDoMode {
|
||||
|
|
|
|||
48
src/krill.rs
48
src/krill.rs
|
|
@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
|||
use uuid::Uuid;
|
||||
|
||||
use crate::enums::ModelType;
|
||||
use crate::Units;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum PodProtocolVersion {
|
||||
|
|
@ -16,6 +17,40 @@ impl Default for PodProtocolVersion {
|
|||
|
||||
pub type KrillId = Uuid;
|
||||
|
||||
/// Trusted scheduling metadata for one Krill runtime.
|
||||
///
|
||||
/// This profile is administrator/registry data. It is intentionally separate
|
||||
/// from `KrillConfig::settings`, which may contain model-specific runtime
|
||||
/// options and is not a scheduling authority.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KrillRuntimeProfile {
|
||||
pub compute_units: Units,
|
||||
pub min_memory_mb: Option<u64>,
|
||||
pub min_gpu_memory_mb: Option<u64>,
|
||||
}
|
||||
|
||||
impl KrillRuntimeProfile {
|
||||
pub const fn remote() -> Self {
|
||||
Self {
|
||||
compute_units: Units::ZERO,
|
||||
min_memory_mb: None,
|
||||
min_gpu_memory_mb: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn local(
|
||||
compute_units: Units,
|
||||
min_memory_mb: Option<u64>,
|
||||
min_gpu_memory_mb: Option<u64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
compute_units,
|
||||
min_memory_mb,
|
||||
min_gpu_memory_mb,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KrillDescriptor {
|
||||
pub id: KrillId,
|
||||
|
|
@ -24,6 +59,7 @@ pub struct KrillDescriptor {
|
|||
pub model_type: ModelType,
|
||||
pub model_path: Option<String>,
|
||||
pub config: KrillConfig,
|
||||
pub runtime_profile: KrillRuntimeProfile,
|
||||
pub version: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
|
@ -37,6 +73,7 @@ impl Default for KrillDescriptor {
|
|||
model_type: ModelType::Llm,
|
||||
model_path: None,
|
||||
config: KrillConfig::default(),
|
||||
runtime_profile: KrillRuntimeProfile::default(),
|
||||
version: "0.1.0".to_string(),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
|
|
@ -52,6 +89,7 @@ impl KrillDescriptor {
|
|||
model_type,
|
||||
model_path: None,
|
||||
config: KrillConfig::default(),
|
||||
runtime_profile: KrillRuntimeProfile::default(),
|
||||
version: "0.1.0".to_string(),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
|
|
@ -72,6 +110,11 @@ impl KrillDescriptor {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_runtime_profile(mut self, runtime_profile: KrillRuntimeProfile) -> Self {
|
||||
self.runtime_profile = runtime_profile;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_version(mut self, version: String) -> Self {
|
||||
self.version = version;
|
||||
self
|
||||
|
|
@ -143,4 +186,9 @@ mod tests {
|
|||
assert_eq!(config.get("temperature").unwrap().as_f64(), Some(0.7));
|
||||
assert_eq!(config.get("max_tokens").unwrap().as_u64(), Some(2048));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_profile_does_not_consume_pod_units() {
|
||||
assert_eq!(KrillRuntimeProfile::remote().compute_units, Units::ZERO);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
src/lib.rs
42
src/lib.rs
|
|
@ -5,6 +5,7 @@ pub use uuid::Uuid;
|
|||
|
||||
pub mod agents;
|
||||
pub mod ai_response;
|
||||
pub mod compute;
|
||||
pub mod conclusion;
|
||||
pub mod coral;
|
||||
pub mod data_retention;
|
||||
|
|
@ -14,6 +15,7 @@ pub mod krill;
|
|||
pub mod mutations;
|
||||
pub mod principal;
|
||||
pub mod project;
|
||||
pub mod rollback;
|
||||
pub mod sandbox;
|
||||
pub mod sync;
|
||||
pub mod task;
|
||||
|
|
@ -24,6 +26,10 @@ pub use agents::{
|
|||
AgentPolicy, AgentPromptConfig, AgentType, ApprovalMode, PlannedAction, SimulationResult,
|
||||
};
|
||||
pub use ai_response::{Conversation, Message};
|
||||
pub use compute::{
|
||||
CapacitySnapshot, CapacitySnapshotError, ComputeRequirement, PodCapacityConfig,
|
||||
PodHardwareSnapshot, QuayCapacityConfig, QuayHardwareSnapshot, Units,
|
||||
};
|
||||
pub use conclusion::{
|
||||
Artifact, ArtifactType, ConclusionCard, ConclusionMetrics, ConclusionTrigger,
|
||||
};
|
||||
|
|
@ -33,29 +39,35 @@ pub use data_retention::{
|
|||
};
|
||||
pub use enums::*;
|
||||
pub use errors::{AuditOutcome, AuditRecord, FieldError, PublicError, ShoalError, ValidationError};
|
||||
pub use krill::{KrillConfig, KrillDescriptor, KrillId, PodProtocolVersion};
|
||||
pub use krill::{KrillConfig, KrillDescriptor, KrillId, KrillRuntimeProfile, PodProtocolVersion};
|
||||
pub use mutations::{
|
||||
merged_affected_files, normalize_repository_path, FileMutation, PathValidationError,
|
||||
};
|
||||
pub use principal::{
|
||||
AuthError, AuthenticatedPrincipal, AuthorizationService, DispatchAuthError,
|
||||
DiscussionCredentialClaims, DiscussionExecutionPrincipal, ExecutionPrincipal, GlobalRole,
|
||||
ModelGatewayClaims, ModelGatewayPrincipal, PodPrincipal, ProjectAction, ProjectRole,
|
||||
RequestContext, ReservoirConnectionContext, ReservoirPrincipal, ReservoirServiceRole,
|
||||
TokenValidationResult,
|
||||
sign_quay_execution_credential, sign_service_credential, verify_quay_execution_credential,
|
||||
verify_service_credential, AuthError, AuthenticatedPrincipal, AuthorizationService,
|
||||
DiscussionCredentialClaims, DiscussionExecutionPrincipal, DispatchAuthError,
|
||||
ExecutionPrincipal, GlobalRole, ModelGatewayClaims, ModelGatewayPrincipal, PodPrincipal,
|
||||
ProjectAction, ProjectRole, QuayExecutionClaims, RequestContext, ReservoirConnectionContext,
|
||||
ReservoirPrincipal, ReservoirServiceRole, ServiceCredentialClaims, ServiceCredentialError,
|
||||
TokenValidationResult, QUAY_EXECUTION_AUDIENCE,
|
||||
};
|
||||
pub use project::{Project, ProjectFile, ProjectId, ProjectSettings};
|
||||
pub use project::{Project, ProjectFile, ProjectId, ProjectSettings, ValidationCommand};
|
||||
pub use rollback::RollbackReference;
|
||||
pub use sync::{ChangeSetDescriptor, ChangeSetFile, FileOperationKind};
|
||||
pub use task::{Task, TaskResult};
|
||||
pub use todo::{Dependency, ToDo};
|
||||
pub use tools::{
|
||||
compute_tools_hash, format_tool_error, format_tool_result, format_tools_json, is_global_tool,
|
||||
is_reef_proxy_tool, parse_tool_call_blocks, parse_tool_call_stream, tool_definitions,
|
||||
tool_ids_for_agent_type, apply_verified_scope, normalize_tool_call, resolve_effective_project,
|
||||
ApprovalRequirement, CompositionStep, ContextVisibility, EffectiveProject, ExecutionContext,
|
||||
MarketplaceSearchResults, ParsedToolCall, TestAction, ToolComposition, ToolDefinition,
|
||||
ToolDependency, ToolDocumentation, ToolExample, ToolExecutor, ToolListing, ToolParser,
|
||||
ToolPlugin, ToolRegistry, ToolState, ToolTest, ToolTestResult, ToolTestSuite,
|
||||
ToolTestSuiteResult, ToolVersion, VerifiedToolScope, GLOBAL_TOOLS, REEF_PROXY_TOOLS,
|
||||
apply_verified_scope, compute_tools_hash, format_tool_error, format_tool_result,
|
||||
format_tools_json, is_global_tool, normalize_tool_call, parse_tool_call_blocks,
|
||||
parse_tool_call_stream, resolve_effective_project, tool_definitions, tool_ids_for_agent_type,
|
||||
validate_tool_definition, validate_tool_definitions, ApprovalRequirement, CompositionStep,
|
||||
ContextVisibility, EffectiveProject, ExecutionContext, MarketplaceSearchResults,
|
||||
ParsedToolCall, SandboxAccess, TestAction, ToolComposition, ToolDefinition,
|
||||
ToolDefinitionError, ToolDependency, ToolDocumentation, ToolExample, ToolExecutionLocation,
|
||||
ToolExecutor, ToolListing, ToolParser, ToolPlugin, ToolRegistry, ToolState, ToolTest,
|
||||
ToolTestResult, ToolTestSuite, ToolTestSuiteResult, ToolVersion, VerifiedToolScope,
|
||||
GLOBAL_TOOLS,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
340
src/principal.rs
340
src/principal.rs
|
|
@ -190,6 +190,14 @@ pub enum ReservoirPrincipal {
|
|||
service_id: Uuid,
|
||||
role: ReservoirServiceRole,
|
||||
},
|
||||
/// A short-lived Reef-issued service capability. Unlike the legacy
|
||||
/// `Service` variant this principal carries the operation and project
|
||||
/// scope that Reservoir must enforce for every request.
|
||||
ScopedService {
|
||||
service_id: Uuid,
|
||||
role: ReservoirServiceRole,
|
||||
claims: ServiceCredentialClaims,
|
||||
},
|
||||
}
|
||||
|
||||
/// Explicit infrastructure roles accepted by Reservoir. Adding a new service
|
||||
|
|
@ -198,6 +206,16 @@ pub enum ReservoirPrincipal {
|
|||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReservoirServiceRole {
|
||||
ReefStorage,
|
||||
QuayWorkspace,
|
||||
}
|
||||
|
||||
impl ReservoirServiceRole {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReefStorage => "reef_storage",
|
||||
Self::QuayWorkspace => "quay_workspace",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authoritative result returned when Reef validates a user session token.
|
||||
|
|
@ -373,6 +391,20 @@ pub enum CredentialError {
|
|||
AttemptInactive,
|
||||
/// The requested tool is not in the credential's allow-list.
|
||||
ToolNotAllowed,
|
||||
/// The credential was issued for another service or control-plane.
|
||||
AudienceMismatch,
|
||||
/// The credential was issued by an unexpected authority.
|
||||
IssuerMismatch,
|
||||
/// The credential does not grant the requested operation or project.
|
||||
ScopeDenied,
|
||||
/// A Quay authorization does not match the durable execution request.
|
||||
ExecutionMismatch,
|
||||
/// A Quay authorization does not match the leased workspace.
|
||||
WorkspaceMismatch,
|
||||
/// A Quay authorization does not match the trusted tool metadata.
|
||||
ToolMismatch,
|
||||
/// A Quay authorization does not match the trusted Unit cost.
|
||||
UnitsMismatch,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CredentialError {
|
||||
|
|
@ -385,12 +417,241 @@ impl std::fmt::Display for CredentialError {
|
|||
Self::PodMismatch => write!(f, "credential not valid for this pod"),
|
||||
Self::AttemptInactive => write!(f, "attempt inactive"),
|
||||
Self::ToolNotAllowed => write!(f, "tool not in credential allow-list"),
|
||||
Self::AudienceMismatch => write!(f, "credential audience mismatch"),
|
||||
Self::IssuerMismatch => write!(f, "credential issuer mismatch"),
|
||||
Self::ScopeDenied => write!(f, "credential scope denied"),
|
||||
Self::ExecutionMismatch => write!(f, "credential execution mismatch"),
|
||||
Self::WorkspaceMismatch => write!(f, "credential workspace mismatch"),
|
||||
Self::ToolMismatch => write!(f, "credential tool mismatch"),
|
||||
Self::UnitsMismatch => write!(f, "credential Unit requirement mismatch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CredentialError {}
|
||||
|
||||
/// The audience used by Reef-issued execution authorizations delivered to a
|
||||
/// Quay. Keeping this value in the shared contract prevents a token minted
|
||||
/// for one service from being accepted by another worker.
|
||||
pub const QUAY_EXECUTION_AUDIENCE: &str = "quay";
|
||||
|
||||
/// Claims for exactly one authorized Quay execution.
|
||||
///
|
||||
/// These claims are deliberately narrower than the Pod execution credential.
|
||||
/// Reef performs user/agent authorization first, then mints this capability
|
||||
/// for the selected Quay. The Quay must not derive any of these values from
|
||||
/// model-supplied arguments.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct QuayExecutionClaims {
|
||||
pub execution_id: Uuid,
|
||||
pub workspace_lease_id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub quay_id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub tool_id: String,
|
||||
pub compute_units: crate::Units,
|
||||
pub credential_id: Uuid,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl QuayExecutionClaims {
|
||||
/// Validate the parts of a request that are known by the Quay at
|
||||
/// execution time. The signature, audience, issuer, and expiry are
|
||||
/// checked by `verify_quay_execution_credential`.
|
||||
pub fn validate_request(
|
||||
&self,
|
||||
execution_id: Uuid,
|
||||
workspace_lease_id: Uuid,
|
||||
project_id: Uuid,
|
||||
quay_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
tool_id: &str,
|
||||
compute_units: crate::Units,
|
||||
) -> Result<(), CredentialError> {
|
||||
if self.execution_id != execution_id {
|
||||
return Err(CredentialError::ExecutionMismatch);
|
||||
}
|
||||
if self.workspace_lease_id != workspace_lease_id || self.workspace_id != workspace_id {
|
||||
return Err(CredentialError::WorkspaceMismatch);
|
||||
}
|
||||
if self.project_id != project_id || self.quay_id != quay_id {
|
||||
return Err(CredentialError::ScopeDenied);
|
||||
}
|
||||
if self.tool_id != tool_id {
|
||||
return Err(CredentialError::ToolMismatch);
|
||||
}
|
||||
if self.compute_units != compute_units {
|
||||
return Err(CredentialError::UnitsMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A Reef-to-service capability. `operations` contains canonical operation
|
||||
/// names, never free-form request text. `project_id == None` is reserved for
|
||||
/// explicitly project-independent service operations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ServiceCredentialClaims {
|
||||
pub issuer: String,
|
||||
pub audience: String,
|
||||
pub service_role: String,
|
||||
pub project_id: Option<Uuid>,
|
||||
pub operations: Vec<String>,
|
||||
pub issued_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub credential_id: Uuid,
|
||||
}
|
||||
|
||||
impl ServiceCredentialClaims {
|
||||
pub fn allows(&self, operation: &str, project_id: Option<Uuid>, now: DateTime<Utc>) -> bool {
|
||||
now >= self.issued_at
|
||||
&& now <= self.expires_at
|
||||
&& self.operations.iter().any(|allowed| allowed == operation)
|
||||
&& (self.project_id.is_none() || self.project_id == project_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ServiceCredentialError {
|
||||
#[error("service credential payload could not be serialized")]
|
||||
Serialization,
|
||||
#[error("service credential is malformed")]
|
||||
Malformed,
|
||||
#[error("service credential signature is invalid")]
|
||||
InvalidSignature,
|
||||
#[error("service credential is expired")]
|
||||
Expired,
|
||||
#[error("service credential issuer mismatch")]
|
||||
IssuerMismatch,
|
||||
#[error("service credential audience mismatch")]
|
||||
AudienceMismatch,
|
||||
#[error("service credential scope denied")]
|
||||
ScopeDenied,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct QuayExecutionEnvelope {
|
||||
issuer: String,
|
||||
audience: String,
|
||||
claims: QuayExecutionClaims,
|
||||
#[serde(default)]
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ServiceCredentialEnvelope {
|
||||
claims: ServiceCredentialClaims,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
/// Sign a Quay execution capability with Reef's control-plane Ed25519 key.
|
||||
/// The returned value is an opaque `base64url(payload).base64url(signature)`
|
||||
/// token suitable for transport in an MTP response.
|
||||
pub fn sign_quay_execution_credential(
|
||||
claims: &QuayExecutionClaims,
|
||||
issuer: &str,
|
||||
signing_key: &[u8; 32],
|
||||
) -> Result<String, ServiceCredentialError> {
|
||||
let payload = serde_json::json!({
|
||||
"issuer": issuer,
|
||||
"audience": QUAY_EXECUTION_AUDIENCE,
|
||||
"claims": claims,
|
||||
});
|
||||
sign_json_payload(&payload, signing_key)
|
||||
}
|
||||
|
||||
/// Verify a Quay execution capability and its service audience/issuer.
|
||||
pub fn verify_quay_execution_credential(
|
||||
credential: &str,
|
||||
verifying_key: &[u8; 32],
|
||||
expected_issuer: &str,
|
||||
) -> Result<QuayExecutionClaims, ServiceCredentialError> {
|
||||
let payload: QuayExecutionEnvelope = verify_json_payload(credential, verifying_key)?;
|
||||
if payload.issuer != expected_issuer {
|
||||
return Err(ServiceCredentialError::IssuerMismatch);
|
||||
}
|
||||
if payload.audience != QUAY_EXECUTION_AUDIENCE {
|
||||
return Err(ServiceCredentialError::AudienceMismatch);
|
||||
}
|
||||
if Utc::now() > payload.claims.expires_at {
|
||||
return Err(ServiceCredentialError::Expired);
|
||||
}
|
||||
Ok(payload.claims)
|
||||
}
|
||||
|
||||
/// Sign a scoped Reef service credential for Reservoir or another service.
|
||||
pub fn sign_service_credential(
|
||||
claims: &ServiceCredentialClaims,
|
||||
signing_key: &[u8; 32],
|
||||
) -> Result<String, ServiceCredentialError> {
|
||||
let envelope = ServiceCredentialEnvelope {
|
||||
claims: claims.clone(),
|
||||
signature: String::new(),
|
||||
};
|
||||
let payload =
|
||||
serde_json::to_value(envelope).map_err(|_| ServiceCredentialError::Serialization)?;
|
||||
sign_json_payload(&payload, signing_key)
|
||||
}
|
||||
|
||||
/// Verify a scoped service credential and its intended service audience.
|
||||
pub fn verify_service_credential(
|
||||
credential: &str,
|
||||
verifying_key: &[u8; 32],
|
||||
expected_issuer: &str,
|
||||
expected_audience: &str,
|
||||
) -> Result<ServiceCredentialClaims, ServiceCredentialError> {
|
||||
let envelope: ServiceCredentialEnvelope = verify_json_payload(credential, verifying_key)?;
|
||||
if envelope.claims.issuer != expected_issuer {
|
||||
return Err(ServiceCredentialError::IssuerMismatch);
|
||||
}
|
||||
if envelope.claims.audience != expected_audience {
|
||||
return Err(ServiceCredentialError::AudienceMismatch);
|
||||
}
|
||||
if Utc::now() > envelope.claims.expires_at {
|
||||
return Err(ServiceCredentialError::Expired);
|
||||
}
|
||||
Ok(envelope.claims)
|
||||
}
|
||||
|
||||
fn sign_json_payload(
|
||||
value: &serde_json::Value,
|
||||
signing_key: &[u8; 32],
|
||||
) -> Result<String, ServiceCredentialError> {
|
||||
use base64::Engine;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
let bytes = serde_json::to_vec(value).map_err(|_| ServiceCredentialError::Serialization)?;
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
||||
let signature = SigningKey::from_bytes(signing_key).sign(payload.as_bytes());
|
||||
let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
||||
Ok(format!("{payload}.{signature}"))
|
||||
}
|
||||
|
||||
fn verify_json_payload<T: for<'de> Deserialize<'de>>(
|
||||
credential: &str,
|
||||
verifying_key: &[u8; 32],
|
||||
) -> Result<T, ServiceCredentialError> {
|
||||
use base64::Engine;
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
|
||||
let (payload, encoded_signature) = credential
|
||||
.split_once('.')
|
||||
.ok_or(ServiceCredentialError::Malformed)?;
|
||||
let signature_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(encoded_signature)
|
||||
.map_err(|_| ServiceCredentialError::Malformed)?;
|
||||
let signature =
|
||||
Signature::from_slice(&signature_bytes).map_err(|_| ServiceCredentialError::Malformed)?;
|
||||
let key =
|
||||
VerifyingKey::from_bytes(verifying_key).map_err(|_| ServiceCredentialError::Malformed)?;
|
||||
key.verify(payload.as_bytes(), &signature)
|
||||
.map_err(|_| ServiceCredentialError::InvalidSignature)?;
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.map_err(|_| ServiceCredentialError::Malformed)?;
|
||||
serde_json::from_slice(&bytes).map_err(|_| ServiceCredentialError::Malformed)
|
||||
}
|
||||
|
||||
/// The verifiable claims inside a signed execution credential.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialClaims {
|
||||
|
|
@ -1520,4 +1781,83 @@ mod tests {
|
|||
let claims = verify_model_gateway_signature(&credential, &secret).unwrap();
|
||||
assert!(claims.allowed_models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quay_execution_credential_is_narrow_and_signed() {
|
||||
let key = [7_u8; 32];
|
||||
let verifying_key = ed25519_dalek::SigningKey::from_bytes(&key)
|
||||
.verifying_key()
|
||||
.to_bytes();
|
||||
let claims = QuayExecutionClaims {
|
||||
execution_id: Uuid::new_v4(),
|
||||
workspace_lease_id: Uuid::new_v4(),
|
||||
project_id: Uuid::new_v4(),
|
||||
quay_id: Uuid::new_v4(),
|
||||
workspace_id: Uuid::new_v4(),
|
||||
tool_id: "bash".to_string(),
|
||||
compute_units: crate::Units::new(2),
|
||||
credential_id: Uuid::new_v4(),
|
||||
expires_at: Utc::now() + chrono::Duration::minutes(5),
|
||||
};
|
||||
let credential = sign_quay_execution_credential(&claims, "reef-test", &key)
|
||||
.expect("Quay credential should sign");
|
||||
let verified = verify_quay_execution_credential(&credential, &verifying_key, "reef-test")
|
||||
.expect("Quay credential should verify");
|
||||
assert_eq!(verified, claims);
|
||||
claims
|
||||
.validate_request(
|
||||
claims.execution_id,
|
||||
claims.workspace_lease_id,
|
||||
claims.project_id,
|
||||
claims.quay_id,
|
||||
claims.workspace_id,
|
||||
"bash",
|
||||
crate::Units::new(2),
|
||||
)
|
||||
.expect("matching request should be accepted");
|
||||
assert_eq!(
|
||||
claims.validate_request(
|
||||
claims.execution_id,
|
||||
claims.workspace_lease_id,
|
||||
claims.project_id,
|
||||
claims.quay_id,
|
||||
claims.workspace_id,
|
||||
"read_file",
|
||||
crate::Units::new(2),
|
||||
),
|
||||
Err(CredentialError::ToolMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_credential_enforces_audience_and_operation_scope() {
|
||||
let key = [9_u8; 32];
|
||||
let verifying_key = ed25519_dalek::SigningKey::from_bytes(&key)
|
||||
.verifying_key()
|
||||
.to_bytes();
|
||||
let project_id = Uuid::new_v4();
|
||||
let claims = ServiceCredentialClaims {
|
||||
issuer: "reef-test".to_string(),
|
||||
audience: "reservoir".to_string(),
|
||||
service_role: "quay_workspace".to_string(),
|
||||
project_id: Some(project_id),
|
||||
operations: vec!["get_project_head".to_string()],
|
||||
issued_at: Utc::now(),
|
||||
expires_at: Utc::now() + chrono::Duration::minutes(5),
|
||||
credential_id: Uuid::new_v4(),
|
||||
};
|
||||
let credential =
|
||||
sign_service_credential(&claims, &key).expect("service credential should sign");
|
||||
let verified =
|
||||
verify_service_credential(&credential, &verifying_key, "reef-test", "reservoir")
|
||||
.expect("service credential should verify");
|
||||
assert!(verified.allows("get_project_head", Some(project_id), Utc::now()));
|
||||
assert!(!verified.allows("create_candidate", Some(project_id), Utc::now()));
|
||||
assert!(!verified.allows("get_project_head", Some(Uuid::new_v4()), Utc::now()));
|
||||
assert_eq!(
|
||||
verify_service_credential(&credential, &verifying_key, "reef-test", "other-service",)
|
||||
.unwrap_err(),
|
||||
ServiceCredentialError::AudienceMismatch
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
src/project.rs
117
src/project.rs
|
|
@ -2,6 +2,8 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::enums::CompletionPolicy;
|
||||
|
||||
pub type ProjectId = Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -112,10 +114,25 @@ pub struct ProjectSettings {
|
|||
pub run_command: Option<String>,
|
||||
/// Conventional subject/body template used for generated git commits.
|
||||
pub git_commit_template: Option<String>,
|
||||
/// Publication policy for candidate changesets.
|
||||
#[serde(default)]
|
||||
pub completion_policy: CompletionPolicy,
|
||||
/// Whether a candidate must run the configured deterministic checks.
|
||||
#[serde(default)]
|
||||
pub validation_required: bool,
|
||||
/// Commands run against the exact candidate version before publication.
|
||||
#[serde(default)]
|
||||
pub validation_commands: Vec<ValidationCommand>,
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ValidationCommand {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
impl ProjectSettings {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
@ -123,6 +140,9 @@ impl ProjectSettings {
|
|||
build_command: None,
|
||||
run_command: None,
|
||||
git_commit_template: None,
|
||||
completion_policy: CompletionPolicy::default(),
|
||||
validation_required: false,
|
||||
validation_commands: Vec::new(),
|
||||
extra: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
|
@ -147,11 +167,63 @@ impl ProjectSettings {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_completion_policy(mut self, policy: CompletionPolicy) -> Self {
|
||||
self.completion_policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_validation(mut self, required: bool, commands: Vec<ValidationCommand>) -> Self {
|
||||
self.validation_required = required;
|
||||
self.validation_commands = commands;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_extra(&mut self, key: &str, value: serde_json::Value) {
|
||||
if !self.extra.is_object() {
|
||||
self.extra = serde_json::json!({});
|
||||
}
|
||||
if let Some(obj) = self.extra.as_object_mut() {
|
||||
obj.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn review_state_for(&self, has_mutations: bool) -> &'static str {
|
||||
if !has_mutations {
|
||||
return "not_required";
|
||||
}
|
||||
match self.completion_policy {
|
||||
CompletionPolicy::AutoApply | CompletionPolicy::DocumentationOnly => "not_required",
|
||||
CompletionPolicy::ApprovalRequired | CompletionPolicy::DryRun => "pending",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validation_state(&self, has_mutations: bool) -> &'static str {
|
||||
if !has_mutations || !self.validation_required {
|
||||
return "not_required";
|
||||
}
|
||||
if self.validation_commands.is_empty()
|
||||
|| self
|
||||
.validation_commands
|
||||
.iter()
|
||||
.any(|command| command.name.trim().is_empty() || command.command.trim().is_empty())
|
||||
{
|
||||
"configuration_error"
|
||||
} else {
|
||||
"pending"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_publishable(
|
||||
&self,
|
||||
review_state: &str,
|
||||
validation_state: &str,
|
||||
publication_state: &str,
|
||||
) -> bool {
|
||||
self.completion_policy != CompletionPolicy::DryRun
|
||||
&& matches!(review_state, "approved" | "not_required")
|
||||
&& matches!(validation_state, "passed" | "not_required")
|
||||
&& matches!(publication_state, "candidate" | "ready")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -200,4 +272,49 @@ mod tests {
|
|||
assert_eq!(settings.build_command, Some("cargo build".to_string()));
|
||||
assert_eq!(settings.run_command, Some("cargo run".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_publication_settings_roundtrip() {
|
||||
let settings = ProjectSettings::new()
|
||||
.with_completion_policy(CompletionPolicy::AutoApply)
|
||||
.with_validation(
|
||||
true,
|
||||
vec![ValidationCommand {
|
||||
name: "build".to_string(),
|
||||
command: "cargo check".to_string(),
|
||||
}],
|
||||
);
|
||||
let encoded = serde_json::to_string(&settings).expect("settings serialize");
|
||||
let decoded: ProjectSettings =
|
||||
serde_json::from_str(&encoded).expect("settings deserialize");
|
||||
assert_eq!(decoded.completion_policy, CompletionPolicy::AutoApply);
|
||||
assert!(decoded.validation_required);
|
||||
assert_eq!(decoded.validation_commands[0].command, "cargo check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_validation_without_commands_is_configuration_error() {
|
||||
let settings = ProjectSettings::new().with_validation(true, Vec::new());
|
||||
assert_eq!(settings.validation_state(true), "configuration_error");
|
||||
assert!(!settings.is_publishable("not_required", "configuration_error", "candidate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_never_becomes_publishable() {
|
||||
let settings = ProjectSettings::new().with_completion_policy(CompletionPolicy::DryRun);
|
||||
assert_eq!(settings.review_state_for(true), "pending");
|
||||
assert!(!settings.is_publishable("approved", "not_required", "candidate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_required_validation_command_is_configuration_error() {
|
||||
let settings = ProjectSettings::new().with_validation(
|
||||
true,
|
||||
vec![ValidationCommand {
|
||||
name: "check".to_string(),
|
||||
command: " ".to_string(),
|
||||
}],
|
||||
);
|
||||
assert_eq!(settings.validation_state(true), "configuration_error");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
src/rollback.rs
Normal file
50
src/rollback.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Opaque reference to a pre-mutation snapshot owned by the workspace
|
||||
/// executor. Reef stores this reference as workflow metadata; it must never
|
||||
/// contain a host path or the snapshot bytes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RollbackReference {
|
||||
pub reference_id: Uuid,
|
||||
pub execution_id: Uuid,
|
||||
pub workspace_lease_id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl RollbackReference {
|
||||
pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
|
||||
self.expires_at <= now
|
||||
}
|
||||
|
||||
pub fn validates_execution(&self, execution_id: Uuid) -> bool {
|
||||
self.execution_id == execution_id && self.reference_id != Uuid::nil()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reference() -> RollbackReference {
|
||||
RollbackReference {
|
||||
reference_id: Uuid::new_v4(),
|
||||
execution_id: Uuid::new_v4(),
|
||||
workspace_lease_id: Uuid::new_v4(),
|
||||
workspace_id: Uuid::new_v4(),
|
||||
project_id: Uuid::new_v4(),
|
||||
expires_at: Utc::now() + chrono::Duration::minutes(1),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_is_opaque_and_execution_bound() {
|
||||
let reference = reference();
|
||||
assert!(reference.validates_execution(reference.execution_id));
|
||||
assert!(!reference.validates_execution(Uuid::new_v4()));
|
||||
assert!(!reference.is_expired(Utc::now()));
|
||||
}
|
||||
}
|
||||
81
src/sync.rs
81
src/sync.rs
|
|
@ -1,4 +1,48 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// The operation applied to one repository-relative regular file.
|
||||
///
|
||||
/// This is deliberately shared by Pod, Reservoir, Reef, and Dock. Git object
|
||||
/// IDs identify stored objects; the hashes on this protocol model identify
|
||||
/// file contents and are always SHA-256.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FileOperationKind {
|
||||
Create,
|
||||
Modify,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl FileOperationKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Create => "create",
|
||||
Self::Modify => "modify",
|
||||
Self::Delete => "delete",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One file in an immutable candidate changeset. Content is fetched from
|
||||
/// Reservoir at the candidate version instead of being embedded here.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChangeSetFile {
|
||||
pub path: String,
|
||||
pub operation: FileOperationKind,
|
||||
pub base_sha256: Option<String>,
|
||||
pub result_sha256: Option<String>,
|
||||
}
|
||||
|
||||
/// Metadata shared by candidate registration, review, and synchronization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChangeSetDescriptor {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub base_version: Option<String>,
|
||||
pub candidate_version: String,
|
||||
pub files: Vec<ChangeSetFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ConflictResolution {
|
||||
|
|
@ -31,6 +75,43 @@ impl ResolutionStrategy {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod changeset_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn changeset_file_uses_wire_operation_names() {
|
||||
let file = ChangeSetFile {
|
||||
path: "src/lib.rs".to_string(),
|
||||
operation: FileOperationKind::Modify,
|
||||
base_sha256: Some("base".to_string()),
|
||||
result_sha256: Some("result".to_string()),
|
||||
};
|
||||
let json = serde_json::to_value(file).expect("changeset file serializes");
|
||||
assert_eq!(json["operation"], "modify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changeset_descriptor_round_trips() {
|
||||
let descriptor = ChangeSetDescriptor {
|
||||
id: Uuid::new_v4(),
|
||||
project_id: Uuid::new_v4(),
|
||||
base_version: None,
|
||||
candidate_version: "candidate".to_string(),
|
||||
files: vec![ChangeSetFile {
|
||||
path: "new.txt".to_string(),
|
||||
operation: FileOperationKind::Create,
|
||||
base_sha256: None,
|
||||
result_sha256: Some("sha256".to_string()),
|
||||
}],
|
||||
};
|
||||
let encoded = serde_json::to_string(&descriptor).expect("descriptor serializes");
|
||||
let decoded: ChangeSetDescriptor =
|
||||
serde_json::from_str(&encoded).expect("descriptor deserializes");
|
||||
assert_eq!(decoded, descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileConflict {
|
||||
pub file: String,
|
||||
|
|
|
|||
303
src/tools.rs
303
src/tools.rs
|
|
@ -2,7 +2,7 @@ use async_trait::async_trait;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::principal::ProjectAction;
|
||||
use crate::{principal::ProjectAction, Units};
|
||||
|
||||
/// Authoritative execution ownership for every tool.
|
||||
/// Each tool declares exactly one execution location. Connector-krill uses this
|
||||
|
|
@ -10,19 +10,78 @@ use crate::principal::ProjectAction;
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolExecutionLocation {
|
||||
/// The tool has not been resolved against the canonical registry.
|
||||
/// This is intentionally the deserialization default: callers must reject
|
||||
/// it instead of silently executing a legacy or incomplete definition.
|
||||
Unknown,
|
||||
/// Tool executes exclusively on Reef (the authorization boundary).
|
||||
/// Connector must not locally execute after Reef rejection/failure.
|
||||
Reef,
|
||||
/// Tool executes on Quay inside the leased project workspace.
|
||||
Quay,
|
||||
/// Tool executes locally on the connector (e.g. web_search, web_fetch).
|
||||
Local,
|
||||
}
|
||||
|
||||
impl Default for ToolExecutionLocation {
|
||||
fn default() -> Self {
|
||||
Self::Local
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ToolDefinitionError {
|
||||
#[error("tool '{tool_id}' has an unknown execution location")]
|
||||
UnknownExecutionLocation { tool_id: String },
|
||||
#[error("sandbox tool '{tool_id}' must run on Quay")]
|
||||
SandboxToolMustRunOnQuay { tool_id: String },
|
||||
#[error("Quay tool '{tool_id}' must declare sandbox access")]
|
||||
QuayToolRequiresSandbox { tool_id: String },
|
||||
}
|
||||
|
||||
/// Validate the independent ownership and workspace axes of one canonical
|
||||
/// tool definition. Callers should run this over the complete registry at
|
||||
/// startup and fail closed if any definition is invalid.
|
||||
pub fn validate_tool_definition(tool: &ToolDefinition) -> Result<(), ToolDefinitionError> {
|
||||
if tool.execution_location == ToolExecutionLocation::Unknown {
|
||||
return Err(ToolDefinitionError::UnknownExecutionLocation {
|
||||
tool_id: tool.id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if tool.sandbox_access != SandboxAccess::None
|
||||
&& tool.execution_location != ToolExecutionLocation::Quay
|
||||
{
|
||||
return Err(ToolDefinitionError::SandboxToolMustRunOnQuay {
|
||||
tool_id: tool.id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if tool.execution_location == ToolExecutionLocation::Quay
|
||||
&& tool.sandbox_access == SandboxAccess::None
|
||||
{
|
||||
return Err(ToolDefinitionError::QuayToolRequiresSandbox {
|
||||
tool_id: tool.id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_tool_definitions(tools: &[ToolDefinition]) -> Result<(), ToolDefinitionError> {
|
||||
tools.iter().try_for_each(validate_tool_definition)
|
||||
}
|
||||
|
||||
/* Whether a tool needs a leased Quay workspace, and whether it may mutate it. */
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SandboxAccess {
|
||||
#[default]
|
||||
None,
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
/* Context in which a tool should be available */
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
|
@ -154,17 +213,11 @@ pub fn apply_verified_scope(
|
|||
}
|
||||
|
||||
if let Some(todo_id) = scope.todo_id {
|
||||
args.insert(
|
||||
"todo_id".into(),
|
||||
serde_json::json!(todo_id.to_string()),
|
||||
);
|
||||
args.insert("todo_id".into(), serde_json::json!(todo_id.to_string()));
|
||||
}
|
||||
|
||||
if let Some(pod_id) = scope.pod_id {
|
||||
args.insert(
|
||||
"pod_id".into(),
|
||||
serde_json::json!(pod_id.to_string()),
|
||||
);
|
||||
args.insert("pod_id".into(), serde_json::json!(pod_id.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -247,10 +300,7 @@ pub enum EffectiveProject {
|
|||
/// No project source supplied — valid for non-project-scoped tools.
|
||||
None,
|
||||
/// Two or more distinct project UUIDs were supplied and conflict.
|
||||
Conflicting {
|
||||
first: Uuid,
|
||||
second: Uuid,
|
||||
},
|
||||
Conflicting { first: Uuid, second: Uuid },
|
||||
}
|
||||
|
||||
/// Resolve a single effective project from the three main sources available
|
||||
|
|
@ -389,12 +439,16 @@ pub struct ToolDefinition {
|
|||
1+ = only visible to agents with max_depth >= this value. */
|
||||
#[serde(default)]
|
||||
pub required_depth: u32,
|
||||
/* Authoritative execution ownership. Every privileged or project-changing
|
||||
tool declares Reef; tools that execute only locally declare Local.
|
||||
Connector-krill must never locally execute a Reef-owned tool after
|
||||
any Reef failure (rejection, auth failure, timeout, connection error). */
|
||||
/* Authoritative execution ownership. Project workspace tools declare Quay;
|
||||
orchestration tools declare Reef; connector-global tools declare Local. */
|
||||
#[serde(default)]
|
||||
pub execution_location: ToolExecutionLocation,
|
||||
/// Whether this tool requires and may mutate a Quay workspace.
|
||||
#[serde(default)]
|
||||
pub sandbox_access: SandboxAccess,
|
||||
/// Trusted Quay admission weight for the canonical operation.
|
||||
#[serde(default)]
|
||||
pub compute_units: Units,
|
||||
}
|
||||
|
||||
const fn default_timeout_ms() -> u64 {
|
||||
|
|
@ -420,6 +474,8 @@ impl Default for ToolDefinition {
|
|||
context_visibility: ContextVisibility::default(),
|
||||
required_depth: 0,
|
||||
execution_location: ToolExecutionLocation::default(),
|
||||
sandbox_access: SandboxAccess::default(),
|
||||
compute_units: Units::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -974,6 +1030,8 @@ macro_rules! tool {
|
|||
context_visibility: ContextVisibility::default(),
|
||||
required_depth: 0,
|
||||
execution_location: ToolExecutionLocation::default(),
|
||||
sandbox_access: SandboxAccess::default(),
|
||||
compute_units: Units::ZERO,
|
||||
}
|
||||
};
|
||||
($name:expr, $desc:expr, $cat:expr, $dangerous:expr, $approval:expr, $scoped:expr, $params:expr, $timeout:expr) => {
|
||||
|
|
@ -994,6 +1052,8 @@ macro_rules! tool {
|
|||
context_visibility: ContextVisibility::default(),
|
||||
required_depth: 0,
|
||||
execution_location: ToolExecutionLocation::default(),
|
||||
sandbox_access: SandboxAccess::default(),
|
||||
compute_units: Units::ZERO,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1198,34 +1258,6 @@ pub const AGENT_ONLY_TOOLS: &[&str] = &[
|
|||
"kanban_remove_task",
|
||||
];
|
||||
|
||||
/* Tools that proxy to the Reef API when a `REEF_URL` / `REEF_API_URL` is
|
||||
configured. These tools have both a local implementation and a Reef-backed
|
||||
implementation (via Reservoir). When Reef is available the Reef path is
|
||||
preferred so that file operations go through the Reservoir-backed sandbox;
|
||||
when Reef is unreachable the local fallback executes instead.
|
||||
|
||||
This is the Krill → Reef proxy set, not the full set of Reef-only tools.
|
||||
Tools like kanban_*, document_*, and strategic items are always executed on
|
||||
Reef via MTP and are NOT in this list — they have no local implementation.
|
||||
|
||||
DEPRECATED: Use `ToolDefinition::execution_location` instead. This list is
|
||||
retained only for backward compatibility and will be removed in a future release. */
|
||||
#[deprecated(note = "Use ToolDefinition::execution_location instead")]
|
||||
pub const REEF_PROXY_TOOLS: &[&str] = &[
|
||||
"read_file",
|
||||
"file_read",
|
||||
"write_file",
|
||||
"file_write",
|
||||
"edit_file",
|
||||
"list_directory",
|
||||
"search_files",
|
||||
"grep",
|
||||
"bash",
|
||||
"execute",
|
||||
"delete_workspace",
|
||||
"list_files",
|
||||
];
|
||||
|
||||
/* Tools that operate without a project scope (global). These are always
|
||||
executed locally on the Krill and never need project_id injection. */
|
||||
pub const GLOBAL_TOOLS: &[&str] = &["web_search", "web_fetch", "web_api"];
|
||||
|
|
@ -1235,14 +1267,6 @@ pub fn is_global_tool(name: &str) -> bool {
|
|||
GLOBAL_TOOLS.contains(&name)
|
||||
}
|
||||
|
||||
/* Returns true if the tool name should proxy to Reef when Reef is available.
|
||||
|
||||
DEPRECATED: Use `ToolDefinition::execution_location` instead. */
|
||||
#[deprecated(note = "Use ToolDefinition::execution_location instead")]
|
||||
pub fn is_reef_proxy_tool(name: &str) -> bool {
|
||||
REEF_PROXY_TOOLS.contains(&name)
|
||||
}
|
||||
|
||||
/* Returns all built-in tool definitions across all categories. */
|
||||
pub fn tool_definitions() -> Vec<ToolDefinition> {
|
||||
let mut tools = vec![
|
||||
|
|
@ -2243,6 +2267,33 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
|||
per-tool metadata. A project-scoped tool without an action causes
|
||||
authorization to fail closed. */
|
||||
let action_map: std::collections::HashMap<&str, ProjectAction> = [
|
||||
// Project filesystem, execution, git and build tools. These must not
|
||||
// inherit the non-project Read fallback: the project membership check
|
||||
// is part of their security boundary.
|
||||
("read_file", ProjectAction::Read),
|
||||
("file_read", ProjectAction::Read),
|
||||
("list_directory", ProjectAction::Read),
|
||||
("search_files", ProjectAction::Read),
|
||||
("grep", ProjectAction::Read),
|
||||
("list_files", ProjectAction::Read),
|
||||
("git_status", ProjectAction::Read),
|
||||
("git_diff", ProjectAction::Read),
|
||||
("git_log", ProjectAction::Read),
|
||||
("git_branch", ProjectAction::Read),
|
||||
("workspace_info", ProjectAction::Read),
|
||||
("write_file", ProjectAction::ModifyFiles),
|
||||
("file_write", ProjectAction::ModifyFiles),
|
||||
("edit_file", ProjectAction::ModifyFiles),
|
||||
("bash", ProjectAction::ModifyFiles),
|
||||
("execute", ProjectAction::ModifyFiles),
|
||||
("delete_workspace", ProjectAction::ModifyFiles),
|
||||
("cargo_check", ProjectAction::ModifyFiles),
|
||||
("npm_build", ProjectAction::ModifyFiles),
|
||||
("python_check", ProjectAction::ModifyFiles),
|
||||
("test_runner", ProjectAction::ModifyFiles),
|
||||
("create_workspace", ProjectAction::ModifyFiles),
|
||||
("create_venv", ProjectAction::ModifyFiles),
|
||||
("install_dependencies", ProjectAction::ModifyFiles),
|
||||
// Kanban read
|
||||
("kanban_list_board", ProjectAction::Read),
|
||||
// Kanban todo mutations
|
||||
|
|
@ -2270,7 +2321,6 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
|||
// Documentation write
|
||||
("store_file_doc", ProjectAction::ModifyFiles),
|
||||
// File tools
|
||||
("list_files", ProjectAction::Read),
|
||||
// Strategic tools (read-only analysis)
|
||||
("propose_strategic_item", ProjectAction::CreateTodo),
|
||||
("split_task", ProjectAction::ModifyFiles),
|
||||
|
|
@ -2286,6 +2336,16 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
|||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Keep the scope decision beside the action decision. A tool with a
|
||||
// project action is necessarily project-scoped; this prevents a newly
|
||||
// added filesystem/build tool from accidentally being authorized as a
|
||||
// global read operation.
|
||||
for tool in tools.iter_mut() {
|
||||
if action_map.contains_key(tool.id.as_str()) {
|
||||
tool.project_scoped = true;
|
||||
}
|
||||
}
|
||||
|
||||
for tool in tools.iter_mut() {
|
||||
if tool.project_scoped {
|
||||
if let Some(&action) = action_map.get(tool.id.as_str()) {
|
||||
|
|
@ -2372,11 +2432,9 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
|||
}
|
||||
}
|
||||
|
||||
/* Assign execution_location for every tool.
|
||||
Reef-owned tools must never fall back to local execution. This metadata
|
||||
replaces the old REEF_PROXY_TOOLS list and name-prefix routing. */
|
||||
let reef_tools: std::collections::HashSet<&str> = [
|
||||
// Filesystem (Reef-backed via Reservoir)
|
||||
/* Assign execution ownership and the independent workspace/capacity
|
||||
metadata for every canonical tool. */
|
||||
let quay_tools: std::collections::HashSet<&str> = [
|
||||
"read_file",
|
||||
"file_read",
|
||||
"write_file",
|
||||
|
|
@ -2386,25 +2444,27 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
|||
"search_files",
|
||||
"grep",
|
||||
"list_files",
|
||||
// Execution (Reef-backed)
|
||||
"bash",
|
||||
"execute",
|
||||
"delete_workspace",
|
||||
// Git (Reef-backed)
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"git_log",
|
||||
"git_branch",
|
||||
// Build (Reef-backed)
|
||||
"cargo_check",
|
||||
"npm_build",
|
||||
"python_check",
|
||||
"test_runner",
|
||||
// Workspace (Reef-backed)
|
||||
"create_workspace",
|
||||
"delete_workspace",
|
||||
"create_venv",
|
||||
"install_dependencies",
|
||||
"workspace_info",
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let reef_tools: std::collections::HashSet<&str> = [
|
||||
// Kanban (always Reef — no valid local fallback)
|
||||
"kanban_list_board",
|
||||
"kanban_create_todo",
|
||||
|
|
@ -2446,13 +2506,34 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
|||
.collect();
|
||||
|
||||
for tool in tools.iter_mut() {
|
||||
if reef_tools.contains(tool.id.as_str()) {
|
||||
if quay_tools.contains(tool.id.as_str()) {
|
||||
tool.execution_location = ToolExecutionLocation::Quay;
|
||||
tool.sandbox_access = match tool.id.as_str() {
|
||||
"read_file" | "file_read" | "list_directory" | "search_files" | "grep"
|
||||
| "list_files" | "git_status" | "git_diff" | "git_log" | "git_branch"
|
||||
| "workspace_info" => SandboxAccess::ReadOnly,
|
||||
_ => SandboxAccess::ReadWrite,
|
||||
};
|
||||
// The cost is trusted registry metadata, never a model argument.
|
||||
tool.compute_units = match tool.id.as_str() {
|
||||
"cargo_check" | "npm_build" | "python_check" | "test_runner" => Units::new(2),
|
||||
_ => Units::new(1),
|
||||
};
|
||||
} else if reef_tools.contains(tool.id.as_str()) {
|
||||
tool.execution_location = ToolExecutionLocation::Reef;
|
||||
tool.sandbox_access = SandboxAccess::None;
|
||||
tool.compute_units = Units::ZERO;
|
||||
} else {
|
||||
tool.execution_location = ToolExecutionLocation::Local;
|
||||
tool.sandbox_access = SandboxAccess::None;
|
||||
tool.compute_units = Units::ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(error) = validate_tool_definitions(&tools) {
|
||||
panic!("invalid canonical tool definition: {error}");
|
||||
}
|
||||
|
||||
tools
|
||||
}
|
||||
|
||||
|
|
@ -3334,10 +3415,11 @@ Done with tools.
|
|||
fn every_tool_has_declared_execution_location() {
|
||||
let tools = tool_definitions();
|
||||
for tool in &tools {
|
||||
// Every tool must have either Reef or Local — the enum guarantees this,
|
||||
// but we verify the field is present and meaningful.
|
||||
// Unknown is reserved for incomplete/deserialized definitions and
|
||||
// must never be present in the canonical registry.
|
||||
assert!(
|
||||
tool.execution_location == ToolExecutionLocation::Reef
|
||||
|| tool.execution_location == ToolExecutionLocation::Quay
|
||||
|| tool.execution_location == ToolExecutionLocation::Local,
|
||||
"tool '{}' has unclassified execution_location",
|
||||
tool.id
|
||||
|
|
@ -3346,18 +3428,18 @@ Done with tools.
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn reef_owned_mutation_tools_are_classified() {
|
||||
fn quay_owned_workspace_tools_are_classified() {
|
||||
let tools = tool_definitions();
|
||||
let reef_tools: Vec<&str> = tools
|
||||
let quay_tools: Vec<&str> = tools
|
||||
.iter()
|
||||
.filter(|t| t.execution_location == ToolExecutionLocation::Reef)
|
||||
.filter(|t| t.execution_location == ToolExecutionLocation::Quay)
|
||||
.map(|t| t.id.as_str())
|
||||
.collect();
|
||||
// Privileged mutation tools must be Reef-owned
|
||||
// Workspace mutation tools must be Quay-owned.
|
||||
for name in &["write_file", "edit_file", "bash", "delete_workspace"] {
|
||||
assert!(
|
||||
reef_tools.contains(name),
|
||||
"privileged tool '{}' must be Reef-owned",
|
||||
quay_tools.contains(name),
|
||||
"workspace tool '{}' must be Quay-owned",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
|
@ -3377,6 +3459,33 @@ Done with tools.
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_execution_tools_have_explicit_actions() {
|
||||
let tools = tool_definitions();
|
||||
for name in [
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"bash",
|
||||
"git_status",
|
||||
"cargo_check",
|
||||
"npm_build",
|
||||
"python_check",
|
||||
"test_runner",
|
||||
"create_workspace",
|
||||
"create_venv",
|
||||
"install_dependencies",
|
||||
"workspace_info",
|
||||
] {
|
||||
let tool = tools.iter().find(|tool| tool.id == name).unwrap();
|
||||
assert!(tool.project_scoped, "{name} must be project-scoped");
|
||||
assert!(
|
||||
tool.required_project_action.is_some(),
|
||||
"{name} must declare a project action"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tool_is_both_reef_and_local() {
|
||||
let tools = tool_definitions();
|
||||
|
|
@ -3385,6 +3494,7 @@ Done with tools.
|
|||
// the field is consistently set.
|
||||
assert!(
|
||||
tool.execution_location == ToolExecutionLocation::Reef
|
||||
|| tool.execution_location == ToolExecutionLocation::Quay
|
||||
|| tool.execution_location == ToolExecutionLocation::Local,
|
||||
"tool '{}' has invalid execution_location",
|
||||
tool.id
|
||||
|
|
@ -3437,6 +3547,36 @@ Done with tools.
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_tool_sandbox_metadata_is_valid() {
|
||||
let tools = tool_definitions();
|
||||
validate_tool_definitions(&tools).expect("canonical tool metadata must be valid");
|
||||
|
||||
for tool in tools {
|
||||
if tool.execution_location == ToolExecutionLocation::Quay {
|
||||
assert_ne!(tool.sandbox_access, SandboxAccess::None);
|
||||
assert!(tool.compute_units > Units::ZERO);
|
||||
} else {
|
||||
assert_eq!(tool.sandbox_access, SandboxAccess::None);
|
||||
assert_eq!(tool.compute_units, Units::ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_validation_rejects_wrong_owner() {
|
||||
let tool = ToolDefinition {
|
||||
id: "unsafe_reef_tool".to_string(),
|
||||
execution_location: ToolExecutionLocation::Reef,
|
||||
sandbox_access: SandboxAccess::ReadOnly,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_tool_definition(&tool),
|
||||
Err(ToolDefinitionError::SandboxToolMustRunOnQuay { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_verified_scope_normalizes_agent_run_id() {
|
||||
let run_id = Uuid::new_v4();
|
||||
|
|
@ -3446,14 +3586,8 @@ Done with tools.
|
|||
};
|
||||
let mut args = serde_json::Map::new();
|
||||
// Client supplies a conflicting agent_run_id
|
||||
args.insert(
|
||||
"agent_run_id".into(),
|
||||
serde_json::json!("spoofed-value"),
|
||||
);
|
||||
args.insert(
|
||||
"run_id".into(),
|
||||
serde_json::json!("spoofed-run"),
|
||||
);
|
||||
args.insert("agent_run_id".into(), serde_json::json!("spoofed-value"));
|
||||
args.insert("run_id".into(), serde_json::json!("spoofed-run"));
|
||||
|
||||
apply_verified_scope(&mut args, &scope);
|
||||
|
||||
|
|
@ -3476,10 +3610,7 @@ Done with tools.
|
|||
..Default::default()
|
||||
};
|
||||
let mut args = serde_json::Map::new();
|
||||
args.insert(
|
||||
"project_id".into(),
|
||||
serde_json::json!("spoofed-project"),
|
||||
);
|
||||
args.insert("project_id".into(), serde_json::json!("spoofed-project"));
|
||||
|
||||
apply_verified_scope(&mut args, &scope);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue