Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
- group: features
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py"
- group: extensions
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
steps:
- uses: actions/checkout@v6

Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,9 @@ trace_*.json
# Local Claude Code settings (machine-specific, should not be committed)
.claude/settings.local.json
.worktrees/

# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
34 changes: 34 additions & 0 deletions src/config/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,40 @@ impl DatabaseConfig {
})
}

/// Create a config from a raw PostgreSQL URL (for wizard/testing).
pub fn from_postgres_url(url: &str, pool_size: usize) -> Self {
Self {
backend: DatabaseBackend::Postgres,
url: SecretString::from(url.to_string()),
pool_size,
ssl_mode: SslMode::from_env(),
libsql_path: None,
libsql_url: None,
libsql_auth_token: None,
}
}

/// Create a config for a libSQL database (for wizard/testing).
///
/// Empty strings for `turso_url` and `turso_token` are treated as `None`.
pub fn from_libsql_path(
path: &str,
turso_url: Option<&str>,
turso_token: Option<&str>,
) -> Self {
let turso_url = turso_url.filter(|s| !s.is_empty());
let turso_token = turso_token.filter(|s| !s.is_empty());
Self {
backend: DatabaseBackend::LibSql,
url: SecretString::from("unused://libsql".to_string()),
pool_size: 1,
ssl_mode: SslMode::default(),
libsql_path: Some(PathBuf::from(path)),
libsql_url: turso_url.map(String::from),
libsql_auth_token: turso_token.map(|t| SecretString::from(t.to_string())),
}
}

/// Get the database URL (exposes the secret).
pub fn url(&self) -> &str {
self.url.expose_secret()
Expand Down
151 changes: 140 additions & 11 deletions src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ pub async fn connect_with_handles(
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
}
#[cfg(feature = "postgres")]
_ => {
crate::config::DatabaseBackend::Postgres => {
let pg = postgres::PgBackend::new(config)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
Expand All @@ -115,10 +115,11 @@ pub async fn connect_with_handles(

Ok((Arc::new(pg) as Arc<dyn Database>, handles))
}
#[cfg(not(feature = "postgres"))]
_ => Err(DatabaseError::Pool(
"No database backend available. Enable 'postgres' or 'libsql' feature.".to_string(),
)),
#[allow(unreachable_patterns)]
_ => Err(DatabaseError::Pool(format!(
"Database backend '{}' is not available. Rebuild with the appropriate feature flag.",
config.backend
))),
}
}

Expand Down Expand Up @@ -161,7 +162,7 @@ pub async fn create_secrets_store(
)))
}
#[cfg(feature = "postgres")]
_ => {
crate::config::DatabaseBackend::Postgres => {
let pg = postgres::PgBackend::new(config)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
Expand All @@ -172,14 +173,142 @@ pub async fn create_secrets_store(
crypto,
)))
}
#[cfg(not(feature = "postgres"))]
_ => Err(DatabaseError::Pool(
"No database backend available for secrets. Enable 'postgres' or 'libsql' feature."
.to_string(),
)),
#[allow(unreachable_patterns)]
_ => Err(DatabaseError::Pool(format!(
"Database backend '{}' is not available for secrets. Rebuild with the appropriate feature flag.",
config.backend
))),
}
}

// ==================== Wizard / testing helpers ====================

/// Connect to the database WITHOUT running migrations, validating
/// prerequisites when applicable (PostgreSQL version, pgvector).
///
/// Returns both the `Database` trait object and backend-specific handles.
/// Used by the wizard to test connectivity before committing — call
/// [`Database::run_migrations`] on the returned trait object when ready.
pub async fn connect_without_migrations(
config: &crate::config::DatabaseConfig,
) -> Result<(Arc<dyn Database>, DatabaseHandles), DatabaseError> {
let mut handles = DatabaseHandles::default();

match config.backend {
#[cfg(feature = "libsql")]
crate::config::DatabaseBackend::LibSql => {
use secrecy::ExposeSecret as _;

let default_path = crate::config::default_libsql_path();
let db_path = config.libsql_path.as_deref().unwrap_or(&default_path);

let backend = if let Some(ref url) = config.libsql_url {
let token = config.libsql_auth_token.as_ref().ok_or_else(|| {
DatabaseError::Pool(
"LIBSQL_AUTH_TOKEN required when LIBSQL_URL is set".to_string(),
)
})?;
libsql::LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret())
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?
} else {
libsql::LibSqlBackend::new_local(db_path)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?
};

handles.libsql_db = Some(backend.shared_db());

Ok((Arc::new(backend) as Arc<dyn Database>, handles))
}
#[cfg(feature = "postgres")]
crate::config::DatabaseBackend::Postgres => {
let pg = postgres::PgBackend::new(config)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?;

handles.pg_pool = Some(pg.pool());

// Validate PostgreSQL prerequisites (version, pgvector)
validate_postgres(&pg.pool()).await?;

Ok((Arc::new(pg) as Arc<dyn Database>, handles))
}
#[allow(unreachable_patterns)]
_ => Err(DatabaseError::Pool(format!(
"Database backend '{}' is not available. Rebuild with the appropriate feature flag.",
config.backend
))),
}
}

/// Validate PostgreSQL prerequisites (version >= 15, pgvector available).
///
/// Returns `Ok(())` if all prerequisites are met, or a `DatabaseError`
/// with a user-facing message describing the issue.
#[cfg(feature = "postgres")]
async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), DatabaseError> {
let client = pool
.get()
.await
.map_err(|e| DatabaseError::Pool(format!("Failed to connect: {}", e)))?;

// Check PostgreSQL server version (need 15+ for pgvector).
let version_row = client
.query_one("SHOW server_version", &[])
.await
.map_err(|e| DatabaseError::Query(format!("Failed to query server version: {}", e)))?;
let version_str: &str = version_row.get(0);
let major_version = version_str
.split('.')
.next()
.and_then(|v| v.parse::<u32>().ok())
.ok_or_else(|| {
DatabaseError::Pool(format!(
"Could not parse PostgreSQL version from '{}'. \
Expected a numeric major version (e.g., '15.2').",
version_str
))
})?;

const MIN_PG_MAJOR_VERSION: u32 = 15;

if major_version < MIN_PG_MAJOR_VERSION {
return Err(DatabaseError::Pool(format!(
"PostgreSQL {} detected. IronClaw requires PostgreSQL {} or later \
for pgvector support.\n\
Upgrade: https://www.postgresql.org/download/",
version_str, MIN_PG_MAJOR_VERSION
)));
}

// Check if pgvector extension is available.
let pgvector_row = client
.query_opt(
"SELECT 1 FROM pg_available_extensions WHERE name = 'vector'",
&[],
)
.await
.map_err(|e| {
DatabaseError::Query(format!("Failed to check pgvector availability: {}", e))
})?;

if pgvector_row.is_none() {
return Err(DatabaseError::Pool(format!(
"pgvector extension not found on your PostgreSQL server.\n\n\
Install it:\n \
macOS: brew install pgvector\n \
Ubuntu: apt install postgresql-{0}-pgvector\n \
Docker: use the pgvector/pgvector:pg{0} image\n \
Source: https://github.com/pgvector/pgvector#installation\n\n\
Then restart PostgreSQL and re-run: ironclaw onboard",
major_version
)));
}

Ok(())
}

// ==================== Sub-traits ====================
//
// Each sub-trait groups related persistence methods. The `Database` supertrait
Expand Down
43 changes: 40 additions & 3 deletions src/extensions/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3817,9 +3817,16 @@ impl ExtensionManager {
{
let token = token_value.trim();
if !token.is_empty() {
let encoded =
url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
let url = endpoint_template.replace(&format!("{{{}}}", secret_def.name), &encoded);
// Telegram tokens contain colons (numeric_id:token_part) in the URL path,
// not query parameters, so URL-encoding breaks the endpoint.
// For other extensions, keep encoding to handle special chars in query parameters.
let url = if name == "telegram" {
endpoint_template.replace(&format!("{{{}}}", secret_def.name), token)
} else {
let encoded =
url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
endpoint_template.replace(&format!("{{{}}}", secret_def.name), &encoded)
};
// SSRF defense: block private IPs, localhost, cloud metadata endpoints
crate::tools::builtin::skill_tools::validate_fetch_url(&url)
.map_err(|e| ExtensionError::Other(format!("SSRF blocked: {}", e)))?;
Expand Down Expand Up @@ -5668,4 +5675,34 @@ mod tests {
"Display should contain 'validation failed', got: {msg}"
);
}

#[test]
fn test_telegram_token_colon_preserved_in_validation_url() {
// Regression: Telegram tokens (format: numeric_id:alphanumeric_string) must NOT
// have their colon URL-encoded to %3A, as this breaks the validation endpoint.
// Previously: form_urlencoded::byte_serialize encoded the token, causing 404s.
// Fixed by removing URL-encoding and using the token directly.
let endpoint_template = "https://api.telegram.org/bot{telegram_bot_token}/getMe";
let secret_name = "telegram_bot_token";
let token = "123456789:AABBccDDeeFFgg_Test-Token";

// Simulate the fixed validation URL building logic
let url = endpoint_template.replace(&format!("{{{}}}", secret_name), token);

// Verify colon is preserved
let expected = "https://api.telegram.org/bot123456789:AABBccDDeeFFgg_Test-Token/getMe";
if url != expected {
panic!("URL mismatch: expected {expected}, got {url}"); // safety: test assertion
}

// Verify it does NOT contain the broken percent-encoded version
if url.contains("%3A") {
panic!("URL contains URL-encoded colon (%3A): {url}"); // safety: test assertion
}

// Verify the URL contains the original colon
if !url.contains("123456789:AABBccDDeeFFgg_Test-Token") {
panic!("URL missing token: {url}"); // safety: test assertion
}
}
}
39 changes: 39 additions & 0 deletions src/llm/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,42 @@ pub struct NearAiConfig {
/// Enable cascade mode for smart routing. Default: true.
pub smart_routing_cascade: bool,
}

impl NearAiConfig {
/// Create a minimal config suitable for listing available models.
///
/// Reads `NEARAI_API_KEY` from the environment and selects the
/// appropriate base URL (cloud-api when API key is present,
/// private.near.ai for session-token auth).
pub(crate) fn for_model_discovery() -> Self {
let api_key = std::env::var("NEARAI_API_KEY")
.ok()
.filter(|k| !k.is_empty())
.map(SecretString::from);

let default_base = if api_key.is_some() {
"https://cloud-api.near.ai"
} else {
"https://private.near.ai"
};
let base_url =
std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string());

Self {
model: String::new(),
cheap_model: None,
base_url,
api_key,
fallback_model: None,
max_retries: 3,
circuit_breaker_threshold: None,
circuit_breaker_recovery_secs: 30,
response_cache_enabled: false,
response_cache_ttl_secs: 3600,
response_cache_max_entries: 1000,
failover_cooldown_secs: 300,
failover_cooldown_threshold: 3,
smart_routing_cascade: true,
}
}
}
1 change: 1 addition & 0 deletions src/llm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub mod session;
pub mod smart_routing;

pub mod image_models;
pub mod models;
pub mod reasoning_models;
pub mod vision_models;

Expand Down
Loading
Loading