feat: resolve upstream auth at runtime
This commit is contained in:
@@ -6,7 +6,6 @@ rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
aes-gcm.workspace = true
|
||||
argon2.workspace = true
|
||||
axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
|
||||
@@ -227,6 +227,7 @@ mod tests {
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_runtime::SecretCrypto;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
@@ -236,7 +237,6 @@ mod tests {
|
||||
use crate::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
||||
secret_crypto::SecretCrypto,
|
||||
service::{AdminService, OperationPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -251,5 +251,11 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
||||
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
|
||||
RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"runtime_secret_error"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error",
|
||||
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ mod app;
|
||||
mod auth;
|
||||
mod error;
|
||||
mod routes;
|
||||
mod secret_crypto;
|
||||
mod service;
|
||||
mod state;
|
||||
mod storage;
|
||||
@@ -16,10 +15,10 @@ use tracing::info;
|
||||
use crate::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig},
|
||||
secret_crypto::SecretCrypto,
|
||||
service::AdminService,
|
||||
state::AppState,
|
||||
};
|
||||
use crank_runtime::SecretCrypto;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
use aes_gcm::{
|
||||
Aes256Gcm, KeyInit, Nonce,
|
||||
aead::{Aead, OsRng, rand_core::RngCore},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SecretCrypto {
|
||||
cipher: Aes256Gcm,
|
||||
key_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CipherEnvelope {
|
||||
nonce_b64: String,
|
||||
ciphertext_b64: String,
|
||||
}
|
||||
|
||||
impl SecretCrypto {
|
||||
pub fn new(master_key: &str) -> Result<Self, ApiError> {
|
||||
let trimmed = master_key.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ApiError::internal("CRANK_MASTER_KEY must not be empty"));
|
||||
}
|
||||
|
||||
let digest = Sha256::digest(trimmed.as_bytes());
|
||||
let cipher = Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| {
|
||||
ApiError::internal(format!("failed to initialize secret crypto: {error}"))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
cipher,
|
||||
key_version: "v1".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn key_version(&self) -> &str {
|
||||
&self.key_version
|
||||
}
|
||||
|
||||
pub fn encrypt(&self, value: &Value) -> Result<String, ApiError> {
|
||||
let plaintext = serde_json::to_vec(value).map_err(|error| {
|
||||
ApiError::internal(format!("failed to serialize secret value: {error}"))
|
||||
})?;
|
||||
let mut nonce_bytes = [0_u8; 12];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = self
|
||||
.cipher
|
||||
.encrypt(nonce, plaintext.as_ref())
|
||||
.map_err(|error| {
|
||||
ApiError::internal(format!("failed to encrypt secret value: {error}"))
|
||||
})?;
|
||||
let envelope = CipherEnvelope {
|
||||
nonce_b64: STANDARD.encode(nonce_bytes),
|
||||
ciphertext_b64: STANDARD.encode(ciphertext),
|
||||
};
|
||||
|
||||
serde_json::to_string(&envelope).map_err(|error| {
|
||||
ApiError::internal(format!("failed to encode secret ciphertext: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn decrypt(&self, ciphertext: &str) -> Result<Value, ApiError> {
|
||||
let envelope: CipherEnvelope = serde_json::from_str(ciphertext).map_err(|error| {
|
||||
ApiError::internal(format!("failed to decode secret envelope: {error}"))
|
||||
})?;
|
||||
let nonce_bytes = STANDARD.decode(envelope.nonce_b64).map_err(|error| {
|
||||
ApiError::internal(format!("failed to decode secret nonce: {error}"))
|
||||
})?;
|
||||
let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| {
|
||||
ApiError::internal(format!("failed to decode secret payload: {error}"))
|
||||
})?;
|
||||
let plaintext = self
|
||||
.cipher
|
||||
.decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref())
|
||||
.map_err(|error| {
|
||||
ApiError::internal(format!("failed to decrypt secret value: {error}"))
|
||||
})?;
|
||||
|
||||
serde_json::from_slice(&plaintext).map_err(|error| {
|
||||
ApiError::internal(format!("failed to deserialize secret value: {error}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::SecretCrypto;
|
||||
|
||||
#[test]
|
||||
fn roundtrips_secret_payload() {
|
||||
let crypto = SecretCrypto::new("test-master-key").unwrap();
|
||||
let plaintext = json!({
|
||||
"token": "top-secret",
|
||||
"username": "demo"
|
||||
});
|
||||
|
||||
let ciphertext = crypto.encrypt(&plaintext).unwrap();
|
||||
let decrypted = crypto.decrypt(&ciphertext).unwrap();
|
||||
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,9 @@ use crank_registry::{
|
||||
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
||||
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
|
||||
};
|
||||
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
|
||||
use crank_runtime::{
|
||||
PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, RuntimeOperation, SecretCrypto,
|
||||
};
|
||||
use crank_schema::Schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -46,7 +48,6 @@ use crate::{
|
||||
hash_session_secret, verify_password,
|
||||
},
|
||||
error::ApiError,
|
||||
secret_crypto::SecretCrypto,
|
||||
storage::LocalArtifactStorage,
|
||||
};
|
||||
|
||||
@@ -1918,8 +1919,18 @@ impl AdminService {
|
||||
}
|
||||
};
|
||||
|
||||
let resolved_auth = self
|
||||
.resolve_operation_auth(workspace_id, &runtime.execution_config)
|
||||
.await;
|
||||
let started_at = std::time::Instant::now();
|
||||
match self.runtime.execute(&runtime, &payload.input).await {
|
||||
match match resolved_auth {
|
||||
Ok(resolved_auth) => {
|
||||
self.runtime
|
||||
.execute_with_auth(&runtime, &payload.input, resolved_auth.as_ref())
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
} {
|
||||
Ok(output) => {
|
||||
let duration_ms =
|
||||
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
@@ -1973,6 +1984,78 @@ impl AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_operation_auth(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
) -> Result<Option<ResolvedAuth>, RuntimeError> {
|
||||
let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let auth_profile = self
|
||||
.registry
|
||||
.get_auth_profile(workspace_id, auth_profile_id)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
||||
auth_profile_id: auth_profile_id.as_str().to_owned(),
|
||||
})?;
|
||||
|
||||
self.resolve_auth_profile(workspace_id, &auth_profile)
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
async fn resolve_auth_profile(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
auth_profile: &AuthProfile,
|
||||
) -> Result<ResolvedAuth, RuntimeError> {
|
||||
let mut secrets = BTreeMap::new();
|
||||
let used_at = now_string().map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: error.to_string(),
|
||||
})?;
|
||||
|
||||
for secret_id in auth_profile.config.secret_ids() {
|
||||
let secret = self
|
||||
.registry
|
||||
.get_secret(workspace_id, secret_id)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
})?;
|
||||
let version = self
|
||||
.registry
|
||||
.get_current_secret_version(workspace_id, secret_id)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
version: secret.secret.current_version,
|
||||
})?;
|
||||
let plaintext = self
|
||||
.secret_crypto
|
||||
.decrypt(&version.secret_version.ciphertext)?;
|
||||
self.registry
|
||||
.touch_secret(workspace_id, secret_id, &used_at)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: error.to_string(),
|
||||
})?;
|
||||
secrets.insert(secret_id.clone(), plaintext);
|
||||
}
|
||||
|
||||
ResolvedAuth::from_profile(auth_profile, &secrets)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_auth_profiles(
|
||||
&self,
|
||||
@@ -2032,7 +2115,10 @@ impl AdminService {
|
||||
updated_at: now,
|
||||
last_used_at: None,
|
||||
};
|
||||
let ciphertext = self.secret_crypto.encrypt(&payload.value)?;
|
||||
let ciphertext = self
|
||||
.secret_crypto
|
||||
.encrypt(&payload.value)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
|
||||
self.registry
|
||||
.create_secret(CreateSecretRequest {
|
||||
@@ -2061,7 +2147,10 @@ impl AdminService {
|
||||
}
|
||||
|
||||
let now = now_string()?;
|
||||
let ciphertext = self.secret_crypto.encrypt(&payload.value)?;
|
||||
let ciphertext = self
|
||||
.secret_crypto
|
||||
.encrypt(&payload.value)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
self.registry
|
||||
.rotate_secret(RotateSecretRequest {
|
||||
workspace_id,
|
||||
@@ -4200,6 +4289,12 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
||||
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
||||
RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"secret_not_found"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error",
|
||||
RuntimeError::SecretCrypto { .. } => "secret_crypto_error",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+143
-15
@@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
convert::Infallible,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
@@ -19,15 +20,16 @@ use axum::{
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_core::{
|
||||
AsyncJobHandle, AsyncJobId, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource,
|
||||
InvocationStatus, JobStatus, PlatformApiKeyScope, StreamSession, StreamSessionId, StreamStatus,
|
||||
AsyncJobHandle, AsyncJobId, AuthProfile, InvocationLevel, InvocationLog, InvocationLogId,
|
||||
InvocationSource, InvocationStatus, JobStatus, PlatformApiKeyScope, SecretId, StreamSession,
|
||||
StreamSessionId, StreamStatus,
|
||||
};
|
||||
use crank_registry::{
|
||||
CreateAsyncJobRequest, CreateInvocationLogRequest, CreateStreamSessionRequest,
|
||||
PostgresRegistry, PublishedAgentTool, UpdateAsyncJobStatusRequest,
|
||||
UpdateStreamSessionStateRequest,
|
||||
};
|
||||
use crank_runtime::{RuntimeError, RuntimeExecutor, RuntimeOperation};
|
||||
use crank_runtime::{ResolvedAuth, RuntimeError, RuntimeExecutor, RuntimeOperation, SecretCrypto};
|
||||
use futures_util::stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -58,6 +60,7 @@ pub struct AppState {
|
||||
registry: PostgresRegistry,
|
||||
catalog: PublishedToolCatalog,
|
||||
runtime: RuntimeExecutor,
|
||||
secret_crypto: SecretCrypto,
|
||||
sessions: SessionStore,
|
||||
allowed_origins: AllowedOrigins,
|
||||
}
|
||||
@@ -118,11 +121,13 @@ pub fn build_app(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
secret_crypto: SecretCrypto,
|
||||
) -> Router {
|
||||
let state = Arc::new(AppState {
|
||||
registry: registry.clone(),
|
||||
catalog: PublishedToolCatalog::new(registry, refresh_interval),
|
||||
runtime: RuntimeExecutor::new(),
|
||||
secret_crypto,
|
||||
sessions: SessionStore::new(),
|
||||
allowed_origins: AllowedOrigins::new(public_base_url),
|
||||
});
|
||||
@@ -509,6 +514,87 @@ struct StoredSessionState {
|
||||
batch_size: usize,
|
||||
}
|
||||
|
||||
async fn resolve_operation_auth(
|
||||
state: &Arc<AppState>,
|
||||
workspace_id: &crank_core::WorkspaceId,
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
) -> Result<Option<ResolvedAuth>, RuntimeError> {
|
||||
resolve_runtime_auth_for_task(
|
||||
&state.registry,
|
||||
&state.secret_crypto,
|
||||
workspace_id,
|
||||
execution_config,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_runtime_auth_for_task(
|
||||
registry: &PostgresRegistry,
|
||||
secret_crypto: &SecretCrypto,
|
||||
workspace_id: &crank_core::WorkspaceId,
|
||||
execution_config: &crank_core::ExecutionConfig,
|
||||
) -> Result<Option<ResolvedAuth>, RuntimeError> {
|
||||
let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let auth_profile = registry
|
||||
.get_auth_profile(workspace_id, auth_profile_id)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingAuthProfile {
|
||||
auth_profile_id: auth_profile_id.as_str().to_owned(),
|
||||
})?;
|
||||
|
||||
resolve_auth_profile(registry, secret_crypto, workspace_id, &auth_profile)
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
async fn resolve_auth_profile(
|
||||
registry: &PostgresRegistry,
|
||||
secret_crypto: &SecretCrypto,
|
||||
workspace_id: &crank_core::WorkspaceId,
|
||||
auth_profile: &AuthProfile,
|
||||
) -> Result<ResolvedAuth, RuntimeError> {
|
||||
let mut secrets = BTreeMap::new();
|
||||
let used_at = now_rfc3339();
|
||||
|
||||
for secret_id in auth_profile.config.secret_ids() {
|
||||
let secret = registry
|
||||
.get_secret(workspace_id, secret_id)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingSecret {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
})?;
|
||||
let version = registry
|
||||
.get_current_secret_version(workspace_id, secret_id)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: error.to_string(),
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::MissingSecretVersion {
|
||||
secret_id: secret_id.as_str().to_owned(),
|
||||
version: secret.secret.current_version,
|
||||
})?;
|
||||
let plaintext = secret_crypto.decrypt(&version.secret_version.ciphertext)?;
|
||||
registry
|
||||
.touch_secret(workspace_id, secret_id, &used_at)
|
||||
.await
|
||||
.map_err(|error| RuntimeError::SecretCrypto {
|
||||
details: error.to_string(),
|
||||
})?;
|
||||
secrets.insert(SecretId::new(secret_id.as_str()), plaintext);
|
||||
}
|
||||
|
||||
ResolvedAuth::from_profile(auth_profile, &secrets)
|
||||
}
|
||||
|
||||
async fn handle_base_tool_call(
|
||||
state: Arc<AppState>,
|
||||
session: &SessionState,
|
||||
@@ -528,11 +614,13 @@ async fn handle_base_tool_call(
|
||||
.map(|streaming| streaming.mode),
|
||||
Some(crank_core::ExecutionMode::Window)
|
||||
);
|
||||
let resolved_auth =
|
||||
resolve_operation_auth(&state, &tool.workspace_id, &operation.execution_config).await;
|
||||
|
||||
let result = if is_window_mode {
|
||||
state
|
||||
let result = match resolved_auth {
|
||||
Ok(resolved_auth) if is_window_mode => state
|
||||
.runtime
|
||||
.execute_window(&operation, &arguments)
|
||||
.execute_window_with_auth(&operation, &arguments, resolved_auth.as_ref())
|
||||
.await
|
||||
.map(|output| {
|
||||
json!({
|
||||
@@ -543,9 +631,14 @@ async fn handle_base_tool_call(
|
||||
"truncated": output.truncated,
|
||||
"has_more": output.has_more,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
state.runtime.execute(&operation, &arguments).await
|
||||
}),
|
||||
Ok(resolved_auth) => {
|
||||
state
|
||||
.runtime
|
||||
.execute_with_auth(&operation, &arguments, resolved_auth.as_ref())
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
|
||||
match result {
|
||||
@@ -619,11 +712,25 @@ async fn handle_session_start_call(
|
||||
);
|
||||
};
|
||||
|
||||
match state
|
||||
.runtime
|
||||
.execute_session_seed(&runtime_operation, &arguments)
|
||||
.await
|
||||
{
|
||||
let resolved_auth = resolve_operation_auth(
|
||||
&state,
|
||||
&tool.workspace_id,
|
||||
&runtime_operation.execution_config,
|
||||
)
|
||||
.await;
|
||||
match match resolved_auth {
|
||||
Ok(resolved_auth) => {
|
||||
state
|
||||
.runtime
|
||||
.execute_session_seed_with_auth(
|
||||
&runtime_operation,
|
||||
&arguments,
|
||||
resolved_auth.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
} {
|
||||
Ok(seed) => {
|
||||
let batch_size = streaming.max_items.unwrap_or(10).max(1) as usize;
|
||||
let preview_count = seed.items.len().min(batch_size);
|
||||
@@ -983,13 +1090,28 @@ async fn handle_async_job_start_call(
|
||||
}
|
||||
|
||||
let registry = state.registry.clone();
|
||||
let secret_crypto = state.secret_crypto.clone();
|
||||
let tool_for_task = tool.clone();
|
||||
let arguments_for_task = arguments.clone();
|
||||
let job_id = job.id.clone();
|
||||
tokio::spawn(async move {
|
||||
let runtime = RuntimeExecutor::new();
|
||||
let task_operation = runtime_operation(&tool_for_task);
|
||||
let result = runtime.execute(&task_operation, &arguments_for_task).await;
|
||||
let resolved_auth = resolve_runtime_auth_for_task(
|
||||
®istry,
|
||||
&secret_crypto,
|
||||
&tool_for_task.workspace_id,
|
||||
&task_operation.execution_config,
|
||||
)
|
||||
.await;
|
||||
let result = match resolved_auth {
|
||||
Ok(resolved_auth) => {
|
||||
runtime
|
||||
.execute_with_auth(&task_operation, &arguments_for_task, resolved_auth.as_ref())
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let finished_at = now_rfc3339();
|
||||
|
||||
let update_result = match result {
|
||||
@@ -1732,6 +1854,12 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_error",
|
||||
RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"secret_not_found"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error",
|
||||
RuntimeError::SecretCrypto { .. } => "secret_crypto_error",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-17
@@ -6,6 +6,7 @@ mod session;
|
||||
use std::{env, net::SocketAddr, time::Duration};
|
||||
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_runtime::SecretCrypto;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
@@ -30,7 +31,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.unwrap_or_else(|| Duration::from_secs(5));
|
||||
let socket_addr: SocketAddr = bind_addr.parse()?;
|
||||
let registry = PostgresRegistry::connect(&database_url).await?;
|
||||
let app = build_app(registry, refresh_interval, public_base_url);
|
||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||
let app = build_app(registry, refresh_interval, public_base_url, secret_crypto);
|
||||
let listener = TcpListener::bind(socket_addr).await?;
|
||||
|
||||
info!("mcp-server listening on {}", socket_addr);
|
||||
@@ -68,6 +70,7 @@ mod tests {
|
||||
CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry,
|
||||
PublishAgentRequest, PublishRequest,
|
||||
};
|
||||
use crank_runtime::SecretCrypto;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use futures_util::stream;
|
||||
use serde_json::{Value, json};
|
||||
@@ -86,6 +89,19 @@ mod tests {
|
||||
"default"
|
||||
}
|
||||
|
||||
fn build_test_app(
|
||||
registry: PostgresRegistry,
|
||||
refresh_interval: Duration,
|
||||
public_base_url: Option<String>,
|
||||
) -> axum::Router {
|
||||
build_app(
|
||||
registry,
|
||||
refresh_interval,
|
||||
public_base_url,
|
||||
SecretCrypto::new("test-master-key").unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initializes_lists_and_calls_published_tool_via_mcp() {
|
||||
let registry = test_registry().await;
|
||||
@@ -114,7 +130,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -220,7 +236,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -284,7 +300,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -326,7 +342,7 @@ mod tests {
|
||||
publish_agent_with_bindings(®istry, "sales-init", vec![]).await;
|
||||
let api_key =
|
||||
create_platform_api_key(®istry, "mcp-init", &[PlatformApiKeyScope::Read]).await;
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -388,7 +404,7 @@ mod tests {
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -438,7 +454,7 @@ mod tests {
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -489,7 +505,7 @@ mod tests {
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -518,7 +534,7 @@ mod tests {
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -562,7 +578,7 @@ mod tests {
|
||||
&[PlatformApiKeyScope::Read],
|
||||
)
|
||||
.await;
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -589,7 +605,7 @@ mod tests {
|
||||
async fn refreshes_published_tools_without_restart() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -700,7 +716,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -747,7 +763,7 @@ mod tests {
|
||||
async fn rejects_initialize_without_platform_api_key() {
|
||||
let registry = test_registry().await;
|
||||
publish_agent_with_bindings(®istry, "sales-auth", vec![]).await;
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -796,7 +812,7 @@ mod tests {
|
||||
let api_key =
|
||||
create_platform_api_key(®istry, "mcp-read", &[PlatformApiKeyScope::Read]).await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -857,7 +873,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -1001,7 +1017,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
@@ -1148,7 +1164,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
let base_url = spawn_mcp_server(build_test_app(
|
||||
registry,
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
|
||||
Reference in New Issue
Block a user