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
+162
View File
@@ -0,0 +1,162 @@
//! Import command for migrating data from other AI systems.
use std::path::PathBuf;
use std::sync::Arc;
use clap::Subcommand;
#[cfg(feature = "import")]
use crate::import::ImportOptions;
#[cfg(feature = "import")]
use crate::import::openclaw::OpenClawImporter;
/// Import data from other AI systems.
#[derive(Subcommand, Debug, Clone)]
pub enum ImportCommand {
/// Import from OpenClaw (memory, history, settings, credentials)
#[cfg(feature = "import")]
Openclaw {
/// Path to OpenClaw directory (default: ~/.openclaw)
#[arg(long)]
path: Option<PathBuf>,
/// Dry-run mode: show what would be imported without writing
#[arg(long)]
dry_run: bool,
/// Re-embed memory if dimensions don't match target provider
#[arg(long)]
re_embed: bool,
/// User ID for imported data (default: 'default')
#[arg(long)]
user_id: Option<String>,
},
}
/// Run an import command.
#[cfg(feature = "import")]
pub async fn run_import_command(
cmd: &ImportCommand,
config: &crate::config::Config,
) -> anyhow::Result<()> {
match cmd {
ImportCommand::Openclaw {
path,
dry_run,
re_embed,
user_id,
} => run_import_openclaw(config, path.clone(), *dry_run, *re_embed, user_id.clone()).await,
}
}
/// Run the OpenClaw import.
#[cfg(feature = "import")]
async fn run_import_openclaw(
config: &crate::config::Config,
openclaw_path: Option<PathBuf>,
dry_run: bool,
re_embed: bool,
user_id: Option<String>,
) -> anyhow::Result<()> {
use secrecy::SecretString;
// Determine OpenClaw path
let openclaw_path = if let Some(path) = openclaw_path {
path
} else if let Some(path) = OpenClawImporter::detect() {
path
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".openclaw")
};
let user_id = user_id.unwrap_or_else(|| "default".to_string());
println!("🔍 OpenClaw Import");
println!(" Path: {}", openclaw_path.display());
println!(" User: {}", user_id);
if dry_run {
println!(" Mode: DRY RUN (no data will be written)");
}
println!();
// Initialize database
let db = crate::db::connect_from_config(&config.database)
.await
.map_err(|e| anyhow::anyhow!("Failed to initialize database: {}", e))?;
// Initialize secrets store with master key from env or keychain
let secrets_crypto = if let Ok(master_key_hex) = std::env::var("SECRETS_MASTER_KEY") {
Arc::new(
crate::secrets::SecretsCrypto::new(SecretString::from(master_key_hex))
.map_err(|e| anyhow::anyhow!("Failed to initialize secrets: {}", e))?,
)
} else {
match crate::secrets::keychain::get_master_key().await {
Ok(key_bytes) => {
let key_hex: String = key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
Arc::new(
crate::secrets::SecretsCrypto::new(SecretString::from(key_hex))
.map_err(|e| anyhow::anyhow!("Failed to initialize secrets: {}", e))?,
)
}
Err(_) => {
return Err(anyhow::anyhow!(
"No secrets master key found. Set SECRETS_MASTER_KEY env var or run 'ironclaw onboard' first."
));
}
}
};
let secrets: Arc<dyn crate::secrets::SecretsStore> = Arc::new(
crate::secrets::InMemorySecretsStore::new(secrets_crypto.clone()),
);
// Initialize workspace
let workspace = crate::workspace::Workspace::new_with_db(user_id.clone(), db.clone());
let opts = ImportOptions {
openclaw_path,
dry_run,
re_embed,
user_id,
};
let importer = OpenClawImporter::new(db, workspace, secrets, opts);
let stats = importer.import().await?;
// Print results
println!("Import Complete");
println!();
println!("Summary:");
println!(" Documents: {}", stats.documents);
println!(" Chunks: {}", stats.chunks);
println!(" Conversations: {}", stats.conversations);
println!(" Messages: {}", stats.messages);
println!(" Settings: {}", stats.settings);
println!(" Secrets: {}", stats.secrets);
if stats.skipped > 0 {
println!(" Skipped: {}", stats.skipped);
}
if stats.re_embed_queued > 0 {
println!(" Re-embed queued: {}", stats.re_embed_queued);
}
println!();
println!("Total imported: {}", stats.total_imported());
if dry_run {
println!();
println!("[DRY RUN] No data was written.");
}
Ok(())
}
#[cfg(not(feature = "import"))]
pub async fn run_import_command(
_cmd: &ImportCommand,
_config: &crate::config::Config,
) -> anyhow::Result<()> {
anyhow::bail!("Import feature not enabled. Compile with --features import")
}
+31
View File
@@ -14,6 +14,8 @@
mod completion;
mod config;
mod doctor;
#[cfg(feature = "import")]
pub mod import;
mod mcp;
pub mod memory;
pub mod oauth_defaults;
@@ -26,6 +28,8 @@ mod tool;
pub use completion::Completion;
pub use config::{ConfigCommand, run_config_command};
pub use doctor::run_doctor_command;
#[cfg(feature = "import")]
pub use import::{ImportCommand, run_import_command};
pub use mcp::{McpCommand, run_mcp_command};
pub use memory::MemoryCommand;
pub use memory::run_memory_command_with_db;
@@ -183,6 +187,15 @@ pub enum Command {
)]
Completion(Completion),
/// Import data from other AI systems
#[cfg(feature = "import")]
#[command(
subcommand,
about = "Import from other AI systems",
long_about = "Migrate data from other AI assistants like OpenClaw.\nExample: ironclaw import openclaw"
)]
Import(ImportCommand),
/// Run as a sandboxed worker inside a Docker container (internal use).
/// This is invoked automatically by the orchestrator, not by users directly.
#[command(hide = true)]
@@ -282,6 +295,7 @@ mod tests {
}
#[test]
#[cfg(feature = "import")]
fn test_help_output() {
let mut cmd = Cli::command();
let help = cmd.render_help().to_string();
@@ -289,9 +303,26 @@ mod tests {
}
#[test]
#[cfg(not(feature = "import"))]
fn test_help_output_without_import() {
let mut cmd = Cli::command();
let help = cmd.render_help().to_string();
assert_snapshot!(help);
}
#[test]
#[cfg(feature = "import")]
fn test_long_help_output() {
let mut cmd = Cli::command();
let help = cmd.render_long_help().to_string();
assert_snapshot!(help);
}
#[test]
#[cfg(not(feature = "import"))]
fn test_long_help_output_without_import() {
let mut cmd = Cli::command();
let help = cmd.render_long_help().to_string();
assert_snapshot!(help);
}
}
@@ -1,5 +1,6 @@
---
source: src/cli/mod.rs
assertion_line: 302
expression: help
---
Secure personal AI assistant that protects your data and expands its capabilities
@@ -19,6 +20,7 @@ Commands:
doctor Run diagnostics
status Show system status
completion Generate completions
import Import from other AI systems
help Print this message or the help of the given subcommand(s)
Options:
@@ -0,0 +1,32 @@
---
source: src/cli/mod.rs
assertion_line: 310
expression: help
---
Secure personal AI assistant that protects your data and expands its capabilities
Usage: ironclaw [OPTIONS] [COMMAND]
Commands:
run Run the AI agent
onboard Run interactive setup wizard
config Manage app configs
tool Manage WASM tools
registry Browse/install extensions
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
service Manage OS service
doctor Run diagnostics
status Show system status
completion Generate completions
help Print this message or the help of the given subcommand(s)
Options:
--cli-only Run in interactive CLI mode only (disable other channels)
--no-db Skip database connection (for testing)
-m, --message <MESSAGE> Single message mode - send one message and exit
-c, --config <CONFIG> Configuration file path (optional, uses env vars by default)
--no-onboard Skip first-run onboarding check
-h, --help Print help (see more with '--help')
-V, --version Print version
@@ -1,5 +1,6 @@
---
source: src/cli/mod.rs
assertion_line: 318
expression: help
---
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
@@ -22,6 +23,7 @@ Commands:
doctor Run diagnostics
status Show system status
completion Generate completions
import Import from other AI systems
help Print this message or the help of the given subcommand(s)
Options:
@@ -0,0 +1,48 @@
---
source: src/cli/mod.rs
assertion_line: 326
expression: help
---
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
Examples:
ironclaw run # Start the agent
ironclaw config list # List configs
Usage: ironclaw [OPTIONS] [COMMAND]
Commands:
run Run the AI agent
onboard Run interactive setup wizard
config Manage app configs
tool Manage WASM tools
registry Browse/install extensions
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
service Manage OS service
doctor Run diagnostics
status Show system status
completion Generate completions
help Print this message or the help of the given subcommand(s)
Options:
--cli-only
Run in interactive CLI mode only (disable other channels)
--no-db
Skip database connection (for testing)
-m, --message <MESSAGE>
Single message mode - send one message and exit
-c, --config <CONFIG>
Configuration file path (optional, uses env vars by default)
--no-onboard
Skip first-run onboarding check
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
+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"));
}
}
+2
View File
@@ -54,6 +54,8 @@ pub mod evaluation;
pub mod extensions;
pub mod history;
pub mod hooks;
#[cfg(feature = "import")]
pub mod import;
pub mod llm;
pub mod observability;
pub mod orchestrator;
+6
View File
@@ -86,6 +86,12 @@ async fn async_main() -> anyhow::Result<()> {
init_cli_tracing();
return completion.run();
}
#[cfg(feature = "import")]
Some(Command::Import(import_cmd)) => {
init_cli_tracing();
let config = ironclaw::config::Config::from_env().await?;
return ironclaw::cli::run_import_command(import_cmd, &config).await;
}
Some(Command::Worker {
job_id,
orchestrator_url,