diff --git a/README.md b/README.md index b846955..dbf4419 100644 --- a/README.md +++ b/README.md @@ -66,28 +66,28 @@ RustifyMyClaw runs locally. Messages in -> directly to your Agent, responses out curl -fsSL https://raw.githubusercontent.com/Escoto/RustifyMyClaw/main/scripts/install.sh | bash ``` -**crates.io (any platform with Rust installed):** +**Specific version:** ```bash -cargo install rustifymyclaw +curl -fsSL https://raw.githubusercontent.com/Escoto/RustifyMyClaw/main/scripts/install.sh | bash -s -- v0.1.0 ``` -**Windows (Chocolatey):** +**Windows (PowerShell script):** ```powershell -choco install rustifymyclaw +irm https://raw.githubusercontent.com/Escoto/RustifyMyClaw/main/scripts/install.ps1 | iex ``` -**Windows (PowerShell script):** +**Windows (Chocolatey):** ```powershell -irm https://raw.githubusercontent.com/Escoto/RustifyMyClaw/main/scripts/install.ps1 | iex +choco install rustifymyclaw ``` -The installer downloads the binary, verifies its SHA256 checksum, creates a starter config, and adds it to your PATH. To install a specific version: +**crates.io (any platform with Rust installed):** ```bash -curl -fsSL https://raw.githubusercontent.com/Escoto/RustifyMyClaw/main/scripts/install.sh | bash -s -- v0.1.0 +cargo install rustifymyclaw ``` Or [build from source](docs/building-from-source.md). @@ -149,6 +149,32 @@ Validate your config without starting the daemon: rustifymyclaw --validate ``` +### 4. Run as a Linux daemon + +> [!IMPORTANT] +> **Requires explicite workspace write permissions** + +```bash +curl -fsSL https://raw.githubusercontent.com/Escoto/RustifyMyClaw/main/scripts/install.sh | sudo bash -s -- --system +``` + +This places the binary in `/usr/local/bin/`, config in `/etc/rustifymyclaw/`, and installs a hardened systemd unit. Then: + +```bash +sudo nano /etc/rustifymyclaw/config.yaml # configure workspaces/channels +sudo nano /etc/rustifymyclaw/env # add API tokens +sudo systemctl enable --now rustifymyclaw +journalctl -u rustifymyclaw -f # check logs +``` + +To allow your CLI Agent to **write**, use the built-in command: + +```bash +sudo rustifymyclaw config allow-path /home/user/projects/my-project +``` + +See [docs/configuration.md](docs/configuration.md#running-as-a-systemd-service-linux) for full setup details and security hardening options. + ## Backends RustifyMyClaw proxies to whichever AI CLI tool you have installed locally. Adding a new backend is one file and one trait implementation — see [How to Add a New Backend](CLAUDE.md). diff --git a/docs/configuration.md b/docs/configuration.md index 0a072df..2739cf2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,9 +9,10 @@ RustifyMyClaw resolves the config path using this priority chain: | 1 | `-f` / `--config-file` CLI flag | `rustifymyclaw -f ./my-config.yaml` | | 2 | `RUSTIFYMYCLAW_CONFIG` environment variable | `export RUSTIFYMYCLAW_CONFIG=~/projects/config.yaml` | | 3 | `./config.yaml` in the current working directory | `cd my-project && rustifymyclaw` | -| 4 | Platform default | `~/.rustifymyclaw/config.yaml` (Unix) or `%APPDATA%\RustifyMyClaw\config.yaml` (Windows) | +| 4 | `~/.rustifymyclaw/config.yaml` | Per-user config (Unix) or `%APPDATA%\RustifyMyClaw\config.yaml` (Windows) | +| 5 | `/etc/rustifymyclaw/config.yaml` | System-wide config (Unix only) | -The first match wins. Use `rustifymyclaw config path` to see which path would be used from your current directory. +The first **existing** file wins. Use `rustifymyclaw config path` to see which path would be used from your current directory. ## Full annotated example @@ -187,3 +188,84 @@ RustifyMyClaw watches `config.yaml` for changes using the `notify` crate. Change | `allowed_users` | Logged as changed. Requires restart to apply. | Invalid configs (YAML errors, missing env vars, validation failures) are logged and the running config remains in effect. + +## Running as a systemd service (Linux) + +### System-wide install + +```bash +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. + +### Manual setup + +1. Copy the binary to `/usr/local/bin/` and the unit file: + +```bash +sudo cp rustifymyclaw /usr/local/bin/ +sudo chmod 755 /usr/local/bin/rustifymyclaw +sudo cp systemd/rustifymyclaw.service /etc/systemd/system/ +sudo systemctl daemon-reload +``` + +2. Create the config directory and files: + +```bash +sudo mkdir -p /etc/rustifymyclaw +sudo cp examples/config.yaml /etc/rustifymyclaw/config.yaml +sudo cp systemd/env.example /etc/rustifymyclaw/env +sudo chmod 640 /etc/rustifymyclaw/config.yaml +sudo chmod 600 /etc/rustifymyclaw/env +``` + +3. Edit `config.yaml` with your workspaces and channels. + +```bash +sudo nano /etc/rustifymyclaw/config.yaml +``` + +4. Add API tokens to `env`: + +```bash +sudo nano /etc/rustifymyclaw/env +``` + +5. Enable and start: + +```bash +sudo systemctl enable --now rustifymyclaw +``` + +### Enable daemon to access your workspaces / directory permissions + +Because `DynamicUser=yes` runs the daemon 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. + +Use the built-in command: + +```bash +sudo rustifymyclaw config allow-path /home/user/projects/my-project +``` + +Or, manually: + +```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` + +### Security hardening + +The included systemd unit uses: + +- **`DynamicUser=yes`** — allocates an ephemeral service user, no manual user creation. +- **`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. +- **`PrivateTmp=yes`** — isolated `/tmp` namespace. +- **`EnvironmentFile`** — secrets loaded from `/etc/rustifymyclaw/env` (mode 600). + + diff --git a/scripts/install.sh b/scripts/install.sh index b48b2db..d13d95f 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -9,9 +9,32 @@ set -euo pipefail REPO="Escoto/RustifyMyClaw" BINARY_NAME="rustifymyclaw" +GITHUB_API="https://api.github.com/repos/${REPO}" +SYSTEM_INSTALL=false + +# Paths — overridden when --system is passed INSTALL_DIR="${HOME}/.rustifymyclaw" CONFIG_FILE="${INSTALL_DIR}/config.yaml" -GITHUB_API="https://api.github.com/repos/${REPO}" + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +for arg in "$@"; do + case "$arg" in + --system) SYSTEM_INSTALL=true ;; + esac +done + +if $SYSTEM_INSTALL; then + if [ "$(id -u)" -ne 0 ]; then + echo "Error: --system install requires root. Run with sudo." >&2 + exit 1 + fi + INSTALL_DIR="/usr/local/bin" + CONFIG_DIR="/etc/rustifymyclaw" + CONFIG_FILE="${CONFIG_DIR}/config.yaml" + ENV_FILE="${CONFIG_DIR}/env" +fi # --------------------------------------------------------------------------- # Output helpers @@ -164,15 +187,23 @@ download_and_verify() { # Install binary # --------------------------------------------------------------------------- install_binary() { - mkdir -p "$INSTALL_DIR" - - info "Extracting to ${INSTALL_DIR}..." - tar xzf "$ARTIFACT_PATH" -C "$INSTALL_DIR" - chmod +x "${INSTALL_DIR}/${BINARY_NAME}" - - # macOS: remove quarantine attribute - if [ "$(uname -s)" = "Darwin" ]; then - xattr -d com.apple.quarantine "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true + if $SYSTEM_INSTALL; then + info "Extracting binary to ${INSTALL_DIR}..." + local tmp_extract + tmp_extract=$(mktemp -d) + tar xzf "$ARTIFACT_PATH" -C "$tmp_extract" + install -m 755 "${tmp_extract}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" + rm -rf "$tmp_extract" + else + mkdir -p "$INSTALL_DIR" + info "Extracting to ${INSTALL_DIR}..." + tar xzf "$ARTIFACT_PATH" -C "$INSTALL_DIR" + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + + # macOS: remove quarantine attribute + if [ "$(uname -s)" = "Darwin" ]; then + xattr -d com.apple.quarantine "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true + fi fi info "Binary installed at ${INSTALL_DIR}/${BINARY_NAME}" @@ -182,18 +213,57 @@ install_binary() { # Config scaffold # --------------------------------------------------------------------------- write_config() { + if $SYSTEM_INSTALL; then + mkdir -p "$CONFIG_DIR" + chmod 755 "$CONFIG_DIR" + fi + if [ -f "$CONFIG_FILE" ]; then info "Existing config preserved at ${CONFIG_FILE}" + else + local config_url="https://raw.githubusercontent.com/${REPO}/main/examples/config.yaml" + info "Downloading example config..." + download_file "$config_url" "$CONFIG_FILE" || \ + die "Failed to download example config from ${config_url}" + if $SYSTEM_INSTALL; then + chmod 640 "$CONFIG_FILE" + else + chmod 600 "$CONFIG_FILE" + fi + info "Starter config created at ${CONFIG_FILE}" + fi + + if $SYSTEM_INSTALL; then + if [ ! -f "$ENV_FILE" ]; then + local env_url="https://raw.githubusercontent.com/${REPO}/main/systemd/env.example" + 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)" + else + info "Existing env file preserved at ${ENV_FILE}" + fi + fi +} + +# --------------------------------------------------------------------------- +# Systemd unit installation (--system only) +# --------------------------------------------------------------------------- +install_systemd_unit() { + if ! $SYSTEM_INSTALL; then return fi - local config_url="https://raw.githubusercontent.com/${REPO}/main/examples/config.yaml" - info "Downloading example config..." - download_file "$config_url" "$CONFIG_FILE" || \ - die "Failed to download example config from ${config_url}" + local unit_url="https://raw.githubusercontent.com/${REPO}/main/systemd/rustifymyclaw.service" + local unit_dest="/etc/systemd/system/rustifymyclaw.service" - chmod 600 "$CONFIG_FILE" - info "Starter config created at ${CONFIG_FILE}" + info "Installing systemd unit..." + download_file "$unit_url" "$unit_dest" || \ + die "Failed to download systemd unit file" + chmod 644 "$unit_dest" + systemctl daemon-reload + info "Systemd unit installed at ${unit_dest}" } # --------------------------------------------------------------------------- @@ -247,13 +317,24 @@ main() { printf "\n RustifyMyClaw Installer\n\n" detect_platform - resolve_version "${1:-}" + # Pass first non-flag argument as version + local ver_arg="" + for arg in "$@"; do + case "$arg" in + --*) ;; + *) ver_arg="$arg"; break ;; + esac + done + resolve_version "${ver_arg}" info "Installing RustifyMyClaw ${VERSION} for ${PLATFORM}" download_and_verify install_binary write_config - update_path + install_systemd_unit + if ! $SYSTEM_INSTALL; then + update_path + fi printf "\n" info "RustifyMyClaw ${VERSION} installed successfully!" @@ -261,16 +342,31 @@ main() { printf " Binary: %s/%s\n" "$INSTALL_DIR" "$BINARY_NAME" printf " Config: %s\n" "$CONFIG_FILE" printf "\n" - printf " Next steps:\n" - printf " 1. Edit %s\n" "$CONFIG_FILE" - printf " - Set your workspace directory\n" - printf " - Configure your channel (Telegram / WhatsApp / Slack)\n" - printf " - Set allowed_users\n" - printf " 2. Export required environment variables:\n" - printf " export TELEGRAM_BOT_TOKEN=your_token_here\n" - printf " 3. Start the daemon:\n" - printf " rustifymyclaw\n" - printf " 4. Open a new terminal or run: source ~/.bashrc\n" + + if $SYSTEM_INSTALL; then + printf " Next steps:\n" + printf " 1. Edit %s\n" "$CONFIG_FILE" + printf " - Set your workspace directory\n" + printf " - Configure your channel (Telegram / WhatsApp / Slack)\n" + printf " - Set allowed_users\n" + printf " 2. Add API tokens to %s\n" "$ENV_FILE" + printf " 3. Enable and start the service:\n" + printf " sudo systemctl enable --now rustifymyclaw\n" + printf " 4. Check logs:\n" + printf " journalctl -u rustifymyclaw -f\n" + else + printf " Next steps:\n" + printf " 1. Edit %s\n" "$CONFIG_FILE" + printf " - Set your workspace directory\n" + printf " - Configure your channel (Telegram / WhatsApp / Slack)\n" + printf " - Set allowed_users\n" + printf " 2. Export required environment variables:\n" + printf " export TELEGRAM_BOT_TOKEN=your_token_here\n" + printf " 3. Start the daemon:\n" + printf " rustifymyclaw\n" + printf " 4. Open a new terminal or run: source ~/.bashrc\n" + fi + printf "\n" printf " Full config reference:\n" printf " https://github.com/%s/blob/main/docs/configuration.md\n\n" "$REPO" diff --git a/src/cli/config_cmd/allow_path.rs b/src/cli/config_cmd/allow_path.rs new file mode 100644 index 0000000..da0b989 --- /dev/null +++ b/src/cli/config_cmd/allow_path.rs @@ -0,0 +1,102 @@ +use std::path::Path; + +#[cfg(target_os = "windows")] +use anyhow::bail; +use anyhow::Result; + +/// Result of merging a new path into existing override content. +#[cfg(any(not(target_os = "windows"), test))] +enum MergeResult { + /// Path was already present; no changes needed. + AlreadyPresent, + /// New content to write. + Updated(String), +} + +/// Pure logic: merge a new `ReadWritePaths=` entry into existing override content. +#[cfg(any(not(target_os = "windows"), test))] +fn merge_allowed_path(existing: &str, path_str: &str) -> MergeResult { + let entry = format!("ReadWritePaths={path_str}"); + + if existing.lines().any(|line| line.trim() == entry) { + return MergeResult::AlreadyPresent; + } + + let new_content = if existing.is_empty() { + format!("[Service]\n{entry}\n") + } else if existing.contains("[Service]") { + let mut result = String::new(); + for line in existing.lines() { + result.push_str(line); + result.push('\n'); + if line.trim() == "[Service]" { + result.push_str(&entry); + result.push('\n'); + } + } + result + } else { + format!("{existing}\n[Service]\n{entry}\n") + }; + + MergeResult::Updated(new_content) +} + +pub fn run(path: &Path) -> Result<()> { + #[cfg(target_os = "windows")] + { + let _ = path; + bail!("allow-path is only supported on Linux with systemd"); + } + + #[cfg(not(target_os = "windows"))] + { + use anyhow::Context; + + const OVERRIDE_DIR: &str = "/etc/systemd/system/rustifymyclaw.service.d"; + const OVERRIDE_FILE: &str = "override.conf"; + + let canonical = std::fs::canonicalize(path) + .with_context(|| format!("path does not exist: {}", path.display()))?; + let canonical_str = canonical.to_string_lossy(); + + let override_dir = Path::new(OVERRIDE_DIR); + let override_path = override_dir.join(OVERRIDE_FILE); + + let existing = if override_path.exists() { + std::fs::read_to_string(&override_path) + .with_context(|| format!("cannot read {}", override_path.display()))? + } else { + String::new() + }; + + match merge_allowed_path(&existing, &canonical_str) { + MergeResult::AlreadyPresent => { + println!("{} is already in the allowed paths.", canonical.display()); + } + MergeResult::Updated(new_content) => { + std::fs::create_dir_all(override_dir).with_context(|| { + format!("cannot create {OVERRIDE_DIR}. Are you running with sudo?") + })?; + + std::fs::write(&override_path, &new_content).with_context(|| { + format!( + "cannot write {}. Are you running with sudo?", + override_path.display() + ) + })?; + + println!("Allowed workspace path: {}", canonical.display()); + println!(); + println!("Reload and restart the service to apply:"); + println!(" sudo systemctl daemon-reload && sudo systemctl restart rustifymyclaw"); + } + } + + Ok(()) + } +} + +#[cfg(test)] +#[path = "../../tests/cli/config_cmd/allow_path_test.rs"] +mod tests; diff --git a/src/cli/config_cmd/dotted_path.rs b/src/cli/config_cmd/dotted_path.rs index 44debea..8f6afdb 100644 --- a/src/cli/config_cmd/dotted_path.rs +++ b/src/cli/config_cmd/dotted_path.rs @@ -139,93 +139,5 @@ pub fn auto_value(raw: &str) -> Value { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_simple_key() { - let segs = parse("output").unwrap(); - assert_eq!(segs, vec![Segment::Key("output".into())]); - } - - #[test] - fn parse_dotted_keys() { - let segs = parse("output.max_message_chars").unwrap(); - assert_eq!( - segs, - vec![ - Segment::Key("output".into()), - Segment::Key("max_message_chars".into()), - ] - ); - } - - #[test] - fn parse_with_index() { - let segs = parse("workspaces[0].channels[1].kind").unwrap(); - assert_eq!( - segs, - vec![ - Segment::Key("workspaces".into()), - Segment::Index(0), - Segment::Key("channels".into()), - Segment::Index(1), - Segment::Key("kind".into()), - ] - ); - } - - #[test] - fn parse_empty_errors() { - assert!(parse("").is_err()); - } - - #[test] - fn parse_unclosed_bracket_errors() { - assert!(parse("workspaces[0").is_err()); - } - - #[test] - fn auto_value_int() { - assert_eq!(auto_value("42"), Value::Number(42.into())); - } - - #[test] - fn auto_value_bool() { - assert_eq!(auto_value("true"), Value::Bool(true)); - assert_eq!(auto_value("false"), Value::Bool(false)); - } - - #[test] - fn auto_value_string() { - assert_eq!(auto_value("hello"), Value::String("hello".into())); - } - - #[test] - fn auto_value_null() { - assert_eq!(auto_value("null"), Value::Null); - } - - #[test] - fn resolve_nested_value() { - let yaml: Value = serde_yaml::from_str("a:\n b:\n - x\n - y").unwrap(); - let segs = parse("a.b[1]").unwrap(); - let val = resolve(&yaml, &segs).unwrap(); - assert_eq!(val, &Value::String("y".into())); - } - - #[test] - fn resolve_missing_key_errors() { - let yaml: Value = serde_yaml::from_str("a: 1").unwrap(); - let segs = parse("b").unwrap(); - assert!(resolve(&yaml, &segs).is_err()); - } - - #[test] - fn resolve_mut_creates_missing_key() { - let mut yaml: Value = serde_yaml::from_str("a: {}").unwrap(); - let segs = parse("a.new_key").unwrap(); - let val = resolve_mut(&mut yaml, &segs).unwrap(); - assert_eq!(val, &Value::Null); - } -} +#[path = "../../tests/cli/config_cmd/dotted_path_test.rs"] +mod tests; diff --git a/src/cli/config_cmd/mod.rs b/src/cli/config_cmd/mod.rs index 2d90b64..b9472b8 100644 --- a/src/cli/config_cmd/mod.rs +++ b/src/cli/config_cmd/mod.rs @@ -1,3 +1,4 @@ +mod allow_path; mod dotted_path; mod get; mod init; @@ -40,6 +41,11 @@ pub enum ConfigAction { /// New value (auto-detected as int, bool, or string). value: String, }, + /// Grant the systemd service read-write access to a workspace directory (Linux only, requires sudo). + AllowPath { + /// Filesystem path to allow (e.g. /home/user/projects/my-project). + path: std::path::PathBuf, + }, } pub fn run(action: &ConfigAction, config_path: &Path) -> Result<()> { @@ -50,5 +56,6 @@ pub fn run(action: &ConfigAction, config_path: &Path) -> Result<()> { ConfigAction::Init { file, dir } => init::run(file.as_deref(), dir.as_deref(), config_path), ConfigAction::Get { key } => get::run(config_path, key), ConfigAction::Set { key, value } => set::run(config_path, key, value), + ConfigAction::AllowPath { path } => allow_path::run(path), } } diff --git a/src/config.rs b/src/config.rs index fb326d0..414aa8f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -99,7 +99,10 @@ pub fn effective_output_config(global: &OutputConfig, channel: &ChannelConfig) - /// 1. Explicit CLI flag (`-f` / `--config-file`) /// 2. `RUSTIFYMYCLAW_CONFIG` environment variable /// 3. `./config.yaml` in the current working directory (only if the file exists) -/// 4. Platform default ([`dirs_path`]) +/// 4. `~/.rustifymyclaw/config.yaml` (only if the file exists) +/// 5. `/etc/rustifymyclaw/config.yaml` (Unix only, only if the file exists) +/// 6. Platform default ([`dirs_path`]) as final fallback (even if missing, so +/// [`load_from_path`] can produce a clear error) pub fn resolve_path(cli_override: Option) -> PathBuf { if let Some(p) = cli_override { return p; @@ -113,7 +116,20 @@ pub fn resolve_path(cli_override: Option) -> PathBuf { if cwd_candidate.exists() { return cwd_candidate; } - dirs_path() + let home_candidate = dirs_path(); + if home_candidate.exists() { + return home_candidate; + } + #[cfg(not(target_os = "windows"))] + { + let etc_candidate = PathBuf::from("/etc/rustifymyclaw/config.yaml"); + if etc_candidate.exists() { + 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. + home_candidate } /// Load and validate config from an explicit path. diff --git a/src/tests/cli/config_cmd/allow_path_test.rs b/src/tests/cli/config_cmd/allow_path_test.rs new file mode 100644 index 0000000..c2ab3a6 --- /dev/null +++ b/src/tests/cli/config_cmd/allow_path_test.rs @@ -0,0 +1,80 @@ +use super::*; + +#[test] +fn empty_existing_creates_service_section() { + let result = merge_allowed_path("", "/home/user/project-a"); + match result { + MergeResult::Updated(content) => { + assert_eq!(content, "[Service]\nReadWritePaths=/home/user/project-a\n"); + } + MergeResult::AlreadyPresent => panic!("expected Updated, got AlreadyPresent"), + } +} + +#[test] +fn second_path_appends_under_service_section() { + let existing = "[Service]\nReadWritePaths=/home/user/project-a\n"; + let result = merge_allowed_path(existing, "/home/user/project-b"); + match result { + MergeResult::Updated(content) => { + assert!( + content.contains("ReadWritePaths=/home/user/project-a"), + "first path should be preserved, got:\n{content}" + ); + assert!( + content.contains("ReadWritePaths=/home/user/project-b"), + "second path should be added, got:\n{content}" + ); + } + MergeResult::AlreadyPresent => panic!("expected Updated, got AlreadyPresent"), + } +} + +#[test] +fn three_paths_all_preserved() { + // Add first. + let mut content = match merge_allowed_path("", "/path/a") { + MergeResult::Updated(c) => c, + MergeResult::AlreadyPresent => panic!("first path should be new"), + }; + // Add second. + match merge_allowed_path(&content, "/path/b") { + MergeResult::Updated(c) => content = c, + MergeResult::AlreadyPresent => panic!("second path should be new"), + } + // Add third. + match merge_allowed_path(&content, "/path/c") { + MergeResult::Updated(c) => content = c, + MergeResult::AlreadyPresent => panic!("third path should be new"), + } + + assert!( + content.contains("ReadWritePaths=/path/a"), + "missing /path/a" + ); + assert!( + content.contains("ReadWritePaths=/path/b"), + "missing /path/b" + ); + assert!( + content.contains("ReadWritePaths=/path/c"), + "missing /path/c" + ); + + // Exactly one [Service] header. + let service_count = content.matches("[Service]").count(); + assert_eq!( + service_count, 1, + "expected one [Service] section, got {service_count}" + ); +} + +#[test] +fn duplicate_path_is_detected() { + let existing = "[Service]\nReadWritePaths=/home/user/project-a\n"; + let result = merge_allowed_path(existing, "/home/user/project-a"); + assert!( + matches!(result, MergeResult::AlreadyPresent), + "duplicate path should return AlreadyPresent" + ); +} diff --git a/src/tests/cli/config_cmd/dotted_path_test.rs b/src/tests/cli/config_cmd/dotted_path_test.rs new file mode 100644 index 0000000..167a921 --- /dev/null +++ b/src/tests/cli/config_cmd/dotted_path_test.rs @@ -0,0 +1,89 @@ +use super::*; +use serde_yaml::Value; + +#[test] +fn parse_simple_key() { + let segs = parse("output").unwrap(); + assert_eq!(segs, vec![Segment::Key("output".into())]); +} + +#[test] +fn parse_dotted_keys() { + let segs = parse("output.max_message_chars").unwrap(); + assert_eq!( + segs, + vec![ + Segment::Key("output".into()), + Segment::Key("max_message_chars".into()), + ] + ); +} + +#[test] +fn parse_with_index() { + let segs = parse("workspaces[0].channels[1].kind").unwrap(); + assert_eq!( + segs, + vec![ + Segment::Key("workspaces".into()), + Segment::Index(0), + Segment::Key("channels".into()), + Segment::Index(1), + Segment::Key("kind".into()), + ] + ); +} + +#[test] +fn parse_empty_errors() { + assert!(parse("").is_err()); +} + +#[test] +fn parse_unclosed_bracket_errors() { + assert!(parse("workspaces[0").is_err()); +} + +#[test] +fn auto_value_int() { + assert_eq!(auto_value("42"), Value::Number(42.into())); +} + +#[test] +fn auto_value_bool() { + assert_eq!(auto_value("true"), Value::Bool(true)); + assert_eq!(auto_value("false"), Value::Bool(false)); +} + +#[test] +fn auto_value_string() { + assert_eq!(auto_value("hello"), Value::String("hello".into())); +} + +#[test] +fn auto_value_null() { + assert_eq!(auto_value("null"), Value::Null); +} + +#[test] +fn resolve_nested_value() { + let yaml: Value = serde_yaml::from_str("a:\n b:\n - x\n - y").unwrap(); + let segs = parse("a.b[1]").unwrap(); + let val = resolve(&yaml, &segs).unwrap(); + assert_eq!(val, &Value::String("y".into())); +} + +#[test] +fn resolve_missing_key_errors() { + let yaml: Value = serde_yaml::from_str("a: 1").unwrap(); + let segs = parse("b").unwrap(); + assert!(resolve(&yaml, &segs).is_err()); +} + +#[test] +fn resolve_mut_creates_missing_key() { + let mut yaml: Value = serde_yaml::from_str("a: {}").unwrap(); + let segs = parse("a.new_key").unwrap(); + let val = resolve_mut(&mut yaml, &segs).unwrap(); + assert_eq!(val, &Value::Null); +} diff --git a/src/tests/config_test.rs b/src/tests/config_test.rs index 7b9ca82..f705101 100644 --- a/src/tests/config_test.rs +++ b/src/tests/config_test.rs @@ -414,7 +414,7 @@ fn resolve_path_falls_back_to_dirs_path() { let key = "RUSTIFYMYCLAW_CONFIG"; std::env::remove_var(key); // When no CLI flag, no env var, and no ./config.yaml in CWD, - // resolve_path should return the platform default. + // resolve_path should return the platform default (home dir path). let result = resolve_path(None); // The fallback is dirs_path() — at minimum it ends with config.yaml. assert!( @@ -423,3 +423,26 @@ fn resolve_path_falls_back_to_dirs_path() { result.display() ); } + +/// When neither home-dir nor /etc/ config exists, resolve_path still returns +/// a usable path (the home-dir default) so load_from_path can produce a +/// clear error message. +#[test] +fn resolve_path_returns_home_default_when_no_files_exist() { + let key = "RUSTIFYMYCLAW_CONFIG"; + std::env::remove_var(key); + let result = resolve_path(None); + // Should be the home-based path, not /etc/ + let expected_suffix = std::path::Path::new("config.yaml"); + assert!( + result.ends_with(expected_suffix), + "expected path ending in config.yaml, got: {}", + result.display() + ); + #[cfg(not(target_os = "windows"))] + assert!( + !result.starts_with("/etc/"), + "should prefer home-dir fallback over /etc/, got: {}", + result.display() + ); +} diff --git a/systemd/env.example b/systemd/env.example new file mode 100644 index 0000000..2b13458 --- /dev/null +++ b/systemd/env.example @@ -0,0 +1,8 @@ +# /etc/rustifymyclaw/env — API tokens and secrets +# Permissions: chmod 600 /etc/rustifymyclaw/env +# +# Uncomment and set the variables your channels require: +# TELEGRAM_BOT_TOKEN= +# SLACK_BOT_TOKEN= +# SLACK_APP_TOKEN= +# WHATSAPP_TOKEN= diff --git a/systemd/rustifymyclaw.service b/systemd/rustifymyclaw.service new file mode 100644 index 0000000..2326fd7 --- /dev/null +++ b/systemd/rustifymyclaw.service @@ -0,0 +1,28 @@ +[Unit] +Description=RustifyMyClaw — messaging-to-AI bridge daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/rustifymyclaw +Restart=on-failure +RestartSec=5 + +# Security hardening +DynamicUser=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=yes +ReadOnlyPaths=/ +ConfigurationDirectory=rustifymyclaw +EnvironmentFile=-/etc/rustifymyclaw/env + +# Logging — stdout/stderr go to journal +StandardOutput=journal +StandardError=journal +SyslogIdentifier=rustifymyclaw + +[Install] +WantedBy=multi-user.target