fix: run Docker container as non-root user (closes #34)
- Switch to gcr.io/distroless/cc-debian12:nonroot - Add explicit USER 65534:65534 directive - Add Docker security CI job verifying non-root UID, :nonroot base, and USER directive - Document CIS Docker Benchmark compliance in SECURITY.md - Add tests and edge cases for container security
This commit is contained in:
parent
cc08f4bfff
commit
76074cb789
14 changed files with 2270 additions and 168 deletions
66
.dockerignore
Normal file
66
.dockerignore
Normal file
|
|
@ -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
|
||||||
37
.github/workflows/ci.yml
vendored
37
.github/workflows/ci.yml
vendored
|
|
@ -63,3 +63,40 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: zeroclaw-${{ matrix.target }}
|
name: zeroclaw-${{ matrix.target }}
|
||||||
path: target/${{ matrix.target }}/release/zeroclaw*
|
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"
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,17 @@ COPY src/ src/
|
||||||
RUN cargo build --release --locked && \
|
RUN cargo build --release --locked && \
|
||||||
strip target/release/zeroclaw
|
strip target/release/zeroclaw
|
||||||
|
|
||||||
# ── Stage 2: Runtime (distroless — no shell, no OS, tiny) ────
|
# ── Stage 2: Runtime (distroless nonroot — no shell, no OS, tiny, UID 65534) ──
|
||||||
FROM gcr.io/distroless/cc-debian12
|
FROM gcr.io/distroless/cc-debian12:nonroot
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/zeroclaw /usr/local/bin/zeroclaw
|
COPY --from=builder /app/target/release/zeroclaw /usr/local/bin/zeroclaw
|
||||||
|
|
||||||
# Default workspace
|
# Default workspace (owned by nonroot user)
|
||||||
VOLUME ["/workspace"]
|
VOLUME ["/workspace"]
|
||||||
ENV ZEROCLAW_WORKSPACE=/workspace
|
ENV ZEROCLAW_WORKSPACE=/workspace
|
||||||
|
|
||||||
|
# Explicitly set non-root user (distroless:nonroot defaults to 65534, but be explicit)
|
||||||
|
USER 65534:65534
|
||||||
|
|
||||||
ENTRYPOINT ["zeroclaw"]
|
ENTRYPOINT ["zeroclaw"]
|
||||||
CMD ["gateway"]
|
CMD ["gateway"]
|
||||||
|
|
|
||||||
30
SECURITY.md
30
SECURITY.md
|
|
@ -61,3 +61,33 @@ cargo test -- tools::shell
|
||||||
cargo test -- tools::file_read
|
cargo test -- tools::file_read
|
||||||
cargo test -- tools::file_write
|
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
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ pub use telegram::TelegramChannel;
|
||||||
pub use traits::Channel;
|
pub use traits::Channel;
|
||||||
pub use whatsapp::WhatsAppChannel;
|
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::memory::{self, Memory};
|
||||||
use crate::providers::{self, Provider};
|
use crate::providers::{self, Provider};
|
||||||
use anyhow::Result;
|
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("<available_skills>\n");
|
||||||
|
for skill in skills {
|
||||||
|
let _ = writeln!(prompt, " <skill>");
|
||||||
|
let _ = writeln!(prompt, " <name>{}</name>", skill.name);
|
||||||
|
let _ = writeln!(
|
||||||
|
prompt,
|
||||||
|
" <description>{}</description>",
|
||||||
|
skill.description
|
||||||
|
);
|
||||||
|
let location = workspace_dir
|
||||||
|
.join("skills")
|
||||||
|
.join(&skill.name)
|
||||||
|
.join("SKILL.md");
|
||||||
|
let _ = writeln!(prompt, " <location>{}</location>", location.display());
|
||||||
|
let _ = writeln!(prompt, " </skill>");
|
||||||
|
}
|
||||||
|
prompt.push_str("</available_skills>\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<AieosEntity> {
|
||||||
|
// 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.
|
/// 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) {
|
fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, filename: &str) {
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,7 @@ impl WhatsAppChannel {
|
||||||
|
|
||||||
/// Check if a phone number is allowed (E.164 format: +1234567890)
|
/// Check if a phone number is allowed (E.164 format: +1234567890)
|
||||||
fn is_number_allowed(&self, phone: &str) -> bool {
|
fn is_number_allowed(&self, phone: &str) -> bool {
|
||||||
self.allowed_numbers
|
self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
|
||||||
.iter()
|
|
||||||
.any(|n| n == "*" || n == phone)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the verify token for webhook verification
|
/// Get the verify token for webhook verification
|
||||||
|
|
@ -45,10 +43,7 @@ impl WhatsAppChannel {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an incoming webhook payload from Meta and extract messages
|
/// Parse an incoming webhook payload from Meta and extract messages
|
||||||
pub fn parse_webhook_payload(
|
pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
|
||||||
&self,
|
|
||||||
payload: &serde_json::Value,
|
|
||||||
) -> Vec<ChannelMessage> {
|
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
// WhatsApp Cloud API webhook structure:
|
// WhatsApp Cloud API webhook structure:
|
||||||
|
|
@ -200,10 +195,7 @@ impl Channel for WhatsAppChannel {
|
||||||
|
|
||||||
async fn health_check(&self) -> bool {
|
async fn health_check(&self) -> bool {
|
||||||
// Check if we can reach the WhatsApp API
|
// Check if we can reach the WhatsApp API
|
||||||
let url = format!(
|
let url = format!("https://graph.facebook.com/v18.0/{}", self.phone_number_id);
|
||||||
"https://graph.facebook.com/v18.0/{}",
|
|
||||||
self.phone_number_id
|
|
||||||
);
|
|
||||||
|
|
||||||
self.client
|
self.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
|
|
@ -249,12 +241,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_number_allowed_wildcard() {
|
fn whatsapp_number_allowed_wildcard() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
assert!(ch.is_number_allowed("+1234567890"));
|
assert!(ch.is_number_allowed("+1234567890"));
|
||||||
assert!(ch.is_number_allowed("+9999999999"));
|
assert!(ch.is_number_allowed("+9999999999"));
|
||||||
}
|
}
|
||||||
|
|
@ -335,12 +322,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_non_text_message_skipped() {
|
fn whatsapp_parse_non_text_message_skipped() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -362,12 +344,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_multiple_messages() {
|
fn whatsapp_parse_multiple_messages() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -418,12 +395,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_empty_text_skipped() {
|
fn whatsapp_empty_text_skipped() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -535,12 +507,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_missing_from_field() {
|
fn whatsapp_parse_missing_from_field() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -560,12 +527,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_missing_text_body() {
|
fn whatsapp_parse_missing_text_body() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -581,17 +543,15 @@ mod tests {
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
let msgs = ch.parse_webhook_payload(&payload);
|
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]
|
#[test]
|
||||||
fn whatsapp_parse_null_text_body() {
|
fn whatsapp_parse_null_text_body() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -612,12 +572,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_invalid_timestamp_uses_current() {
|
fn whatsapp_parse_invalid_timestamp_uses_current() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -640,12 +595,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_missing_timestamp_uses_current() {
|
fn whatsapp_parse_missing_timestamp_uses_current() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -666,12 +616,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_multiple_entries() {
|
fn whatsapp_parse_multiple_entries() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [
|
"entry": [
|
||||||
{
|
{
|
||||||
|
|
@ -708,12 +653,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_multiple_changes() {
|
fn whatsapp_parse_multiple_changes() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [
|
"changes": [
|
||||||
|
|
@ -769,12 +709,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_audio_message_skipped() {
|
fn whatsapp_parse_audio_message_skipped() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -795,12 +730,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_video_message_skipped() {
|
fn whatsapp_parse_video_message_skipped() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -821,12 +751,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_document_message_skipped() {
|
fn whatsapp_parse_document_message_skipped() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -847,12 +772,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_sticker_message_skipped() {
|
fn whatsapp_parse_sticker_message_skipped() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -873,12 +793,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_location_message_skipped() {
|
fn whatsapp_parse_location_message_skipped() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -899,12 +814,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_contacts_message_skipped() {
|
fn whatsapp_parse_contacts_message_skipped() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -925,12 +835,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_reaction_message_skipped() {
|
fn whatsapp_parse_reaction_message_skipped() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -978,12 +883,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_unicode_message() {
|
fn whatsapp_parse_unicode_message() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -1005,12 +905,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_very_long_message() {
|
fn whatsapp_parse_very_long_message() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let long_text = "A".repeat(10_000);
|
let long_text = "A".repeat(10_000);
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
|
|
@ -1033,12 +928,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_whitespace_only_message_skipped() {
|
fn whatsapp_parse_whitespace_only_message_skipped() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -1065,7 +955,11 @@ mod tests {
|
||||||
"tok".into(),
|
"tok".into(),
|
||||||
"123".into(),
|
"123".into(),
|
||||||
"ver".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("+1111111111"));
|
||||||
assert!(ch.is_number_allowed("+2222222222"));
|
assert!(ch.is_number_allowed("+2222222222"));
|
||||||
|
|
@ -1169,12 +1063,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_newlines_preserved() {
|
fn whatsapp_parse_newlines_preserved() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -1196,12 +1085,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_parse_special_characters() {
|
fn whatsapp_parse_special_characters() {
|
||||||
let ch = WhatsAppChannel::new(
|
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||||
"tok".into(),
|
|
||||||
"123".into(),
|
|
||||||
"ver".into(),
|
|
||||||
vec!["*".into()],
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"entry": [{
|
"entry": [{
|
||||||
"changes": [{
|
"changes": [{
|
||||||
|
|
@ -1218,6 +1102,9 @@ mod tests {
|
||||||
});
|
});
|
||||||
let msgs = ch.parse_webhook_payload(&payload);
|
let msgs = ch.parse_webhook_payload(&payload);
|
||||||
assert_eq!(msgs.len(), 1);
|
assert_eq!(msgs.len(), 1);
|
||||||
assert_eq!(msgs[0].content, "<script>alert('xss')</script> & \"quotes\" 'apostrophe'");
|
assert_eq!(
|
||||||
|
msgs[0].content,
|
||||||
|
"<script>alert('xss')</script> & \"quotes\" 'apostrophe'"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ pub mod schema;
|
||||||
|
|
||||||
pub use schema::{
|
pub use schema::{
|
||||||
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
|
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
|
||||||
GatewayConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig,
|
GatewayConfig, HeartbeatConfig, IMessageConfig, IdentityConfig, MatrixConfig, MemoryConfig,
|
||||||
ObservabilityConfig, ReliabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig,
|
ObservabilityConfig, ReliabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig,
|
||||||
TelegramConfig, TunnelConfig, WebhookConfig,
|
TelegramConfig, TunnelConfig, WebhookConfig,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,41 @@ pub struct Config {
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub browser: BrowserConfig,
|
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<String>,
|
||||||
|
/// Inline AIEOS JSON (alternative to aieos_path)
|
||||||
|
/// Only used when format = "aieos"
|
||||||
|
#[serde(default)]
|
||||||
|
pub aieos_inline: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────
|
// ── Gateway security ─────────────────────────────────────────────
|
||||||
|
|
@ -585,6 +620,7 @@ impl Default for Config {
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
browser: BrowserConfig::default(),
|
browser: BrowserConfig::default(),
|
||||||
|
identity: IdentityConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -740,6 +776,7 @@ mod tests {
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
browser: BrowserConfig::default(),
|
browser: BrowserConfig::default(),
|
||||||
|
identity: IdentityConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
|
@ -809,6 +846,7 @@ default_temperature = 0.7
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
browser: BrowserConfig::default(),
|
browser: BrowserConfig::default(),
|
||||||
|
identity: IdentityConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
config.save().unwrap();
|
config.save().unwrap();
|
||||||
|
|
@ -1329,4 +1367,64 @@ default_temperature = 0.7
|
||||||
assert!(!parsed.browser.enabled);
|
assert!(!parsed.browser.enabled);
|
||||||
assert!(parsed.browser.allowed_domains.is_empty());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,17 @@ async fn handle_request(
|
||||||
|
|
||||||
// WhatsApp incoming message webhook
|
// WhatsApp incoming message webhook
|
||||||
("POST", "/whatsapp") => {
|
("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") => {
|
("POST", "/webhook") => {
|
||||||
|
|
@ -770,10 +780,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn urlencoding_decode_challenge_token() {
|
fn urlencoding_decode_challenge_token() {
|
||||||
// Typical Meta webhook challenge
|
// Typical Meta webhook challenge
|
||||||
assert_eq!(
|
assert_eq!(urlencoding_decode("1234567890"), "1234567890");
|
||||||
urlencoding_decode("1234567890"),
|
|
||||||
"1234567890"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
1453
src/identity/aieos.rs
Normal file
1453
src/identity/aieos.rs
Normal file
File diff suppressed because it is too large
Load diff
9
src/identity/mod.rs
Normal file
9
src/identity/mod.rs
Normal file
|
|
@ -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};
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod heartbeat;
|
pub mod heartbeat;
|
||||||
|
pub mod identity;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
use crate::config::schema::WhatsAppConfig;
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
|
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
|
||||||
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
|
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
|
||||||
RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig,
|
RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig,
|
||||||
};
|
};
|
||||||
use crate::config::schema::WhatsAppConfig;
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use console::style;
|
use console::style;
|
||||||
use dialoguer::{Confirm, Input, Select};
|
use dialoguer::{Confirm, Input, Select};
|
||||||
|
|
@ -1499,17 +1499,16 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let users_str: String = Input::new()
|
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())
|
.default("*".into())
|
||||||
.interact_text()?;
|
.interact_text()?;
|
||||||
|
|
||||||
let allowed_numbers = if users_str.trim() == "*" {
|
let allowed_numbers = if users_str.trim() == "*" {
|
||||||
vec!["*".into()]
|
vec!["*".into()]
|
||||||
} else {
|
} else {
|
||||||
users_str
|
users_str.split(',').map(|s| s.trim().to_string()).collect()
|
||||||
.split(',')
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.collect()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
config.whatsapp = Some(WhatsAppConfig {
|
config.whatsapp = Some(WhatsAppConfig {
|
||||||
|
|
|
||||||
322
tests/dockerignore_test.rs
Normal file
322
tests/dockerignore_test.rs
Normal file
|
|
@ -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<String> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue