diff --git a/docs/configuration.md b/docs/configuration.md index ac2bee2..14eaafd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -197,7 +197,7 @@ Invalid configs (YAML errors, missing env vars, validation failures) are logged sudo bash scripts/install.sh --system ``` -This installs the binary to `/usr/local/bin/`, creates `/etc/rustifymyclaw/` with a starter config, env file, and systemd unit. +This installs the binary to `/usr/local/bin/`, creates the `rustifymyclaw` system user and group, sets up `/etc/rustifymyclaw/` with a starter config, env file, and systemd unit. ### Manual setup @@ -210,14 +210,20 @@ sudo cp systemd/rustifymyclaw.service /etc/systemd/system/ sudo systemctl daemon-reload ``` -2. Create the config directory and files: +2. Create the service user and config directory: ```bash +sudo groupadd -r rustifymyclaw +sudo useradd -r -g rustifymyclaw -s /usr/sbin/nologin -d /nonexistent rustifymyclaw sudo mkdir -p /etc/rustifymyclaw sudo cp examples/config.yaml /etc/rustifymyclaw/config.yaml sudo cp systemd/env.example /etc/rustifymyclaw/env +sudo chown root:rustifymyclaw /etc/rustifymyclaw +sudo chmod 750 /etc/rustifymyclaw +sudo chown root:rustifymyclaw /etc/rustifymyclaw/config.yaml sudo chmod 640 /etc/rustifymyclaw/config.yaml -sudo chmod 600 /etc/rustifymyclaw/env +sudo chown root:rustifymyclaw /etc/rustifymyclaw/env +sudo chmod 640 /etc/rustifymyclaw/env ``` 3. Edit `config.yaml` with your workspaces and channels. @@ -247,11 +253,11 @@ If you wish to use a configuration file other than the default `/etc/rustifymycl RUSTIFYMYCLAW_CONFIG=/path/to/your/custom-config.yaml ``` -**Important:** The daemon runs as a transient non-root user. Any custom configuration file must be world-readable (e.g., `chmod 644`) so the daemon can access it. +**Important:** The daemon runs as the `rustifymyclaw` system user. Any custom configuration file must be readable by this user (e.g., `chown root:rustifymyclaw` and `chmod 640`). ### Enable daemon to access your workspaces / directory permissions -Same as before. Because the daemon runs as an ephemeral user, workspace directories are read-only by default. Without this step your CLI agent can still read the project and answer questions about it, but cannot write or edit files. If you expect your agent to have write permissions, you must explicitly allow each workspace path. +The daemon runs as the dedicated `rustifymyclaw` system user. Workspace directories are read-only by default. Without this step your CLI agent can still read the project and answer questions about it, but cannot write or edit files. If you expect your agent to have write permissions, you must explicitly allow each workspace path. Use the built-in command: @@ -259,24 +265,53 @@ Use the built-in command: sudo rustifymyclaw config allow-path /home/user/projects/my-project ``` -Or, manually: +This command does three things: +1. **Grants traverse (`x`) ACLs** on each parent directory so the daemon can reach the workspace (e.g., `/home/user`, `/home/user/projects`). +2. **Grants recursive read/write ACLs** on the workspace directory and all contents, with default ACLs so newly created files inherit permissions. +3. **Adds a `ReadWritePaths=` entry** to the systemd override so the sandbox allows writes. -```ini -# /etc/systemd/system/rustifymyclaw.service.d/override.conf -[Service] -ReadWritePaths=/home/user/projects/my-project -``` Then reload: `sudo systemctl daemon-reload && sudo systemctl restart rustifymyclaw` +**Prerequisites:** The `acl` package must be installed (`sudo apt install acl` on Debian/Ubuntu, `sudo dnf install acl` on Fedora/RHEL). + +To revoke access manually, remove the ACLs and the systemd override entry: + +```bash +sudo setfacl -R -x u:rustifymyclaw /home/user/projects/my-project +sudo setfacl -R -d -x u:rustifymyclaw /home/user/projects/my-project +# Then edit /etc/systemd/system/rustifymyclaw.service.d/override.conf +# and remove the corresponding ReadWritePaths= line. +sudo systemctl daemon-reload && sudo systemctl restart rustifymyclaw +``` + +**Note on default ACLs:** When `allow-path` sets default ACLs on a workspace directory, newly created files inherit permissions for the `rustifymyclaw` user. This changes how `umask` interacts with file creation in that directory. The ACL mask, not the process `umask`, determines effective permissions for new files. + ### Security hardening The included systemd unit uses: -- **`DynamicUser=yes`** — allocates an ephemeral service user, no manual user creation. +- **`User=rustifymyclaw`** / **`Group=rustifymyclaw`** — dedicated static system user with no login shell and no home directory. - **`NoNewPrivileges=yes`** — the process cannot gain new privileges. - **`ProtectSystem=strict`** — the filesystem is read-only except for allowed paths. -- **`ProtectHome=read-only`** — home directories are visible but not writable. +- **`ProtectHome=false`** — home directories are accessible (workspace traversal requires this; actual access is gated by POSIX ACLs). +- **`ReadOnlyPaths=/`** — everything read-only by default; individual workspaces are granted write access via `allow-path`. - **`PrivateTmp=yes`** — isolated `/tmp` namespace. -- **`EnvironmentFile`** — secrets loaded from `/etc/rustifymyclaw/env` (mode 600). - - +- **`EnvironmentFile`** — secrets loaded from `/etc/rustifymyclaw/env` (mode 640, owned by `root:rustifymyclaw`). + +### Migrating from DynamicUser + +Versions prior to this release used `DynamicUser=yes` which allocated an ephemeral UID. If you are upgrading from an earlier version: + +1. The service now runs as the static `rustifymyclaw` user instead of a dynamic UID. +2. Any manual ACLs you previously granted to the dynamic UID are orphaned — re-apply them with `sudo rustifymyclaw config allow-path `. +3. The installer (`scripts/install.sh --system`) creates the user automatically. For manual upgrades, create the user first: + ```bash + sudo groupadd -r rustifymyclaw + sudo useradd -r -g rustifymyclaw -s /usr/sbin/nologin -d /nonexistent rustifymyclaw + ``` +4. Update ownership on config files: + ```bash + sudo chown root:rustifymyclaw /etc/rustifymyclaw /etc/rustifymyclaw/config.yaml /etc/rustifymyclaw/env + sudo chmod 750 /etc/rustifymyclaw + sudo chmod 640 /etc/rustifymyclaw/config.yaml /etc/rustifymyclaw/env + ``` diff --git a/scripts/install.sh b/scripts/install.sh index 681a64a..89189bb 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -215,7 +215,8 @@ install_binary() { write_config() { if $SYSTEM_INSTALL; then mkdir -p "$CONFIG_DIR" - chmod 755 "$CONFIG_DIR" + chown root:rustifymyclaw "$CONFIG_DIR" + chmod 750 "$CONFIG_DIR" fi if [ -f "$CONFIG_FILE" ]; then @@ -226,7 +227,8 @@ write_config() { download_file "$config_url" "$CONFIG_FILE" || \ die "Failed to download example config from ${config_url}" if $SYSTEM_INSTALL; then - chmod 644 "$CONFIG_FILE" + chown root:rustifymyclaw "$CONFIG_FILE" + chmod 640 "$CONFIG_FILE" else chmod 600 "$CONFIG_FILE" fi @@ -239,11 +241,34 @@ write_config() { info "Downloading env template..." download_file "$env_url" "$ENV_FILE" || \ die "Failed to download env template from ${env_url}" - chmod 600 "$ENV_FILE" - info "Env file created at ${ENV_FILE} (chmod 600)" + info "Env file created at ${ENV_FILE}" else info "Existing env file preserved at ${ENV_FILE}" fi + chown root:rustifymyclaw "$ENV_FILE" + chmod 640 "$ENV_FILE" + info "Permissions secured for ${ENV_FILE} (root:rustifymyclaw 640)" + fi +} + +# --------------------------------------------------------------------------- +# Service user creation (--system only) +# --------------------------------------------------------------------------- +create_service_user() { + if ! $SYSTEM_INSTALL; then + return + fi + + if ! getent group rustifymyclaw >/dev/null 2>&1; then + info "Creating system group: rustifymyclaw" + groupadd -r rustifymyclaw + fi + + if ! id -u rustifymyclaw >/dev/null 2>&1; then + info "Creating system user: rustifymyclaw" + useradd -r -g rustifymyclaw -s /usr/sbin/nologin -d /nonexistent rustifymyclaw + else + info "System user rustifymyclaw already exists." fi } @@ -330,6 +355,7 @@ main() { download_and_verify install_binary + create_service_user write_config install_systemd_unit if ! $SYSTEM_INSTALL; then diff --git a/src/cli/config_cmd/allow_path.rs b/src/cli/config_cmd/allow_path.rs index da0b989..2f3b077 100644 --- a/src/cli/config_cmd/allow_path.rs +++ b/src/cli/config_cmd/allow_path.rs @@ -42,6 +42,23 @@ fn merge_allowed_path(existing: &str, path_str: &str) -> MergeResult { MergeResult::Updated(new_content) } +/// Collect parent directories that need execute traversal ACLs. +/// Returns ancestors from shallowest to deepest, excluding `/` and the path itself. +#[cfg(any(not(target_os = "windows"), test))] +fn traversal_parents(path: &Path) -> Vec<&Path> { + let mut parents = Vec::new(); + let mut current = path.parent(); + while let Some(p) = current { + if p == Path::new("/") { + break; + } + parents.push(p); + current = p.parent(); + } + parents.reverse(); + parents +} + pub fn run(path: &Path) -> Result<()> { #[cfg(target_os = "windows")] { @@ -51,15 +68,101 @@ pub fn run(path: &Path) -> Result<()> { #[cfg(not(target_os = "windows"))] { - use anyhow::Context; + use anyhow::{bail, Context}; const OVERRIDE_DIR: &str = "/etc/systemd/system/rustifymyclaw.service.d"; const OVERRIDE_FILE: &str = "override.conf"; + const SERVICE_USER: &str = "rustifymyclaw"; let canonical = std::fs::canonicalize(path) .with_context(|| format!("path does not exist: {}", path.display()))?; let canonical_str = canonical.to_string_lossy(); + // ── Fail fast: check setfacl availability ────────────────────── + let setfacl_check = std::process::Command::new("which") + .arg("setfacl") + .output() + .context("failed to check for setfacl")?; + + if !setfacl_check.status.success() { + bail!( + "setfacl is not installed. Install the acl package first:\n \ + sudo apt install acl # Debian/Ubuntu\n \ + sudo dnf install acl # Fedora/RHEL" + ); + } + + // ── Parent directory traversal ACLs (execute only) ───────────── + let parents = traversal_parents(&canonical); + for parent in &parents { + let status = std::process::Command::new("setfacl") + .args([ + "-m", + &format!("u:{SERVICE_USER}:x"), + &parent.to_string_lossy(), + ]) + .status() + .with_context(|| format!("failed to run setfacl on {}", parent.display()))?; + + if !status.success() { + bail!( + "setfacl failed on {}. Are you running with sudo?", + parent.display() + ); + } + } + + if !parents.is_empty() { + println!( + "Set traverse (x) ACL on {} parent director{}.", + parents.len(), + if parents.len() == 1 { "y" } else { "ies" } + ); + } + + // ── Workspace ACLs (recursive read/write/traverse) ──────────── + let status = std::process::Command::new("setfacl") + .args([ + "-R", + "-m", + &format!("u:{SERVICE_USER}:rwX"), + &*canonical_str, + ]) + .status() + .with_context(|| format!("failed to set ACLs on {}", canonical.display()))?; + + if !status.success() { + bail!( + "setfacl -R failed on {}. Are you running with sudo?", + canonical.display() + ); + } + + // ── Default ACLs (so new files inherit permissions) ──────────── + let status = std::process::Command::new("setfacl") + .args([ + "-R", + "-d", + "-m", + &format!("u:{SERVICE_USER}:rwX"), + &*canonical_str, + ]) + .status() + .with_context(|| format!("failed to set default ACLs on {}", canonical.display()))?; + + if !status.success() { + bail!( + "setfacl -R -d failed on {}. Are you running with sudo?", + canonical.display() + ); + } + + println!( + "Set read/write ACLs on {} (with default ACLs for new files).", + canonical.display() + ); + + // ── Systemd override (ReadWritePaths) ────────────────────────── let override_dir = Path::new(OVERRIDE_DIR); let override_path = override_dir.join(OVERRIDE_FILE); @@ -72,7 +175,10 @@ pub fn run(path: &Path) -> Result<()> { match merge_allowed_path(&existing, &canonical_str) { MergeResult::AlreadyPresent => { - println!("{} is already in the allowed paths.", canonical.display()); + println!( + "{} is already in the systemd allowed paths.", + canonical.display() + ); } MergeResult::Updated(new_content) => { std::fs::create_dir_all(override_dir).with_context(|| { @@ -86,13 +192,17 @@ pub fn run(path: &Path) -> Result<()> { ) })?; - println!("Allowed workspace path: {}", canonical.display()); - println!(); - println!("Reload and restart the service to apply:"); - println!(" sudo systemctl daemon-reload && sudo systemctl restart rustifymyclaw"); + println!( + "Added ReadWritePaths={} to systemd override.", + canonical.display() + ); } } + println!(); + println!("Reload and restart the service to apply:"); + println!(" sudo systemctl daemon-reload && sudo systemctl restart rustifymyclaw"); + Ok(()) } } diff --git a/src/config.rs b/src/config.rs index 414aa8f..78e3c0f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -127,8 +127,14 @@ pub fn resolve_path(cli_override: Option) -> PathBuf { return etc_candidate; } } - // Final fallback: return the home path even if it doesn't exist, so - // load_from_path produces a clear "cannot read config file" error. + + // Final fallback: if HOME is missing, we're likely a daemon; return /etc path + // so the resulting "file not found" error points to the right location. + #[cfg(not(target_os = "windows"))] + if std::env::var("HOME").is_err() { + return PathBuf::from("/etc/rustifymyclaw/config.yaml"); + } + home_candidate } diff --git a/src/tests/cli/config_cmd/allow_path_test.rs b/src/tests/cli/config_cmd/allow_path_test.rs index c2ab3a6..1023366 100644 --- a/src/tests/cli/config_cmd/allow_path_test.rs +++ b/src/tests/cli/config_cmd/allow_path_test.rs @@ -1,4 +1,7 @@ use super::*; +use std::path::Path; + +// ── merge_allowed_path tests ─────────────────────────────────────────────── #[test] fn empty_existing_creates_service_section() { @@ -78,3 +81,45 @@ fn duplicate_path_is_detected() { "duplicate path should return AlreadyPresent" ); } + +// ── traversal_parents tests ──────────────────────────────────────────────── + +#[test] +fn traversal_parents_typical_home_path() { + let parents = traversal_parents(Path::new("/home/rafa/repos/Claudio")); + let expected: Vec<&Path> = vec![ + Path::new("/home"), + Path::new("/home/rafa"), + Path::new("/home/rafa/repos"), + ]; + assert_eq!(parents, expected); +} + +#[test] +fn traversal_parents_shallow_path() { + let parents = traversal_parents(Path::new("/opt/workspace")); + let expected: Vec<&Path> = vec![Path::new("/opt")]; + assert_eq!(parents, expected); +} + +#[test] +fn traversal_parents_root_child() { + // Path directly under / — no parents need traverse ACL. + let parents = traversal_parents(Path::new("/data")); + assert!( + parents.is_empty(), + "root-level path should have no traversal parents" + ); +} + +#[test] +fn traversal_parents_deeply_nested() { + let parents = traversal_parents(Path::new("/a/b/c/d/e")); + let expected: Vec<&Path> = vec![ + Path::new("/a"), + Path::new("/a/b"), + Path::new("/a/b/c"), + Path::new("/a/b/c/d"), + ]; + assert_eq!(parents, expected); +} diff --git a/systemd/rustifymyclaw.service b/systemd/rustifymyclaw.service index 2326fd7..478fb29 100644 --- a/systemd/rustifymyclaw.service +++ b/systemd/rustifymyclaw.service @@ -10,10 +10,11 @@ Restart=on-failure RestartSec=5 # Security hardening -DynamicUser=yes +User=rustifymyclaw +Group=rustifymyclaw NoNewPrivileges=yes ProtectSystem=strict -ProtectHome=read-only +ProtectHome=false PrivateTmp=yes ReadOnlyPaths=/ ConfigurationDirectory=rustifymyclaw diff --git a/tests/daemon-end-to-end/test_cleanup.sh b/tests/daemon-end-to-end/test_cleanup.sh new file mode 100644 index 0000000..b220c65 --- /dev/null +++ b/tests/daemon-end-to-end/test_cleanup.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# RustifyMyClaw — Daemon Test Cleanup +# +# Reverses everything done by test_setup_automation.sh and the manual test +# phases so the machine is back to a clean state. +# +# Run with: sudo bash scripts/test_cleanup.sh +# +# What this script removes: +# - Stops and disables the systemd service +# - Removes the unit file and override directory +# - Removes the binary from /usr/local/bin +# - Removes /etc/rustifymyclaw (config, env, backups) +# - Removes POSIX ACLs from the test workspace and its parents +# - Optionally removes the test workspace directory +# - Removes the rustifymyclaw system user and group +# +# Idempotent — safe to run even if some artifacts are already gone. +# --------------------------------------------------------------------------- + +REAL_USER="${SUDO_USER:-$USER}" +REAL_HOME=$(eval echo "~${REAL_USER}") +TEST_WORKSPACE="${REAL_HOME}/projects/test-workspace" + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- +USE_COLOR=false +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + USE_COLOR=true +fi + +_c() { if $USE_COLOR; then printf "\033[%sm" "$1"; fi; } + +info() { printf "%s[+]%s %s\n" "$(_c '0;32')" "$(_c 0)" "$*"; } +warn() { printf "%s[!]%s %s\n" "$(_c '0;33')" "$(_c 0)" "$*"; } +error() { printf "%s[-]%s %s\n" "$(_c '0;31')" "$(_c 0)" "$*" >&2; } +die() { error "$@"; exit 1; } +header() { printf "\n%s=== %s ===%s\n\n" "$(_c '1;36')" "$*" "$(_c 0)"; } +success() { printf "\n%s[OK]%s %s\n" "$(_c '1;32')" "$(_c 0)" "$*"; } + +# --------------------------------------------------------------------------- +# Pre-flight +# --------------------------------------------------------------------------- +if [ "$(id -u)" -ne 0 ]; then + die "Run with sudo: sudo bash $0" +fi + +printf "\n RustifyMyClaw — Daemon Test Cleanup\n\n" +printf " This will remove all daemon artifacts from this machine.\n" +printf " (The repo and cargo build artifacts are NOT touched.)\n\n" +read -rp " Continue? [y/N] " confirm +case "$confirm" in + [yY]|[yY][eE][sS]) ;; + *) echo "Aborted."; exit 0 ;; +esac + +# --------------------------------------------------------------------------- +# Step 1: Stop and disable service +# --------------------------------------------------------------------------- +header "Step 1/7: Stop and disable service" + +if systemctl is-active --quiet rustifymyclaw 2>/dev/null; then + systemctl stop rustifymyclaw + info "Service stopped." +else + info "Service was not running." +fi + +if systemctl is-enabled --quiet rustifymyclaw 2>/dev/null; then + systemctl disable rustifymyclaw + info "Service disabled." +else + info "Service was not enabled." +fi + +# --------------------------------------------------------------------------- +# Step 2: Remove unit file and overrides +# --------------------------------------------------------------------------- +header "Step 2/7: Remove systemd unit and overrides" + +if [ -f /etc/systemd/system/rustifymyclaw.service ]; then + rm -f /etc/systemd/system/rustifymyclaw.service + info "Removed /etc/systemd/system/rustifymyclaw.service" +else + info "Unit file already absent." +fi + +if [ -d /etc/systemd/system/rustifymyclaw.service.d ]; then + rm -rf /etc/systemd/system/rustifymyclaw.service.d + info "Removed override directory." +else + info "Override directory already absent." +fi + +systemctl daemon-reload +info "systemd reloaded." + +# --------------------------------------------------------------------------- +# Step 3: Remove binary +# --------------------------------------------------------------------------- +header "Step 3/7: Remove binary" + +if [ -f /usr/local/bin/rustifymyclaw ]; then + rm -f /usr/local/bin/rustifymyclaw + info "Removed /usr/local/bin/rustifymyclaw" +else + info "Binary already absent." +fi + +# --------------------------------------------------------------------------- +# Step 4: Remove config directory +# --------------------------------------------------------------------------- +header "Step 4/7: Remove /etc/rustifymyclaw" + +if [ -d /etc/rustifymyclaw ]; then + rm -rf /etc/rustifymyclaw + info "Removed /etc/rustifymyclaw" +else + info "Config directory already absent." +fi + +# --------------------------------------------------------------------------- +# Step 5: Remove ACLs +# --------------------------------------------------------------------------- +header "Step 5/7: Remove POSIX ACLs" + +if command -v setfacl >/dev/null 2>&1; then + # Workspace ACLs + if [ -d "$TEST_WORKSPACE" ]; then + setfacl -R -b "$TEST_WORKSPACE" 2>/dev/null || true + info "Cleared ACLs on ${TEST_WORKSPACE}" + fi + + # Parent traversal ACLs — only remove the rustifymyclaw user entry, not all ACLs + for dir in "${REAL_HOME}" "${REAL_HOME}/projects"; do + if [ -d "$dir" ]; then + setfacl -x u:rustifymyclaw "$dir" 2>/dev/null || true + info "Removed rustifymyclaw ACL from ${dir}" + fi + done +else + warn "setfacl not found — skipping ACL cleanup. Install acl and re-run, or clean manually." +fi + +# --------------------------------------------------------------------------- +# Step 6: Optionally remove test workspace +# --------------------------------------------------------------------------- +header "Step 6/7: Test workspace" + +if [ -d "$TEST_WORKSPACE" ]; then + printf " Remove %s? [y/N] " "$TEST_WORKSPACE" + read -r remove_ws + case "$remove_ws" in + [yY]|[yY][eE][sS]) + rm -rf "$TEST_WORKSPACE" + info "Removed ${TEST_WORKSPACE}" + ;; + *) + info "Kept ${TEST_WORKSPACE}" + ;; + esac +else + info "Test workspace already absent." +fi + +# --------------------------------------------------------------------------- +# Step 7: Remove system user and group +# --------------------------------------------------------------------------- +header "Step 7/7: Remove system user and group" + +if id -u rustifymyclaw >/dev/null 2>&1; then + userdel rustifymyclaw 2>/dev/null || true + info "Removed user rustifymyclaw." +else + info "User rustifymyclaw already absent." +fi + +if getent group rustifymyclaw >/dev/null 2>&1; then + groupdel rustifymyclaw 2>/dev/null || true + info "Removed group rustifymyclaw." +else + info "Group rustifymyclaw already absent." +fi + +# --------------------------------------------------------------------------- +# Done +# --------------------------------------------------------------------------- +printf "\n" +success "Cleanup complete. Machine is back to pre-test state." +printf "\n The repo and build artifacts were not touched.\n" +printf " To re-test, run: sudo bash scripts/test_setup_automation.sh\n\n" diff --git a/tests/daemon-end-to-end/test_instructions.md b/tests/daemon-end-to-end/test_instructions.md new file mode 100644 index 0000000..2917156 --- /dev/null +++ b/tests/daemon-end-to-end/test_instructions.md @@ -0,0 +1,468 @@ +# Daemon Test Instructions + +Repeatable test plan for RustifyMyClaw running as a systemd daemon on Ubuntu. + +**Workflow:** Run `test_setup_automation.sh` first, follow this document phase by phase, then run `test_cleanup.sh` when done. + +--- + +## Prerequisites + +- Ubuntu machine (or Debian-based) with systemd +- `sudo` access +- At least one CLI backend installed (`claude`, `codex`, or `gemini`) +- A messaging channel ready to test (Telegram is simplest) +- The repo cloned and on the `bug/daemon_nuances` branch + +--- + +## Phase 0: Automated Setup + +Run the setup script from the repo root on your Ubuntu machine: + +```bash +chmod +x scripts/test_setup_automation.sh +sudo bash scripts/test_setup_automation.sh +``` + +The script will: + +1. Install the `acl` package if missing. +2. Build the release binary and run `clippy` + `cargo test`. +3. Run `scripts/install.sh --system` (creates user, config dir, unit file). +4. Copy the freshly built binary over the one the installer downloaded. +5. Create a test workspace directory at `~/projects/test-workspace`. +6. Prompt you to fill in the config and env file. +7. Run `allow-path` on the test workspace. +8. Reload systemd. + +When the script finishes, review its output and confirm all steps passed before continuing. + +--- + +## Phase 1: Verify Installation + +Run each check and tick it off: + +```bash +# Binary +ls -la /usr/local/bin/rustifymyclaw + +# Service user +id rustifymyclaw +# Expected: system UID, group=rustifymyclaw + +# Group +getent group rustifymyclaw + +# Config directory +ls -la /etc/rustifymyclaw/ +# Expected: config.yaml (640 root:rustifymyclaw), env (640 root:rustifymyclaw) + +stat -c '%a %U:%G' /etc/rustifymyclaw +# Expected: 750 root:rustifymyclaw + +# Unit file +ls -la /etc/systemd/system/rustifymyclaw.service + +# Unit loaded +systemctl status rustifymyclaw +# Expected: loaded (inactive is fine at this point) +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Binary at `/usr/local/bin/rustifymyclaw` | File exists, 755 | | +| 2 | `id rustifymyclaw` | System UID, group rustifymyclaw, shell nologin | | +| 3 | `getent group rustifymyclaw` | Group exists | | +| 4 | Config dir perms | 750 root:rustifymyclaw | | +| 5 | `config.yaml` perms | 640 root:rustifymyclaw | | +| 6 | `env` perms | 640 root:rustifymyclaw | | +| 7 | Unit file exists | `/etc/systemd/system/rustifymyclaw.service` | | +| 8 | `systemctl status` shows loaded | Loaded (inactive OK) | | + +--- + +## Phase 2: Verify Config Readability + +```bash +# Daemon user can read config +sudo -u rustifymyclaw cat /etc/rustifymyclaw/config.yaml +# Should succeed (output the yaml) + +# Daemon user can read env +sudo -u rustifymyclaw cat /etc/rustifymyclaw/env +# Should succeed +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Daemon reads config.yaml | File contents printed | | +| 2 | Daemon reads env | File contents printed | | + +--- + +## Phase 3: Config Path Resolution + +```bash +# Without HOME, should resolve to /etc path +sudo -u rustifymyclaw env -u HOME /usr/local/bin/rustifymyclaw config path +# Expected: /etc/rustifymyclaw/config.yaml + +# With RUSTIFYMYCLAW_CONFIG override +sudo -u rustifymyclaw env -u HOME RUSTIFYMYCLAW_CONFIG=/tmp/fake.yaml \ + /usr/local/bin/rustifymyclaw config path +# Expected: /tmp/fake.yaml +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | No HOME fallback | `/etc/rustifymyclaw/config.yaml` | | +| 2 | Env override | `/tmp/fake.yaml` | | + +--- + +## Phase 4: Workspace Permissions + +If you did NOT let the setup script run `allow-path` (or want to re-test): + +```bash +sudo /usr/local/bin/rustifymyclaw config allow-path /home/$USER/projects/test-workspace +``` + +Verify: + +```bash +# Workspace ACLs +getfacl /home/$USER/projects/test-workspace +# Expected: user:rustifymyclaw:rwx + +# Parent traversal ACLs +getfacl /home/$USER +# Expected: user:rustifymyclaw:--x + +getfacl /home/$USER/projects +# Expected: user:rustifymyclaw:--x + +# Systemd override +cat /etc/systemd/system/rustifymyclaw.service.d/override.conf +# Expected: ReadWritePaths=/home//projects/test-workspace + +# Idempotency — run again +sudo /usr/local/bin/rustifymyclaw config allow-path /home/$USER/projects/test-workspace +# Expected: "already in the systemd allowed paths" +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Workspace ACL | `user:rustifymyclaw:rwx` | | +| 2 | Parent ACL (home) | `user:rustifymyclaw:--x` | | +| 3 | Parent ACL (projects) | `user:rustifymyclaw:--x` | | +| 4 | Override file content | `ReadWritePaths=` under `[Service]` | | +| 5 | Idempotent re-run | "already in the systemd allowed paths" | | + +--- + +## Phase 5: Start the Daemon + +```bash +sudo systemctl daemon-reload +sudo systemctl start rustifymyclaw +``` + +Verify: + +```bash +# Status +systemctl status rustifymyclaw +# Expected: active (running) + +# Logs — check for clean startup +journalctl -u rustifymyclaw -n 50 --no-pager +# Look for: config loaded, workspace registered, channel started, NO errors + +# Process user +ps aux | grep rustifymyclaw +# Expected: running as rustifymyclaw user +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Status is `active (running)` | Yes | | +| 2 | Journal: config loaded | No env var or validation errors | | +| 3 | Journal: workspace registered | Workspace name appears | | +| 4 | Journal: channel started | Channel provider initialized | | +| 5 | Process user | `rustifymyclaw` | | +| 6 | No permission errors in journal | Clean | | + +--- + +## Phase 6: Functional Tests (via messaging channel) + +Open a tail on the journal in a separate terminal: + +```bash +journalctl -u rustifymyclaw -f +``` + +Then send messages from your allowed user account: + +| # | Send | Expected response | Journal check | Pass? | +|---|------|-------------------|---------------|-------| +| 1 | `hello, what directory are you working in?` | References the workspace path | Prompt dispatched, response sent | | +| 2 | Follow-up question about the previous answer | Coherent continuation (session active) | `-c` flag used (claude-cli) | | +| 3 | `/new` | Session reset confirmation | Session reset logged | | +| 4 | Question after `/new` | Fresh context (no memory of prior exchange) | No `-c` flag | | +| 5 | `/status` | Workspace and session info | | | +| 6 | `/help` | List of available commands | | | +| 7 | Ask for a very long code listing (>4000 chars) | Multiple chunked messages | Chunking logged | | +| 8 | **From a different, unauthorized account** | **No response at all** | `trace!` level log for unauthorized | | + +--- + +## Phase 7: Config Hot-Reload + +Keep the journal tail open. + +### 7a: Rate limit reload + +```bash +sudo bash -c 'cat >> /etc/rustifymyclaw/config.yaml << "EOF" + +limits: + max_requests: 2 + window_seconds: 60 +EOF' +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Journal within ~1s | "config change detected: rate limits (hot-reloaded)" | | +| 2 | Send 3 messages quickly | Third is rate-limited | | + +### 7b: Invalid config + +```bash +# Break the YAML +sudo bash -c 'echo " :::invalid" >> /etc/rustifymyclaw/config.yaml' +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Journal logs parse error | Yes, running config unchanged | | +| 2 | Send a message | Still works normally | | + +```bash +# Fix it — remove the bad line +sudo sed -i '/:::invalid/d' /etc/rustifymyclaw/config.yaml +``` + +### 7c: Restart-required change + +```bash +# Change workspace name (requires restart) +sudo sed -i 's/name: "test-workspace"/name: "renamed-workspace"/' /etc/rustifymyclaw/config.yaml +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Journal shows warning | "workspaces added or removed - restart required" | | + +```bash +# Revert +sudo sed -i 's/name: "renamed-workspace"/name: "test-workspace"/' /etc/rustifymyclaw/config.yaml +``` + +### 7d: Clean up rate limits + +```bash +# Remove the limits block if you don't want it persisted +sudo sed -i '/^limits:/,/^[^ ]/{ /^limits:/d; /^ max_requests:/d; /^ window_seconds:/d; }' /etc/rustifymyclaw/config.yaml +``` + +--- + +## Phase 8: Restart & Stop Behavior + +### 8a: Graceful restart + +```bash +sudo systemctl restart rustifymyclaw +journalctl -u rustifymyclaw -n 20 --no-pager +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Clean shutdown messages | Shutdown logged | | +| 2 | Clean startup messages | Config loaded, channels started | | +| 3 | No orphaned processes | `pgrep -c rustifymyclaw` returns 1 | | + +### 8b: Graceful stop + +```bash +sudo systemctl stop rustifymyclaw +journalctl -u rustifymyclaw -n 10 --no-pager +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Shutdown log messages present | Yes | | +| 2 | Process gone | `pgrep rustifymyclaw` returns nothing | | + +### 8c: Crash recovery (Restart=on-failure) + +```bash +sudo systemctl start rustifymyclaw + +# Kill it hard +sudo kill -9 $(pidof rustifymyclaw) + +# Wait ~6 seconds (RestartSec=5) +sleep 6 +systemctl status rustifymyclaw +``` + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Status after kill + wait | `active (running)` — systemd restarted it | | + +```bash +sudo systemctl stop rustifymyclaw +``` + +--- + +## Phase 9: Edge Cases + +For each test below, attempt a restart and check the journal. **Restore the config after each test.** + +Keep a backup: + +```bash +sudo cp /etc/rustifymyclaw/config.yaml /etc/rustifymyclaw/config.yaml.bak +sudo cp /etc/rustifymyclaw/env /etc/rustifymyclaw/env.bak +``` + +### 9a: Missing environment variable + +```bash +sudo sed -i '/TELEGRAM_BOT_TOKEN/d' /etc/rustifymyclaw/env +sudo systemctl restart rustifymyclaw +journalctl -u rustifymyclaw -n 10 --no-pager +# Expected: error naming the missing variable +``` + +| Pass? | | +|-------|-| + +Restore: `sudo cp /etc/rustifymyclaw/env.bak /etc/rustifymyclaw/env` + +### 9b: Non-existent workspace directory + +```bash +sudo sed -i 's|directory:.*|directory: "/nonexistent/path"|' /etc/rustifymyclaw/config.yaml +sudo systemctl restart rustifymyclaw +journalctl -u rustifymyclaw -n 10 --no-pager +# Expected: "directory does not exist" +``` + +| Pass? | | +|-------|-| + +Restore: `sudo cp /etc/rustifymyclaw/config.yaml.bak /etc/rustifymyclaw/config.yaml` + +### 9c: Empty allowed_users + +```bash +sudo sed -i '/allowed_users:/,/^[^ ]/{/allowed_users:/!{/^ /d}}' /etc/rustifymyclaw/config.yaml +sudo sed -i 's/allowed_users:.*/allowed_users: []/' /etc/rustifymyclaw/config.yaml +sudo systemctl restart rustifymyclaw +journalctl -u rustifymyclaw -n 10 --no-pager +# Expected: "allowed_users must be non-empty" +``` + +| Pass? | | +|-------|-| + +Restore: `sudo cp /etc/rustifymyclaw/config.yaml.bak /etc/rustifymyclaw/config.yaml` + +### 9d: Unknown backend + +```bash +sudo sed -i 's/backend:.*/backend: "unknown-cli"/' /etc/rustifymyclaw/config.yaml +sudo systemctl restart rustifymyclaw +journalctl -u rustifymyclaw -n 10 --no-pager +# Expected: "unknown backend" +``` + +| Pass? | | +|-------|-| + +Restore: `sudo cp /etc/rustifymyclaw/config.yaml.bak /etc/rustifymyclaw/config.yaml` + +### 9e: Config unreadable by daemon user + +```bash +sudo chown root:root /etc/rustifymyclaw/config.yaml +sudo chmod 600 /etc/rustifymyclaw/config.yaml +sudo systemctl restart rustifymyclaw +journalctl -u rustifymyclaw -n 10 --no-pager +# Expected: "cannot read config file" or permission denied +``` + +| Pass? | | +|-------|-| + +Restore: + +```bash +sudo chown root:rustifymyclaw /etc/rustifymyclaw/config.yaml +sudo chmod 640 /etc/rustifymyclaw/config.yaml +``` + +### 9f: setfacl not installed + +```bash +sudo apt remove -y acl +sudo /usr/local/bin/rustifymyclaw config allow-path /home/$USER/projects/test-workspace +# Expected: "setfacl is not installed" +sudo apt install -y acl +``` + +| Pass? | | +|-------|-| + +--- + +## Phase 10: Boot Persistence + +```bash +sudo systemctl enable rustifymyclaw +sudo reboot +``` + +After reboot: + +```bash +systemctl status rustifymyclaw +# Expected: active (running) +``` + +Send a test message through your channel to confirm it's functional. + +| # | Check | Expected | Pass? | +|---|-------|----------|-------| +| 1 | Service auto-started | `active (running)` | | +| 2 | Functional after reboot | Response received | | + +--- + +## Done + +When finished, run the cleanup script: + +```bash +sudo bash scripts/test_cleanup.sh +``` + +See the cleanup script header for what it removes. diff --git a/tests/daemon-end-to-end/test_setup_automation.sh b/tests/daemon-end-to-end/test_setup_automation.sh new file mode 100644 index 0000000..1d3bc15 --- /dev/null +++ b/tests/daemon-end-to-end/test_setup_automation.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# RustifyMyClaw — Daemon Test Setup +# +# Automates the repeatable setup for daemon testing on Ubuntu/Debian. +# Run from the repo root with: sudo bash scripts/test_setup_automation.sh +# +# What this script does: +# 1. Installs the acl package if missing +# 2. Builds the release binary and runs clippy + tests +# 3. Runs scripts/install.sh --system +# 4. Copies the freshly built binary over the downloaded one +# 5. Creates a test workspace directory +# 6. Pauses for you to edit config.yaml and env +# 7. Runs allow-path on the test workspace +# 8. Reloads systemd +# +# Idempotent — safe to run multiple times. +# --------------------------------------------------------------------------- + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +REAL_USER="${SUDO_USER:-$USER}" +REAL_HOME=$(eval echo "~${REAL_USER}") +TEST_WORKSPACE="${REAL_HOME}/projects/test-workspace" +CONFIG_FILE="/etc/rustifymyclaw/config.yaml" +ENV_FILE="/etc/rustifymyclaw/env" +BINARY="target/release/rustifymyclaw" + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- +USE_COLOR=false +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + USE_COLOR=true +fi + +_c() { if $USE_COLOR; then printf "\033[%sm" "$1"; fi; } + +info() { printf "%s[+]%s %s\n" "$(_c '0;32')" "$(_c 0)" "$*"; } +warn() { printf "%s[!]%s %s\n" "$(_c '0;33')" "$(_c 0)" "$*"; } +error() { printf "%s[-]%s %s\n" "$(_c '0;31')" "$(_c 0)" "$*" >&2; } +die() { error "$@"; exit 1; } +header() { printf "\n%s=== %s ===%s\n\n" "$(_c '1;36')" "$*" "$(_c 0)"; } +success() { printf "\n%s[OK]%s %s\n" "$(_c '1;32')" "$(_c 0)" "$*"; } + +pause_for_user() { + printf "\n%s[?]%s %s\n" "$(_c '1;33')" "$(_c 0)" "$*" + read -rp " Press Enter when ready (or Ctrl-C to abort)..." +} + +# --------------------------------------------------------------------------- +# Pre-flight +# --------------------------------------------------------------------------- +if [ "$(id -u)" -ne 0 ]; then + die "Run with sudo: sudo bash $0" +fi + +if [ ! -f "${REPO_ROOT}/Cargo.toml" ]; then + die "Run from the repo root. Could not find Cargo.toml in ${REPO_ROOT}" +fi + +cd "$REPO_ROOT" + +# --------------------------------------------------------------------------- +# Step 1: Install acl package +# --------------------------------------------------------------------------- +header "Step 1/8: Install acl package" + +if command -v setfacl >/dev/null 2>&1; then + info "acl is already installed." +else + info "Installing acl..." + apt-get update -qq && apt-get install -y -qq acl + info "acl installed." +fi + +# --------------------------------------------------------------------------- +# Step 2: Build and validate +# --------------------------------------------------------------------------- +header "Step 2/8: Build release binary + clippy + tests" + +info "Running cargo fmt --check..." +su - "$REAL_USER" -c "cd ${REPO_ROOT} && cargo fmt --check" || \ + die "cargo fmt --check failed. Run 'cargo fmt' first." + +info "Building release binary..." +su - "$REAL_USER" -c "cd ${REPO_ROOT} && cargo build --release" + +info "Running clippy..." +su - "$REAL_USER" -c "cd ${REPO_ROOT} && cargo clippy -- -D warnings" + +info "Running tests..." +su - "$REAL_USER" -c "cd ${REPO_ROOT} && cargo test" + +success "Build and validation passed." + +# --------------------------------------------------------------------------- +# Step 3: System install +# --------------------------------------------------------------------------- +header "Step 3/8: Run install.sh --system" + +bash "${REPO_ROOT}/scripts/install.sh" --system +success "System install completed." + +# --------------------------------------------------------------------------- +# Step 4: Replace binary with freshly built one +# --------------------------------------------------------------------------- +header "Step 4/8: Copy local build to /usr/local/bin/" + +install -m 755 "${REPO_ROOT}/${BINARY}" /usr/local/bin/rustifymyclaw +info "Installed $(rustifymyclaw --version 2>/dev/null || echo 'local build') to /usr/local/bin/" +success "Local binary deployed." + +# --------------------------------------------------------------------------- +# Step 5: Create test workspace +# --------------------------------------------------------------------------- +header "Step 5/8: Create test workspace" + +if [ -d "$TEST_WORKSPACE" ]; then + info "Test workspace already exists at ${TEST_WORKSPACE}" +else + su - "$REAL_USER" -c "mkdir -p ${TEST_WORKSPACE}" + info "Created ${TEST_WORKSPACE}" +fi + +success "Workspace ready at ${TEST_WORKSPACE}" + +# --------------------------------------------------------------------------- +# Step 6: Config and env — interactive +# --------------------------------------------------------------------------- +header "Step 6/8: Configure config.yaml and env" + +if [ -f "$CONFIG_FILE" ]; then + info "Current config.yaml:" + echo "---" + cat "$CONFIG_FILE" + echo "---" +fi + +cat < + +GUIDANCE + +pause_for_user "Edit ${CONFIG_FILE} and ${ENV_FILE} now (in another terminal), then come back here." + +# Verify file permissions: run "cat" as the rustifymyclaw *user* +info "Verifying config is readable by daemon user..." +if sudo -u rustifymyclaw cat "$CONFIG_FILE" >/dev/null 2>&1; then + info "Daemon user can read config.yaml" +else + warn "Daemon user CANNOT read config.yaml — check ownership (should be root:rustifymyclaw 640)" +fi + +if sudo -u rustifymyclaw cat "$ENV_FILE" >/dev/null 2>&1; then + info "Daemon user can read env" +else + warn "Daemon user CANNOT read env — check ownership (should be root:rustifymyclaw 640)" +fi + +success "Configuration step done." + +# --------------------------------------------------------------------------- +# Step 7: allow-path +# --------------------------------------------------------------------------- +header "Step 7/8: Grant daemon access to workspace" + +/usr/local/bin/rustifymyclaw config allow-path "$TEST_WORKSPACE" + +info "Verifying daemon user has read/write access to workspace..." +TEST_FILE="${TEST_WORKSPACE}/.rustifymyclaw-acl-test" +if sudo -u rustifymyclaw touch "$TEST_FILE" 2>/dev/null; then + if sudo -u rustifymyclaw rm "$TEST_FILE" 2>/dev/null; then + info "Daemon user can read and write in ${TEST_WORKSPACE}" + else + warn "Daemon user can create but NOT delete files in ${TEST_WORKSPACE}" + fi +else + warn "Daemon user CANNOT write to ${TEST_WORKSPACE} — ACLs may not have applied correctly" +fi + +success "allow-path completed for ${TEST_WORKSPACE}" + +# --------------------------------------------------------------------------- +# Step 8: Reload systemd +# --------------------------------------------------------------------------- +header "Step 8/8: Reload systemd" + +systemctl daemon-reload +info "systemd reloaded." + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +printf "\n" +success "Setup complete!" +cat <