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"));
+}