mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* fix(ci): use explicit features in WASM WIT compat test to avoid sqlite3 symbol conflicts The `import` feature (added in #903) brings in `rusqlite[bundled]` which conflicts with `libsql-ffi` — both bundle SQLite C code, causing duplicate symbol linker errors. Use explicit features matching the test matrix instead of `--all-features`. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: replace rusqlite with libsql in import module to fix sqlite3 symbol conflict The `import` feature used `rusqlite[bundled]` which bundled its own SQLite C code, conflicting with `libsql-ffi` (also bundles SQLite). This caused duplicate `sqlite3_*` symbol linker errors when both features were enabled via `--all-features`. Replace `rusqlite` with `libsql` (already a dependency) in the import reader. The `import` feature now implies `libsql`. This eliminates the duplicate symbol conflict and allows `--all-features` to compile cleanly. Also restores `--all-features` in the WASM WIT compat CI test (now safe) and converts all import test helpers from rusqlite to libsql. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: apply cargo fmt formatting fixes Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
443 lines
15 KiB
Rust
443 lines
15 KiB
Rust
//! Read-only extraction layer for OpenClaw data.
|
|
//!
|
|
//! Handles opening OpenClaw SQLite databases and reading configuration
|
|
//! without making any modifications.
|
|
|
|
use std::fmt;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use secrecy::SecretString;
|
|
|
|
use crate::import::ImportError;
|
|
|
|
/// OpenClaw configuration structure (parsed from openclaw.json).
|
|
#[derive(Debug, Clone)]
|
|
pub struct OpenClawConfig {
|
|
pub llm: Option<OpenClawLlmConfig>,
|
|
pub embeddings: Option<OpenClawEmbeddingsConfig>,
|
|
pub other_settings: std::collections::HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct OpenClawLlmConfig {
|
|
pub provider: Option<String>,
|
|
pub model: Option<String>,
|
|
pub api_key: Option<SecretString>,
|
|
pub base_url: Option<String>,
|
|
}
|
|
|
|
impl fmt::Debug for OpenClawLlmConfig {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("OpenClawLlmConfig")
|
|
.field("provider", &self.provider)
|
|
.field("model", &self.model)
|
|
.field("api_key", &self.api_key.as_ref().map(|_| "***REDACTED***"))
|
|
.field("base_url", &self.base_url)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct OpenClawEmbeddingsConfig {
|
|
pub model: Option<String>,
|
|
pub api_key: Option<SecretString>,
|
|
pub provider: Option<String>,
|
|
}
|
|
|
|
impl fmt::Debug for OpenClawEmbeddingsConfig {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("OpenClawEmbeddingsConfig")
|
|
.field("model", &self.model)
|
|
.field("api_key", &self.api_key.as_ref().map(|_| "***REDACTED***"))
|
|
.field("provider", &self.provider)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// A memory chunk from OpenClaw's database.
|
|
#[derive(Debug, Clone)]
|
|
pub struct OpenClawMemoryChunk {
|
|
pub path: String,
|
|
pub content: String,
|
|
pub embedding: Option<Vec<f32>>,
|
|
pub chunk_index: i32,
|
|
}
|
|
|
|
/// A conversation from OpenClaw's database.
|
|
#[derive(Debug, Clone)]
|
|
pub struct OpenClawConversation {
|
|
pub id: String,
|
|
pub channel: String,
|
|
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
pub messages: Vec<OpenClawMessage>,
|
|
}
|
|
|
|
/// A message within an OpenClaw conversation.
|
|
#[derive(Debug, Clone)]
|
|
pub struct OpenClawMessage {
|
|
pub role: String,
|
|
pub content: String,
|
|
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
}
|
|
|
|
/// Open an OpenClaw SQLite database file via libsql for read-only access.
|
|
#[cfg(feature = "import")]
|
|
async fn open_sqlite(db_path: &Path) -> Result<libsql::Connection, ImportError> {
|
|
let db = libsql::Builder::new_local(db_path)
|
|
.build()
|
|
.await
|
|
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
db.connect().map_err(|e| ImportError::Sqlite(e.to_string()))
|
|
}
|
|
|
|
/// Reader for OpenClaw data files and databases.
|
|
pub struct OpenClawReader {
|
|
openclaw_dir: PathBuf,
|
|
}
|
|
|
|
impl OpenClawReader {
|
|
/// Create a new OpenClaw reader for the given directory.
|
|
pub fn new(openclaw_dir: &Path) -> Result<Self, ImportError> {
|
|
if !openclaw_dir.exists() {
|
|
return Err(ImportError::NotFound {
|
|
path: openclaw_dir.to_path_buf(),
|
|
reason: "Directory does not exist".to_string(),
|
|
});
|
|
}
|
|
|
|
Ok(Self {
|
|
openclaw_dir: openclaw_dir.to_path_buf(),
|
|
})
|
|
}
|
|
|
|
/// Check if an OpenClaw installation exists at ~/.openclaw.
|
|
pub fn detect(home_dir: &Path) -> bool {
|
|
let openclaw_dir = home_dir.join(".openclaw");
|
|
let config_file = openclaw_dir.join("openclaw.json");
|
|
config_file.exists()
|
|
}
|
|
|
|
/// Read and parse openclaw.json configuration.
|
|
pub fn read_config(&self) -> Result<OpenClawConfig, ImportError> {
|
|
let config_path = self.openclaw_dir.join("openclaw.json");
|
|
|
|
if !config_path.exists() {
|
|
return Err(ImportError::NotFound {
|
|
path: config_path,
|
|
reason: "openclaw.json not found".to_string(),
|
|
});
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&config_path).map_err(ImportError::Io)?;
|
|
|
|
#[cfg(feature = "import")]
|
|
{
|
|
let config: serde_json::Value =
|
|
json5::from_str(&content).map_err(|e| ImportError::ConfigParse(e.to_string()))?;
|
|
|
|
// Extract LLM config
|
|
let llm = config
|
|
.get("llm")
|
|
.and_then(|v| v.as_object())
|
|
.map(|llm_obj| OpenClawLlmConfig {
|
|
provider: llm_obj
|
|
.get("provider")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string()),
|
|
model: llm_obj
|
|
.get("model")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string()),
|
|
api_key: llm_obj
|
|
.get("api_key")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| SecretString::new(s.to_string().into_boxed_str())),
|
|
base_url: llm_obj
|
|
.get("base_url")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string()),
|
|
});
|
|
|
|
// Extract embeddings config
|
|
let embeddings = config
|
|
.get("embeddings")
|
|
.and_then(|v| v.as_object())
|
|
.map(|emb_obj| OpenClawEmbeddingsConfig {
|
|
model: emb_obj
|
|
.get("model")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string()),
|
|
api_key: emb_obj
|
|
.get("api_key")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| SecretString::new(s.to_string().into_boxed_str())),
|
|
provider: emb_obj
|
|
.get("provider")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string()),
|
|
});
|
|
|
|
// Store remaining settings
|
|
let mut other_settings = std::collections::HashMap::new();
|
|
if let Some(obj) = config.as_object() {
|
|
for (k, v) in obj {
|
|
if k != "llm" && k != "embeddings" {
|
|
other_settings.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(OpenClawConfig {
|
|
llm,
|
|
embeddings,
|
|
other_settings,
|
|
})
|
|
}
|
|
|
|
#[cfg(not(feature = "import"))]
|
|
{
|
|
Err(ImportError::ConfigParse(
|
|
"Import feature not enabled (compile with --features import)".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// List all agent `.sqlite` files in the agents/ directory, sorted by name for deterministic order.
|
|
pub fn list_agent_dbs(&self) -> Result<Vec<(String, PathBuf)>, ImportError> {
|
|
let agents_dir = self.openclaw_dir.join("agents");
|
|
|
|
if !agents_dir.exists() {
|
|
// No agents directory is fine (might have no saved conversations)
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut dbs = Vec::new();
|
|
for entry in std::fs::read_dir(&agents_dir).map_err(ImportError::Io)? {
|
|
let entry = entry.map_err(ImportError::Io)?;
|
|
let path = entry.path();
|
|
if path.extension().and_then(|s| s.to_str()) == Some("sqlite") {
|
|
match path.file_stem().and_then(|s| s.to_str()) {
|
|
Some(name) => dbs.push((name.to_string(), path)),
|
|
None => {
|
|
tracing::warn!(
|
|
"Skipping agent database with non-UTF-8 filename: {:?}",
|
|
path
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by agent name for deterministic ordering
|
|
dbs.sort_by(|a, b| a.0.cmp(&b.0));
|
|
|
|
Ok(dbs)
|
|
}
|
|
|
|
/// Read all memory chunks from an OpenClaw SQLite database.
|
|
#[cfg(feature = "import")]
|
|
pub async fn read_memory_chunks(
|
|
&self,
|
|
db_path: &Path,
|
|
) -> Result<Vec<OpenClawMemoryChunk>, ImportError> {
|
|
let conn = open_sqlite(db_path).await?;
|
|
|
|
let mut rows = conn
|
|
.query(
|
|
"SELECT path, content, embedding, chunk_index FROM chunks",
|
|
(),
|
|
)
|
|
.await
|
|
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
|
|
let mut result = Vec::new();
|
|
while let Some(row) = rows
|
|
.next()
|
|
.await
|
|
.map_err(|e| ImportError::Sqlite(e.to_string()))?
|
|
{
|
|
let path: String = row.get(0).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
let content: String = row.get(1).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
let embedding_blob: Option<Vec<u8>> =
|
|
row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
let chunk_index: i32 = row.get(3).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
|
|
// Convert binary embedding blob to Vec<f32> if present
|
|
let embedding = embedding_blob.map(|bytes| {
|
|
bytes
|
|
.chunks(4)
|
|
.map(|chunk| {
|
|
if chunk.len() == 4 {
|
|
f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
|
|
} else {
|
|
0.0
|
|
}
|
|
})
|
|
.collect()
|
|
});
|
|
|
|
result.push(OpenClawMemoryChunk {
|
|
path,
|
|
content,
|
|
embedding,
|
|
chunk_index,
|
|
});
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Read all conversations from an OpenClaw SQLite database.
|
|
#[cfg(feature = "import")]
|
|
pub async fn read_conversations(
|
|
&self,
|
|
db_path: &Path,
|
|
) -> Result<Vec<OpenClawConversation>, ImportError> {
|
|
let conn = open_sqlite(db_path).await?;
|
|
|
|
let mut conv_rows = conn
|
|
.query(
|
|
"SELECT id, channel, created_at FROM conversations ORDER BY created_at DESC",
|
|
(),
|
|
)
|
|
.await
|
|
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
|
|
let mut conversations = Vec::new();
|
|
while let Some(row) = conv_rows
|
|
.next()
|
|
.await
|
|
.map_err(|e| ImportError::Sqlite(e.to_string()))?
|
|
{
|
|
let id: String = row.get(0).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
let channel: String = row.get(1).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
let created_at: Option<String> =
|
|
row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
|
|
let created_at = created_at
|
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
|
.map(|dt| dt.with_timezone(&chrono::Utc));
|
|
|
|
// Read messages for this conversation
|
|
let mut msg_rows = conn
|
|
.query(
|
|
"SELECT role, content, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at",
|
|
libsql::params![id.as_str()],
|
|
)
|
|
.await
|
|
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
|
|
let mut messages = Vec::new();
|
|
while let Some(msg_row) = msg_rows
|
|
.next()
|
|
.await
|
|
.map_err(|e| ImportError::Sqlite(e.to_string()))?
|
|
{
|
|
let role: String = msg_row
|
|
.get(0)
|
|
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
let content: String = msg_row
|
|
.get(1)
|
|
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
let msg_created_at: Option<String> = msg_row
|
|
.get(2)
|
|
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
|
|
|
let msg_created_at = msg_created_at
|
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
|
.map(|dt| dt.with_timezone(&chrono::Utc));
|
|
|
|
messages.push(OpenClawMessage {
|
|
role,
|
|
content,
|
|
created_at: msg_created_at,
|
|
});
|
|
}
|
|
|
|
conversations.push(OpenClawConversation {
|
|
id,
|
|
channel,
|
|
created_at,
|
|
messages,
|
|
});
|
|
}
|
|
|
|
Ok(conversations)
|
|
}
|
|
|
|
/// List workspace markdown files available for import.
|
|
pub fn list_workspace_files(&self) -> Result<usize, ImportError> {
|
|
let workspace_dir = self.openclaw_dir.join("workspace");
|
|
|
|
if !workspace_dir.exists() {
|
|
return Ok(0);
|
|
}
|
|
|
|
let mut count = 0;
|
|
if let Ok(entries) = std::fs::read_dir(&workspace_dir) {
|
|
for entry in entries.flatten() {
|
|
if let Some(ext) = entry.path().extension()
|
|
&& ext == "md"
|
|
{
|
|
count += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(count)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod security_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_llm_config_debug_redacts_api_key() {
|
|
let config = OpenClawLlmConfig {
|
|
provider: Some("openai".to_string()),
|
|
model: Some("gpt-4".to_string()),
|
|
api_key: Some(SecretString::new("sk-secret-key-12345".into())),
|
|
base_url: Some("https://api.openai.com".to_string()),
|
|
};
|
|
|
|
let debug_output = format!("{:?}", config);
|
|
|
|
// Verify the actual API key is never exposed in debug output
|
|
assert!(!debug_output.contains("sk-secret-key-12345"));
|
|
// Verify the redaction marker is present
|
|
assert!(debug_output.contains("***REDACTED***"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_embeddings_config_debug_redacts_api_key() {
|
|
let config = OpenClawEmbeddingsConfig {
|
|
model: Some("text-embedding-3-large".to_string()),
|
|
api_key: Some(SecretString::new("sk-embed-secret-67890".into())),
|
|
provider: Some("openai".to_string()),
|
|
};
|
|
|
|
let debug_output = format!("{:?}", config);
|
|
|
|
// Verify the actual API key is never exposed in debug output
|
|
assert!(!debug_output.contains("sk-embed-secret-67890"));
|
|
// Verify the redaction marker is present
|
|
assert!(debug_output.contains("***REDACTED***"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_llm_config_without_api_key() {
|
|
let config = OpenClawLlmConfig {
|
|
provider: Some("openai".to_string()),
|
|
model: Some("gpt-4".to_string()),
|
|
api_key: None,
|
|
base_url: None,
|
|
};
|
|
|
|
let debug_output = format!("{:?}", config);
|
|
|
|
// Should show None for missing API key
|
|
assert!(debug_output.contains("api_key: None"));
|
|
}
|
|
}
|