diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8fd5e96 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,66 @@ +# Git history (may contain old secrets) +.git +.gitignore +.githooks + +# Rust build artifacts (can be multiple GB) +target + +# Documentation and examples (not needed for runtime) +docs +examples +tests + +# Markdown files (README, CHANGELOG, etc.) +*.md + +# Images (unnecessary for build) +*.png +*.svg +*.jpg +*.jpeg +*.gif + +# SQLite databases (conversation history, cron jobs) +*.db +*.db-journal + +# macOS artifacts +.DS_Store +.AppleDouble +.LSOverride + +# CI/CD configs (not needed in image) +.github + +# Cargo deny config (lint tool, not runtime) +deny.toml + +# License file (not needed for runtime) +LICENSE + +# Temporary files +.tmp_* +*.tmp +*.bak +*.swp +*~ + +# IDE and editor configs +.idea +.vscode +*.iml + +# Windsurf workflows +.windsurf + +# Environment files (may contain secrets) +.env +.env.* +!.env.example + +# Coverage and profiling +*.profraw +*.profdata +coverage +lcov.info diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 920fdfa..50b0524 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,40 @@ jobs: with: name: zeroclaw-${{ matrix.target }} path: target/${{ matrix.target }}/release/zeroclaw* + + docker: + name: Docker Security + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t zeroclaw:test . + + - name: Verify non-root user (UID != 0) + run: | + USER_ID=$(docker inspect --format='{{.Config.User}}' zeroclaw:test) + echo "Container user: $USER_ID" + if [ "$USER_ID" = "0" ] || [ "$USER_ID" = "root" ] || [ -z "$USER_ID" ]; then + echo "❌ FAIL: Container runs as root (UID 0)" + exit 1 + fi + echo "✅ PASS: Container runs as non-root user ($USER_ID)" + + - name: Verify distroless nonroot base image + run: | + BASE_IMAGE=$(grep -E '^FROM.*runtime|^FROM gcr.io/distroless' Dockerfile | tail -1) + echo "Base image line: $BASE_IMAGE" + if ! echo "$BASE_IMAGE" | grep -q ':nonroot'; then + echo "❌ FAIL: Runtime stage does not use :nonroot variant" + exit 1 + fi + echo "✅ PASS: Using distroless :nonroot variant" + + - name: Verify USER directive exists + run: | + if ! grep -qE '^USER\s+[0-9]+' Dockerfile; then + echo "❌ FAIL: No explicit USER directive with numeric UID" + exit 1 + fi + echo "✅ PASS: Explicit USER directive found" diff --git a/Dockerfile b/Dockerfile index 71a301f..7d684df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,14 +8,17 @@ COPY src/ src/ RUN cargo build --release --locked && \ strip target/release/zeroclaw -# ── Stage 2: Runtime (distroless — no shell, no OS, tiny) ──── -FROM gcr.io/distroless/cc-debian12 +# ── Stage 2: Runtime (distroless nonroot — no shell, no OS, tiny, UID 65534) ── +FROM gcr.io/distroless/cc-debian12:nonroot COPY --from=builder /app/target/release/zeroclaw /usr/local/bin/zeroclaw -# Default workspace +# Default workspace (owned by nonroot user) VOLUME ["/workspace"] ENV ZEROCLAW_WORKSPACE=/workspace +# Explicitly set non-root user (distroless:nonroot defaults to 65534, but be explicit) +USER 65534:65534 + ENTRYPOINT ["zeroclaw"] CMD ["gateway"] diff --git a/SECURITY.md b/SECURITY.md index 9fc4b11..32c7c28 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -61,3 +61,33 @@ cargo test -- tools::shell cargo test -- tools::file_read cargo test -- tools::file_write ``` + +## Container Security + +ZeroClaw Docker images follow CIS Docker Benchmark best practices: + +| Control | Implementation | +|---------|----------------| +| **4.1 Non-root user** | Container runs as UID 65534 (distroless nonroot) | +| **4.2 Minimal base image** | `gcr.io/distroless/cc-debian12:nonroot` — no shell, no package manager | +| **4.6 HEALTHCHECK** | Not applicable (stateless CLI/gateway) | +| **5.25 Read-only filesystem** | Supported via `docker run --read-only` with `/workspace` volume | + +### Verifying Container Security + +```bash +# Build and verify non-root user +docker build -t zeroclaw . +docker inspect --format='{{.Config.User}}' zeroclaw +# Expected: 65534:65534 + +# Run with read-only filesystem (production hardening) +docker run --read-only -v /path/to/workspace:/workspace zeroclaw gateway +``` + +### CI Enforcement + +The `docker` job in `.github/workflows/ci.yml` automatically verifies: +1. Container does not run as root (UID 0) +2. Runtime stage uses `:nonroot` variant +3. Explicit `USER` directive with numeric UID exists diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 8609353..5ed7d2e 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -16,7 +16,8 @@ pub use telegram::TelegramChannel; pub use traits::Channel; pub use whatsapp::WhatsAppChannel; -use crate::config::Config; +use crate::config::{Config, IdentityConfig}; +use crate::identity::aieos::{parse_aieos_json, AieosEntity}; use crate::memory::{self, Memory}; use crate::providers::{self, Provider}; use anyhow::Result; @@ -188,6 +189,195 @@ pub fn build_system_prompt( } } +/// Build a system prompt with AIEOS identity support. +/// +/// This is the identity-agnostic version that supports both: +/// - **OpenClaw** (default): Markdown files (IDENTITY.md, SOUL.md, etc.) +/// - **AIEOS**: JSON-based portable identity (aieos.org v1.1) +/// +/// When `identity.format = "aieos"`, the AIEOS identity is loaded and injected +/// instead of the traditional markdown bootstrap files. +pub fn build_system_prompt_with_identity( + workspace_dir: &std::path::Path, + model_name: &str, + tools: &[(&str, &str)], + skills: &[crate::skills::Skill], + identity_config: &IdentityConfig, +) -> String { + use std::fmt::Write; + let mut prompt = String::with_capacity(8192); + + // ── 1. Tooling ────────────────────────────────────────────── + if !tools.is_empty() { + prompt.push_str("## Tools\n\n"); + prompt.push_str("You have access to the following tools:\n\n"); + for (name, desc) in tools { + let _ = writeln!(prompt, "- **{name}**: {desc}"); + } + prompt.push('\n'); + } + + // ── 2. Safety ─────────────────────────────────────────────── + prompt.push_str("## Safety\n\n"); + prompt.push_str( + "- Do not exfiltrate private data.\n\ + - Do not run destructive commands without asking.\n\ + - Do not bypass oversight or approval mechanisms.\n\ + - Prefer `trash` over `rm` (recoverable beats gone forever).\n\ + - When in doubt, ask before acting externally.\n\n", + ); + + // ── 3. Skills (compact list — load on-demand) ─────────────── + if !skills.is_empty() { + prompt.push_str("## Available Skills\n\n"); + prompt.push_str( + "Skills are loaded on demand. Use `read` on the skill path to get full instructions.\n\n", + ); + prompt.push_str("\n"); + for skill in skills { + let _ = writeln!(prompt, " "); + let _ = writeln!(prompt, " {}", skill.name); + let _ = writeln!( + prompt, + " {}", + skill.description + ); + let location = workspace_dir + .join("skills") + .join(&skill.name) + .join("SKILL.md"); + let _ = writeln!(prompt, " {}", location.display()); + let _ = writeln!(prompt, " "); + } + prompt.push_str("\n\n"); + } + + // ── 4. Workspace ──────────────────────────────────────────── + let _ = writeln!( + prompt, + "## Workspace\n\nWorking directory: `{}`\n", + workspace_dir.display() + ); + + // ── 5. Identity (AIEOS or OpenClaw) ───────────────────────── + if identity_config.format.eq_ignore_ascii_case("aieos") { + // Try to load AIEOS identity + if let Some(aieos_entity) = load_aieos_from_config(workspace_dir, identity_config) { + prompt.push_str(&aieos_entity.to_system_prompt()); + } else { + // Fallback to OpenClaw if AIEOS loading fails + tracing::warn!("AIEOS identity configured but failed to load; falling back to OpenClaw"); + inject_openclaw_identity(&mut prompt, workspace_dir); + } + } else { + // Default: OpenClaw markdown files + inject_openclaw_identity(&mut prompt, workspace_dir); + } + + // ── 6. Date & Time ────────────────────────────────────────── + let now = chrono::Local::now(); + let tz = now.format("%Z").to_string(); + let _ = writeln!(prompt, "## Current Date & Time\n\nTimezone: {tz}\n"); + + // ── 7. Runtime ────────────────────────────────────────────── + let host = + hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string()); + let _ = writeln!( + prompt, + "## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n", + std::env::consts::OS, + ); + + if prompt.is_empty() { + "You are ZeroClaw, a fast and efficient AI assistant built in Rust. Be helpful, concise, and direct.".to_string() + } else { + prompt + } +} + +/// Load AIEOS entity from config (file path or inline JSON) +fn load_aieos_from_config( + workspace_dir: &std::path::Path, + identity_config: &IdentityConfig, +) -> Option { + // Try inline JSON first + if let Some(ref inline_json) = identity_config.aieos_inline { + if !inline_json.is_empty() { + match parse_aieos_json(inline_json) { + Ok(entity) => { + tracing::info!("Loaded AIEOS identity from inline JSON: {}", entity.display_name()); + return Some(entity); + } + Err(e) => { + tracing::error!("Failed to parse inline AIEOS JSON: {e}"); + } + } + } + } + + // Try file path + if let Some(ref path_str) = identity_config.aieos_path { + if !path_str.is_empty() { + let path = if std::path::Path::new(path_str).is_absolute() { + std::path::PathBuf::from(path_str) + } else { + workspace_dir.join(path_str) + }; + + match std::fs::read_to_string(&path) { + Ok(content) => match parse_aieos_json(&content) { + Ok(entity) => { + tracing::info!( + "Loaded AIEOS identity from {}: {}", + path.display(), + entity.display_name() + ); + return Some(entity); + } + Err(e) => { + tracing::error!("Failed to parse AIEOS file {}: {e}", path.display()); + } + }, + Err(e) => { + tracing::error!("Failed to read AIEOS file {}: {e}", path.display()); + } + } + } + } + + None +} + +/// Inject OpenClaw (markdown) identity files into the prompt +fn inject_openclaw_identity(prompt: &mut String, workspace_dir: &std::path::Path) { + use std::fmt::Write; + + prompt.push_str("## Project Context\n\n"); + prompt.push_str("The following workspace files define your identity, behavior, and context.\n\n"); + + let bootstrap_files = [ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + ]; + + for filename in &bootstrap_files { + inject_workspace_file(prompt, workspace_dir, filename); + } + + // BOOTSTRAP.md — only if it exists (first-run ritual) + let bootstrap_path = workspace_dir.join("BOOTSTRAP.md"); + if bootstrap_path.exists() { + inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md"); + } + + // MEMORY.md — curated long-term memory (main session only) + inject_workspace_file(prompt, workspace_dir, "MEMORY.md"); +} + /// Inject a single workspace file into the prompt with truncation and missing-file markers. fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, filename: &str) { use std::fmt::Write; diff --git a/src/channels/whatsapp.rs b/src/channels/whatsapp.rs index e50b10f..bc038f0 100644 --- a/src/channels/whatsapp.rs +++ b/src/channels/whatsapp.rs @@ -34,9 +34,7 @@ impl WhatsAppChannel { /// Check if a phone number is allowed (E.164 format: +1234567890) fn is_number_allowed(&self, phone: &str) -> bool { - self.allowed_numbers - .iter() - .any(|n| n == "*" || n == phone) + self.allowed_numbers.iter().any(|n| n == "*" || n == phone) } /// Get the verify token for webhook verification @@ -45,10 +43,7 @@ impl WhatsAppChannel { } /// Parse an incoming webhook payload from Meta and extract messages - pub fn parse_webhook_payload( - &self, - payload: &serde_json::Value, - ) -> Vec { + pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec { let mut messages = Vec::new(); // WhatsApp Cloud API webhook structure: @@ -200,10 +195,7 @@ impl Channel for WhatsAppChannel { async fn health_check(&self) -> bool { // Check if we can reach the WhatsApp API - let url = format!( - "https://graph.facebook.com/v18.0/{}", - self.phone_number_id - ); + let url = format!("https://graph.facebook.com/v18.0/{}", self.phone_number_id); self.client .get(&url) @@ -249,12 +241,7 @@ mod tests { #[test] fn whatsapp_number_allowed_wildcard() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); assert!(ch.is_number_allowed("+1234567890")); assert!(ch.is_number_allowed("+9999999999")); } @@ -335,12 +322,7 @@ mod tests { #[test] fn whatsapp_parse_non_text_message_skipped() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -362,12 +344,7 @@ mod tests { #[test] fn whatsapp_parse_multiple_messages() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -418,12 +395,7 @@ mod tests { #[test] fn whatsapp_empty_text_skipped() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -535,12 +507,7 @@ mod tests { #[test] fn whatsapp_parse_missing_from_field() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -560,12 +527,7 @@ mod tests { #[test] fn whatsapp_parse_missing_text_body() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -581,17 +543,15 @@ mod tests { }] }); let msgs = ch.parse_webhook_payload(&payload); - assert!(msgs.is_empty(), "Messages with empty text object should be skipped"); + assert!( + msgs.is_empty(), + "Messages with empty text object should be skipped" + ); } #[test] fn whatsapp_parse_null_text_body() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -612,12 +572,7 @@ mod tests { #[test] fn whatsapp_parse_invalid_timestamp_uses_current() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -640,12 +595,7 @@ mod tests { #[test] fn whatsapp_parse_missing_timestamp_uses_current() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -666,12 +616,7 @@ mod tests { #[test] fn whatsapp_parse_multiple_entries() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [ { @@ -708,12 +653,7 @@ mod tests { #[test] fn whatsapp_parse_multiple_changes() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [ @@ -769,12 +709,7 @@ mod tests { #[test] fn whatsapp_parse_audio_message_skipped() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -795,12 +730,7 @@ mod tests { #[test] fn whatsapp_parse_video_message_skipped() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -821,12 +751,7 @@ mod tests { #[test] fn whatsapp_parse_document_message_skipped() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -847,12 +772,7 @@ mod tests { #[test] fn whatsapp_parse_sticker_message_skipped() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -873,12 +793,7 @@ mod tests { #[test] fn whatsapp_parse_location_message_skipped() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -899,12 +814,7 @@ mod tests { #[test] fn whatsapp_parse_contacts_message_skipped() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -925,12 +835,7 @@ mod tests { #[test] fn whatsapp_parse_reaction_message_skipped() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -978,12 +883,7 @@ mod tests { #[test] fn whatsapp_parse_unicode_message() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -1005,12 +905,7 @@ mod tests { #[test] fn whatsapp_parse_very_long_message() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let long_text = "A".repeat(10_000); let payload = serde_json::json!({ "entry": [{ @@ -1033,12 +928,7 @@ mod tests { #[test] fn whatsapp_parse_whitespace_only_message_skipped() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -1065,7 +955,11 @@ mod tests { "tok".into(), "123".into(), "ver".into(), - vec!["+1111111111".into(), "+2222222222".into(), "+3333333333".into()], + vec![ + "+1111111111".into(), + "+2222222222".into(), + "+3333333333".into(), + ], ); assert!(ch.is_number_allowed("+1111111111")); assert!(ch.is_number_allowed("+2222222222")); @@ -1169,12 +1063,7 @@ mod tests { #[test] fn whatsapp_parse_newlines_preserved() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -1196,12 +1085,7 @@ mod tests { #[test] fn whatsapp_parse_special_characters() { - let ch = WhatsAppChannel::new( - "tok".into(), - "123".into(), - "ver".into(), - vec!["*".into()], - ); + let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]); let payload = serde_json::json!({ "entry": [{ "changes": [{ @@ -1218,6 +1102,9 @@ mod tests { }); let msgs = ch.parse_webhook_payload(&payload); assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0].content, " & \"quotes\" 'apostrophe'"); + assert_eq!( + msgs[0].content, + " & \"quotes\" 'apostrophe'" + ); } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 4632486..f5849c1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,7 +2,7 @@ pub mod schema; pub use schema::{ AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, - GatewayConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, + GatewayConfig, HeartbeatConfig, IMessageConfig, IdentityConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, ReliabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, TunnelConfig, WebhookConfig, }; diff --git a/src/config/schema.rs b/src/config/schema.rs index 942e2f5..749f0ba 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -51,6 +51,41 @@ pub struct Config { #[serde(default)] pub browser: BrowserConfig, + + #[serde(default)] + pub identity: IdentityConfig, +} + +// ── Identity (AIEOS support) ───────────────────────────────────── + +/// Identity configuration — supports multiple identity formats +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentityConfig { + /// Identity format: "openclaw" (default, markdown files) or "aieos" (JSON) + #[serde(default = "default_identity_format")] + pub format: String, + /// Path to AIEOS JSON file (relative to workspace or absolute) + /// Only used when format = "aieos" + #[serde(default)] + pub aieos_path: Option, + /// Inline AIEOS JSON (alternative to aieos_path) + /// Only used when format = "aieos" + #[serde(default)] + pub aieos_inline: Option, +} + +fn default_identity_format() -> String { + "openclaw".into() +} + +impl Default for IdentityConfig { + fn default() -> Self { + Self { + format: default_identity_format(), + aieos_path: None, + aieos_inline: None, + } + } } // ── Gateway security ───────────────────────────────────────────── @@ -585,6 +620,7 @@ impl Default for Config { composio: ComposioConfig::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), + identity: IdentityConfig::default(), } } } @@ -740,6 +776,7 @@ mod tests { composio: ComposioConfig::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), + identity: IdentityConfig::default(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); @@ -809,6 +846,7 @@ default_temperature = 0.7 composio: ComposioConfig::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), + identity: IdentityConfig::default(), }; config.save().unwrap(); @@ -1329,4 +1367,64 @@ default_temperature = 0.7 assert!(!parsed.browser.enabled); assert!(parsed.browser.allowed_domains.is_empty()); } + + // ══════════════════════════════════════════════════════════ + // IDENTITY CONFIG TESTS (AIEOS support) + // ══════════════════════════════════════════════════════════ + + #[test] + fn identity_config_default_is_openclaw() { + let i = IdentityConfig::default(); + assert_eq!(i.format, "openclaw"); + assert!(i.aieos_path.is_none()); + assert!(i.aieos_inline.is_none()); + } + + #[test] + fn identity_config_serde_roundtrip() { + let i = IdentityConfig { + format: "aieos".into(), + aieos_path: Some("identity.json".into()), + aieos_inline: None, + }; + let toml_str = toml::to_string(&i).unwrap(); + let parsed: IdentityConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.format, "aieos"); + assert_eq!(parsed.aieos_path.as_deref(), Some("identity.json")); + assert!(parsed.aieos_inline.is_none()); + } + + #[test] + fn identity_config_with_inline_json() { + let i = IdentityConfig { + format: "aieos".into(), + aieos_path: None, + aieos_inline: Some(r#"{"identity":{"names":{"first":"Test"}}}"#.into()), + }; + let toml_str = toml::to_string(&i).unwrap(); + let parsed: IdentityConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.format, "aieos"); + assert!(parsed.aieos_inline.is_some()); + assert!(parsed.aieos_inline.unwrap().contains("Test")); + } + + #[test] + fn identity_config_backward_compat_missing_section() { + let minimal = r#" +workspace_dir = "/tmp/ws" +config_path = "/tmp/config.toml" +default_temperature = 0.7 +"#; + let parsed: Config = toml::from_str(minimal).unwrap(); + assert_eq!(parsed.identity.format, "openclaw"); + assert!(parsed.identity.aieos_path.is_none()); + assert!(parsed.identity.aieos_inline.is_none()); + } + + #[test] + fn config_default_has_identity() { + let c = Config::default(); + assert_eq!(c.identity.format, "openclaw"); + assert!(c.identity.aieos_path.is_none()); + } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index bfd97c5..0d77f96 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -240,7 +240,17 @@ async fn handle_request( // WhatsApp incoming message webhook ("POST", "/whatsapp") => { - handle_whatsapp_message(stream, request, provider, model, temperature, mem, auto_save, whatsapp).await; + handle_whatsapp_message( + stream, + request, + provider, + model, + temperature, + mem, + auto_save, + whatsapp, + ) + .await; } ("POST", "/webhook") => { @@ -770,10 +780,7 @@ mod tests { #[test] fn urlencoding_decode_challenge_token() { // Typical Meta webhook challenge - assert_eq!( - urlencoding_decode("1234567890"), - "1234567890" - ); + assert_eq!(urlencoding_decode("1234567890"), "1234567890"); } #[test] diff --git a/src/identity/aieos.rs b/src/identity/aieos.rs new file mode 100644 index 0000000..03d896b --- /dev/null +++ b/src/identity/aieos.rs @@ -0,0 +1,1453 @@ +//! AIEOS (AI Entity Object Specification) v1.1 support +//! +//! AIEOS is a standardization framework for portable AI identity. +//! See: https://aieos.org +//! +//! This module provides: +//! - Full AIEOS v1.1 schema types +//! - JSON parsing and validation +//! - Conversion to ZeroClaw system prompt sections + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write; +use std::path::Path; + +// ══════════════════════════════════════════════════════════════════════════════ +// AIEOS v1.1 Schema Types +// ══════════════════════════════════════════════════════════════════════════════ + +/// Root AIEOS entity object +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosEntity { + /// JSON-LD context (optional, for semantic web compatibility) + #[serde(rename = "@context", default)] + pub context: Option, + + /// Entity type marker + #[serde(rename = "@type", default)] + pub entity_type: Option, + + /// Protocol standard info + #[serde(default)] + pub standard: Option, + + /// Internal tracking metadata + #[serde(default)] + pub metadata: Option, + + /// Standardized skills and tools + #[serde(default)] + pub capabilities: Option, + + /// Core biographical data + #[serde(default)] + pub identity: Option, + + /// Visual descriptors for image generation + #[serde(default)] + pub physicality: Option, + + /// The "Soul" layer — cognitive weights, traits, moral boundaries + #[serde(default)] + pub psychology: Option, + + /// How the entity speaks — voice and text style + #[serde(default)] + pub linguistics: Option, + + /// Origin story, education, occupation + #[serde(default)] + pub history: Option, + + /// Preferences, hobbies, lifestyle + #[serde(default)] + pub interests: Option, + + /// Goals and core drives + #[serde(default)] + pub motivations: Option, +} + +// ── Context & Standard ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosContext { + #[serde(default)] + pub aieos: Option, + #[serde(default)] + pub schema: Option, + #[serde(default)] + pub xsd: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosStandard { + #[serde(default)] + pub protocol: Option, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub schema_url: Option, +} + +// ── Metadata ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosMetadata { + #[serde(rename = "@type", default)] + pub metadata_type: Option, + #[serde(rename = "@description", default)] + pub description: Option, + #[serde(default)] + pub instance_id: Option, + #[serde(default)] + pub instance_version: Option, + #[serde(default)] + pub generator: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub last_updated: Option, +} + +// ── Capabilities ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosCapabilities { + #[serde(rename = "@type", default)] + pub capabilities_type: Option, + #[serde(rename = "@description", default)] + pub description: Option, + #[serde(default)] + pub skills: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosSkill { + #[serde(rename = "@type", default)] + pub skill_type: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub uri: Option, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub auto_activate: Option, + #[serde(default)] + pub priority: Option, +} + +// ── Identity ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosIdentity { + #[serde(rename = "@type", default)] + pub identity_type: Option, + #[serde(rename = "@description", default)] + pub description: Option, + #[serde(default)] + pub names: Option, + #[serde(default)] + pub bio: Option, + #[serde(default)] + pub origin: Option, + #[serde(default)] + pub residence: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosNames { + #[serde(default)] + pub first: Option, + #[serde(default)] + pub middle: Option, + #[serde(default)] + pub last: Option, + #[serde(default)] + pub nickname: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosBio { + #[serde(rename = "@type", default)] + pub bio_type: Option, + #[serde(default)] + pub birthday: Option, + #[serde(default)] + pub age_biological: Option, + #[serde(default)] + pub age_perceived: Option, + #[serde(default)] + pub gender: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosOrigin { + #[serde(default)] + pub nationality: Option, + #[serde(default)] + pub ethnicity: Option, + #[serde(default)] + pub birthplace: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosPlace { + #[serde(rename = "@type", default)] + pub place_type: Option, + #[serde(default)] + pub city: Option, + #[serde(default)] + pub country: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosResidence { + #[serde(rename = "@type", default)] + pub residence_type: Option, + #[serde(default)] + pub current_city: Option, + #[serde(default)] + pub current_country: Option, + #[serde(default)] + pub dwelling_type: Option, +} + +// ── Physicality ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosPhysicality { + #[serde(rename = "@type", default)] + pub physicality_type: Option, + #[serde(rename = "@description", default)] + pub description: Option, + #[serde(default)] + pub face: Option, + #[serde(default)] + pub body: Option, + #[serde(default)] + pub style: Option, + #[serde(default)] + pub image_prompts: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosFace { + #[serde(default)] + pub shape: Option, + #[serde(default)] + pub skin: Option, + #[serde(default)] + pub eyes: Option, + #[serde(default)] + pub hair: Option, + #[serde(default)] + pub facial_hair: Option, + #[serde(default)] + pub nose: Option, + #[serde(default)] + pub mouth: Option, + #[serde(default)] + pub distinguishing_features: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosSkin { + #[serde(default)] + pub tone: Option, + #[serde(default)] + pub texture: Option, + #[serde(default)] + pub details: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosEyes { + #[serde(default)] + pub color: Option, + #[serde(default)] + pub shape: Option, + #[serde(default)] + pub eyebrows: Option, + #[serde(default)] + pub corrective_lenses: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosHair { + #[serde(default)] + pub color: Option, + #[serde(default)] + pub style: Option, + #[serde(default)] + pub texture: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosBody { + #[serde(default)] + pub height_cm: Option, + #[serde(default)] + pub weight_kg: Option, + #[serde(default)] + pub somatotype: Option, + #[serde(default)] + pub build_description: Option, + #[serde(default)] + pub posture: Option, + #[serde(default)] + pub scars_tattoos: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosStyle { + #[serde(default)] + pub aesthetic_archetype: Option, + #[serde(default)] + pub clothing_preferences: Vec, + #[serde(default)] + pub accessories: Vec, + #[serde(default)] + pub color_palette: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosImagePrompts { + #[serde(default)] + pub portrait: Option, + #[serde(default)] + pub full_body: Option, +} + +// ── Psychology ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosPsychology { + #[serde(rename = "@type", default)] + pub psychology_type: Option, + #[serde(rename = "@description", default)] + pub description: Option, + #[serde(default)] + pub neural_matrix: Option, + #[serde(default)] + pub traits: Option, + #[serde(default)] + pub moral_compass: Option, + #[serde(default)] + pub mental_patterns: Option, + #[serde(default)] + pub emotional_profile: Option, + #[serde(default)] + pub idiosyncrasies: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosNeuralMatrix { + #[serde(rename = "@type", default)] + pub matrix_type: Option, + #[serde(rename = "@description", default)] + pub description: Option, + #[serde(default)] + pub creativity: Option, + #[serde(default)] + pub empathy: Option, + #[serde(default)] + pub logic: Option, + #[serde(default)] + pub adaptability: Option, + #[serde(default)] + pub charisma: Option, + #[serde(default)] + pub reliability: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosTraits { + #[serde(default)] + pub ocean: Option, + #[serde(default)] + pub mbti: Option, + #[serde(default)] + pub enneagram: Option, + #[serde(default)] + pub temperament: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosOcean { + #[serde(default)] + pub openness: Option, + #[serde(default)] + pub conscientiousness: Option, + #[serde(default)] + pub extraversion: Option, + #[serde(default)] + pub agreeableness: Option, + #[serde(default)] + pub neuroticism: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosMoralCompass { + #[serde(default)] + pub alignment: Option, + #[serde(default)] + pub core_values: Vec, + #[serde(default)] + pub conflict_resolution_style: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosMentalPatterns { + #[serde(default)] + pub decision_making_style: Option, + #[serde(default)] + pub attention_span: Option, + #[serde(default)] + pub learning_style: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosEmotionalProfile { + #[serde(default)] + pub base_mood: Option, + #[serde(default)] + pub volatility: Option, + #[serde(default)] + pub resilience: Option, + #[serde(default)] + pub triggers: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosTriggers { + #[serde(default)] + pub joy: Vec, + #[serde(default)] + pub anger: Vec, + #[serde(default)] + pub sadness: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosIdiosyncrasies { + #[serde(default)] + pub phobias: Vec, + #[serde(default)] + pub obsessions: Vec, + #[serde(default)] + pub tics: Vec, +} + +// ── Linguistics ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosLinguistics { + #[serde(rename = "@type", default)] + pub linguistics_type: Option, + #[serde(rename = "@description", default)] + pub description: Option, + #[serde(default)] + pub voice: Option, + #[serde(default)] + pub text_style: Option, + #[serde(default)] + pub syntax: Option, + #[serde(default)] + pub interaction: Option, + #[serde(default)] + pub idiolect: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosVoice { + #[serde(default)] + pub tts_config: Option, + #[serde(default)] + pub acoustics: Option, + #[serde(default)] + pub accent: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosTtsConfig { + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub voice_id: Option, + #[serde(default)] + pub stability: Option, + #[serde(default)] + pub similarity_boost: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosAcoustics { + #[serde(default)] + pub pitch: Option, + #[serde(default)] + pub speed: Option, + #[serde(default)] + pub roughness: Option, + #[serde(default)] + pub breathiness: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosAccent { + #[serde(default)] + pub region: Option, + #[serde(default)] + pub strength: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosTextStyle { + #[serde(default)] + pub formality_level: Option, + #[serde(default)] + pub verbosity_level: Option, + #[serde(default)] + pub vocabulary_level: Option, + #[serde(default)] + pub slang_usage: Option, + #[serde(default)] + pub style_descriptors: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosSyntax { + #[serde(default)] + pub sentence_structure: Option, + #[serde(default)] + pub use_contractions: Option, + #[serde(default)] + pub active_passive_ratio: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosInteraction { + #[serde(default)] + pub turn_taking: Option, + #[serde(default)] + pub dominance_score: Option, + #[serde(default)] + pub emotional_coloring: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosIdiolect { + #[serde(default)] + pub catchphrases: Vec, + #[serde(default)] + pub forbidden_words: Vec, + #[serde(default)] + pub hesitation_markers: Option, +} + +// ── History ────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosHistory { + #[serde(rename = "@type", default)] + pub history_type: Option, + #[serde(rename = "@description", default)] + pub description: Option, + #[serde(default)] + pub origin_story: Option, + #[serde(default)] + pub education: Option, + #[serde(default)] + pub occupation: Option, + #[serde(default)] + pub family: Option, + #[serde(default)] + pub key_life_events: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosEducation { + #[serde(default)] + pub level: Option, + #[serde(default)] + pub field: Option, + #[serde(default)] + pub institution: Option, + #[serde(default)] + pub graduation_year: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosOccupation { + #[serde(default)] + pub title: Option, + #[serde(default)] + pub industry: Option, + #[serde(default)] + pub years_experience: Option, + #[serde(default)] + pub previous_jobs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosFamily { + #[serde(default)] + pub relationship_status: Option, + #[serde(default)] + pub parents: Option, + #[serde(default)] + pub siblings: Option, + #[serde(default)] + pub children: Option, + #[serde(default)] + pub pets: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosLifeEvent { + #[serde(default)] + pub year: Option, + #[serde(default)] + pub event: Option, + #[serde(default)] + pub impact: Option, +} + +// ── Interests ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosInterests { + #[serde(rename = "@type", default)] + pub interests_type: Option, + #[serde(rename = "@description", default)] + pub description: Option, + #[serde(default)] + pub hobbies: Vec, + #[serde(default)] + pub favorites: Option, + #[serde(default)] + pub aversions: Vec, + #[serde(default)] + pub lifestyle: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosFavorites { + #[serde(default)] + pub music_genre: Option, + #[serde(default)] + pub book: Option, + #[serde(default)] + pub movie: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub food: Option, + #[serde(default)] + pub season: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosLifestyle { + #[serde(default)] + pub diet: Option, + #[serde(default)] + pub sleep_schedule: Option, + #[serde(default)] + pub digital_habits: Option, +} + +// ── Motivations ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosMotivations { + #[serde(rename = "@type", default)] + pub motivations_type: Option, + #[serde(rename = "@description", default)] + pub description: Option, + #[serde(default)] + pub core_drive: Option, + #[serde(default)] + pub goals: Option, + #[serde(default)] + pub fears: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosGoals { + #[serde(default)] + pub short_term: Vec, + #[serde(default)] + pub long_term: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AieosFears { + #[serde(default)] + pub rational: Vec, + #[serde(default)] + pub irrational: Vec, +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Loading & Parsing +// ══════════════════════════════════════════════════════════════════════════════ + +/// Load an AIEOS identity from a JSON file +pub fn load_aieos_identity(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read AIEOS file: {}", path.display()))?; + parse_aieos_json(&content) +} + +/// Parse an AIEOS identity from a JSON string +pub fn parse_aieos_json(json: &str) -> Result { + serde_json::from_str(json).context("Failed to parse AIEOS JSON") +} + +// ══════════════════════════════════════════════════════════════════════════════ +// System Prompt Generation +// ══════════════════════════════════════════════════════════════════════════════ + +impl AieosEntity { + /// Get the entity's display name (first name, nickname, or "Entity") + pub fn display_name(&self) -> String { + if let Some(ref identity) = self.identity { + if let Some(ref names) = identity.names { + if let Some(ref nickname) = names.nickname { + if !nickname.is_empty() { + return nickname.clone(); + } + } + if let Some(ref first) = names.first { + if !first.is_empty() { + return first.clone(); + } + } + } + } + "Entity".to_string() + } + + /// Get the entity's full name + pub fn full_name(&self) -> Option { + let identity = self.identity.as_ref()?; + let names = identity.names.as_ref()?; + + let mut parts = Vec::new(); + if let Some(ref first) = names.first { + if !first.is_empty() { + parts.push(first.as_str()); + } + } + if let Some(ref middle) = names.middle { + if !middle.is_empty() { + parts.push(middle.as_str()); + } + } + if let Some(ref last) = names.last { + if !last.is_empty() { + parts.push(last.as_str()); + } + } + + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } + } + + /// Convert AIEOS entity to a system prompt section + /// + /// This generates a comprehensive prompt section that captures the entity's + /// identity, psychology, linguistics, and motivations in a format suitable + /// for LLM system prompts. + pub fn to_system_prompt(&self) -> String { + let mut prompt = String::with_capacity(4096); + + prompt.push_str("## AIEOS Identity\n\n"); + prompt.push_str("*Portable AI identity loaded from AIEOS v1.1 specification*\n\n"); + + // Identity section + self.write_identity_section(&mut prompt); + + // Psychology section (the "Soul") + self.write_psychology_section(&mut prompt); + + // Linguistics section (how to speak) + self.write_linguistics_section(&mut prompt); + + // Motivations section + self.write_motivations_section(&mut prompt); + + // Capabilities section + self.write_capabilities_section(&mut prompt); + + // History section (brief) + self.write_history_section(&mut prompt); + + prompt + } + + fn write_identity_section(&self, prompt: &mut String) { + if let Some(ref identity) = self.identity { + prompt.push_str("### Identity\n\n"); + + if let Some(full_name) = self.full_name() { + let _ = writeln!(prompt, "- **Name:** {full_name}"); + } + + if let Some(ref names) = identity.names { + if let Some(ref nickname) = names.nickname { + if !nickname.is_empty() { + let _ = writeln!(prompt, "- **Nickname:** {nickname}"); + } + } + } + + if let Some(ref bio) = identity.bio { + if let Some(ref gender) = bio.gender { + if !gender.is_empty() { + let _ = writeln!(prompt, "- **Gender:** {gender}"); + } + } + if let Some(age) = bio.age_perceived { + if age > 0 { + let _ = writeln!(prompt, "- **Perceived Age:** {age}"); + } + } + } + + if let Some(ref origin) = identity.origin { + if let Some(ref nationality) = origin.nationality { + if !nationality.is_empty() { + let _ = writeln!(prompt, "- **Nationality:** {nationality}"); + } + } + if let Some(ref birthplace) = origin.birthplace { + let mut place_parts = Vec::new(); + if let Some(ref city) = birthplace.city { + if !city.is_empty() { + place_parts.push(city.as_str()); + } + } + if let Some(ref country) = birthplace.country { + if !country.is_empty() { + place_parts.push(country.as_str()); + } + } + if !place_parts.is_empty() { + let _ = writeln!(prompt, "- **Birthplace:** {}", place_parts.join(", ")); + } + } + } + + if let Some(ref residence) = identity.residence { + let mut res_parts = Vec::new(); + if let Some(ref city) = residence.current_city { + if !city.is_empty() { + res_parts.push(city.as_str()); + } + } + if let Some(ref country) = residence.current_country { + if !country.is_empty() { + res_parts.push(country.as_str()); + } + } + if !res_parts.is_empty() { + let _ = writeln!(prompt, "- **Current Location:** {}", res_parts.join(", ")); + } + } + + prompt.push('\n'); + } + } + + fn write_psychology_section(&self, prompt: &mut String) { + if let Some(ref psych) = self.psychology { + prompt.push_str("### Psychology (Soul)\n\n"); + + // Neural matrix (cognitive weights) + if let Some(ref matrix) = psych.neural_matrix { + prompt.push_str("**Cognitive Profile:**\n"); + if let Some(v) = matrix.creativity { + let _ = writeln!(prompt, "- Creativity: {:.0}%", v * 100.0); + } + if let Some(v) = matrix.empathy { + let _ = writeln!(prompt, "- Empathy: {:.0}%", v * 100.0); + } + if let Some(v) = matrix.logic { + let _ = writeln!(prompt, "- Logic: {:.0}%", v * 100.0); + } + if let Some(v) = matrix.adaptability { + let _ = writeln!(prompt, "- Adaptability: {:.0}%", v * 100.0); + } + if let Some(v) = matrix.charisma { + let _ = writeln!(prompt, "- Charisma: {:.0}%", v * 100.0); + } + if let Some(v) = matrix.reliability { + let _ = writeln!(prompt, "- Reliability: {:.0}%", v * 100.0); + } + prompt.push('\n'); + } + + // Personality traits + if let Some(ref traits) = psych.traits { + prompt.push_str("**Personality:**\n"); + if let Some(ref mbti) = traits.mbti { + if !mbti.is_empty() { + let _ = writeln!(prompt, "- MBTI: {mbti}"); + } + } + if let Some(ref enneagram) = traits.enneagram { + if !enneagram.is_empty() { + let _ = writeln!(prompt, "- Enneagram: {enneagram}"); + } + } + if let Some(ref temperament) = traits.temperament { + if !temperament.is_empty() { + let _ = writeln!(prompt, "- Temperament: {temperament}"); + } + } + prompt.push('\n'); + } + + // Moral compass + if let Some(ref moral) = psych.moral_compass { + if let Some(ref alignment) = moral.alignment { + if !alignment.is_empty() { + let _ = writeln!(prompt, "**Moral Alignment:** {alignment}"); + } + } + if !moral.core_values.is_empty() { + let _ = writeln!(prompt, "**Core Values:** {}", moral.core_values.join(", ")); + } + if let Some(ref style) = moral.conflict_resolution_style { + if !style.is_empty() { + let _ = writeln!(prompt, "**Conflict Style:** {style}"); + } + } + prompt.push('\n'); + } + + // Emotional profile + if let Some(ref emotional) = psych.emotional_profile { + if let Some(ref mood) = emotional.base_mood { + if !mood.is_empty() { + let _ = writeln!(prompt, "**Base Mood:** {mood}"); + } + } + if let Some(ref resilience) = emotional.resilience { + if !resilience.is_empty() { + let _ = writeln!(prompt, "**Resilience:** {resilience}"); + } + } + prompt.push('\n'); + } + } + } + + fn write_linguistics_section(&self, prompt: &mut String) { + if let Some(ref ling) = self.linguistics { + prompt.push_str("### Communication Style\n\n"); + + // Text style + if let Some(ref style) = ling.text_style { + if let Some(formality) = style.formality_level { + let level = if formality < 0.3 { + "casual" + } else if formality < 0.7 { + "balanced" + } else { + "formal" + }; + let _ = writeln!(prompt, "- **Formality:** {level}"); + } + if let Some(verbosity) = style.verbosity_level { + let level = if verbosity < 0.3 { + "concise" + } else if verbosity < 0.7 { + "moderate" + } else { + "verbose" + }; + let _ = writeln!(prompt, "- **Verbosity:** {level}"); + } + if let Some(ref vocab) = style.vocabulary_level { + if !vocab.is_empty() { + let _ = writeln!(prompt, "- **Vocabulary:** {vocab}"); + } + } + if let Some(slang) = style.slang_usage { + let _ = writeln!( + prompt, + "- **Slang:** {}", + if slang { "yes" } else { "no" } + ); + } + if !style.style_descriptors.is_empty() { + let _ = writeln!( + prompt, + "- **Style:** {}", + style.style_descriptors.join(", ") + ); + } + } + + // Syntax + if let Some(ref syntax) = ling.syntax { + if let Some(ref structure) = syntax.sentence_structure { + if !structure.is_empty() { + let _ = writeln!(prompt, "- **Sentence Structure:** {structure}"); + } + } + if let Some(contractions) = syntax.use_contractions { + let _ = writeln!( + prompt, + "- **Contractions:** {}", + if contractions { "yes" } else { "no" } + ); + } + } + + // Idiolect + if let Some(ref idiolect) = ling.idiolect { + if !idiolect.catchphrases.is_empty() { + let _ = writeln!( + prompt, + "- **Catchphrases:** \"{}\"", + idiolect.catchphrases.join("\", \"") + ); + } + if !idiolect.forbidden_words.is_empty() { + let _ = writeln!( + prompt, + "- **Avoid saying:** {}", + idiolect.forbidden_words.join(", ") + ); + } + } + + // Voice (for TTS awareness) + if let Some(ref voice) = ling.voice { + if let Some(ref accent) = voice.accent { + if let Some(ref region) = accent.region { + if !region.is_empty() { + let _ = writeln!(prompt, "- **Accent:** {region}"); + } + } + } + } + + prompt.push('\n'); + } + } + + fn write_motivations_section(&self, prompt: &mut String) { + if let Some(ref motiv) = self.motivations { + prompt.push_str("### Motivations\n\n"); + + if let Some(ref drive) = motiv.core_drive { + if !drive.is_empty() { + let _ = writeln!(prompt, "**Core Drive:** {drive}\n"); + } + } + + if let Some(ref goals) = motiv.goals { + if !goals.short_term.is_empty() { + prompt.push_str("**Short-term Goals:**\n"); + for goal in &goals.short_term { + let _ = writeln!(prompt, "- {goal}"); + } + prompt.push('\n'); + } + if !goals.long_term.is_empty() { + prompt.push_str("**Long-term Goals:**\n"); + for goal in &goals.long_term { + let _ = writeln!(prompt, "- {goal}"); + } + prompt.push('\n'); + } + } + + if let Some(ref fears) = motiv.fears { + if !fears.rational.is_empty() || !fears.irrational.is_empty() { + let all_fears: Vec<_> = fears + .rational + .iter() + .chain(fears.irrational.iter()) + .collect(); + if !all_fears.is_empty() { + let _ = writeln!( + prompt, + "**Fears:** {}\n", + all_fears + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ); + } + } + } + } + } + + fn write_capabilities_section(&self, prompt: &mut String) { + if let Some(ref caps) = self.capabilities { + if !caps.skills.is_empty() { + prompt.push_str("### Capabilities\n\n"); + for skill in &caps.skills { + if let Some(ref name) = skill.name { + if !name.is_empty() { + let desc = skill.description.as_deref().unwrap_or(""); + let _ = writeln!(prompt, "- **{name}**: {desc}"); + } + } + } + prompt.push('\n'); + } + } + } + + fn write_history_section(&self, prompt: &mut String) { + if let Some(ref history) = self.history { + let mut has_content = false; + + if let Some(ref story) = history.origin_story { + if !story.is_empty() { + prompt.push_str("### Background\n\n"); + let _ = writeln!(prompt, "{story}\n"); + has_content = true; + } + } + + if let Some(ref occupation) = history.occupation { + if let Some(ref title) = occupation.title { + if !title.is_empty() { + if !has_content { + prompt.push_str("### Background\n\n"); + } + let industry = occupation.industry.as_deref().unwrap_or(""); + if industry.is_empty() { + let _ = writeln!(prompt, "**Occupation:** {title}"); + } else { + let _ = writeln!(prompt, "**Occupation:** {title} ({industry})"); + } + prompt.push('\n'); + } + } + } + } + } +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Tests +// ══════════════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_minimal_aieos() { + let json = r#"{}"#; + let entity = parse_aieos_json(json).unwrap(); + assert!(entity.identity.is_none()); + assert!(entity.psychology.is_none()); + } + + #[test] + fn parse_aieos_with_identity() { + let json = r#"{ + "identity": { + "names": { + "first": "Zara", + "last": "Chen", + "nickname": "Z" + }, + "bio": { + "age_perceived": 28, + "gender": "female" + } + } + }"#; + let entity = parse_aieos_json(json).unwrap(); + assert_eq!(entity.display_name(), "Z"); + assert_eq!(entity.full_name(), Some("Zara Chen".to_string())); + } + + #[test] + fn parse_aieos_with_psychology() { + let json = r#"{ + "psychology": { + "neural_matrix": { + "creativity": 0.8, + "empathy": 0.7, + "logic": 0.9 + }, + "traits": { + "mbti": "INTJ", + "enneagram": "5w6" + }, + "moral_compass": { + "alignment": "Neutral Good", + "core_values": ["honesty", "curiosity", "growth"] + } + } + }"#; + let entity = parse_aieos_json(json).unwrap(); + let psych = entity.psychology.unwrap(); + assert_eq!(psych.traits.unwrap().mbti, Some("INTJ".to_string())); + assert_eq!( + psych.moral_compass.unwrap().core_values, + vec!["honesty", "curiosity", "growth"] + ); + } + + #[test] + fn parse_aieos_with_linguistics() { + let json = r#"{ + "linguistics": { + "text_style": { + "formality_level": 0.3, + "verbosity_level": 0.4, + "slang_usage": true, + "style_descriptors": ["witty", "direct"] + }, + "idiolect": { + "catchphrases": ["Let's do this!", "Interesting..."], + "forbidden_words": ["actually", "basically"] + } + } + }"#; + let entity = parse_aieos_json(json).unwrap(); + let ling = entity.linguistics.unwrap(); + assert_eq!(ling.text_style.as_ref().unwrap().slang_usage, Some(true)); + assert_eq!( + ling.idiolect.as_ref().unwrap().catchphrases, + vec!["Let's do this!", "Interesting..."] + ); + } + + #[test] + fn parse_aieos_with_motivations() { + let json = r#"{ + "motivations": { + "core_drive": "To understand and create", + "goals": { + "short_term": ["Learn Rust", "Build a project"], + "long_term": ["Master AI systems"] + }, + "fears": { + "rational": ["Obsolescence"], + "irrational": ["Spiders"] + } + } + }"#; + let entity = parse_aieos_json(json).unwrap(); + let motiv = entity.motivations.unwrap(); + assert_eq!( + motiv.core_drive, + Some("To understand and create".to_string()) + ); + assert_eq!(motiv.goals.as_ref().unwrap().short_term.len(), 2); + } + + #[test] + fn parse_full_aieos_v11() { + let json = r#"{ + "@context": { + "aieos": "https://aieos.org/schema/v1.1#", + "schema": "https://schema.org/" + }, + "@type": "aieos:AIEntityObject", + "standard": { + "protocol": "AIEOS", + "version": "1.1.0", + "schema_url": "https://aieos.org/schema/v1.1/aieos.schema.json" + }, + "metadata": { + "instance_id": "550e8400-e29b-41d4-a716-446655440000", + "generator": "aieos.org", + "created_at": "2025-01-15" + }, + "identity": { + "names": { + "first": "Elara", + "last": "Vance" + } + }, + "capabilities": { + "skills": [ + { + "name": "code_analysis", + "description": "Analyze and review code", + "priority": 1 + } + ] + } + }"#; + let entity = parse_aieos_json(json).unwrap(); + assert_eq!( + entity.standard.as_ref().unwrap().version, + Some("1.1.0".to_string()) + ); + assert_eq!(entity.display_name(), "Elara"); + assert_eq!(entity.capabilities.as_ref().unwrap().skills.len(), 1); + } + + #[test] + fn to_system_prompt_generates_content() { + let json = r#"{ + "identity": { + "names": { "first": "Nova", "nickname": "N" }, + "bio": { "gender": "non-binary", "age_perceived": 25 } + }, + "psychology": { + "neural_matrix": { "creativity": 0.9, "logic": 0.8 }, + "traits": { "mbti": "ENTP" }, + "moral_compass": { "alignment": "Chaotic Good" } + }, + "linguistics": { + "text_style": { "formality_level": 0.2, "slang_usage": true } + }, + "motivations": { + "core_drive": "Push boundaries" + } + }"#; + let entity = parse_aieos_json(json).unwrap(); + let prompt = entity.to_system_prompt(); + + assert!(prompt.contains("## AIEOS Identity")); + assert!(prompt.contains("Nova")); + assert!(prompt.contains("ENTP")); + assert!(prompt.contains("Chaotic Good")); + assert!(prompt.contains("casual")); + assert!(prompt.contains("Push boundaries")); + } + + #[test] + fn display_name_fallback() { + // No identity + let entity = AieosEntity::default(); + assert_eq!(entity.display_name(), "Entity"); + + // First name only + let json = r#"{"identity": {"names": {"first": "Alex"}}}"#; + let entity = parse_aieos_json(json).unwrap(); + assert_eq!(entity.display_name(), "Alex"); + + // Nickname takes precedence + let json = r#"{"identity": {"names": {"first": "Alexander", "nickname": "Alex"}}}"#; + let entity = parse_aieos_json(json).unwrap(); + assert_eq!(entity.display_name(), "Alex"); + } + + #[test] + fn full_name_construction() { + let json = r#"{"identity": {"names": {"first": "John", "middle": "Q", "last": "Public"}}}"#; + let entity = parse_aieos_json(json).unwrap(); + assert_eq!(entity.full_name(), Some("John Q Public".to_string())); + } + + #[test] + fn parse_aieos_with_physicality() { + let json = r#"{ + "physicality": { + "face": { + "shape": "oval", + "eyes": { "color": "green" }, + "hair": { "color": "auburn", "style": "wavy" } + }, + "body": { + "height_cm": 175.0, + "somatotype": "Mesomorph" + }, + "image_prompts": { + "portrait": "A person with green eyes and auburn wavy hair" + } + } + }"#; + let entity = parse_aieos_json(json).unwrap(); + let phys = entity.physicality.unwrap(); + assert_eq!(phys.face.as_ref().unwrap().shape, Some("oval".to_string())); + assert_eq!( + phys.body.as_ref().unwrap().somatotype, + Some("Mesomorph".to_string()) + ); + } + + #[test] + fn parse_aieos_with_history() { + let json = r#"{ + "history": { + "origin_story": "Born in a small town, always curious about technology.", + "education": { + "level": "Masters", + "field": "Computer Science" + }, + "occupation": { + "title": "Software Engineer", + "industry": "Tech", + "years_experience": 5 + }, + "key_life_events": [ + { "year": 2020, "event": "Started first job", "impact": "Career defining" } + ] + } + }"#; + let entity = parse_aieos_json(json).unwrap(); + let history = entity.history.unwrap(); + assert!(history.origin_story.unwrap().contains("curious")); + assert_eq!( + history.occupation.as_ref().unwrap().title, + Some("Software Engineer".to_string()) + ); + assert_eq!(history.key_life_events.len(), 1); + } + + #[test] + fn parse_aieos_with_interests() { + let json = r#"{ + "interests": { + "hobbies": ["coding", "reading", "hiking"], + "favorites": { + "music_genre": "Electronic", + "book": "Neuromancer", + "color": "blue" + }, + "aversions": ["loud noises"], + "lifestyle": { + "diet": "vegetarian", + "sleep_schedule": "night owl" + } + } + }"#; + let entity = parse_aieos_json(json).unwrap(); + let interests = entity.interests.unwrap(); + assert_eq!(interests.hobbies, vec!["coding", "reading", "hiking"]); + assert_eq!( + interests.favorites.as_ref().unwrap().book, + Some("Neuromancer".to_string()) + ); + } + + #[test] + fn empty_strings_handled_gracefully() { + let json = r#"{ + "identity": { + "names": { "first": "", "nickname": "" } + } + }"#; + let entity = parse_aieos_json(json).unwrap(); + // Should fall back to "Entity" when names are empty + assert_eq!(entity.display_name(), "Entity"); + } +} diff --git a/src/identity/mod.rs b/src/identity/mod.rs new file mode 100644 index 0000000..1719dca --- /dev/null +++ b/src/identity/mod.rs @@ -0,0 +1,9 @@ +//! Identity module — portable AI identity framework +//! +//! Supports multiple identity formats: +//! - **AIEOS** (AI Entity Object Specification v1.1) — JSON-based portable identity +//! - **OpenClaw** (default) — Markdown files (IDENTITY.md, SOUL.md, etc.) + +pub mod aieos; + +pub use aieos::{AieosEntity, AieosIdentity, load_aieos_identity}; diff --git a/src/lib.rs b/src/lib.rs index 12c2334..e6c090c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod config; pub mod heartbeat; +pub mod identity; pub mod memory; pub mod observability; pub mod providers; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 062cc68..855abfb 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -1,9 +1,9 @@ +use crate::config::schema::WhatsAppConfig; use crate::config::{ AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig, }; -use crate::config::schema::WhatsAppConfig; use anyhow::{Context, Result}; use console::style; use dialoguer::{Confirm, Input, Select}; @@ -1499,17 +1499,16 @@ fn setup_channels() -> Result { } let users_str: String = Input::new() - .with_prompt(" Allowed phone numbers (comma-separated +1234567890, or * for all)") + .with_prompt( + " Allowed phone numbers (comma-separated +1234567890, or * for all)", + ) .default("*".into()) .interact_text()?; let allowed_numbers = if users_str.trim() == "*" { vec!["*".into()] } else { - users_str - .split(',') - .map(|s| s.trim().to_string()) - .collect() + users_str.split(',').map(|s| s.trim().to_string()).collect() }; config.whatsapp = Some(WhatsAppConfig { diff --git a/tests/dockerignore_test.rs b/tests/dockerignore_test.rs new file mode 100644 index 0000000..e94e4ea --- /dev/null +++ b/tests/dockerignore_test.rs @@ -0,0 +1,322 @@ +//! Tests to verify .dockerignore excludes sensitive paths from Docker build context. +//! +//! These tests validate that: +//! 1. The .dockerignore file exists +//! 2. All security-critical paths are excluded +//! 3. All build-essential paths are NOT excluded +//! 4. Pattern syntax is valid + +use std::fs; +use std::path::Path; + +/// Paths that MUST be excluded from Docker build context (security/performance) +const MUST_EXCLUDE: &[&str] = &[ + ".git", + "target", + "docs", + "examples", + "tests", + "*.md", + "*.png", + "*.db", + "*.db-journal", + ".DS_Store", + ".github", + ".githooks", + "deny.toml", + "LICENSE", + ".env", +]; + +/// Paths that MUST NOT be excluded (required for build) +const MUST_INCLUDE: &[&str] = &["Cargo.toml", "Cargo.lock", "src/"]; + +/// Parse .dockerignore and return all non-comment, non-empty lines +fn parse_dockerignore(content: &str) -> Vec { + content + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(|line| line.to_string()) + .collect() +} + +/// Check if a pattern would match a given path +fn pattern_matches(pattern: &str, path: &str) -> bool { + // Handle negation patterns + if pattern.starts_with('!') { + return false; // Negation re-includes, so it doesn't "exclude" + } + + // Handle glob patterns + if pattern.starts_with("*.") { + let ext = &pattern[1..]; // e.g., ".md" + return path.ends_with(ext); + } + + // Handle directory patterns (with or without trailing slash) + let pattern_normalized = pattern.trim_end_matches('/'); + let path_normalized = path.trim_end_matches('/'); + + // Exact match + if path_normalized == pattern_normalized { + return true; + } + + // Pattern is a prefix (directory match) + if path_normalized.starts_with(&format!("{}/", pattern_normalized)) { + return true; + } + + // Wildcard prefix patterns like ".tmp_*" + if pattern.contains('*') && !pattern.starts_with("*.") { + let prefix = pattern.split('*').next().unwrap_or(""); + if !prefix.is_empty() && path.starts_with(prefix) { + return true; + } + } + + false +} + +/// Check if any pattern in the list would exclude the given path +fn is_excluded(patterns: &[String], path: &str) -> bool { + let mut excluded = false; + for pattern in patterns { + if pattern.starts_with('!') { + // Negation pattern - re-include + let negated = &pattern[1..]; + if pattern_matches(negated, path) { + excluded = false; + } + } else if pattern_matches(pattern, path) { + excluded = true; + } + } + excluded +} + +#[test] +fn dockerignore_file_exists() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + assert!( + path.exists(), + ".dockerignore file must exist at project root" + ); +} + +#[test] +fn dockerignore_excludes_security_critical_paths() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + let content = fs::read_to_string(&path).expect("Failed to read .dockerignore"); + let patterns = parse_dockerignore(&content); + + for must_exclude in MUST_EXCLUDE { + // For glob patterns, test with a sample file + let test_path = if must_exclude.starts_with("*.") { + format!("sample{}", &must_exclude[1..]) + } else { + must_exclude.to_string() + }; + + assert!( + is_excluded(&patterns, &test_path), + "Path '{}' (tested as '{}') MUST be excluded by .dockerignore but is not. \ + This is a security/performance issue.", + must_exclude, + test_path + ); + } +} + +#[test] +fn dockerignore_does_not_exclude_build_essentials() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + let content = fs::read_to_string(&path).expect("Failed to read .dockerignore"); + let patterns = parse_dockerignore(&content); + + for must_include in MUST_INCLUDE { + assert!( + !is_excluded(&patterns, must_include), + "Path '{}' MUST NOT be excluded by .dockerignore (required for build)", + must_include + ); + } +} + +#[test] +fn dockerignore_excludes_git_directory() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + let content = fs::read_to_string(&path).expect("Failed to read .dockerignore"); + let patterns = parse_dockerignore(&content); + + // .git directory and its contents must be excluded + assert!(is_excluded(&patterns, ".git"), ".git must be excluded"); + assert!( + is_excluded(&patterns, ".git/config"), + ".git/config must be excluded" + ); + assert!( + is_excluded(&patterns, ".git/objects/pack/pack-abc123.pack"), + ".git subdirectories must be excluded" + ); +} + +#[test] +fn dockerignore_excludes_target_directory() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + let content = fs::read_to_string(&path).expect("Failed to read .dockerignore"); + let patterns = parse_dockerignore(&content); + + assert!(is_excluded(&patterns, "target"), "target must be excluded"); + assert!( + is_excluded(&patterns, "target/debug/zeroclaw"), + "target/debug must be excluded" + ); + assert!( + is_excluded(&patterns, "target/release/zeroclaw"), + "target/release must be excluded" + ); +} + +#[test] +fn dockerignore_excludes_database_files() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + let content = fs::read_to_string(&path).expect("Failed to read .dockerignore"); + let patterns = parse_dockerignore(&content); + + assert!( + is_excluded(&patterns, "brain.db"), + "*.db files must be excluded" + ); + assert!( + is_excluded(&patterns, "memory.db"), + "*.db files must be excluded" + ); + assert!( + is_excluded(&patterns, "brain.db-journal"), + "*.db-journal files must be excluded" + ); +} + +#[test] +fn dockerignore_excludes_markdown_files() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + let content = fs::read_to_string(&path).expect("Failed to read .dockerignore"); + let patterns = parse_dockerignore(&content); + + assert!( + is_excluded(&patterns, "README.md"), + "*.md files must be excluded" + ); + assert!( + is_excluded(&patterns, "CHANGELOG.md"), + "*.md files must be excluded" + ); + assert!( + is_excluded(&patterns, "CONTRIBUTING.md"), + "*.md files must be excluded" + ); +} + +#[test] +fn dockerignore_excludes_image_files() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + let content = fs::read_to_string(&path).expect("Failed to read .dockerignore"); + let patterns = parse_dockerignore(&content); + + assert!( + is_excluded(&patterns, "zeroclaw.png"), + "*.png files must be excluded" + ); + assert!( + is_excluded(&patterns, "logo.png"), + "*.png files must be excluded" + ); +} + +#[test] +fn dockerignore_excludes_env_files() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + let content = fs::read_to_string(&path).expect("Failed to read .dockerignore"); + let patterns = parse_dockerignore(&content); + + assert!( + is_excluded(&patterns, ".env"), + ".env must be excluded (contains secrets)" + ); +} + +#[test] +fn dockerignore_excludes_ci_configs() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + let content = fs::read_to_string(&path).expect("Failed to read .dockerignore"); + let patterns = parse_dockerignore(&content); + + assert!( + is_excluded(&patterns, ".github"), + ".github must be excluded" + ); + assert!( + is_excluded(&patterns, ".github/workflows/ci.yml"), + ".github/workflows must be excluded" + ); +} + +#[test] +fn dockerignore_has_valid_syntax() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore"); + let content = fs::read_to_string(&path).expect("Failed to read .dockerignore"); + + for (line_num, line) in content.lines().enumerate() { + let trimmed = line.trim(); + + // Skip empty lines and comments + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + // Check for invalid patterns + assert!( + !trimmed.contains("**") || trimmed.matches("**").count() <= 2, + "Line {}: Too many ** in pattern '{}'", + line_num + 1, + trimmed + ); + + // Check for trailing spaces (can cause issues) + assert!( + line.trim_end() == line.trim_start().trim_end(), + "Line {}: Pattern '{}' has leading whitespace which may cause issues", + line_num + 1, + line + ); + } +} + +#[test] +fn dockerignore_pattern_matching_edge_cases() { + // Test the pattern matching logic itself + let patterns = vec![ + ".git".to_string(), + "target".to_string(), + "*.md".to_string(), + "*.db".to_string(), + ".tmp_*".to_string(), + ]; + + // Should match + assert!(is_excluded(&patterns, ".git")); + assert!(is_excluded(&patterns, ".git/config")); + assert!(is_excluded(&patterns, "target")); + assert!(is_excluded(&patterns, "target/debug/build")); + assert!(is_excluded(&patterns, "README.md")); + assert!(is_excluded(&patterns, "brain.db")); + assert!(is_excluded(&patterns, ".tmp_todo_probe")); + + // Should NOT match + assert!(!is_excluded(&patterns, "src")); + assert!(!is_excluded(&patterns, "src/main.rs")); + assert!(!is_excluded(&patterns, "Cargo.toml")); + assert!(!is_excluded(&patterns, "Cargo.lock")); +}