mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
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:
co-authored by
Claude Haiku 4.5
parent
b0214fef41
commit
26068db24b
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user