diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml new file mode 100644 index 00000000..c013e031 --- /dev/null +++ b/.github/workflows/code_style.yml @@ -0,0 +1,21 @@ +name: Code Style +on: + pull_request: + +jobs: + codestyle: + name: Code Style (fmt + clippy) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + profile: minimal + components: rustfmt, clippy + - name: Check formatting + run: | + cargo fmt --all -- --check + - name: Check lints (cargo clippy) + run: cargo clippy -- -D warnings diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 00000000..3feeb17b --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,29 @@ +name: Release-plz + +permissions: + pull-requests: write + contents: write + +on: + push: + branches: + - main + +jobs: + release-plz: + name: Release-plz + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Install packages (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --assume-yes libudev-dev + - name: Run release-plz + uses: MarcoIeni/release-plz-action@v0.5 + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..118f02d0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,324 @@ +# This file was autogenerated by dist: https://opensource.axo.dev/cargo-dist/ +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - uses: swatinem/rust-cache@v2 + with: + key: ${{ join(matrix.targets, '-') }} + cache-provider: ${{ matrix.cache_provider }} + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + publish-npm: + needs: + - plan + - host + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - name: Fetch npm packages + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: npm/ + merge-multiple: true + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + - run: | + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do + pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output) + npm publish --access public "./npm/${pkg}" + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + announce: + needs: + - plan + - host + - publish-npm + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..1e5d624f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Run Tests +on: + pull_request: + push: + branches: + - main + +jobs: + tests: + name: Run Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + profile: minimal + - name: Run Tests + run: cargo test --all-features -- --nocapture diff --git a/Cargo.toml b/Cargo.toml index 6b3c0711..de0d8181 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,16 @@ version = "0.1.0" edition = "2024" rust-version = "1.85" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" +authors = ["NEAR AI "] license = "MIT OR Apache-2.0" +homepage = "https://github.com/nearai/ironclaw" +repository = "https://github.com/nearai/ironclaw" + +[package.metadata.wix] +upgrade-guid = "D0156E61-BA37-451E-8AB9-1A2ECCCFA48F" +path-guid = "F90B6EA6-87F7-499B-BB19-CF55DE1EB339" +license = false +eula = false [dependencies] # Async runtime @@ -127,3 +136,47 @@ tempfile = "3" [features] default = [] integration = [] + +# The profile that 'cargo dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" + +# Config for 'dist' +[workspace.metadata.dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.30.3" +allow-dirty = ["ci"] +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell", "npm", "msi"] +# Publish jobs to run in CI +publish-jobs = ["npm"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "aarch64-pc-windows-msvc", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", +] +# The archive format to use for windows builds (defaults .zip) +windows-archive = ".tar.gz" +# The archive format to use for non-windows builds (defaults .tar.xz) +unix-archive = ".tar.gz" +# Which actions to run on pull requests +pr-run-mode = "upload" +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = false + +[workspace.metadata.dist.github-custom-runners] +aarch64-unknown-linux-gnu = "ubuntu-24.04-arm" +x86_64-unknown-linux-gnu = "ubuntu-22.04" +x86_64-pc-windows-msvc = "windows-2022" +aarch64-pc-windows-msvc = "windows-2025" +x86_64-apple-darwin = "macos-15-intel" +aarch64-apple-darwin = "macos-14" diff --git a/build.rs b/build.rs index 01ea2da8..8f695ee0 100644 --- a/build.rs +++ b/build.rs @@ -80,25 +80,26 @@ fn main() { .map(|s| s.success()) .unwrap_or(false); - if !component_ok - { + if !component_ok { // Fallback: copy raw module if wasm-tools unavailable if std::fs::copy(&raw_wasm, &wasm_out).is_err() { - eprintln!( - "cargo:warning=wasm-tools not found. Run: cargo install wasm-tools" - ); + eprintln!("cargo:warning=wasm-tools not found. Run: cargo install wasm-tools"); } } else { // Strip debug info (use temp file to avoid clobbering) let stripped = wasm_out.with_extension("wasm.stripped"); let strip_ok = Command::new("wasm-tools") - .args(["strip", wasm_out.to_str().unwrap(), "-o", stripped.to_str().unwrap()]) + .args([ + "strip", + wasm_out.to_str().unwrap(), + "-o", + stripped.to_str().unwrap(), + ]) .current_dir(&root) .status() .map(|s| s.success()) .unwrap_or(false); - if strip_ok - { + if strip_ok { let _ = std::fs::rename(&stripped, &wasm_out); } } diff --git a/examples/test_heartbeat.rs b/examples/test_heartbeat.rs index ca158f5a..188009bb 100644 --- a/examples/test_heartbeat.rs +++ b/examples/test_heartbeat.rs @@ -28,7 +28,9 @@ async fn main() -> anyhow::Result<()> { println!("=== Heartbeat Integration Test ===\n"); // 1. Load config - let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("Config: {}", e))?; + let config = Config::from_env() + .await + .map_err(|e| anyhow::anyhow!("Config: {}", e))?; println!("[1/6] Config loaded"); println!(" heartbeat.enabled = {}", config.heartbeat.enabled); println!( diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 5ede9a13..cbf8b7b8 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -469,7 +469,7 @@ pub fn create_wasm_channel_router( } #[cfg(test)] - mod tests { +mod tests { use std::sync::Arc; use crate::channels::wasm::capabilities::ChannelCapabilities; diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index bc7f0ac8..0288a2d7 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -43,12 +43,12 @@ use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView}; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::error::WasmChannelError; use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage}; -use crate::pairing::PairingStore; use crate::channels::wasm::router::RegisteredEndpoint; use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime}; use crate::channels::wasm::schema::ChannelConfig; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::error::ChannelError; +use crate::pairing::PairingStore; use crate::safety::LeakDetector; use crate::tools::wasm::LogLevel; use crate::tools::wasm::WasmResourceLimiter; @@ -1190,6 +1190,7 @@ impl WasmChannel { /// /// Static method for use by the background typing repeat task (which /// doesn't have access to `&self`). + #[allow(clippy::too_many_arguments)] async fn execute_status( channel_name: &str, runtime: &Arc, @@ -2073,12 +2074,12 @@ mod tests { use std::sync::Arc; use crate::channels::Channel; - use crate::pairing::PairingStore; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::runtime::{ PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, }; use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel}; + use crate::pairing::PairingStore; use crate::tools::wasm::ResourceLimits; fn create_test_channel() -> WasmChannel { @@ -2525,8 +2526,13 @@ mod tests { ); creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string()); - let store = - ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds); + let store = ChannelStoreData::new( + 1024 * 1024, + "test", + ChannelCapabilities::default(), + creds, + Arc::new(PairingStore::new()), + ); let error = "HTTP request failed: error sending request for url \ (https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)"; @@ -2556,6 +2562,7 @@ mod tests { "test", ChannelCapabilities::default(), std::collections::HashMap::new(), + Arc::new(PairingStore::new()), ); let input = "some error message"; @@ -2569,8 +2576,13 @@ mod tests { let mut creds = std::collections::HashMap::new(); creds.insert("EMPTY_TOKEN".to_string(), String::new()); - let store = - ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds); + let store = ChannelStoreData::new( + 1024 * 1024, + "test", + ChannelCapabilities::default(), + creds, + Arc::new(PairingStore::new()), + ); let input = "should not match anything"; assert_eq!(store.redact_credentials(input), input); diff --git a/src/cli/config.rs b/src/cli/config.rs index 7754c925..894ea710 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -71,7 +71,9 @@ pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { /// Bootstrap a DB connection for config commands. async fn connect_store() -> anyhow::Result { - let config = crate::config::Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?; + let config = crate::config::Config::from_env() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; let store = crate::history::Store::new(&config.database).await?; store.run_migrations().await?; Ok(store) diff --git a/src/cli/pairing.rs b/src/cli/pairing.rs index ef00ec52..494e191e 100644 --- a/src/cli/pairing.rs +++ b/src/cli/pairing.rs @@ -52,7 +52,10 @@ fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), Strin let requests = store.list_pending(channel).map_err(|e| e.to_string())?; if json { - println!("{}", serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())?); + println!( + "{}", + serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())? + ); return Ok(()); } @@ -69,9 +72,7 @@ fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), Strin .and_then(|m| m.as_object()) .map(|o| { o.iter() - .filter_map(|(k, v)| { - v.as_str().map(|s| format!("{}={}", k, s)) - }) + .filter_map(|(k, v)| v.as_str().map(|s| format!("{}={}", k, s))) .collect::>() .join(", ") }) @@ -88,7 +89,10 @@ fn run_approve(store: &PairingStore, channel: &str, code: &str) -> Result<(), St println!("Approved {} sender {}.", channel, entry.id); Ok(()) } - Ok(None) => Err(format!("No pending pairing request found for code: {}", code)), + Ok(None) => Err(format!( + "No pending pairing request found for code: {}", + code + )), Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err( "Too many failed approve attempts. Wait a few minutes before trying again.".to_string(), ), diff --git a/src/lib.rs b/src/lib.rs index af5f0a1a..461bff3d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,6 @@ pub mod bootstrap; pub mod channels; pub mod cli; pub mod config; -pub mod pairing; pub mod context; pub mod error; pub mod estimation; @@ -52,6 +51,7 @@ pub mod extensions; pub mod history; pub mod llm; pub mod orchestrator; +pub mod pairing; pub mod safety; pub mod sandbox; pub mod secrets; diff --git a/src/main.rs b/src/main.rs index d2ab5740..9a381f4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,6 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx use ironclaw::{ agent::{Agent, AgentDeps, SessionManager}, - pairing::PairingStore, channels::{ ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer, WebhookServerConfig, @@ -30,6 +29,7 @@ use ironclaw::{ ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore, api::OrchestratorState, }, + pairing::PairingStore, safety::SafetyLayer, secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore}, setup::{SetupConfig, SetupWizard}, @@ -86,7 +86,9 @@ async fn main() -> anyhow::Result<()> { // Memory commands need database (and optionally embeddings) let _ = dotenvy::dotenv(); - let config = Config::from_env().await.map_err(|e| anyhow::anyhow!("{}", e))?; + let config = Config::from_env() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; let store = ironclaw::history::Store::new(&config.database).await?; store.run_migrations().await?; diff --git a/src/pairing/store.rs b/src/pairing/store.rs index 941509d5..464c5a39 100644 --- a/src/pairing/store.rs +++ b/src/pairing/store.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use std::fs; use std::io::{Seek, SeekFrom, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use fs4::FileExt; @@ -94,17 +94,17 @@ fn safe_channel_key(channel: &str) -> Result { Ok(safe) } -fn pairing_path(base_dir: &PathBuf, channel: &str) -> Result { +fn pairing_path(base_dir: &Path, channel: &str) -> Result { let key = safe_channel_key(channel)?; Ok(base_dir.join(format!("{}-pairing.json", key))) } -fn allow_from_path(base_dir: &PathBuf, channel: &str) -> Result { +fn allow_from_path(base_dir: &Path, channel: &str) -> Result { let key = safe_channel_key(channel)?; Ok(base_dir.join(format!("{}-allowFrom.json", key))) } -fn approve_attempts_path(base_dir: &PathBuf, channel: &str) -> Result { +fn approve_attempts_path(base_dir: &Path, channel: &str) -> Result { let key = safe_channel_key(channel)?; Ok(base_dir.join(format!("{}-approve-attempts.json", key))) } @@ -236,10 +236,11 @@ impl PairingStore { file.lock_exclusive()?; let content = fs::read_to_string(&path).unwrap_or_default(); - let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile { - version: 1, - requests: Vec::new(), - }); + let mut store: PairingStoreFile = + serde_json::from_str(&content).unwrap_or(PairingStoreFile { + version: 1, + requests: Vec::new(), + }); let now = now_iso(); let now_secs = now_secs(); @@ -296,7 +297,10 @@ impl PairingStore { self.write_pairing_file_locked(&mut file, channel, &store.requests)?; fs4::FileExt::unlock(&file)?; - Ok(UpsertResult { code, created: true }) + Ok(UpsertResult { + code, + created: true, + }) } fn is_approve_rate_limited(&self, channel: &str) -> Result { @@ -306,8 +310,7 @@ impl PairingStore { Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(e) => return Err(e.into()), }; - let mut data: ApproveAttemptsFile = - serde_json::from_str(&content).unwrap_or_default(); + let mut data: ApproveAttemptsFile = serde_json::from_str(&content).unwrap_or_default(); let now = now_secs(); let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS); data.failed_at.retain(|&t| t >= cutoff); @@ -321,11 +324,11 @@ impl PairingStore { .read(true) .write(true) .create(true) + .truncate(true) .open(&path)?; file.lock_exclusive()?; let content = fs::read_to_string(&path).unwrap_or_default(); - let mut data: ApproveAttemptsFile = - serde_json::from_str(&content).unwrap_or_default(); + let mut data: ApproveAttemptsFile = serde_json::from_str(&content).unwrap_or_default(); let now = now_secs(); data.failed_at.push(now); let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS); @@ -368,10 +371,11 @@ impl PairingStore { file.lock_exclusive()?; let content = fs::read_to_string(&path).unwrap_or_default(); - let mut store: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile { - version: 1, - requests: Vec::new(), - }); + let mut store: PairingStoreFile = + serde_json::from_str(&content).unwrap_or(PairingStoreFile { + version: 1, + requests: Vec::new(), + }); let now_secs = now_secs(); store.requests.retain(|r| !is_expired(r, now_secs)); @@ -409,10 +413,11 @@ impl PairingStore { Err(e) => return Err(e.into()), }; - let file: AllowFromStoreFile = serde_json::from_str(&content).unwrap_or(AllowFromStoreFile { - version: 1, - allow_from: Vec::new(), - }); + let file: AllowFromStoreFile = + serde_json::from_str(&content).unwrap_or(AllowFromStoreFile { + version: 1, + allow_from: Vec::new(), + }); Ok(file.allow_from) } @@ -433,10 +438,9 @@ impl PairingStore { if let Some(u) = username { let u = u.trim().to_lowercase(); let u_norm = u.strip_prefix('@').unwrap_or(&u); - if allow - .iter() - .any(|e| e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm)) - { + if allow.iter().any(|e| { + e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm) + }) { return Ok(true); } } @@ -456,6 +460,7 @@ impl PairingStore { .read(true) .write(true) .create(true) + .truncate(true) .open(&path)?; file.lock_exclusive()?; @@ -563,11 +568,20 @@ mod tests { fn test_upsert_request_creates_new() { let (store, _) = test_store(); let result = store - .upsert_request("telegram", "user123", Some(serde_json::json!({"chat_id": 456}))) + .upsert_request( + "telegram", + "user123", + Some(serde_json::json!({"chat_id": 456})), + ) .unwrap(); assert!(result.created); assert_eq!(result.code.len(), PAIRING_CODE_LENGTH); - assert!(result.code.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8)))); + assert!( + result + .code + .chars() + .all(|c| PAIRING_ALPHABET.contains(&(c as u8))) + ); } #[test] @@ -575,7 +589,9 @@ mod tests { let (store, _) = test_store(); let r1 = store.upsert_request("telegram", "user123", None).unwrap(); assert!(r1.created); - let r2 = store.upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1}))).unwrap(); + let r2 = store + .upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1}))) + .unwrap(); assert!(!r2.created); assert_eq!(r1.code, r2.code); @@ -633,21 +649,35 @@ mod tests { let r = store.upsert_request("telegram", "user999", None).unwrap(); store.approve("telegram", &r.code).unwrap(); - assert!(store.is_sender_allowed("telegram", "user999", None).unwrap()); + assert!( + store + .is_sender_allowed("telegram", "user999", None) + .unwrap() + ); assert!(!store.is_sender_allowed("telegram", "other", None).unwrap()); } #[test] fn test_is_sender_allowed_by_username() { let (store, _) = test_store(); - store.upsert_request("telegram", "alice", Some(serde_json::json!({"username": "alice"}))).unwrap(); + store + .upsert_request( + "telegram", + "alice", + Some(serde_json::json!({"username": "alice"})), + ) + .unwrap(); let pending = store.list_pending("telegram").unwrap(); store.approve("telegram", &pending[0].code).unwrap(); // approve adds id to allow_from. For username we need to add it manually. // Actually approve adds entry.id which is "alice". So is_sender_allowed("telegram", "alice", None) would work. assert!(store.is_sender_allowed("telegram", "alice", None).unwrap()); - assert!(store.is_sender_allowed("telegram", "alice", Some("alice")).unwrap()); + assert!( + store + .is_sender_allowed("telegram", "alice", Some("alice")) + .unwrap() + ); } #[test] diff --git a/src/secrets/keychain.rs b/src/secrets/keychain.rs index 0ab810ba..1b90a106 100644 --- a/src/secrets/keychain.rs +++ b/src/secrets/keychain.rs @@ -101,15 +101,13 @@ mod platform { let ss = SecretService::connect(EncryptionType::Dh) .await .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) + SecretError::KeychainError(format!("Failed to connect to secret service: {}", e)) })?; - let collection = ss.get_default_collection().await.map_err(|e| { - SecretError::KeychainError(format!("Failed to get collection: {}", e)) - })?; + let collection = ss + .get_default_collection() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to get collection: {}", e)))?; // Unlock if needed if collection.is_locked().await.unwrap_or(true) { @@ -132,9 +130,7 @@ mod platform { "text/plain", ) .await - .map_err(|e| { - SecretError::KeychainError(format!("Failed to create secret: {}", e)) - })?; + .map_err(|e| SecretError::KeychainError(format!("Failed to create secret: {}", e)))?; Ok(()) } @@ -144,10 +140,7 @@ mod platform { let ss = SecretService::connect(EncryptionType::Dh) .await .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) + SecretError::KeychainError(format!("Failed to connect to secret service: {}", e)) })?; let items = ss @@ -188,10 +181,7 @@ mod platform { let ss = SecretService::connect(EncryptionType::Dh) .await .map_err(|e| { - SecretError::KeychainError(format!( - "Failed to connect to secret service: {}", - e - )) + SecretError::KeychainError(format!("Failed to connect to secret service: {}", e)) })?; let items = ss diff --git a/src/secrets/types.rs b/src/secrets/types.rs index d493fe73..3d82a334 100644 --- a/src/secrets/types.rs +++ b/src/secrets/types.rs @@ -192,9 +192,10 @@ impl CreateSecretParams { } /// Where a credential should be injected in an HTTP request. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub enum CredentialLocation { /// Inject as Authorization header (e.g., "Bearer {secret}") + #[default] AuthorizationBearer, /// Inject as Authorization header with Basic auth AuthorizationBasic { username: String }, @@ -209,12 +210,6 @@ pub enum CredentialLocation { UrlPath { placeholder: String }, } -impl Default for CredentialLocation { - fn default() -> Self { - Self::AuthorizationBearer - } -} - /// Mapping from a secret name to where it should be injected. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CredentialMapping { diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 021938c5..6197e0f2 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -782,12 +782,9 @@ impl SetupWizard { fn save_and_summarize(&mut self) -> Result<(), SetupError> { self.settings.onboard_completed = true; - self.settings.save().map_err(|e| { - SetupError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Failed to save settings: {}", e), - )) - })?; + self.settings + .save() + .map_err(|e| std::io::Error::other(format!("Failed to save settings: {}", e)))?; println!(); print_success("Configuration saved to ~/.ironclaw/"); diff --git a/src/tools/builtin/file.rs b/src/tools/builtin/file.rs index 14b76677..3d08cc92 100644 --- a/src/tools/builtin/file.rs +++ b/src/tools/builtin/file.rs @@ -36,7 +36,7 @@ fn is_workspace_path(path: &str) -> bool { .and_then(|f| f.to_str()) .unwrap_or(path); - WORKSPACE_FILES.iter().any(|ws| *ws == filename) + WORKSPACE_FILES.contains(&filename) || path.starts_with("daily/") || path.starts_with("context/") } diff --git a/src/tools/mcp/config.rs b/src/tools/mcp/config.rs index 4eb3fc2f..f7041934 100644 --- a/src/tools/mcp/config.rs +++ b/src/tools/mcp/config.rs @@ -365,12 +365,7 @@ pub async fn save_mcp_servers_to_db( store .set_setting(user_id, "mcp_servers", &value) .await - .map_err(|e| { - ConfigError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )) - })?; + .map_err(std::io::Error::other)?; Ok(()) } diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 845af8e2..4e833573 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -422,7 +422,7 @@ impl Default for ToolRegistry { #[cfg(test)] mod tests { use super::*; - use crate::tools::tool::EchoTool; + use crate::tools::registry::EchoTool; #[tokio::test] async fn test_register_and_get() { diff --git a/src/tools/tool.rs b/src/tools/tool.rs index bfe51820..b2f242bb 100644 --- a/src/tools/tool.rs +++ b/src/tools/tool.rs @@ -199,57 +199,57 @@ pub trait Tool: Send + Sync { } } -/// A simple no-op tool for testing. -#[derive(Debug)] -pub struct EchoTool; - -#[async_trait] -impl Tool for EchoTool { - fn name(&self) -> &str { - "echo" - } - - fn description(&self) -> &str { - "Echoes back the input message. Useful for testing." - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "The message to echo back" - } - }, - "required": ["message"] - }) - } - - async fn execute( - &self, - params: serde_json::Value, - _ctx: &JobContext, - ) -> Result { - let message = params - .get("message") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - ToolError::InvalidParameters("missing 'message' parameter".to_string()) - })?; - - Ok(ToolOutput::text(message, Duration::from_millis(1))) - } - - fn requires_sanitization(&self) -> bool { - false // Echo is a trusted internal tool - } -} - #[cfg(test)] mod tests { use super::*; + /// A simple no-op tool for testing. + #[derive(Debug)] + pub struct EchoTool; + + #[async_trait] + impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + + fn description(&self) -> &str { + "Echoes back the input message. Useful for testing." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The message to echo back" + } + }, + "required": ["message"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + let message = params + .get("message") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("missing 'message' parameter".to_string()) + })?; + + Ok(ToolOutput::text(message, Duration::from_millis(1))) + } + + fn requires_sanitization(&self) -> bool { + false // Echo is a trusted internal tool + } + } + #[tokio::test] async fn test_echo_tool() { let tool = EchoTool; diff --git a/src/workspace/repository.rs b/src/workspace/repository.rs index e350a2e3..f9e87219 100644 --- a/src/workspace/repository.rs +++ b/src/workspace/repository.rs @@ -404,14 +404,13 @@ impl Repository { Vec::new() }; - let vector_results = if config.use_vector && embedding.is_some() { - self.vector_search( - user_id, - agent_id, - embedding.unwrap(), - config.pre_fusion_limit, - ) - .await? + let vector_results = if config.use_vector { + if let Some(embedding) = embedding { + self.vector_search(user_id, agent_id, embedding, config.pre_fusion_limit) + .await? + } else { + Vec::new() + } } else { Vec::new() }; diff --git a/tests/pairing_integration.rs b/tests/pairing_integration.rs index 041f7e97..62cadd49 100644 --- a/tests/pairing_integration.rs +++ b/tests/pairing_integration.rs @@ -3,7 +3,7 @@ //! Verifies the full pairing lifecycle: upsert → list → approve → allowFrom → is_sender_allowed. //! Uses temp directory for isolation. -use ironclaw::cli::{run_pairing_command_with_store, PairingCommand}; +use ironclaw::cli::{PairingCommand, run_pairing_command_with_store}; use ironclaw::pairing::PairingStore; use tempfile::TempDir; @@ -19,10 +19,16 @@ fn test_pairing_flow_unknown_user_to_approved() { let channel = "telegram"; // 1. Unknown user sends first message -> upsert creates request - let r1 = store.upsert_request(channel, "user_12345", Some(serde_json::json!({ - "chat_id": 999, - "username": "alice" - }))).unwrap(); + let r1 = store + .upsert_request( + channel, + "user_12345", + Some(serde_json::json!({ + "chat_id": 999, + "username": "alice" + })), + ) + .unwrap(); assert!(r1.created); assert!(!r1.code.is_empty()); assert_eq!(r1.code.len(), 8); @@ -34,7 +40,11 @@ fn test_pairing_flow_unknown_user_to_approved() { assert_eq!(pending[0].code, r1.code); // 3. User is not allowed yet - assert!(!store.is_sender_allowed(channel, "user_12345", Some("alice")).unwrap()); + assert!( + !store + .is_sender_allowed(channel, "user_12345", Some("alice")) + .unwrap() + ); // 4. Approve via code let approved = store.approve(channel, &r1.code).unwrap(); @@ -42,8 +52,16 @@ fn test_pairing_flow_unknown_user_to_approved() { assert_eq!(approved.unwrap().id, "user_12345"); // 5. User is now allowed - assert!(store.is_sender_allowed(channel, "user_12345", None).unwrap()); - assert!(store.is_sender_allowed(channel, "user_12345", Some("alice")).unwrap()); + assert!( + store + .is_sender_allowed(channel, "user_12345", None) + .unwrap() + ); + assert!( + store + .is_sender_allowed(channel, "user_12345", Some("alice")) + .unwrap() + ); // 6. Pending list is empty let pending_after = store.list_pending(channel).unwrap(); @@ -69,7 +87,11 @@ fn test_pairing_flow_cli_approve() { }, ); assert!(result.is_ok()); - assert!(store.is_sender_allowed("telegram", "user_999", None).unwrap()); + assert!( + store + .is_sender_allowed("telegram", "user_999", None) + .unwrap() + ); } #[test] @@ -109,4 +131,3 @@ fn test_pairing_multiple_channels_isolated() { store.approve("slack", &r_slack.code).unwrap(); assert!(store.is_sender_allowed("slack", "user_b", None).unwrap()); } - diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index ca636e1f..5d1fdf58 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -10,11 +10,11 @@ use std::collections::HashMap; use std::sync::Arc; use ironclaw::channels::Channel; -use ironclaw::pairing::PairingStore; use ironclaw::channels::wasm::{ ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint, WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, }; +use ironclaw::pairing::PairingStore; use tempfile::TempDir; /// Create a test runtime for WASM channel operations. diff --git a/wix/main.wxs b/wix/main.wxs new file mode 100644 index 00000000..cb94f1a7 --- /dev/null +++ b/wix/main.wxs @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 1 + + + + + + + + + + + + + + + + + +