Compare commits
11 changed files with 101 additions and 1120 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1724,7 +1724,6 @@ dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"chrono",
|
"chrono",
|
||||||
"ed25519-dalek",
|
|
||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
"mtp",
|
"mtp",
|
||||||
"regex",
|
"regex",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ mtp = { git = "https://git@git.methanium.net/methanium/mtp.git", features = [
|
||||||
] }
|
] }
|
||||||
tokio = "1"
|
tokio = "1"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
ed25519-dalek = "2"
|
|
||||||
url = "2"
|
url = "2"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
|
|
|
||||||
219
src/compute.rs
219
src/compute.rs
|
|
@ -1,219 +0,0 @@
|
||||||
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,25 +1,6 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ToDoMode {
|
pub enum ToDoMode {
|
||||||
|
|
|
||||||
48
src/krill.rs
48
src/krill.rs
|
|
@ -2,7 +2,6 @@ use chrono::{DateTime, Utc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::enums::ModelType;
|
use crate::enums::ModelType;
|
||||||
use crate::Units;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub enum PodProtocolVersion {
|
pub enum PodProtocolVersion {
|
||||||
|
|
@ -17,40 +16,6 @@ impl Default for PodProtocolVersion {
|
||||||
|
|
||||||
pub type KrillId = Uuid;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct KrillDescriptor {
|
pub struct KrillDescriptor {
|
||||||
pub id: KrillId,
|
pub id: KrillId,
|
||||||
|
|
@ -59,7 +24,6 @@ pub struct KrillDescriptor {
|
||||||
pub model_type: ModelType,
|
pub model_type: ModelType,
|
||||||
pub model_path: Option<String>,
|
pub model_path: Option<String>,
|
||||||
pub config: KrillConfig,
|
pub config: KrillConfig,
|
||||||
pub runtime_profile: KrillRuntimeProfile,
|
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +37,6 @@ impl Default for KrillDescriptor {
|
||||||
model_type: ModelType::Llm,
|
model_type: ModelType::Llm,
|
||||||
model_path: None,
|
model_path: None,
|
||||||
config: KrillConfig::default(),
|
config: KrillConfig::default(),
|
||||||
runtime_profile: KrillRuntimeProfile::default(),
|
|
||||||
version: "0.1.0".to_string(),
|
version: "0.1.0".to_string(),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +52,6 @@ impl KrillDescriptor {
|
||||||
model_type,
|
model_type,
|
||||||
model_path: None,
|
model_path: None,
|
||||||
config: KrillConfig::default(),
|
config: KrillConfig::default(),
|
||||||
runtime_profile: KrillRuntimeProfile::default(),
|
|
||||||
version: "0.1.0".to_string(),
|
version: "0.1.0".to_string(),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
}
|
}
|
||||||
|
|
@ -110,11 +72,6 @@ impl KrillDescriptor {
|
||||||
self
|
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 {
|
pub fn with_version(mut self, version: String) -> Self {
|
||||||
self.version = version;
|
self.version = version;
|
||||||
self
|
self
|
||||||
|
|
@ -186,9 +143,4 @@ mod tests {
|
||||||
assert_eq!(config.get("temperature").unwrap().as_f64(), Some(0.7));
|
assert_eq!(config.get("temperature").unwrap().as_f64(), Some(0.7));
|
||||||
assert_eq!(config.get("max_tokens").unwrap().as_u64(), Some(2048));
|
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,7 +5,6 @@ pub use uuid::Uuid;
|
||||||
|
|
||||||
pub mod agents;
|
pub mod agents;
|
||||||
pub mod ai_response;
|
pub mod ai_response;
|
||||||
pub mod compute;
|
|
||||||
pub mod conclusion;
|
pub mod conclusion;
|
||||||
pub mod coral;
|
pub mod coral;
|
||||||
pub mod data_retention;
|
pub mod data_retention;
|
||||||
|
|
@ -15,7 +14,6 @@ pub mod krill;
|
||||||
pub mod mutations;
|
pub mod mutations;
|
||||||
pub mod principal;
|
pub mod principal;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod rollback;
|
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
|
|
@ -26,10 +24,6 @@ pub use agents::{
|
||||||
AgentPolicy, AgentPromptConfig, AgentType, ApprovalMode, PlannedAction, SimulationResult,
|
AgentPolicy, AgentPromptConfig, AgentType, ApprovalMode, PlannedAction, SimulationResult,
|
||||||
};
|
};
|
||||||
pub use ai_response::{Conversation, Message};
|
pub use ai_response::{Conversation, Message};
|
||||||
pub use compute::{
|
|
||||||
CapacitySnapshot, CapacitySnapshotError, ComputeRequirement, PodCapacityConfig,
|
|
||||||
PodHardwareSnapshot, QuayCapacityConfig, QuayHardwareSnapshot, Units,
|
|
||||||
};
|
|
||||||
pub use conclusion::{
|
pub use conclusion::{
|
||||||
Artifact, ArtifactType, ConclusionCard, ConclusionMetrics, ConclusionTrigger,
|
Artifact, ArtifactType, ConclusionCard, ConclusionMetrics, ConclusionTrigger,
|
||||||
};
|
};
|
||||||
|
|
@ -39,35 +33,29 @@ pub use data_retention::{
|
||||||
};
|
};
|
||||||
pub use enums::*;
|
pub use enums::*;
|
||||||
pub use errors::{AuditOutcome, AuditRecord, FieldError, PublicError, ShoalError, ValidationError};
|
pub use errors::{AuditOutcome, AuditRecord, FieldError, PublicError, ShoalError, ValidationError};
|
||||||
pub use krill::{KrillConfig, KrillDescriptor, KrillId, KrillRuntimeProfile, PodProtocolVersion};
|
pub use krill::{KrillConfig, KrillDescriptor, KrillId, PodProtocolVersion};
|
||||||
pub use mutations::{
|
pub use mutations::{
|
||||||
merged_affected_files, normalize_repository_path, FileMutation, PathValidationError,
|
merged_affected_files, normalize_repository_path, FileMutation, PathValidationError,
|
||||||
};
|
};
|
||||||
pub use principal::{
|
pub use principal::{
|
||||||
sign_quay_execution_credential, sign_service_credential, verify_quay_execution_credential,
|
AuthError, AuthenticatedPrincipal, AuthorizationService, DispatchAuthError,
|
||||||
verify_service_credential, AuthError, AuthenticatedPrincipal, AuthorizationService,
|
DiscussionCredentialClaims, DiscussionExecutionPrincipal, ExecutionPrincipal, GlobalRole,
|
||||||
DiscussionCredentialClaims, DiscussionExecutionPrincipal, DispatchAuthError,
|
ModelGatewayClaims, ModelGatewayPrincipal, PodPrincipal, ProjectAction, ProjectRole,
|
||||||
ExecutionPrincipal, GlobalRole, ModelGatewayClaims, ModelGatewayPrincipal, PodPrincipal,
|
RequestContext, ReservoirConnectionContext, ReservoirPrincipal, ReservoirServiceRole,
|
||||||
ProjectAction, ProjectRole, QuayExecutionClaims, RequestContext, ReservoirConnectionContext,
|
TokenValidationResult,
|
||||||
ReservoirPrincipal, ReservoirServiceRole, ServiceCredentialClaims, ServiceCredentialError,
|
|
||||||
TokenValidationResult, QUAY_EXECUTION_AUDIENCE,
|
|
||||||
};
|
};
|
||||||
pub use project::{Project, ProjectFile, ProjectId, ProjectSettings, ValidationCommand};
|
pub use project::{Project, ProjectFile, ProjectId, ProjectSettings};
|
||||||
pub use rollback::RollbackReference;
|
|
||||||
pub use sync::{ChangeSetDescriptor, ChangeSetFile, FileOperationKind};
|
|
||||||
pub use task::{Task, TaskResult};
|
pub use task::{Task, TaskResult};
|
||||||
pub use todo::{Dependency, ToDo};
|
pub use todo::{Dependency, ToDo};
|
||||||
pub use tools::{
|
pub use tools::{
|
||||||
apply_verified_scope, compute_tools_hash, format_tool_error, format_tool_result,
|
compute_tools_hash, format_tool_error, format_tool_result, format_tools_json, is_global_tool,
|
||||||
format_tools_json, is_global_tool, normalize_tool_call, parse_tool_call_blocks,
|
is_reef_proxy_tool, parse_tool_call_blocks, parse_tool_call_stream, tool_definitions,
|
||||||
parse_tool_call_stream, resolve_effective_project, tool_definitions, tool_ids_for_agent_type,
|
tool_ids_for_agent_type, apply_verified_scope, normalize_tool_call, resolve_effective_project,
|
||||||
validate_tool_definition, validate_tool_definitions, ApprovalRequirement, CompositionStep,
|
ApprovalRequirement, CompositionStep, ContextVisibility, EffectiveProject, ExecutionContext,
|
||||||
ContextVisibility, EffectiveProject, ExecutionContext, MarketplaceSearchResults,
|
MarketplaceSearchResults, ParsedToolCall, TestAction, ToolComposition, ToolDefinition,
|
||||||
ParsedToolCall, SandboxAccess, TestAction, ToolComposition, ToolDefinition,
|
ToolDependency, ToolDocumentation, ToolExample, ToolExecutor, ToolListing, ToolParser,
|
||||||
ToolDefinitionError, ToolDependency, ToolDocumentation, ToolExample, ToolExecutionLocation,
|
ToolPlugin, ToolRegistry, ToolState, ToolTest, ToolTestResult, ToolTestSuite,
|
||||||
ToolExecutor, ToolListing, ToolParser, ToolPlugin, ToolRegistry, ToolState, ToolTest,
|
ToolTestSuiteResult, ToolVersion, VerifiedToolScope, GLOBAL_TOOLS, REEF_PROXY_TOOLS,
|
||||||
ToolTestResult, ToolTestSuite, ToolTestSuiteResult, ToolVersion, VerifiedToolScope,
|
|
||||||
GLOBAL_TOOLS,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
340
src/principal.rs
340
src/principal.rs
|
|
@ -190,14 +190,6 @@ pub enum ReservoirPrincipal {
|
||||||
service_id: Uuid,
|
service_id: Uuid,
|
||||||
role: ReservoirServiceRole,
|
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
|
/// Explicit infrastructure roles accepted by Reservoir. Adding a new service
|
||||||
|
|
@ -206,16 +198,6 @@ pub enum ReservoirPrincipal {
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ReservoirServiceRole {
|
pub enum ReservoirServiceRole {
|
||||||
ReefStorage,
|
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.
|
/// Authoritative result returned when Reef validates a user session token.
|
||||||
|
|
@ -391,20 +373,6 @@ pub enum CredentialError {
|
||||||
AttemptInactive,
|
AttemptInactive,
|
||||||
/// The requested tool is not in the credential's allow-list.
|
/// The requested tool is not in the credential's allow-list.
|
||||||
ToolNotAllowed,
|
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 {
|
impl std::fmt::Display for CredentialError {
|
||||||
|
|
@ -417,241 +385,12 @@ impl std::fmt::Display for CredentialError {
|
||||||
Self::PodMismatch => write!(f, "credential not valid for this pod"),
|
Self::PodMismatch => write!(f, "credential not valid for this pod"),
|
||||||
Self::AttemptInactive => write!(f, "attempt inactive"),
|
Self::AttemptInactive => write!(f, "attempt inactive"),
|
||||||
Self::ToolNotAllowed => write!(f, "tool not in credential allow-list"),
|
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 {}
|
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.
|
/// The verifiable claims inside a signed execution credential.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CredentialClaims {
|
pub struct CredentialClaims {
|
||||||
|
|
@ -1781,83 +1520,4 @@ mod tests {
|
||||||
let claims = verify_model_gateway_signature(&credential, &secret).unwrap();
|
let claims = verify_model_gateway_signature(&credential, &secret).unwrap();
|
||||||
assert!(claims.allowed_models.is_empty());
|
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,8 +2,6 @@ use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::enums::CompletionPolicy;
|
|
||||||
|
|
||||||
pub type ProjectId = Uuid;
|
pub type ProjectId = Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -114,25 +112,10 @@ pub struct ProjectSettings {
|
||||||
pub run_command: Option<String>,
|
pub run_command: Option<String>,
|
||||||
/// Conventional subject/body template used for generated git commits.
|
/// Conventional subject/body template used for generated git commits.
|
||||||
pub git_commit_template: Option<String>,
|
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)]
|
#[serde(flatten)]
|
||||||
pub extra: serde_json::Value,
|
pub extra: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct ValidationCommand {
|
|
||||||
pub name: String,
|
|
||||||
pub command: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProjectSettings {
|
impl ProjectSettings {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -140,9 +123,6 @@ impl ProjectSettings {
|
||||||
build_command: None,
|
build_command: None,
|
||||||
run_command: None,
|
run_command: None,
|
||||||
git_commit_template: None,
|
git_commit_template: None,
|
||||||
completion_policy: CompletionPolicy::default(),
|
|
||||||
validation_required: false,
|
|
||||||
validation_commands: Vec::new(),
|
|
||||||
extra: serde_json::json!({}),
|
extra: serde_json::json!({}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -167,63 +147,11 @@ impl ProjectSettings {
|
||||||
self
|
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) {
|
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() {
|
if let Some(obj) = self.extra.as_object_mut() {
|
||||||
obj.insert(key.to_string(), value);
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -272,49 +200,4 @@ mod tests {
|
||||||
assert_eq!(settings.build_command, Some("cargo build".to_string()));
|
assert_eq!(settings.build_command, Some("cargo build".to_string()));
|
||||||
assert_eq!(settings.run_command, Some("cargo run".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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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,48 +1,4 @@
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub enum ConflictResolution {
|
pub enum ConflictResolution {
|
||||||
|
|
@ -75,43 +31,6 @@ 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FileConflict {
|
pub struct FileConflict {
|
||||||
pub file: String,
|
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 serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{principal::ProjectAction, Units};
|
use crate::principal::ProjectAction;
|
||||||
|
|
||||||
/// Authoritative execution ownership for every tool.
|
/// Authoritative execution ownership for every tool.
|
||||||
/// Each tool declares exactly one execution location. Connector-krill uses this
|
/// Each tool declares exactly one execution location. Connector-krill uses this
|
||||||
|
|
@ -10,78 +10,19 @@ use crate::{principal::ProjectAction, Units};
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ToolExecutionLocation {
|
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).
|
/// Tool executes exclusively on Reef (the authorization boundary).
|
||||||
/// Connector must not locally execute after Reef rejection/failure.
|
/// Connector must not locally execute after Reef rejection/failure.
|
||||||
Reef,
|
Reef,
|
||||||
/// Tool executes on Quay inside the leased project workspace.
|
|
||||||
Quay,
|
|
||||||
/// Tool executes locally on the connector (e.g. web_search, web_fetch).
|
/// Tool executes locally on the connector (e.g. web_search, web_fetch).
|
||||||
Local,
|
Local,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ToolExecutionLocation {
|
impl Default for ToolExecutionLocation {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Unknown
|
Self::Local
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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 */
|
/* Context in which a tool should be available */
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
|
@ -213,11 +154,17 @@ pub fn apply_verified_scope(
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(todo_id) = scope.todo_id {
|
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 {
|
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()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,7 +247,10 @@ pub enum EffectiveProject {
|
||||||
/// No project source supplied — valid for non-project-scoped tools.
|
/// No project source supplied — valid for non-project-scoped tools.
|
||||||
None,
|
None,
|
||||||
/// Two or more distinct project UUIDs were supplied and conflict.
|
/// 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
|
/// Resolve a single effective project from the three main sources available
|
||||||
|
|
@ -439,16 +389,12 @@ pub struct ToolDefinition {
|
||||||
1+ = only visible to agents with max_depth >= this value. */
|
1+ = only visible to agents with max_depth >= this value. */
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub required_depth: u32,
|
pub required_depth: u32,
|
||||||
/* Authoritative execution ownership. Project workspace tools declare Quay;
|
/* Authoritative execution ownership. Every privileged or project-changing
|
||||||
orchestration tools declare Reef; connector-global tools declare Local. */
|
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). */
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub execution_location: ToolExecutionLocation,
|
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 {
|
const fn default_timeout_ms() -> u64 {
|
||||||
|
|
@ -474,8 +420,6 @@ impl Default for ToolDefinition {
|
||||||
context_visibility: ContextVisibility::default(),
|
context_visibility: ContextVisibility::default(),
|
||||||
required_depth: 0,
|
required_depth: 0,
|
||||||
execution_location: ToolExecutionLocation::default(),
|
execution_location: ToolExecutionLocation::default(),
|
||||||
sandbox_access: SandboxAccess::default(),
|
|
||||||
compute_units: Units::ZERO,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1030,8 +974,6 @@ macro_rules! tool {
|
||||||
context_visibility: ContextVisibility::default(),
|
context_visibility: ContextVisibility::default(),
|
||||||
required_depth: 0,
|
required_depth: 0,
|
||||||
execution_location: ToolExecutionLocation::default(),
|
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) => {
|
($name:expr, $desc:expr, $cat:expr, $dangerous:expr, $approval:expr, $scoped:expr, $params:expr, $timeout:expr) => {
|
||||||
|
|
@ -1052,8 +994,6 @@ macro_rules! tool {
|
||||||
context_visibility: ContextVisibility::default(),
|
context_visibility: ContextVisibility::default(),
|
||||||
required_depth: 0,
|
required_depth: 0,
|
||||||
execution_location: ToolExecutionLocation::default(),
|
execution_location: ToolExecutionLocation::default(),
|
||||||
sandbox_access: SandboxAccess::default(),
|
|
||||||
compute_units: Units::ZERO,
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1258,6 +1198,34 @@ pub const AGENT_ONLY_TOOLS: &[&str] = &[
|
||||||
"kanban_remove_task",
|
"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
|
/* Tools that operate without a project scope (global). These are always
|
||||||
executed locally on the Krill and never need project_id injection. */
|
executed locally on the Krill and never need project_id injection. */
|
||||||
pub const GLOBAL_TOOLS: &[&str] = &["web_search", "web_fetch", "web_api"];
|
pub const GLOBAL_TOOLS: &[&str] = &["web_search", "web_fetch", "web_api"];
|
||||||
|
|
@ -1267,6 +1235,14 @@ pub fn is_global_tool(name: &str) -> bool {
|
||||||
GLOBAL_TOOLS.contains(&name)
|
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. */
|
/* Returns all built-in tool definitions across all categories. */
|
||||||
pub fn tool_definitions() -> Vec<ToolDefinition> {
|
pub fn tool_definitions() -> Vec<ToolDefinition> {
|
||||||
let mut tools = vec![
|
let mut tools = vec![
|
||||||
|
|
@ -2267,33 +2243,6 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
||||||
per-tool metadata. A project-scoped tool without an action causes
|
per-tool metadata. A project-scoped tool without an action causes
|
||||||
authorization to fail closed. */
|
authorization to fail closed. */
|
||||||
let action_map: std::collections::HashMap<&str, ProjectAction> = [
|
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 read
|
||||||
("kanban_list_board", ProjectAction::Read),
|
("kanban_list_board", ProjectAction::Read),
|
||||||
// Kanban todo mutations
|
// Kanban todo mutations
|
||||||
|
|
@ -2321,6 +2270,7 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
||||||
// Documentation write
|
// Documentation write
|
||||||
("store_file_doc", ProjectAction::ModifyFiles),
|
("store_file_doc", ProjectAction::ModifyFiles),
|
||||||
// File tools
|
// File tools
|
||||||
|
("list_files", ProjectAction::Read),
|
||||||
// Strategic tools (read-only analysis)
|
// Strategic tools (read-only analysis)
|
||||||
("propose_strategic_item", ProjectAction::CreateTodo),
|
("propose_strategic_item", ProjectAction::CreateTodo),
|
||||||
("split_task", ProjectAction::ModifyFiles),
|
("split_task", ProjectAction::ModifyFiles),
|
||||||
|
|
@ -2336,16 +2286,6 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.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() {
|
for tool in tools.iter_mut() {
|
||||||
if tool.project_scoped {
|
if tool.project_scoped {
|
||||||
if let Some(&action) = action_map.get(tool.id.as_str()) {
|
if let Some(&action) = action_map.get(tool.id.as_str()) {
|
||||||
|
|
@ -2432,9 +2372,11 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Assign execution ownership and the independent workspace/capacity
|
/* Assign execution_location for every tool.
|
||||||
metadata for every canonical tool. */
|
Reef-owned tools must never fall back to local execution. This metadata
|
||||||
let quay_tools: std::collections::HashSet<&str> = [
|
replaces the old REEF_PROXY_TOOLS list and name-prefix routing. */
|
||||||
|
let reef_tools: std::collections::HashSet<&str> = [
|
||||||
|
// Filesystem (Reef-backed via Reservoir)
|
||||||
"read_file",
|
"read_file",
|
||||||
"file_read",
|
"file_read",
|
||||||
"write_file",
|
"write_file",
|
||||||
|
|
@ -2444,27 +2386,25 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
||||||
"search_files",
|
"search_files",
|
||||||
"grep",
|
"grep",
|
||||||
"list_files",
|
"list_files",
|
||||||
|
// Execution (Reef-backed)
|
||||||
"bash",
|
"bash",
|
||||||
"execute",
|
"execute",
|
||||||
|
"delete_workspace",
|
||||||
|
// Git (Reef-backed)
|
||||||
"git_status",
|
"git_status",
|
||||||
"git_diff",
|
"git_diff",
|
||||||
"git_log",
|
"git_log",
|
||||||
"git_branch",
|
"git_branch",
|
||||||
|
// Build (Reef-backed)
|
||||||
"cargo_check",
|
"cargo_check",
|
||||||
"npm_build",
|
"npm_build",
|
||||||
"python_check",
|
"python_check",
|
||||||
"test_runner",
|
"test_runner",
|
||||||
|
// Workspace (Reef-backed)
|
||||||
"create_workspace",
|
"create_workspace",
|
||||||
"delete_workspace",
|
|
||||||
"create_venv",
|
"create_venv",
|
||||||
"install_dependencies",
|
"install_dependencies",
|
||||||
"workspace_info",
|
"workspace_info",
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let reef_tools: std::collections::HashSet<&str> = [
|
|
||||||
// Kanban (always Reef — no valid local fallback)
|
// Kanban (always Reef — no valid local fallback)
|
||||||
"kanban_list_board",
|
"kanban_list_board",
|
||||||
"kanban_create_todo",
|
"kanban_create_todo",
|
||||||
|
|
@ -2506,34 +2446,13 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for tool in tools.iter_mut() {
|
for tool in tools.iter_mut() {
|
||||||
if quay_tools.contains(tool.id.as_str()) {
|
if reef_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.execution_location = ToolExecutionLocation::Reef;
|
||||||
tool.sandbox_access = SandboxAccess::None;
|
|
||||||
tool.compute_units = Units::ZERO;
|
|
||||||
} else {
|
} else {
|
||||||
tool.execution_location = ToolExecutionLocation::Local;
|
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
|
tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3415,11 +3334,10 @@ Done with tools.
|
||||||
fn every_tool_has_declared_execution_location() {
|
fn every_tool_has_declared_execution_location() {
|
||||||
let tools = tool_definitions();
|
let tools = tool_definitions();
|
||||||
for tool in &tools {
|
for tool in &tools {
|
||||||
// Unknown is reserved for incomplete/deserialized definitions and
|
// Every tool must have either Reef or Local — the enum guarantees this,
|
||||||
// must never be present in the canonical registry.
|
// but we verify the field is present and meaningful.
|
||||||
assert!(
|
assert!(
|
||||||
tool.execution_location == ToolExecutionLocation::Reef
|
tool.execution_location == ToolExecutionLocation::Reef
|
||||||
|| tool.execution_location == ToolExecutionLocation::Quay
|
|
||||||
|| tool.execution_location == ToolExecutionLocation::Local,
|
|| tool.execution_location == ToolExecutionLocation::Local,
|
||||||
"tool '{}' has unclassified execution_location",
|
"tool '{}' has unclassified execution_location",
|
||||||
tool.id
|
tool.id
|
||||||
|
|
@ -3428,18 +3346,18 @@ Done with tools.
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quay_owned_workspace_tools_are_classified() {
|
fn reef_owned_mutation_tools_are_classified() {
|
||||||
let tools = tool_definitions();
|
let tools = tool_definitions();
|
||||||
let quay_tools: Vec<&str> = tools
|
let reef_tools: Vec<&str> = tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|t| t.execution_location == ToolExecutionLocation::Quay)
|
.filter(|t| t.execution_location == ToolExecutionLocation::Reef)
|
||||||
.map(|t| t.id.as_str())
|
.map(|t| t.id.as_str())
|
||||||
.collect();
|
.collect();
|
||||||
// Workspace mutation tools must be Quay-owned.
|
// Privileged mutation tools must be Reef-owned
|
||||||
for name in &["write_file", "edit_file", "bash", "delete_workspace"] {
|
for name in &["write_file", "edit_file", "bash", "delete_workspace"] {
|
||||||
assert!(
|
assert!(
|
||||||
quay_tools.contains(name),
|
reef_tools.contains(name),
|
||||||
"workspace tool '{}' must be Quay-owned",
|
"privileged tool '{}' must be Reef-owned",
|
||||||
name
|
name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3459,33 +3377,6 @@ 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]
|
#[test]
|
||||||
fn no_tool_is_both_reef_and_local() {
|
fn no_tool_is_both_reef_and_local() {
|
||||||
let tools = tool_definitions();
|
let tools = tool_definitions();
|
||||||
|
|
@ -3494,7 +3385,6 @@ Done with tools.
|
||||||
// the field is consistently set.
|
// the field is consistently set.
|
||||||
assert!(
|
assert!(
|
||||||
tool.execution_location == ToolExecutionLocation::Reef
|
tool.execution_location == ToolExecutionLocation::Reef
|
||||||
|| tool.execution_location == ToolExecutionLocation::Quay
|
|
||||||
|| tool.execution_location == ToolExecutionLocation::Local,
|
|| tool.execution_location == ToolExecutionLocation::Local,
|
||||||
"tool '{}' has invalid execution_location",
|
"tool '{}' has invalid execution_location",
|
||||||
tool.id
|
tool.id
|
||||||
|
|
@ -3547,36 +3437,6 @@ 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]
|
#[test]
|
||||||
fn apply_verified_scope_normalizes_agent_run_id() {
|
fn apply_verified_scope_normalizes_agent_run_id() {
|
||||||
let run_id = Uuid::new_v4();
|
let run_id = Uuid::new_v4();
|
||||||
|
|
@ -3586,8 +3446,14 @@ Done with tools.
|
||||||
};
|
};
|
||||||
let mut args = serde_json::Map::new();
|
let mut args = serde_json::Map::new();
|
||||||
// Client supplies a conflicting agent_run_id
|
// Client supplies a conflicting agent_run_id
|
||||||
args.insert("agent_run_id".into(), serde_json::json!("spoofed-value"));
|
args.insert(
|
||||||
args.insert("run_id".into(), serde_json::json!("spoofed-run"));
|
"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);
|
apply_verified_scope(&mut args, &scope);
|
||||||
|
|
||||||
|
|
@ -3610,7 +3476,10 @@ Done with tools.
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut args = serde_json::Map::new();
|
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);
|
apply_verified_scope(&mut args, &scope);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue