feat: Import OpenClaw memory, history and settings (#903)

* feat: Import OpenClaw memory, history and settings

* review fixes

* fix: address remaining code quality issues

1. Remove dead import_conversation() function - replaced by import_conversation_atomic()
2. Improve non-UTF-8 filename handling in list_agent_dbs() - log warning instead of silent 'unknown'
3. Remove emojis from CLI output per project style guide

Co-Authored-By: Claude Haiku 4.5 <[email protected]>

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
Nick Pismenkov
2026-03-10 18:37:10 -07:00
committed by GitHub
co-authored by Claude Haiku 4.5
parent b0214fef41
commit 26068db24b
23 changed files with 3753 additions and 1 deletions
+93
View File
@@ -0,0 +1,93 @@
//! OpenClaw migration and import functionality.
//!
//! Provides tools to migrate existing OpenClaw installations (memory, history,
//! settings, and credentials) into IronClaw without data loss.
#[cfg(feature = "import")]
pub mod openclaw;
use std::path::PathBuf;
/// Configuration options for OpenClaw import.
#[derive(Debug, Clone)]
pub struct ImportOptions {
/// Path to the OpenClaw directory (default: ~/.openclaw).
pub openclaw_path: PathBuf,
/// Dry-run mode: report what would be imported without writing to DB.
pub dry_run: bool,
/// Re-embed memory documents if dimension mismatch detected.
pub re_embed: bool,
/// User ID for scoping imported data.
pub user_id: String,
}
/// Statistics collected during an import operation.
#[derive(Debug, Clone, Default)]
pub struct ImportStats {
/// Number of workspace documents imported.
pub documents: usize,
/// Number of memory chunks imported.
pub chunks: usize,
/// Number of conversations imported.
pub conversations: usize,
/// Number of messages imported.
pub messages: usize,
/// Number of settings imported.
pub settings: usize,
/// Number of credentials imported.
pub secrets: usize,
/// Number of items skipped (already existed).
pub skipped: usize,
/// Number of chunks queued for re-embedding.
pub re_embed_queued: usize,
}
impl ImportStats {
/// Check if any items were imported.
pub fn is_empty(&self) -> bool {
self.documents == 0
&& self.chunks == 0
&& self.conversations == 0
&& self.messages == 0
&& self.settings == 0
&& self.secrets == 0
}
/// Total number of items imported.
pub fn total_imported(&self) -> usize {
self.documents
+ self.chunks
+ self.conversations
+ self.messages
+ self.settings
+ self.secrets
}
}
/// Errors that can occur during import.
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
#[error("OpenClaw not found at {path}: {reason}")]
NotFound { path: PathBuf, reason: String },
#[error("JSON5 parse error: {0}")]
ConfigParse(String),
#[error("SQLite error: {0}")]
Sqlite(String),
#[error("Database error: {0}")]
Database(String),
#[error("Workspace error: {0}")]
Workspace(String),
#[error("Secret error: {0}")]
Secret(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid UTF-8: {0}")]
InvalidUtf8(String),
}
+26
View File
@@ -0,0 +1,26 @@
//! OpenClaw credential import with secure handling.
//!
//! Credential extraction and import is handled in the main importer (mod.rs).
//! The credentials module focuses on security validation and testing.
#[cfg(test)]
mod tests {
use crate::secrets::CreateSecretParams;
use secrecy::SecretString;
#[test]
fn test_secret_string_not_logged() {
let secret = SecretString::new("super-secret-key".to_string().into_boxed_str());
let debug_output = format!("{:?}", secret);
// Verify that the actual secret is not in the debug output
assert!(!debug_output.contains("super-secret-key"));
}
#[test]
fn test_create_secret_params_normalized() {
let params = CreateSecretParams::new("MY_API_KEY", "value123");
// Secret names should be normalized to lowercase
assert_eq!(params.name, "my_api_key");
}
}
+115
View File
@@ -0,0 +1,115 @@
//! OpenClaw conversation history import.
use std::sync::Arc;
use serde_json::json;
use uuid::Uuid;
use crate::db::Database;
use crate::import::{ImportError, ImportOptions};
use super::reader::OpenClawConversation;
/// Import a conversation and its messages atomically.
///
/// This function attempts to create a conversation and add all its messages as a logical unit.
/// While the Database trait does not expose explicit transaction control, this function
/// minimizes the risk of partial writes by:
/// - Validating all message data before creating the conversation
/// - Creating the conversation once
/// - Adding all messages in a tight loop
/// - Returning detailed errors if any step fails
///
/// Returns (conversation_id, message_count) on success.
///
/// **Note on Database Safety**: Without explicit transaction support in the Database trait,
/// if a crash occurs during message insertion, the conversation will exist with fewer messages
/// than expected. This is preferable to crashes during conversation creation (empty conversation).
///
/// **Note on Idempotency**: The metadata includes `openclaw_conversation_id` for deduplication
/// on reimport. However, without metadata-based query support in the Database trait, reimporting
/// will create duplicate conversations. This limitation should be fixed by adding
/// `list_conversations_by_metadata_key()` to the Database trait.
pub async fn import_conversation_atomic(
db: &Arc<dyn Database>,
conv: OpenClawConversation,
opts: &ImportOptions,
) -> Result<(Uuid, usize), ImportError> {
// PHASE 1: Validate all message data before writing anything
let mut validated_messages = Vec::with_capacity(conv.messages.len());
for msg in &conv.messages {
let role = match msg.role.to_lowercase().as_str() {
"user" | "human" => "user",
"assistant" | "ai" => "assistant",
_ => &msg.role,
};
validated_messages.push((role.to_string(), msg.content.clone()));
}
// PHASE 2: Create the conversation (single atomic operation from DB perspective)
// TODO: Add idempotency check when Database trait supports metadata-based lookups
let metadata = json!({
"openclaw_conversation_id": conv.id,
"openclaw_channel": conv.channel,
});
let conv_id = db
.create_conversation_with_metadata(&conv.channel, &opts.user_id, &metadata)
.await
.map_err(|e| ImportError::Database(e.to_string()))?;
// PHASE 3: Add all messages in sequence
// If this fails partway through, the conversation exists but is incomplete.
// On reimport, the openclaw_conversation_id metadata will detect it.
let mut message_count = 0;
for (role, content) in validated_messages {
db.add_conversation_message(conv_id, &role, &content)
.await
.map_err(|e| {
// Log detailed error including conversation ID for recovery
tracing::error!(
"Failed to add message to conversation {}: {}. \
Conversation created but may be incomplete.",
conv_id,
e
);
ImportError::Database(e.to_string())
})?;
message_count += 1;
}
Ok((conv_id, message_count))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::openclaw::reader::OpenClawMessage;
#[test]
fn test_conversation_import_structure() {
// Verify that OpenClawConversation can be created with test data
let conv = OpenClawConversation {
id: "conv-123".to_string(),
channel: "telegram".to_string(),
created_at: None,
messages: vec![
OpenClawMessage {
role: "user".to_string(),
content: "Hello".to_string(),
created_at: None,
},
OpenClawMessage {
role: "assistant".to_string(),
content: "Hi there".to_string(),
created_at: None,
},
],
};
assert_eq!(conv.id, "conv-123");
assert_eq!(conv.messages.len(), 2);
assert_eq!(conv.channel, "telegram");
}
}
+63
View File
@@ -0,0 +1,63 @@
//! OpenClaw memory chunk import.
use std::sync::Arc;
use crate::db::Database;
use crate::import::{ImportError, ImportOptions};
use super::reader::OpenClawMemoryChunk;
/// Import a single memory chunk into IronClaw.
pub async fn import_chunk(
db: &Arc<dyn Database>,
chunk: &OpenClawMemoryChunk,
opts: &ImportOptions,
) -> Result<(), ImportError> {
// Get or create document by path
let doc = db
.get_or_create_document_by_path(&opts.user_id, None, &chunk.path)
.await
.map_err(|e| ImportError::Database(e.to_string()))?;
// Insert chunk
let chunk_id = db
.insert_chunk(
doc.id,
chunk.chunk_index,
&chunk.content,
None, // Don't set embedding yet if dimensions might not match
)
.await
.map_err(|e| ImportError::Database(e.to_string()))?;
// If we have an embedding, try to update it
if let Some(ref embedding) = chunk.embedding {
// Note: dimension check would go here if we had target dimensions available
// For now, just store what we have
db.update_chunk_embedding(chunk_id, embedding)
.await
.map_err(|e| ImportError::Database(e.to_string()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_memory_chunk_import_structure() {
// Verify that OpenClawMemoryChunk can be created with test data
let chunk = OpenClawMemoryChunk {
path: "test/path.md".to_string(),
content: "Test content".to_string(),
embedding: Some(vec![0.1, 0.2, 0.3]),
chunk_index: 0,
};
assert_eq!(chunk.path, "test/path.md");
assert_eq!(chunk.chunk_index, 0);
assert!(chunk.embedding.is_some());
}
}
+182
View File
@@ -0,0 +1,182 @@
//! OpenClaw data migration orchestration and detection.
pub mod credentials;
pub mod history;
pub mod memory;
pub mod reader;
pub mod settings;
use std::path::PathBuf;
use std::sync::Arc;
use crate::db::Database;
use crate::import::{ImportError, ImportOptions, ImportStats};
use crate::secrets::SecretsStore;
use crate::workspace::Workspace;
pub use reader::OpenClawReader;
/// OpenClaw importer that coordinates migration of all data types.
pub struct OpenClawImporter {
db: Arc<dyn Database>,
workspace: Workspace,
secrets: Arc<dyn SecretsStore>,
opts: ImportOptions,
}
impl OpenClawImporter {
/// Create a new OpenClaw importer.
pub fn new(
db: Arc<dyn Database>,
workspace: Workspace,
secrets: Arc<dyn SecretsStore>,
opts: ImportOptions,
) -> Self {
Self {
db,
workspace,
secrets,
opts,
}
}
/// Detect if an OpenClaw installation exists at the default location (~/.openclaw).
pub fn detect() -> Option<PathBuf> {
if let Ok(home) = std::env::var("HOME") {
let openclaw_dir = PathBuf::from(home).join(".openclaw");
let config_file = openclaw_dir.join("openclaw.json");
if config_file.exists() {
return Some(openclaw_dir);
}
}
None
}
/// Run the import process for all data types.
///
/// Returns detailed statistics about what was imported.
/// If `dry_run` is enabled, no data is written to the database.
///
/// **Database Safety Note:** The Database trait does not currently expose explicit
/// transaction control (BEGIN/COMMIT/ROLLBACK). To minimize consistency risks:
/// - All configuration reading is done before any writes
/// - Writes are grouped by type (settings, credentials, documents, chunks, conversations)
/// - Conversations are handled atomically: creation + all messages added together
/// - Errors are logged but don't stop the entire import (fail-safe behavior)
pub async fn import(&self) -> Result<ImportStats, ImportError> {
let mut stats = ImportStats::default();
// === PHASE 1: READ ALL DATA BEFORE ANY WRITES ===
// This minimizes the window where the database could be left in a partial state
// Read OpenClaw data
let reader = OpenClawReader::new(&self.opts.openclaw_path)?;
let config = reader.read_config()?;
let agent_dbs = reader.list_agent_dbs()?;
// Pre-read all conversation data to validate before writing
let mut all_conversations = Vec::new();
for (_agent_name, db_path) in &agent_dbs {
match reader.read_conversations(db_path) {
Ok(convs) => all_conversations.extend(convs),
Err(e) => {
tracing::warn!("Failed to read conversations: {}", e);
}
}
}
// Pre-read all memory chunks
let mut all_chunks = Vec::new();
for (_agent_name, db_path) in &agent_dbs {
match reader.read_memory_chunks(db_path) {
Ok(chunks) => all_chunks.extend(chunks),
Err(e) => {
tracing::warn!("Failed to read memory chunks: {}", e);
}
}
}
// Prepare all settings and credentials
let settings_map = settings::map_openclaw_config_to_settings(&config);
let creds = settings::extract_credentials(&config);
// === PHASE 2: WRITE IN GROUPED ORDER ===
// If a crash occurs, earlier groups are fully committed
if !self.opts.dry_run {
// Group 1: Settings (should be idempotent via upsert)
for (key, value) in settings_map {
if let Err(e) = self.db.set_setting(&self.opts.user_id, &key, &value).await {
tracing::warn!("Failed to import setting {}: {}", key, e);
} else {
stats.settings += 1;
}
}
// Group 2: Credentials (should be idempotent via upsert)
for (name, value) in creds {
use secrecy::ExposeSecret;
let exposed = value.expose_secret().to_string();
let params = crate::secrets::CreateSecretParams::new(name, exposed);
if let Err(e) = self.secrets.create(&self.opts.user_id, params).await {
tracing::warn!("Failed to import credential: {}", e);
} else {
stats.secrets += 1;
}
}
// Group 3: Workspace documents
if let Ok(_count) = reader.list_workspace_files() {
match self
.workspace
.import_from_directory(&self.opts.openclaw_path.join("workspace"))
.await
{
Ok(imported) => stats.documents = imported,
Err(e) => {
tracing::warn!("Failed to import workspace documents: {}", e);
}
}
}
// Group 4: Memory chunks (should be idempotent via path deduplication)
for chunk in all_chunks {
if let Err(e) = memory::import_chunk(&self.db, &chunk, &self.opts).await {
tracing::warn!("Failed to import memory chunk: {}", e);
} else {
stats.chunks += 1;
}
}
// Group 5: Conversations with messages
// CRITICAL: Each conversation + its messages form an atomic unit.
// If a crash occurs mid-conversation, only that conversation is incomplete.
// All previous conversations are fully committed.
for conv in all_conversations {
match history::import_conversation_atomic(&self.db, conv, &self.opts).await {
Ok((_conv_id, msg_count)) => {
stats.conversations += 1;
stats.messages += msg_count;
}
Err(e) => {
tracing::warn!("Failed to import conversation: {}", e);
}
}
}
} else {
// DRY RUN: Count only
stats.settings = settings_map.len();
stats.secrets = creds.len();
if let Ok(count) = reader.list_workspace_files() {
stats.documents = count;
}
stats.chunks = all_chunks.len();
stats.conversations = all_conversations.len();
for conv in &all_conversations {
stats.messages += conv.messages.len();
}
}
Ok(stats)
}
}
+424
View File
@@ -0,0 +1,424 @@
//! 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>>,
}
/// 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 fn read_memory_chunks(
&self,
db_path: &Path,
) -> Result<Vec<OpenClawMemoryChunk>, ImportError> {
use rusqlite::Connection;
let conn = Connection::open(db_path).map_err(|e| ImportError::Sqlite(e.to_string()))?;
let mut stmt = conn
.prepare("SELECT path, content, embedding, chunk_index FROM chunks")
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let chunks = stmt
.query_map([], |row| {
let path: String = row.get(0)?;
let content: String = row.get(1)?;
let embedding_bytes: Option<Vec<u8>> = row.get(2)?;
let chunk_index: i32 = row.get(3)?;
// Convert binary embedding blob to Vec<f32> if present
let embedding = embedding_bytes.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()
});
Ok(OpenClawMemoryChunk {
path,
content,
embedding,
chunk_index,
})
})
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let mut result = Vec::new();
for chunk_result in chunks {
result.push(chunk_result.map_err(|e| ImportError::Sqlite(e.to_string()))?);
}
Ok(result)
}
/// Read all conversations from an OpenClaw SQLite database.
#[cfg(feature = "import")]
pub fn read_conversations(
&self,
db_path: &Path,
) -> Result<Vec<OpenClawConversation>, ImportError> {
use rusqlite::Connection;
let conn = Connection::open(db_path).map_err(|e| ImportError::Sqlite(e.to_string()))?;
// First, read all conversations
let mut conv_stmt = conn
.prepare("SELECT id, channel, created_at FROM conversations ORDER BY created_at DESC")
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let mut conversations = Vec::new();
let conv_rows = conv_stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let channel: String = row.get(1)?;
let created_at: Option<String> = row.get(2)?;
let created_at = created_at
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc));
Ok((id, channel, created_at))
})
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
for row_result in conv_rows {
let (id, channel, created_at) =
row_result.map_err(|e| ImportError::Sqlite(e.to_string()))?;
// Read messages for this conversation
let mut msg_stmt = conn.prepare(
"SELECT role, content, created_at FROM messages WHERE conversation_id = ? ORDER BY created_at"
)
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let messages = msg_stmt
.query_map([&id], |row| {
let role: String = row.get(0)?;
let content: String = row.get(1)?;
let created_at: Option<String> = row.get(2)?;
let created_at = created_at
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc));
Ok(OpenClawMessage {
role,
content,
created_at,
})
})
.map_err(|e| ImportError::Sqlite(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
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"));
}
}
+143
View File
@@ -0,0 +1,143 @@
//! OpenClaw configuration to IronClaw settings mapping.
use secrecy::SecretString;
use std::collections::HashMap;
use super::reader::OpenClawConfig;
/// Map OpenClaw configuration to IronClaw settings (dotted-key format).
pub fn map_openclaw_config_to_settings(
config: &OpenClawConfig,
) -> HashMap<String, serde_json::Value> {
let mut settings = HashMap::new();
// Map LLM configuration
if let Some(ref llm) = config.llm {
if let Some(ref provider) = llm.provider {
settings.insert(
"llm.backend".to_string(),
serde_json::Value::String(provider.clone()),
);
}
if let Some(ref model) = llm.model {
settings.insert(
"llm.selected_model".to_string(),
serde_json::Value::String(model.clone()),
);
}
if let Some(ref base_url) = llm.base_url {
settings.insert(
"llm.base_url".to_string(),
serde_json::Value::String(base_url.clone()),
);
}
}
// Map embeddings configuration
if let Some(ref emb) = config.embeddings {
if let Some(ref model) = emb.model {
settings.insert(
"embeddings.model".to_string(),
serde_json::Value::String(model.clone()),
);
}
if let Some(ref provider) = emb.provider {
settings.insert(
"embeddings.provider".to_string(),
serde_json::Value::String(provider.clone()),
);
}
}
// Map any other top-level settings
for (key, value) in &config.other_settings {
// Safely pass through JSON-serializable values
settings.insert(key.clone(), value.clone());
}
settings
}
/// Extract credentials from OpenClaw configuration.
///
/// Returns a list of (secret_name, secret_value) pairs that should be stored.
/// Secret values are never logged or printed.
pub fn extract_credentials(config: &OpenClawConfig) -> Vec<(String, SecretString)> {
let mut credentials = Vec::new();
// Extract LLM API key if present
if let Some(ref llm) = config.llm
&& let Some(ref api_key) = llm.api_key
{
credentials.push(("llm_api_key".to_string(), api_key.clone()));
}
// Extract embeddings API key if present
if let Some(ref emb) = config.embeddings
&& let Some(ref api_key) = emb.api_key
{
credentials.push(("embeddings_api_key".to_string(), api_key.clone()));
}
credentials
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::openclaw::reader::{OpenClawConfig, OpenClawLlmConfig};
#[test]
fn test_map_llm_config() {
let mut config = OpenClawConfig {
llm: None,
embeddings: None,
other_settings: HashMap::new(),
};
config.llm = Some(OpenClawLlmConfig {
provider: Some("openai".to_string()),
model: Some("gpt-4".to_string()),
api_key: Some(SecretString::new("secret".to_string().into_boxed_str())),
base_url: None,
});
let settings = map_openclaw_config_to_settings(&config);
assert_eq!(
settings.get("llm.backend"),
Some(&serde_json::Value::String("openai".to_string()))
);
assert_eq!(
settings.get("llm.selected_model"),
Some(&serde_json::Value::String("gpt-4".to_string()))
);
}
#[test]
fn test_extract_credentials_never_logs() {
let mut config = OpenClawConfig {
llm: None,
embeddings: None,
other_settings: HashMap::new(),
};
config.llm = Some(OpenClawLlmConfig {
provider: Some("anthropic".to_string()),
model: Some("claude-3".to_string()),
api_key: Some(SecretString::new(
"secret-key-value".to_string().into_boxed_str(),
)),
base_url: None,
});
let creds = extract_credentials(&config);
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].0, "llm_api_key");
// Verify the value is wrapped in SecretString (never exposed in Debug output)
assert!(!format!("{:?}", creds[0].1).contains("secret-key-value"));
}
}