ci: Added CI/CD and release pipelines (#45)

This commit is contained in:
Vlad Frolov
2026-02-12 12:25:36 +01:00
committed by GitHub
parent 115b7f38fe
commit 09198c68ab
25 changed files with 886 additions and 161 deletions
+1 -1
View File
@@ -469,7 +469,7 @@ pub fn create_wasm_channel_router(
}
#[cfg(test)]
mod tests {
mod tests {
use std::sync::Arc;
use crate::channels::wasm::capabilities::ChannelCapabilities;
+18 -6
View File
@@ -43,12 +43,12 @@ use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::error::WasmChannelError;
use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
use crate::pairing::PairingStore;
use crate::channels::wasm::router::RegisteredEndpoint;
use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
use crate::channels::wasm::schema::ChannelConfig;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
use crate::pairing::PairingStore;
use crate::safety::LeakDetector;
use crate::tools::wasm::LogLevel;
use crate::tools::wasm::WasmResourceLimiter;
@@ -1190,6 +1190,7 @@ impl WasmChannel {
///
/// Static method for use by the background typing repeat task (which
/// doesn't have access to `&self`).
#[allow(clippy::too_many_arguments)]
async fn execute_status(
channel_name: &str,
runtime: &Arc<WasmChannelRuntime>,
@@ -2073,12 +2074,12 @@ mod tests {
use std::sync::Arc;
use crate::channels::Channel;
use crate::pairing::PairingStore;
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::runtime::{
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
};
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
use crate::pairing::PairingStore;
use crate::tools::wasm::ResourceLimits;
fn create_test_channel() -> WasmChannel {
@@ -2525,8 +2526,13 @@ mod tests {
);
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
let store =
ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds);
let store = ChannelStoreData::new(
1024 * 1024,
"test",
ChannelCapabilities::default(),
creds,
Arc::new(PairingStore::new()),
);
let error = "HTTP request failed: error sending request for url \
(https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)";
@@ -2556,6 +2562,7 @@ mod tests {
"test",
ChannelCapabilities::default(),
std::collections::HashMap::new(),
Arc::new(PairingStore::new()),
);
let input = "some error message";
@@ -2569,8 +2576,13 @@ mod tests {
let mut creds = std::collections::HashMap::new();
creds.insert("EMPTY_TOKEN".to_string(), String::new());
let store =
ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds);
let store = ChannelStoreData::new(
1024 * 1024,
"test",
ChannelCapabilities::default(),
creds,
Arc::new(PairingStore::new()),
);
let input = "should not match anything";
assert_eq!(store.redact_credentials(input), input);
+3 -1
View File
@@ -71,7 +71,9 @@ pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
/// Bootstrap a DB connection for config commands.
async fn connect_store() -> anyhow::Result<crate::history::Store> {
let config = crate::config::Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?;
let config = crate::config::Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let store = crate::history::Store::new(&config.database).await?;
store.run_migrations().await?;
Ok(store)
+9 -5
View File
@@ -52,7 +52,10 @@ fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), Strin
let requests = store.list_pending(channel).map_err(|e| e.to_string())?;
if json {
println!("{}", serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())?);
println!(
"{}",
serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())?
);
return Ok(());
}
@@ -69,9 +72,7 @@ fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), Strin
.and_then(|m| m.as_object())
.map(|o| {
o.iter()
.filter_map(|(k, v)| {
v.as_str().map(|s| format!("{}={}", k, s))
})
.filter_map(|(k, v)| v.as_str().map(|s| format!("{}={}", k, s)))
.collect::<Vec<_>>()
.join(", ")
})
@@ -88,7 +89,10 @@ fn run_approve(store: &PairingStore, channel: &str, code: &str) -> Result<(), St
println!("Approved {} sender {}.", channel, entry.id);
Ok(())
}
Ok(None) => Err(format!("No pending pairing request found for code: {}", code)),
Ok(None) => Err(format!(
"No pending pairing request found for code: {}",
code
)),
Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err(
"Too many failed approve attempts. Wait a few minutes before trying again.".to_string(),
),
+1 -1
View File
@@ -43,7 +43,6 @@ pub mod bootstrap;
pub mod channels;
pub mod cli;
pub mod config;
pub mod pairing;
pub mod context;
pub mod error;
pub mod estimation;
@@ -52,6 +51,7 @@ pub mod extensions;
pub mod history;
pub mod llm;
pub mod orchestrator;
pub mod pairing;
pub mod safety;
pub mod sandbox;
pub mod secrets;
+4 -2
View File
@@ -7,7 +7,6 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx
use ironclaw::{
agent::{Agent, AgentDeps, SessionManager},
pairing::PairingStore,
channels::{
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer,
WebhookServerConfig,
@@ -30,6 +29,7 @@ use ironclaw::{
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
api::OrchestratorState,
},
pairing::PairingStore,
safety::SafetyLayer,
secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore},
setup::{SetupConfig, SetupWizard},
@@ -86,7 +86,9 @@ async fn main() -> anyhow::Result<()> {
// Memory commands need database (and optionally embeddings)
let _ = dotenvy::dotenv();
let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?;
let config = Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let store = ironclaw::history::Store::new(&config.database).await?;
store.run_migrations().await?;
+61 -31
View File
@@ -5,7 +5,7 @@
use std::collections::HashSet;
use std::fs;
use std::io::{Seek, SeekFrom, Write};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use fs4::FileExt;
@@ -94,17 +94,17 @@ fn safe_channel_key(channel: &str) -> Result<String, PairingStoreError> {
Ok(safe)
}
fn pairing_path(base_dir: &PathBuf, channel: &str) -> Result<PathBuf, PairingStoreError> {
fn pairing_path(base_dir: &Path, channel: &str) -> Result<PathBuf, PairingStoreError> {
let key = safe_channel_key(channel)?;
Ok(base_dir.join(format!("{}-pairing.json", key)))
}
fn allow_from_path(base_dir: &PathBuf, channel: &str) -> Result<PathBuf, PairingStoreError> {
fn allow_from_path(base_dir: &Path, channel: &str) -> Result<PathBuf, PairingStoreError> {
let key = safe_channel_key(channel)?;
Ok(base_dir.join(format!("{}-allowFrom.json", key)))
}
fn approve_attempts_path(base_dir: &PathBuf, channel: &str) -> Result<PathBuf, PairingStoreError> {
fn approve_attempts_path(base_dir: &Path, channel: &str) -> Result<PathBuf, PairingStoreError> {
let key = safe_channel_key(channel)?;
Ok(base_dir.join(format!("{}-approve-attempts.json", key)))
}
@@ -236,10 +236,11 @@ impl PairingStore {
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile {
version: 1,
requests: Vec::new(),
});
let mut store: PairingStoreFile =
serde_json::from_str(&content).unwrap_or(PairingStoreFile {
version: 1,
requests: Vec::new(),
});
let now = now_iso();
let now_secs = now_secs();
@@ -296,7 +297,10 @@ impl PairingStore {
self.write_pairing_file_locked(&mut file, channel, &store.requests)?;
fs4::FileExt::unlock(&file)?;
Ok(UpsertResult { code, created: true })
Ok(UpsertResult {
code,
created: true,
})
}
fn is_approve_rate_limited(&self, channel: &str) -> Result<bool, PairingStoreError> {
@@ -306,8 +310,7 @@ impl PairingStore {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e.into()),
};
let mut data: ApproveAttemptsFile =
serde_json::from_str(&content).unwrap_or_default();
let mut data: ApproveAttemptsFile = serde_json::from_str(&content).unwrap_or_default();
let now = now_secs();
let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS);
data.failed_at.retain(|&t| t >= cutoff);
@@ -321,11 +324,11 @@ impl PairingStore {
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut data: ApproveAttemptsFile =
serde_json::from_str(&content).unwrap_or_default();
let mut data: ApproveAttemptsFile = serde_json::from_str(&content).unwrap_or_default();
let now = now_secs();
data.failed_at.push(now);
let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS);
@@ -368,10 +371,11 @@ impl PairingStore {
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile {
version: 1,
requests: Vec::new(),
});
let mut store: PairingStoreFile =
serde_json::from_str(&content).unwrap_or(PairingStoreFile {
version: 1,
requests: Vec::new(),
});
let now_secs = now_secs();
store.requests.retain(|r| !is_expired(r, now_secs));
@@ -409,10 +413,11 @@ impl PairingStore {
Err(e) => return Err(e.into()),
};
let file: AllowFromStoreFile = serde_json::from_str(&content).unwrap_or(AllowFromStoreFile {
version: 1,
allow_from: Vec::new(),
});
let file: AllowFromStoreFile =
serde_json::from_str(&content).unwrap_or(AllowFromStoreFile {
version: 1,
allow_from: Vec::new(),
});
Ok(file.allow_from)
}
@@ -433,10 +438,9 @@ impl PairingStore {
if let Some(u) = username {
let u = u.trim().to_lowercase();
let u_norm = u.strip_prefix('@').unwrap_or(&u);
if allow
.iter()
.any(|e| e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm))
{
if allow.iter().any(|e| {
e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm)
}) {
return Ok(true);
}
}
@@ -456,6 +460,7 @@ impl PairingStore {
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
file.lock_exclusive()?;
@@ -563,11 +568,20 @@ mod tests {
fn test_upsert_request_creates_new() {
let (store, _) = test_store();
let result = store
.upsert_request("telegram", "user123", Some(serde_json::json!({"chat_id": 456})))
.upsert_request(
"telegram",
"user123",
Some(serde_json::json!({"chat_id": 456})),
)
.unwrap();
assert!(result.created);
assert_eq!(result.code.len(), PAIRING_CODE_LENGTH);
assert!(result.code.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8))));
assert!(
result
.code
.chars()
.all(|c| PAIRING_ALPHABET.contains(&(c as u8)))
);
}
#[test]
@@ -575,7 +589,9 @@ mod tests {
let (store, _) = test_store();
let r1 = store.upsert_request("telegram", "user123", None).unwrap();
assert!(r1.created);
let r2 = store.upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1}))).unwrap();
let r2 = store
.upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1})))
.unwrap();
assert!(!r2.created);
assert_eq!(r1.code, r2.code);
@@ -633,21 +649,35 @@ mod tests {
let r = store.upsert_request("telegram", "user999", None).unwrap();
store.approve("telegram", &r.code).unwrap();
assert!(store.is_sender_allowed("telegram", "user999", None).unwrap());
assert!(
store
.is_sender_allowed("telegram", "user999", None)
.unwrap()
);
assert!(!store.is_sender_allowed("telegram", "other", None).unwrap());
}
#[test]
fn test_is_sender_allowed_by_username() {
let (store, _) = test_store();
store.upsert_request("telegram", "alice", Some(serde_json::json!({"username": "alice"}))).unwrap();
store
.upsert_request(
"telegram",
"alice",
Some(serde_json::json!({"username": "alice"})),
)
.unwrap();
let pending = store.list_pending("telegram").unwrap();
store.approve("telegram", &pending[0].code).unwrap();
// approve adds id to allow_from. For username we need to add it manually.
// Actually approve adds entry.id which is "alice". So is_sender_allowed("telegram", "alice", None) would work.
assert!(store.is_sender_allowed("telegram", "alice", None).unwrap());
assert!(store.is_sender_allowed("telegram", "alice", Some("alice")).unwrap());
assert!(
store
.is_sender_allowed("telegram", "alice", Some("alice"))
.unwrap()
);
}
#[test]
+8 -18
View File
@@ -101,15 +101,13 @@ mod platform {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
SecretError::KeychainError(format!("Failed to connect to secret service: {}", e))
})?;
let collection = ss.get_default_collection().await.map_err(|e| {
SecretError::KeychainError(format!("Failed to get collection: {}", e))
})?;
let collection = ss
.get_default_collection()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to get collection: {}", e)))?;
// Unlock if needed
if collection.is_locked().await.unwrap_or(true) {
@@ -132,9 +130,7 @@ mod platform {
"text/plain",
)
.await
.map_err(|e| {
SecretError::KeychainError(format!("Failed to create secret: {}", e))
})?;
.map_err(|e| SecretError::KeychainError(format!("Failed to create secret: {}", e)))?;
Ok(())
}
@@ -144,10 +140,7 @@ mod platform {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
SecretError::KeychainError(format!("Failed to connect to secret service: {}", e))
})?;
let items = ss
@@ -188,10 +181,7 @@ mod platform {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
SecretError::KeychainError(format!("Failed to connect to secret service: {}", e))
})?;
let items = ss
+2 -7
View File
@@ -192,9 +192,10 @@ impl CreateSecretParams {
}
/// Where a credential should be injected in an HTTP request.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub enum CredentialLocation {
/// Inject as Authorization header (e.g., "Bearer {secret}")
#[default]
AuthorizationBearer,
/// Inject as Authorization header with Basic auth
AuthorizationBasic { username: String },
@@ -209,12 +210,6 @@ pub enum CredentialLocation {
UrlPath { placeholder: String },
}
impl Default for CredentialLocation {
fn default() -> Self {
Self::AuthorizationBearer
}
}
/// Mapping from a secret name to where it should be injected.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialMapping {
+3 -6
View File
@@ -782,12 +782,9 @@ impl SetupWizard {
fn save_and_summarize(&mut self) -> Result<(), SetupError> {
self.settings.onboard_completed = true;
self.settings.save().map_err(|e| {
SetupError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to save settings: {}", e),
))
})?;
self.settings
.save()
.map_err(|e| std::io::Error::other(format!("Failed to save settings: {}", e)))?;
println!();
print_success("Configuration saved to ~/.ironclaw/");
+1 -1
View File
@@ -36,7 +36,7 @@ fn is_workspace_path(path: &str) -> bool {
.and_then(|f| f.to_str())
.unwrap_or(path);
WORKSPACE_FILES.iter().any(|ws| *ws == filename)
WORKSPACE_FILES.contains(&filename)
|| path.starts_with("daily/")
|| path.starts_with("context/")
}
+1 -6
View File
@@ -365,12 +365,7 @@ pub async fn save_mcp_servers_to_db(
store
.set_setting(user_id, "mcp_servers", &value)
.await
.map_err(|e| {
ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
))
})?;
.map_err(std::io::Error::other)?;
Ok(())
}
+1 -1
View File
@@ -422,7 +422,7 @@ impl Default for ToolRegistry {
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::tool::EchoTool;
use crate::tools::registry::EchoTool;
#[tokio::test]
async fn test_register_and_get() {
+47 -47
View File
@@ -199,57 +199,57 @@ pub trait Tool: Send + Sync {
}
}
/// A simple no-op tool for testing.
#[derive(Debug)]
pub struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"Echoes back the input message. Useful for testing."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The message to echo back"
}
},
"required": ["message"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'message' parameter".to_string())
})?;
Ok(ToolOutput::text(message, Duration::from_millis(1)))
}
fn requires_sanitization(&self) -> bool {
false // Echo is a trusted internal tool
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A simple no-op tool for testing.
#[derive(Debug)]
pub struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"Echoes back the input message. Useful for testing."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The message to echo back"
}
},
"required": ["message"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'message' parameter".to_string())
})?;
Ok(ToolOutput::text(message, Duration::from_millis(1)))
}
fn requires_sanitization(&self) -> bool {
false // Echo is a trusted internal tool
}
}
#[tokio::test]
async fn test_echo_tool() {
let tool = EchoTool;
+7 -8
View File
@@ -404,14 +404,13 @@ impl Repository {
Vec::new()
};
let vector_results = if config.use_vector && embedding.is_some() {
self.vector_search(
user_id,
agent_id,
embedding.unwrap(),
config.pre_fusion_limit,
)
.await?
let vector_results = if config.use_vector {
if let Some(embedding) = embedding {
self.vector_search(user_id, agent_id, embedding, config.pre_fusion_limit)
.await?
} else {
Vec::new()
}
} else {
Vec::new()
};