Compare commits

..
Author SHA1 Message Date
Illia PolosukhinandClaude Opus 4.6 2e62d71567 feat: Add NEAR key management with transaction signing and policy engine
Implements hybrid-custody NEAR key management where the agent holds scoped
function-call keys for routine operations while high-value operations require
explicit user approval through the existing channel approval flow.

Core infrastructure:
- Ed25519 key generation/import via ed25519-dalek (not near-crypto)
- AES-256-GCM encrypted storage via existing SecretsStore
- Hand-rolled borsh-serializable NEAR transaction types
- NEP-413 intent signing and MPC chain signature support
- Configurable policy engine with transaction analysis pipeline
- Daily spend tracking with automatic midnight UTC reset
- Encrypted backup/restore with Argon2id KDF
- CLI subcommands: generate, import, list, info, remove, export, policy, backup, restore
- NEAR ed25519 secret key leak detection (Critical/Block)
- WASM sign-payload host function (keys never enter WASM memory)
- KeyManager wired into AgentDeps for agent-wide access

Security invariants: private keys never reach the LLM or WASM boundary,
signing happens in host Rust code with Zeroize on drop, every transaction
is analyzed before signing, most-restrictive policy rule wins.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-09 16:24:47 -08:00
214 changed files with 10968 additions and 40702 deletions
-8
View File
@@ -1,8 +0,0 @@
target/
.git/
.env
.env.*
*.md
!CLAUDE.md
node_modules/
tools-src/
+3 -3
View File
@@ -1,15 +1,15 @@
# Database Configuration # Database Configuration
DATABASE_URL=postgres://localhost/ironclaw DATABASE_URL=postgres://ironclaw:password@localhost:5432/ironclaw
DATABASE_POOL_SIZE=10 DATABASE_POOL_SIZE=10
# LLM Provider (NEAR AI) # LLM Provider (NEAR AI)
# NEAR AI provides a unified interface to all models with user authentication # NEAR AI provides a unified interface to all models with user authentication
# Session token is stored in ~/.ironclaw/session.json and managed automatically. # Session token is stored in ~/.near-agent/session.json and managed automatically.
# On first run, the agent will open a browser for OAuth authentication. # On first run, the agent will open a browser for OAuth authentication.
NEARAI_MODEL=claude-3-5-sonnet-20241022 NEARAI_MODEL=claude-3-5-sonnet-20241022
NEARAI_BASE_URL=https://cloud-api.near.ai NEARAI_BASE_URL=https://cloud-api.near.ai
NEARAI_AUTH_URL=https://private.near.ai NEARAI_AUTH_URL=https://private.near.ai
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown # NEARAI_SESSION_PATH=~/.near-agent/session.json # optional, default shown
# Channel Configuration # Channel Configuration
# CLI is always enabled # CLI is always enabled
-22
View File
@@ -1,22 +0,0 @@
name: Code Style
on:
pull_request:
jobs:
codestyle:
name: Code Style (fmt + clippy)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
profile: minimal
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Check formatting
run: |
cargo fmt --all -- --check
- name: Check lints (cargo clippy)
run: cargo clippy -- -D warnings
-67
View File
@@ -1,67 +0,0 @@
name: Release-plz
on:
push:
branches:
- main
jobs:
# Release unpublished packages.
release-plz-release:
if: ${{ github.repository_owner == 'nearai' }}
name: Release-plz release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- &checkout
name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- &install-rust
name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# Generating a GitHub token, so that PRs and tags created by
# the release-plz-action can trigger actions workflows.
- name: Generate GitHub token
uses: actions/create-github-app-token@v2
id: generate-token
with:
# GitHub App ID secret name
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
# GitHub App private key secret name
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
- name: Run release-plz
uses: release-plz/[email protected]
with:
command: release
env:
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# Create a PR with the new versions and changelog, preparing the next release.
release-plz-pr:
if: ${{ github.repository_owner == 'nearai' }}
name: Release-plz PR
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
concurrency:
group: release-plz-${{ github.ref }}
cancel-in-progress: false
steps:
- *checkout
- *install-rust
- uses: Swatinem/rust-cache@v2
- name: Run release-plz
uses: release-plz/[email protected]
with:
command: release-pr
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
-299
View File
@@ -1,299 +0,0 @@
# This file was autogenerated by dist: https://axodotdev.github.io/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:
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:
persist-credentials: false
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:
persist-credentials: false
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<<EOF" >> "$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:
persist-credentials: false
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<<EOF" >> "$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 plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && 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:
persist-credentials: false
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/*
announce:
needs:
- plan
- host
# 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' }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
-21
View File
@@ -1,21 +0,0 @@
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@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
profile: minimal
- uses: Swatinem/rust-cache@v2
- name: Run Tests
run: cargo test --all-features -- --nocapture
-5
View File
@@ -1,11 +1,6 @@
.env .env
.env.local .env.local
.env.*
!.env.example
target/ target/
# WASM build artifacts (loaded from disk, not bundled)
*.wasm
-147
View File
@@ -1,147 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.0](https://github.com/nearai/ironclaw/compare/v0.1.3...v0.2.0) - 2026-02-16
### Added
- mark Ollama + OpenAI-compatible as implemented ([#102](https://github.com/nearai/ironclaw/pull/102))
- multi-provider inference + libSQL onboarding selection ([#92](https://github.com/nearai/ironclaw/pull/92))
- add multi-provider LLM failover with retry backoff ([#28](https://github.com/nearai/ironclaw/pull/28))
- add libSQL/Turso embedded database backend ([#47](https://github.com/nearai/ironclaw/pull/47))
- Move debug log truncation from agent loop to REPL channel ([#65](https://github.com/nearai/ironclaw/pull/65))
### Fixed
- shell destructive-command check bypassed by Value::Object arguments ([#72](https://github.com/nearai/ironclaw/pull/72))
- propagate real tool_call_id instead of hardcoded placeholder ([#73](https://github.com/nearai/ironclaw/pull/73))
- Fix wasm tool schemas and runtime ([#42](https://github.com/nearai/ironclaw/pull/42))
- flatten tool messages for NEAR AI cloud-api compatibility ([#41](https://github.com/nearai/ironclaw/pull/41))
- security hardening across all layers ([#35](https://github.com/nearai/ironclaw/pull/35))
### Other
- Explicitly enable cargo-dist caching for binary artifacts building
- Skip building binary artifacts on every PR
- add module specification rules to CLAUDE.md
- add setup/onboarding specification (src/setup/README.md)
- deduplicate tool code and remove dead stubs ([#98](https://github.com/nearai/ironclaw/pull/98))
- Reformat architecture diagram in README ([#64](https://github.com/nearai/ironclaw/pull/64))
- Add review discipline guidelines to CLAUDE.md ([#68](https://github.com/nearai/ironclaw/pull/68))
- Bump MSRV to 1.92, add GCP deployment files ([#40](https://github.com/nearai/ironclaw/pull/40))
- Add OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models) ([#31](https://github.com/nearai/ironclaw/pull/31))
## [0.1.3](https://github.com/nearai/ironclaw/compare/v0.1.2...v0.1.3) - 2026-02-12
### Other
- Enabled builds caching during CI/CD
- Disabled npm publishing as the name is already taken
## [0.1.2](https://github.com/nearai/ironclaw/compare/v0.1.1...v0.1.2) - 2026-02-12
### Other
- Added Installation instructions for the pre-built binaries
- Disabled Windows ARM64 builds as auto-updater [provided by cargo-dist] does not support this platform yet and it is not a common platform for us to support
## [0.1.1](https://github.com/nearai/ironclaw/compare/v0.1.0...v0.1.1) - 2026-02-12
### Other
- Renamed the secrets in release-plz.yml to match the configuration
- Make sure that the binaries release CD it kicking in after release-plz
## [0.1.0](https://github.com/nearai/ironclaw/releases/tag/v0.1.0) - 2026-02-12
### Added
- Add multi-provider LLM support via rig-core adapter ([#36](https://github.com/nearai/ironclaw/pull/36))
- Sandbox jobs ([#4](https://github.com/nearai/ironclaw/pull/4))
- Add Google Suite & Telegram WASM tools ([#9](https://github.com/nearai/ironclaw/pull/9))
- Improve CLI ([#5](https://github.com/nearai/ironclaw/pull/5))
### Fixed
- resolve runtime panic in Linux keychain integration ([#32](https://github.com/nearai/ironclaw/pull/32))
### Other
- Skip release-plz on forks
- Upgraded release-plz CD pipeline
- Added CI/CD and release pipelines ([#45](https://github.com/nearai/ironclaw/pull/45))
- DM pairing + Telegram channel improvements ([#17](https://github.com/nearai/ironclaw/pull/17))
- Fixes build, adds missing sse event and correct command ([#11](https://github.com/nearai/ironclaw/pull/11))
- Codex/feature parity pr hook ([#6](https://github.com/nearai/ironclaw/pull/6))
- Add WebSocket gateway and control plane ([#8](https://github.com/nearai/ironclaw/pull/8))
- select bundled Telegram channel and auto-install ([#3](https://github.com/nearai/ironclaw/pull/3))
- Adding skills for reusable work
- Fix MCP tool calls, approval loop, shutdown, and improve web UI
- Add auth mode, fix MCP token handling, and parallelize startup loading
- Merge remote-tracking branch 'origin/main' into ui
- Adding web UI
- Rename `setup` CLI command to `onboard` for compatibility
- Add in-chat extension discovery, auth, and activation system
- Add Telegram typing indicator via WIT on-status callback
- Add proactivity features: memory CLI, session pruning, self-repair notifications, slash commands, status diagnostics, context warnings
- Add hosted MCP server support with OAuth 2.1 and token refresh
- Add interactive setup wizard and persistent settings
- Rebrand to IronClaw with security-first mission
- Fix build_software tool stuck in planning mode loop
- Enable sandbox by default
- Fix Telegram Markdown formatting and clarify tool/memory distinctions
- Simplify Telegram channel config with host-injected tunnel/webhook settings
- Apply Telegram channel learnings to WhatsApp implementation
- Merge remote-tracking branch 'origin/main'
- Docker file for sandbox
- Replace hardcoded intent patterns with job tools
- Fix router test to match intentional job creation patterns
- Add Docker execution sandbox for secure shell command isolation
- Move setup wizard credentials to database storage
- Add interactive setup wizard for first-run configuration
- Add Telegram Bot API channel as WASM module
- Add OpenClaw feature parity tracking matrix
- Add Chat Completions API support and expand REPL debugging
- Implementing channels to be handled in wasm
- Support non interactive mode and model selection
- Implement tool approval, fix tool definition refresh, and wire embeddings
- Tool use
- Wiring more
- Add heartbeat integration, planning phase, and auto-repair
- Login flow
- Extend support for session management
- Adding builder capability
- Load tools at launch
- Fix multiline message rendering in TUI
- Parse NEAR AI alternative response format with output field
- Handle NEAR AI plain text responses
- Disable mouse capture to allow text selection in TUI
- Add verbose logging to debug empty NEAR AI responses
- Improve NEAR AI response parsing for varying response formats
- Show status/thinking messages in chat window, debug empty responses
- Add timeout and logging to NEAR AI provider
- Add status updates to show agent thinking/processing state
- Add CLI subcommands for WASM tool management
- Fix TUI shutdown: send /shutdown message and handle in agent loop
- Remove SimpleCliChannel, add Ctrl+D twice quit, redirect logs to TUI
- Fix TuiChannel integration and enable in main.rs
- Integrate Codex patterns: task scheduler, TUI, sessions, compaction
- Adding LICENSE
- Add README with IronClaw branding
- Add WASM sandbox secure API extension
- Wire database Store into agent loop
- Implementing WASM runtime
- Add workspace integration tests
- Compact memory_tree output format
- Replace memory_list with memory_tree tool
- Simplify workspace to path-based storage, remove legacy code
- Add NEAR AI chat-api as default LLM provider
- Add CLAUDE.md project documentation
- Add workspace and memory system (OpenClaw-inspired)
- Initial implementation of the agent framework
+17 -222
View File
@@ -11,13 +11,8 @@
- **Always available** - Multi-channel access with proactive background execution - **Always available** - Multi-channel access with proactive background execution
### Features ### Features
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, WASM channels (Telegram, Slack), web gateway - **Multi-channel input**: TUI (Ratatui), HTTP webhooks, Telegram, WhatsApp, Slack (WASM channels)
- **Parallel job execution** with state machine and self-repair for stuck jobs - **Parallel job execution** with state machine and self-repair for stuck jobs
- **Sandbox execution**: Docker container isolation with orchestrator/worker pattern
- **Claude Code mode**: Delegate jobs to Claude CLI inside containers
- **Routines**: Scheduled (cron) and reactive (event, webhook) task execution
- **Web gateway**: Browser UI with SSE/WebSocket real-time streaming
- **Extension management**: Install, auth, activate MCP/WASM extensions
- **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder - **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF) - **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection - **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection
@@ -64,9 +59,7 @@ src/
│ ├── context_monitor.rs # Memory pressure detection │ ├── context_monitor.rs # Memory pressure detection
│ ├── undo.rs # Turn-based undo/redo with checkpoints │ ├── undo.rs # Turn-based undo/redo with checkpoints
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.) │ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
── task.rs # Sub-task execution framework ── task.rs # Sub-task execution framework
│ ├── routine.rs # Routine types (Trigger, Action, Guardrails)
│ └── routine_engine.rs # Routine execution (cron ticker, event matcher)
├── channels/ # Multi-channel input ├── channels/ # Multi-channel input
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse │ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
@@ -79,33 +72,8 @@ src/
│ │ ├── overlay.rs # Approval overlays │ │ ├── overlay.rs # Approval overlays
│ │ └── composer.rs # Message composition │ │ └── composer.rs # Message composition
│ ├── http.rs # HTTP webhook (axum) with secret validation │ ├── http.rs # HTTP webhook (axum) with secret validation
│ ├── repl.rs # Simple REPL (for testing) │ ├── slack.rs # Stub
── web/ # Web gateway (browser UI) ── telegram.rs # Stub
│ │ ├── mod.rs # Gateway builder, startup
│ │ ├── server.rs # Axum router, 40+ API endpoints
│ │ ├── sse.rs # SSE broadcast manager
│ │ ├── ws.rs # WebSocket gateway + connection tracking
│ │ ├── types.rs # Request/response types, SseEvent enum
│ │ ├── auth.rs # Bearer token auth middleware
│ │ ├── log_layer.rs # Tracing layer for log streaming
│ │ └── static/ # HTML, CSS, JS (single-page app)
│ └── wasm/ # WASM channel runtime
│ ├── mod.rs
│ ├── bundled.rs # Bundled channel discovery
│ └── wrapper.rs # Channel trait wrapper for WASM modules
├── orchestrator/ # Internal HTTP API for sandbox containers
│ ├── mod.rs
│ ├── api.rs # Axum endpoints (LLM proxy, events, prompts)
│ ├── auth.rs # Per-job bearer token store
│ └── job_manager.rs # Container lifecycle (create, stop, cleanup)
├── worker/ # Runs inside Docker containers
│ ├── mod.rs
│ ├── runtime.rs # Worker execution loop (tool calls, LLM)
│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI)
│ ├── api.rs # HTTP client to orchestrator
│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator
├── safety/ # Prompt injection defense ├── safety/ # Prompt injection defense
│ ├── sanitizer.rs # Pattern detection, content escaping │ ├── sanitizer.rs # Pattern detection, content escaping
@@ -128,9 +96,6 @@ src/
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch │ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
│ │ ├── shell.rs # Shell command execution │ │ ├── shell.rs # Shell command execution
│ │ ├── memory.rs # Memory tools (search, write, read, tree) │ │ ├── memory.rs # Memory tools (search, write, read, tree)
│ │ ├── job.rs # CreateJob, ListJobs, JobStatus, CancelJob
│ │ ├── routine.rs # routine_create/list/update/delete/history
│ │ ├── extension_tools.rs # Extension install/auth/activate/remove
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs) │ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
│ ├── builder/ # Dynamic tool building │ ├── builder/ # Dynamic tool building
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language │ │ ├── core.rs # BuildRequirement, SoftwareType, Language
@@ -151,12 +116,6 @@ src/
│ ├── rate_limiter.rs # Per-tool rate limiting │ ├── rate_limiter.rs # Per-tool rate limiting
│ └── storage.rs # Linear memory persistence │ └── storage.rs # Linear memory persistence
├── db/ # Database abstraction layer
│ ├── mod.rs # Database trait (~60 async methods)
│ ├── postgres.rs # PostgreSQL backend (delegates to Store + Repository)
│ ├── libsql_backend.rs # libSQL/Turso backend (embedded SQLite)
│ └── libsql_migrations.rs # SQLite-dialect schema (idempotent)
├── workspace/ # Persistent memory system (OpenClaw-inspired) ├── workspace/ # Persistent memory system (OpenClaw-inspired)
│ ├── mod.rs # Workspace struct, memory operations │ ├── mod.rs # Workspace struct, memory operations
│ ├── document.rs # MemoryDocument, MemoryChunk, WorkspaceEntry │ ├── document.rs # MemoryDocument, MemoryChunk, WorkspaceEntry
@@ -198,9 +157,8 @@ When designing new features or systems, always prefer generic/extensible archite
### Error Handling ### Error Handling
- Use `thiserror` for error types in `error.rs` - Use `thiserror` for error types in `error.rs`
- Never use `.unwrap()` or `.expect()` in production code (tests are fine) - Never use `.unwrap()` in production code (tests are fine)
- Map errors with context: `.map_err(|e| SomeError::Variant { reason: e.to_string() })?` - Map errors with context: `.map_err(|e| SomeError::Variant { reason: e.to_string() })?`
- Before committing, grep for `.unwrap()` and `.expect(` in changed files to catch violations mechanically
### Async ### Async
- All I/O is async with tokio - All I/O is async with tokio
@@ -208,7 +166,6 @@ When designing new features or systems, always prefer generic/extensible archite
- Use `RwLock` for concurrent read/write access - Use `RwLock` for concurrent read/write access
### Traits for Extensibility ### Traits for Extensibility
- `Database` - Add new database backends (must implement all ~60 methods)
- `Channel` - Add new input sources - `Channel` - Add new input sources
- `Tool` - Add new capabilities - `Tool` - Add new capabilities
- `LlmProvider` - Add new LLM backends - `LlmProvider` - Add new LLM backends
@@ -256,12 +213,7 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted
Environment variables (see `.env.example`): Environment variables (see `.env.example`):
```bash ```bash
# Database backend (default: postgres)
DATABASE_BACKEND=postgres # or "libsql" / "turso"
DATABASE_URL=postgres://user:pass@localhost/ironclaw DATABASE_URL=postgres://user:pass@localhost/ironclaw
LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default)
# LIBSQL_URL=libsql://xxx.turso.io # Turso cloud (optional)
# LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL
# NEAR AI (required) # NEAR AI (required)
NEARAI_SESSION_TOKEN=sess_... NEARAI_SESSION_TOKEN=sess_...
@@ -284,30 +236,6 @@ HEARTBEAT_ENABLED=true
HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes
HEARTBEAT_NOTIFY_CHANNEL=tui HEARTBEAT_NOTIFY_CHANNEL=tui
HEARTBEAT_NOTIFY_USER=default HEARTBEAT_NOTIFY_USER=default
# Web gateway
GATEWAY_ENABLED=true
GATEWAY_HOST=127.0.0.1
GATEWAY_PORT=3001
GATEWAY_AUTH_TOKEN=changeme # Required for API access
GATEWAY_USER_ID=default
# Docker sandbox
SANDBOX_ENABLED=true
SANDBOX_IMAGE=ironclaw-worker:latest
SANDBOX_MEMORY_LIMIT_MB=512
SANDBOX_TIMEOUT_SECS=1800
# Claude Code mode (runs inside sandbox containers)
CLAUDE_CODE_ENABLED=false
CLAUDE_CODE_MODEL=claude-sonnet-4-20250514
CLAUDE_CODE_MAX_TURNS=50
CLAUDE_CODE_CONFIG_DIR=/home/worker/.claude
# Routines (scheduled/reactive execution)
ROUTINES_ENABLED=true
ROUTINES_CRON_INTERVAL=60 # Tick interval in seconds
ROUTINES_MAX_CONCURRENT=3
``` ```
### NEAR AI Provider ### NEAR AI Provider
@@ -321,51 +249,7 @@ Session tokens have the format `sess_xxx` (37 characters). They are authenticate
## Database ## Database
IronClaw supports two database backends, selected at compile time via Cargo feature flags and at runtime via the `DATABASE_BACKEND` environment variable. Single migration in `migrations/V1__initial.sql`. Tables:
**IMPORTANT: All new features that touch persistence MUST support both backends.** Implement the operation as a method on the `Database` trait in `src/db/mod.rs`, then add the implementation in both `src/db/postgres.rs` (delegate to Store/Repository) and `src/db/libsql_backend.rs` (native SQL).
### Backends
| Backend | Feature Flag | Default | Use Case |
|---------|-------------|---------|----------|
| PostgreSQL | `postgres` (default) | Yes | Production, existing deployments |
| libSQL/Turso | `libsql` | No | Zero-dependency local mode, edge, Turso cloud |
```bash
# Build with PostgreSQL only (default)
cargo build
# Build with libSQL only
cargo build --no-default-features --features libsql
# Build with both backends available
cargo build --features "postgres,libsql"
```
### Database Trait
The `Database` trait (`src/db/mod.rs`) defines ~60 async methods covering all persistence:
- Conversations, messages, metadata
- Jobs, actions, LLM calls, estimation snapshots
- Sandbox jobs, job events
- Routines, routine runs
- Tool failures, settings
- Workspace: documents, chunks, hybrid search
Both backends implement this trait. PostgreSQL delegates to the existing `Store` + `Repository`. libSQL implements native SQLite-dialect SQL.
### Schema
**PostgreSQL:** `migrations/V1__initial.sql` (351 lines). Uses pgvector for embeddings, tsvector for FTS, PL/pgSQL functions. Managed by `refinery`.
**libSQL:** `src/db/libsql_migrations.rs` (consolidated schema, ~480 lines). Translates PG types:
- `UUID` -> `TEXT`, `TIMESTAMPTZ` -> `TEXT` (ISO-8601), `JSONB` -> `TEXT`
- `VECTOR(1536)` -> `F32_BLOB(1536)` with `libsql_vector_idx`
- `tsvector`/`ts_rank_cd` -> FTS5 virtual table with sync triggers
- PL/pgSQL functions -> SQLite triggers
**Tables (both backends):**
**Core:** **Core:**
- `conversations` - Multi-channel conversation tracking - `conversations` - Multi-channel conversation tracking
@@ -377,41 +261,12 @@ Both backends implement this trait. PostgreSQL delegates to the existing `Store`
**Workspace/Memory:** **Workspace/Memory:**
- `memory_documents` - Flexible path-based files (e.g., "context/vision.md", "daily/2024-01-15.md") - `memory_documents` - Flexible path-based files (e.g., "context/vision.md", "daily/2024-01-15.md")
- `memory_chunks` - Chunked content with FTS and vector indexes - `memory_chunks` - Chunked content with FTS (tsvector) and vector (pgvector) indexes
- `heartbeat_state` - Periodic execution tracking - `heartbeat_state` - Periodic execution tracking
**Other:** Requires pgvector extension: `CREATE EXTENSION IF NOT EXISTS vector;`
- `routines`, `routine_runs` - Scheduled/reactive execution
- `settings` - Per-user key-value settings
- `tool_failures` - Self-repair tracking
- `secrets`, `wasm_tools`, `tool_capabilities` - Extension infrastructure
### Configuration Run migrations: `refinery migrate -c refinery.toml`
```bash
# Backend selection (default: postgres)
DATABASE_BACKEND=libsql
# PostgreSQL
DATABASE_URL=postgres://user:pass@localhost/ironclaw
# libSQL (embedded)
LIBSQL_PATH=~/.ironclaw/ironclaw.db # Default path
# libSQL (Turso cloud sync)
LIBSQL_URL=libsql://your-db.turso.io
LIBSQL_AUTH_TOKEN=your-token # Required when LIBSQL_URL is set
```
### Current Limitations (libSQL backend)
- **Workspace/memory system** not yet wired through Database trait (requires Store migration)
- **Secrets store** not yet available (still requires PostgresSecretsStore)
- **Hybrid search** uses FTS5 only (vector search via libsql_vector_idx not yet implemented)
- **Settings reload from DB** skipped (Config::from_db requires Store)
- No incremental migration versioning (schema is CREATE IF NOT EXISTS, no ALTER TABLE support yet)
- **No encryption at rest** -- The local SQLite database file stores conversation content, job data, workspace memory, and other application data in plaintext. Only secrets (API tokens, credentials) are encrypted via AES-256-GCM before storage. Users handling sensitive data should use full-disk encryption (FileVault, LUKS, BitLocker) or consider the PostgreSQL backend with TDE/encrypted storage.
- **JSON merge patch vs path-targeted update** -- The libSQL backend uses RFC 7396 JSON Merge Patch (`json_patch`) for metadata updates, while PostgreSQL uses path-targeted `jsonb_set`. Merge patch replaces top-level keys entirely, which may drop nested keys not present in the patch. Callers should avoid relying on partial nested object updates in metadata fields.
## Safety Layer ## Safety Layer
@@ -442,14 +297,13 @@ Key test patterns:
## Current Limitations / TODOs ## Current Limitations / TODOs
1. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations 1. **Slack/Telegram channels** - Stubs only, need implementation
2. **Integration tests** - Need testcontainers setup for PostgreSQL 2. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
3. **MCP stdio transport** - Only HTTP transport implemented 3. **Integration tests** - Need testcontainers setup for PostgreSQL
4. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed) 4. **MCP stdio transport** - Only HTTP transport implemented
5. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access 5. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
6. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools 6. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
7. **Webhook trigger endpoint** - Routines webhook trigger not yet exposed in web gateway 7. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
8. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard
### Completed ### Completed
@@ -466,14 +320,6 @@ Key test patterns:
-**Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session -**Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session
-**Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session -**Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session
-**Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty -**Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty
-**Gateway control plane** - Web gateway with 40+ API endpoints, SSE/WebSocket
-**Web Control UI** - Browser-based dashboard with chat, memory, jobs, logs, extensions, routines
-**Slack/Telegram channels** - Implemented as WASM tools
-**Docker sandbox** - Orchestrator/worker containers with per-job auth
-**Claude Code mode** - Delegate jobs to Claude CLI inside containers
-**Routines system** - Cron, event, webhook, and manual triggers with guardrails
-**Extension management** - Install, auth, activate MCP/WASM extensions via CLI and web UI
-**libSQL/Turso backend** - Database trait abstraction (`src/db/`), feature-gated dual backend support (postgres/libsql), embedded SQLite for zero-dependency local mode
## Adding a New Tool ## Adding a New Tool
@@ -630,22 +476,6 @@ RUST_LOG=ironclaw::agent=debug cargo run
RUST_LOG=ironclaw=debug,tower_http=debug cargo run RUST_LOG=ironclaw=debug,tower_http=debug cargo run
``` ```
## Module Specifications
Some modules have a `README.md` that serves as the authoritative specification
for that module's behavior. When modifying code in a module that has a spec:
1. **Read the spec first** before making changes
2. **Code follows spec**: if the spec says X, the code must do X
3. **Update both sides**: if you change behavior, update the spec to match;
if you're implementing a spec change, update the code to match
4. **Spec is the tiebreaker**: when code and spec disagree, the spec is correct
(unless the spec is clearly outdated, in which case fix the spec first)
| Module | Spec File |
|--------|-----------|
| `src/setup/` | `src/setup/README.md` |
## Code Style ## Code Style
- Use `crate::` imports, not `super::` - Use `crate::` imports, not `super::`
@@ -654,37 +484,6 @@ for that module's behavior. When modifying code in a module that has a spec:
- Keep functions focused, extract helpers when logic is reused - Keep functions focused, extract helpers when logic is reused
- Comments for non-obvious logic only - Comments for non-obvious logic only
## Review & Fix Discipline
Hard-won lessons from code review -- follow these when fixing bugs or addressing review feedback.
### Fix the pattern, not just the instance
When a reviewer flags a bug (e.g., TOCTOU race in INSERT + SELECT-back), search the entire codebase for all instances of that same pattern. A fix in `SecretsStore::create()` that doesn't also fix `WasmToolStore::store()` is half a fix.
### Propagate architectural fixes to satellite types
If a core type changes its concurrency model (e.g., `LibSqlBackend` switches to connection-per-operation), every type that was handed a resource from the old model (e.g., `LibSqlSecretsStore`, `LibSqlWasmToolStore` holding a single `Connection`) must also be updated. Grep for the old type across the codebase.
### Schema translation is more than DDL
When translating a database schema between backends (PostgreSQL to libSQL, etc.), check for:
- **Indexes** -- diff `CREATE INDEX` statements between the two schemas
- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
- **Semantic differences** -- document where SQL functions behave differently (e.g., `json_patch` vs `jsonb_set`)
### Feature flag testing
When adding feature-gated code, test compilation with each feature in isolation:
```bash
cargo check # default features
cargo check --no-default-features --features libsql # libsql only
cargo check --all-features # all features
```
Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature.
### Mechanical verification before committing
Run these checks on changed files before committing:
- `grep -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
- `grep -rn 'super::' <files>` -- use `crate::` imports
- If you fixed a pattern bug, `grep` for other instances of that pattern across `src/`
## Workspace & Memory System ## Workspace & Memory System
Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace provides persistent memory for agents with a flexible filesystem-like structure. Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace provides persistent memory for agents with a flexible filesystem-like structure.
@@ -759,7 +558,7 @@ Four tools for LLM use:
### Hybrid Search (RRF) ### Hybrid Search (RRF)
Combines full-text search and vector similarity using Reciprocal Rank Fusion: Combines full-text search (PostgreSQL `ts_rank_cd`) and vector similarity (pgvector cosine) using Reciprocal Rank Fusion:
``` ```
score(d) = Σ 1/(k + rank(d)) for each method where d appears score(d) = Σ 1/(k + rank(d)) for each method where d appears
@@ -767,10 +566,6 @@ score(d) = Σ 1/(k + rank(d)) for each method where d appears
Default k=60. Results from both methods are combined, with documents appearing in both getting boosted scores. Default k=60. Results from both methods are combined, with documents appearing in both getting boosted scores.
**Backend differences:**
- **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF
- **libSQL:** FTS5 for keyword search only (vector search via `libsql_vector_idx` not yet wired)
### Heartbeat System ### Heartbeat System
Proactive periodic execution (default: 30 minutes): Proactive periodic execution (default: 30 minutes):
Generated
+233 -1019
View File
File diff suppressed because it is too large Load Diff
+18 -89
View File
@@ -1,19 +1,10 @@
[package] [package]
name = "ironclaw" name = "ironclaw"
version = "0.2.0" version = "0.1.0"
edition = "2024" edition = "2024"
rust-version = "1.92" rust-version = "1.85"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
authors = ["NEAR AI <[email protected]>"]
license = "MIT OR Apache-2.0" 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] [dependencies]
# Async runtime # Async runtime
@@ -22,20 +13,17 @@ tokio-stream = { version = "0.1", features = ["sync"] }
futures = "0.3" futures = "0.3"
# HTTP client # HTTP client
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
# Serialization # Serialization
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# Database - PostgreSQL (default, feature-gated) # Database
deadpool-postgres = { version = "0.14", optional = true } deadpool-postgres = "0.14"
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"], optional = true } tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"] }
postgres-types = { version = "0.2", features = ["with-serde_json-1"], optional = true } postgres-types = { version = "0.2", features = ["with-serde_json-1"] }
refinery = { version = "0.8", features = ["tokio-postgres"], optional = true } refinery = { version = "0.8", features = ["tokio-postgres"] }
# Database - libSQL/Turso (optional embedded database)
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] }
# Error handling # Error handling
thiserror = "2" thiserror = "2"
@@ -51,7 +39,7 @@ dotenvy = "0.15"
# Core types # Core types
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] } rust_decimal = { version = "1", features = ["serde", "serde-with-str", "db-tokio-postgres", "maths"] }
rust_decimal_macros = "1" rust_decimal_macros = "1"
# Async traits # Async traits
@@ -70,22 +58,17 @@ axum = { version = "0.8", features = ["ws"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors"] } tower-http = { version = "0.6", features = ["trace", "cors"] }
# Cron scheduling for routines
cron = "0.13"
# Safety/sanitization # Safety/sanitization
regex = "1" regex = "1"
aho-corasick = "1" aho-corasick = "1"
# Filesystem paths # Filesystem paths
dirs = "6" dirs = "6"
fs4 = "0.6"
# Secrecy for sensitive values # Secrecy for sensitive values
secrecy = { version = "0.10", features = ["serde"] } secrecy = { version = "0.10", features = ["serde"] }
# URL parsing and encoding # URL encoding for OAuth flow
url = "2"
urlencoding = "2" urlencoding = "2"
# Open URLs in browser # Open URLs in browser
@@ -93,7 +76,7 @@ open = "5"
# Vector embeddings for semantic search # Vector embeddings for semantic search
# The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres) # The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres)
pgvector = { version = "0.4", features = ["postgres"], optional = true } pgvector = { version = "0.4", features = ["postgres"] }
# WASM sandbox for untrusted tool execution # WASM sandbox for untrusted tool execution
wasmtime = { version = "28", features = ["component-model"] } wasmtime = { version = "28", features = ["component-model"] }
@@ -106,10 +89,13 @@ hkdf = "0.12"
sha2 = "0.10" sha2 = "0.10"
blake3 = "1" blake3 = "1"
rand = "0.8" rand = "0.8"
subtle = "2" # Constant-time comparisons for token validation
# Multi-provider LLM support # NEAR key management (ed25519 signing, borsh serialization, base58 encoding)
rig-core = "0.30" ed25519-dalek = { version = "2", features = ["rand_core", "zeroize"] }
borsh = { version = "1", features = ["derive"] }
bs58 = "0.5"
argon2 = "0.5"
zeroize = { version = "1", features = ["derive"] }
# Docker sandbox # Docker sandbox
bollard = "0.18" bollard = "0.18"
@@ -120,7 +106,6 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"]
http-body-util = "0.1" http-body-util = "0.1"
bytes = "1" bytes = "1"
base64 = "0.22.1" base64 = "0.22.1"
mime_guess = "2.0.5"
# macOS keychain # macOS keychain
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
@@ -139,61 +124,5 @@ pretty_assertions = "1"
tempfile = "3" tempfile = "3"
[features] [features]
default = ["postgres", "libsql"] default = []
postgres = [
"dep:deadpool-postgres",
"dep:tokio-postgres",
"dep:postgres-types",
"dep:refinery",
"dep:pgvector",
"rust_decimal/db-tokio-postgres",
]
libsql = ["dep:libsql"]
integration = [] integration = []
[[example]]
name = "test_heartbeat"
required-features = ["postgres"]
# 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"
# 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 = []
# Target platforms to build apps for (Rust target-triple syntax)
targets = [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"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 = "skip"
# Path that installers should place binaries in
install-path = "CARGO_HOME"
# Whether to install an updater program
install-updater = true
# Cache intermediate build artifacts to speed up the release pipelines
cache-builds = true
[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"
x86_64-apple-darwin = "macos-15-intel"
aarch64-apple-darwin = "macos-14"
-46
View File
@@ -1,46 +0,0 @@
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
#
# Build:
# docker build --platform linux/amd64 -t ironclaw:latest .
#
# Run:
# docker run --env-file .env -p 3000:3000 ironclaw:latest
# Stage 1: Build
FROM rust:1.92-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev cmake gcc g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy manifests first for layer caching
COPY Cargo.toml Cargo.lock ./
# Copy source and build artifacts
COPY src/ src/
COPY migrations/ migrations/
COPY wit/ wit/
RUN cargo build --release --bin ironclaw
# Stage 2: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
COPY --from=builder /app/migrations /app/migrations
# Non-root user
RUN useradd -m -u 1000 -s /bin/bash ironclaw
USER ironclaw
EXPOSE 3000
ENV RUST_LOG=ironclaw=info
ENTRYPOINT ["ironclaw"]
-63
View File
@@ -1,63 +0,0 @@
# Multi-stage Dockerfile for the IronClaw worker container.
#
# This image runs the ironclaw binary in worker mode inside Docker containers.
# The orchestrator creates instances of this image for sandboxed job execution.
#
# Build:
# docker build -f Dockerfile.worker -t ironclaw-worker .
#
# The image includes common development tools so workers can build software,
# run tests, and execute shell commands.
FROM rust:1.92-bookworm AS builder
WORKDIR /build
COPY . .
# Build only the ironclaw binary (release mode)
RUN cargo build --release --bin ironclaw
# ---
FROM debian:bookworm-slim
# Install common development tools
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
build-essential \
pkg-config \
libssl-dev \
nodejs \
npm \
python3 \
python3-pip \
python3-venv \
&& rm -rf /var/lib/apt/lists/*
# Install Rust toolchain for the sandbox user
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.92.0 \
&& chmod -R a+r /usr/local/rustup /usr/local/cargo
# Install Claude Code CLI (for claude-bridge mode)
RUN npm install -g @anthropic-ai/claude-code@latest
# Copy the binary
COPY --from=builder /build/target/release/ironclaw /usr/local/bin/ironclaw
# Create non-root user (UID 1000 matches the orchestrator's container config)
RUN useradd -m -u 1000 -s /bin/bash sandbox \
&& mkdir -p /workspace \
&& chown sandbox:sandbox /workspace \
&& mkdir -p /home/sandbox/.claude \
&& chown sandbox:sandbox /home/sandbox/.claude
USER sandbox
WORKDIR /workspace
# The orchestrator passes the full command via Docker cmd.
ENTRYPOINT ["ironclaw"]
+39 -49
View File
@@ -16,8 +16,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes | | Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------| |---------|----------|----------|-------|
| Hub-and-spoke architecture | ✅ | | Web gateway as central hub | | Hub-and-spoke architecture | ✅ | 🚧 | IronClaw has channels but no central gateway |
| WebSocket control plane | ✅ | | Gateway with WebSocket + SSE | | WebSocket control plane | ✅ | | Gateway with ws://127.0.0.1:18789 |
| Single-user system | ✅ | ✅ | | | Single-user system | ✅ | ✅ | |
| Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent | | Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent |
| Session-based messaging | ✅ | ✅ | Per-sender sessions | | Session-based messaging | ✅ | ✅ | Per-sender sessions |
@@ -31,19 +31,19 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes | | Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------| |---------|----------|----------|-------|
| Gateway control plane | ✅ | | Web gateway with 40+ API endpoints | | Gateway control plane | ✅ | | Central WebSocket server |
| HTTP endpoints for Control UI | ✅ | | Web dashboard with chat, memory, jobs, logs, extensions | | HTTP endpoints for Control UI | ✅ | | Web dashboard |
| Channel connection lifecycle | ✅ | | ChannelManager + WebSocket tracker | | Channel connection lifecycle | ✅ | 🚧 | ChannelManager handles streams |
| Session management/routing | ✅ | ✅ | SessionManager exists | | Session management/routing | ✅ | ✅ | SessionManager exists |
| Configuration hot-reload | ✅ | ❌ | | | Configuration hot-reload | ✅ | ❌ | |
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only | | Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
| OpenAI-compatible HTTP API | ✅ | | /v1/chat/completions | | OpenAI-compatible HTTP API | ✅ | | /v1/chat/completions |
| Canvas hosting | ✅ | ❌ | Agent-driven UI | | Canvas hosting | ✅ | ❌ | Agent-driven UI |
| Gateway lock (PID-based) | ✅ | ❌ | | | Gateway lock (PID-based) | ✅ | ❌ | |
| launchd/systemd integration | ✅ | ❌ | | | launchd/systemd integration | ✅ | ❌ | |
| Bonjour/mDNS discovery | ✅ | ❌ | | | Bonjour/mDNS discovery | ✅ | ❌ | |
| Tailscale integration | ✅ | ❌ | | | Tailscale integration | ✅ | ❌ | |
| Health check endpoints | ✅ | | /api/health + /api/gateway/status | | Health check endpoints | ✅ | | |
| `doctor` diagnostics | ✅ | ❌ | | | `doctor` diagnostics | ✅ | ❌ | |
### Owner: _Unassigned_ ### Owner: _Unassigned_
@@ -59,14 +59,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| REPL (simple) | ✅ | ✅ | - | For testing | | REPL (simple) | ✅ | ✅ | - | For testing |
| WASM channels | ❌ | ✅ | - | IronClaw innovation | | WASM channels | ❌ | ✅ | - | IronClaw innovation |
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) | | WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
| Telegram | ✅ | | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username | | Telegram | ✅ | | P1 | grammY (Bot API) |
| Discord | ✅ | ❌ | P2 | discord.js | | Discord | ✅ | ❌ | P2 | discord.js |
| Signal | ✅ | ❌ | P2 | signal-cli | | Signal | ✅ | ❌ | P2 | signal-cli |
| Slack | ✅ | | - | WASM tool | | Slack | ✅ | 🚧 | P1 | Stub exists, needs implementation |
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended | | iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
| Feishu/Lark | ✅ | ❌ | P3 | | | Feishu/Lark | ✅ | ❌ | P3 | |
| LINE | ✅ | ❌ | P3 | | | LINE | ✅ | ❌ | P3 | |
| WebChat | ✅ | | - | Web gateway chat | | WebChat | ✅ | | P2 | Browser-based chat |
| Matrix | ✅ | ❌ | P3 | E2EE support | | Matrix | ✅ | ❌ | P3 | E2EE support |
| Mattermost | ✅ | ❌ | P3 | | | Mattermost | ✅ | ❌ | P3 | |
| Google Chat | ✅ | ❌ | P3 | | | Google Chat | ✅ | ❌ | P3 | |
@@ -79,13 +79,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes | | Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------| |---------|----------|----------|-------|
| DM pairing codes | ✅ | | `ironclaw pairing list/approve`, host APIs | | DM pairing codes | ✅ | | Verification for unknown senders |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store | | Allowlist/blocklist | ✅ | | Per-channel access control |
| Self-message bypass | ✅ | ❌ | Own messages skip pairing | | Self-message bypass | ✅ | ❌ | Own messages skip pairing |
| Mention-based activation | ✅ | | bot_username + respond_to_all_group_messages | | Mention-based activation | ✅ | | Configurable patterns |
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools | | Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
| Thread isolation | ✅ | ✅ | Separate sessions per thread | | Thread isolation | ✅ | ✅ | Separate sessions per thread |
| Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits | | Per-channel media limits | ✅ | | |
| Typing indicators | ✅ | 🚧 | TUI shows status | | Typing indicators | ✅ | 🚧 | TUI shows status |
### Owner: _Unassigned_ ### Owner: _Unassigned_
@@ -99,17 +99,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `run` (agent) | ✅ | ✅ | - | Default command | | `run` (agent) | ✅ | ✅ | - | Default command |
| `tool install/list/remove` | ✅ | ✅ | - | WASM tools | | `tool install/list/remove` | ✅ | ✅ | - | WASM tools |
| `gateway start/stop` | ✅ | ❌ | P2 | | | `gateway start/stop` | ✅ | ❌ | P2 | |
| `onboard` (wizard) | ✅ | | - | Interactive setup | | `onboard` (wizard) | ✅ | | P2 | Interactive setup |
| `tui` | ✅ | ✅ | - | Ratatui TUI | | `tui` | ✅ | ✅ | - | Ratatui TUI |
| `config` | ✅ | | - | Read/write config | | `config` | ✅ | | P2 | Read/write config |
| `channels` | ✅ | ❌ | P2 | Channel management | | `channels` | ✅ | ❌ | P2 | Channel management |
| `models` | ✅ | 🚧 | - | Model selector in TUI | | `models` | ✅ | 🚧 | - | Model selector in TUI |
| `status` | ✅ | | - | System status | | `status` | ✅ | | P2 | System status |
| `agents` | ✅ | ❌ | P3 | Multi-agent management | | `agents` | ✅ | ❌ | P3 | Multi-agent management |
| `sessions` | ✅ | ❌ | P3 | Session listing | | `sessions` | ✅ | ❌ | P3 | Session listing |
| `memory` | ✅ | | - | Memory search CLI | | `memory` | ✅ | | P2 | Memory search CLI |
| `skills` | ✅ | ❌ | P3 | Agent skills | | `skills` | ✅ | ❌ | P3 | Agent skills |
| `pairing` | ✅ | | - | list/approve for channel DM pairing | | `pairing` | ✅ | | P3 | Node pairing |
| `nodes` | ✅ | ❌ | P3 | Device management | | `nodes` | ✅ | ❌ | P3 | Device management |
| `plugins` | ✅ | ❌ | P3 | Plugin management | | `plugins` | ✅ | ❌ | P3 | Plugin management |
| `hooks` | ✅ | ❌ | P2 | Lifecycle hooks | | `hooks` | ✅ | ❌ | P2 | Lifecycle hooks |
@@ -132,8 +132,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes | | Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------| |---------|----------|----------|-------|
| Pi agent runtime | ✅ | | IronClaw uses custom runtime | | Pi agent runtime | ✅ | | IronClaw uses custom runtime |
| RPC-based execution | ✅ | | Orchestrator/worker pattern | | RPC-based execution | ✅ | 🚧 | Worker isolation |
| Multi-provider failover | ✅ | | `FailoverProvider` tries providers sequentially on retryable errors | | Multi-provider failover | ✅ | | Provider fallback chains |
| Per-sender sessions | ✅ | ✅ | | | Per-sender sessions | ✅ | ✅ | |
| Global sessions | ✅ | ❌ | Optional shared context | | Global sessions | ✅ | ❌ | Optional shared context |
| Session pruning | ✅ | ❌ | Auto cleanup old sessions | | Session pruning | ✅ | ❌ | Auto cleanup old sessions |
@@ -164,7 +164,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| AWS Bedrock | ✅ | ❌ | P3 | | | AWS Bedrock | ✅ | ❌ | P3 | |
| Google Gemini | ✅ | ❌ | P3 | | | Google Gemini | ✅ | ❌ | P3 | |
| OpenRouter | ✅ | ❌ | P3 | | | OpenRouter | ✅ | ❌ | P3 | |
| Ollama (local) | ✅ | | - | via `rig::providers::ollama` (full support) | | Ollama (local) | ✅ | | P2 | Local models |
| node-llama-cpp | ✅ | | - | N/A for Rust | | node-llama-cpp | ✅ | | - | N/A for Rust |
| llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings | | llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings |
@@ -173,7 +173,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes | | Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------| |---------|----------|----------|-------|
| Auto-discovery | ✅ | ❌ | | | Auto-discovery | ✅ | ❌ | |
| Failover chains | ✅ | | `FailoverProvider` with configurable `fallback_model` | | Failover chains | ✅ | | Provider fallback |
| Cooldown management | ✅ | ❌ | Skip failed providers | | Cooldown management | ✅ | ❌ | Skip failed providers |
| Per-session model override | ✅ | ✅ | Model selector in TUI | | Per-session model override | ✅ | ✅ | Model selector in TUI |
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut | | Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
@@ -303,13 +303,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Priority | Notes | | Feature | OpenClaw | IronClaw | Priority | Notes |
|---------|----------|----------|----------|-------| |---------|----------|----------|----------|-------|
| Control UI Dashboard | ✅ | | - | Web gateway with chat, memory, jobs, logs, extensions | | Control UI Dashboard | ✅ | | P2 | Web status/config |
| Channel status view | ✅ | 🚧 | P2 | Gateway status widget, full channel view pending | | Channel status view | ✅ | | P2 | |
| Agent management | ✅ | ❌ | P3 | | | Agent management | ✅ | ❌ | P3 | |
| Model selection | ✅ | ✅ | - | TUI only | | Model selection | ✅ | ✅ | - | TUI only |
| Config editing | ✅ | ❌ | P3 | | | Config editing | ✅ | ❌ | P3 | |
| Debug/logs viewer | ✅ | | - | Real-time log streaming with level/target filters | | Debug/logs viewer | ✅ | | P3 | |
| WebChat interface | ✅ | | - | Web gateway chat with SSE/WebSocket | | WebChat interface | ✅ | | P2 | Browser chat |
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI | | Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
### Owner: _Unassigned_ ### Owner: _Unassigned_
@@ -320,13 +320,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Priority | Notes | | Feature | OpenClaw | IronClaw | Priority | Notes |
|---------|----------|----------|----------|-------| |---------|----------|----------|----------|-------|
| Cron jobs | ✅ | | - | Routines with cron trigger | | Cron jobs | ✅ | | P2 | Schedule-based tasks |
| Timezone support | ✅ | | - | Via cron expressions | | Timezone support | ✅ | | P2 | |
| One-shot/recurring jobs | ✅ | | - | Manual + cron triggers | | One-shot/recurring jobs | ✅ | | P2 | |
| `beforeInbound` hook | ✅ | ❌ | P2 | | | `beforeInbound` hook | ✅ | ❌ | P2 | |
| `beforeOutbound` hook | ✅ | ❌ | P2 | | | `beforeOutbound` hook | ✅ | ❌ | P2 | |
| `beforeToolCall` hook | ✅ | ❌ | P2 | | | `beforeToolCall` hook | ✅ | ❌ | P2 | |
| `onMessage` hook | ✅ | | - | Routines with event trigger | | `onMessage` hook | ✅ | | P2 | |
| `onSessionStart` hook | ✅ | ❌ | P2 | | | `onSessionStart` hook | ✅ | ❌ | P2 | |
| `onSessionEnd` hook | ✅ | ❌ | P2 | | | `onSessionEnd` hook | ✅ | ❌ | P2 | |
| `transcribeAudio` hook | ✅ | ❌ | P3 | | | `transcribeAudio` hook | ✅ | ❌ | P3 | |
@@ -346,18 +346,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes | | Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------| |---------|----------|----------|-------|
| Gateway token auth | ✅ | | Bearer token auth on web gateway | | Gateway token auth | ✅ | 🚧 | HTTP webhook secret |
| Device pairing | ✅ | ❌ | | | Device pairing | ✅ | ❌ | |
| Tailscale identity | ✅ | ❌ | | | Tailscale identity | ✅ | ❌ | |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth | | OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
| DM pairing verification | ✅ | | ironclaw pairing approve, host APIs | | DM pairing verification | ✅ | | |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store | | Allowlist/blocklist | ✅ | | |
| Per-group tool policies | ✅ | ❌ | | | Per-group tool policies | ✅ | ❌ | |
| Exec approvals | ✅ | ✅ | TUI overlay | | Exec approvals | ✅ | ✅ | TUI overlay |
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls | | TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
| SSRF protection | ✅ | ✅ | WASM allowlist | | SSRF protection | ✅ | ✅ | WASM allowlist |
| Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 | | Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 |
| Docker sandbox | ✅ | | Orchestrator/worker containers | | Docker sandbox | ✅ | | Uses WASM sandbox |
| WASM sandbox | ❌ | ✅ | IronClaw innovation | | WASM sandbox | ❌ | ✅ | IronClaw innovation |
| Tool policies | ✅ | ✅ | | | Tool policies | ✅ | ✅ | |
| Elevated mode | ✅ | ❌ | | | Elevated mode | ✅ | ❌ | |
@@ -397,7 +397,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
### P0 - Core (Already Done) ### P0 - Core (Already Done)
- ✅ TUI channel with approval overlays - ✅ TUI channel with approval overlays
- ✅ HTTP webhook channel - ✅ HTTP webhook channel
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
- ✅ WASM tool sandbox - ✅ WASM tool sandbox
- ✅ Workspace/memory with hybrid search - ✅ Workspace/memory with hybrid search
- ✅ Prompt injection defense - ✅ Prompt injection defense
@@ -405,32 +404,23 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ✅ Session management - ✅ Session management
- ✅ Context compaction - ✅ Context compaction
- ✅ Model selection - ✅ Model selection
- ✅ Gateway control plane + WebSocket
- ✅ Web Control UI (chat, memory, jobs, logs, extensions, routines)
- ✅ WebChat channel (web gateway)
- ✅ Slack channel (WASM tool)
- ✅ Telegram channel (WASM tool, MTProto)
- ✅ Docker sandbox (orchestrator/worker)
- ✅ Cron job scheduling (routines)
- ✅ CLI subcommands (onboard, config, status, memory)
- ✅ Gateway token auth
### P1 - High Priority ### P1 - High Priority
- ❌ Slack channel (real implementation) - ❌ Slack channel (real implementation)
- Telegram channel (WASM, DM pairing, caption, /start) - Telegram channel
- ❌ WhatsApp channel - ❌ WhatsApp channel
- Multi-provider failover (`FailoverProvider` with retryable error classification) - Multi-provider failover
- ❌ Gateway control plane + WebSocket
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.) - ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
### P2 - Medium Priority ### P2 - Medium Priority
- ❌ Cron job scheduling - ❌ Cron job scheduling
- ❌ Web Control UI - ❌ Web Control UI
- ❌ WebChat channel - ❌ WebChat channel
- 🚧 Media handling (caption support; no image/PDF processing) - Media handling (images, PDFs)
- ❌ CLI subcommands (config, status, memory, doctor) - ❌ CLI subcommands (config, status, memory, doctor)
- ❌ Ollama/local model support - ❌ Ollama/local model support
- ❌ Configuration hot-reload - ❌ Configuration hot-reload
- ❌ Webhook trigger endpoint in web gateway
### P3 - Lower Priority ### P3 - Lower Priority
- ❌ Discord channel - ❌ Discord channel
+36 -86
View File
@@ -43,10 +43,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
### Always Available ### Always Available
- **Multi-channel** - REPL, HTTP webhooks, WASM channels (Telegram, Slack), and web gateway - **Multi-channel** - REPL, HTTP webhooks, and extensible WASM channels (Telegram, Slack, and more)
- **Docker Sandbox** - Isolated container execution with per-job tokens and orchestrator/worker pattern
- **Web Gateway** - Browser UI with real-time SSE/WebSocket streaming
- **Routines** - Cron schedules, event triggers, webhook handlers for background automation
- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks - **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks
- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts - **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts
- **Self-repair** - Automatic detection and recovery of stuck operations - **Self-repair** - Automatic detection and recovery of stuck operations
@@ -68,41 +65,10 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
### Prerequisites ### Prerequisites
- Rust 1.85+ - Rust 1.85+
- PostgreSQL 15+ with [pgvector](https://github.com/pgvector/pgvector) extension - PostgreSQL 15+ with pgvector extension
- NEAR AI account (authentication handled via setup wizard) - NEAR AI account (authentication handled via setup wizard)
## Download or Build ### Build
Visit [Releases page](https://github.com/nearai/ironclaw/releases/) to see the latest updates.
<details>
<summary>Install via Windows Installer (Windows)</summary>
Download the [Windows Installer](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi) and run it.
</details>
<details>
<summary>Install via powershell script (Windows)</summary>
```sh
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
```
</details>
<details>
<summary>Install via shell script (macOS, Linux, Windows/WSL)</summary>
```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
```
</details>
<details>
<summary>Compile the source code (Cargo on Windows, Linux, macOS)</summary>
Install it with `cargo`, just make sure you have [Rust](https://rustup.rs) installed on your computer.
```bash ```bash
# Clone the repository # Clone the repository
@@ -116,10 +82,6 @@ cargo build --release
cargo test cargo test
``` ```
For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first.
</details>
### Database Setup ### Database Setup
```bash ```bash
@@ -135,7 +97,7 @@ psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
Run the setup wizard to configure IronClaw: Run the setup wizard to configure IronClaw:
```bash ```bash
ironclaw onboard ironclaw setup
``` ```
The wizard handles database connection, NEAR AI authentication (via browser OAuth), The wizard handles database connection, NEAR AI authentication (via browser OAuth),
@@ -181,42 +143,37 @@ External content passes through multiple security layers:
## Architecture ## Architecture
``` ```
┌────────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────
Channels │ │ Channels
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │ ┌──────┐ ┌──────┐ ┌─────────────┐
│ │ REPL │ │ HTTP │ │WASM Channels│ │ Web Gateway │ │ │ REPL │ │ HTTP │ │ WASM Channels│
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ └──┬───┘ └──┬───┘ └──────┬──────
│ │ └──────┬──────┘ └─────────┴─────────────┘
└─────────┴──────────────┴────────────────┘
┌────▼────┐
┌─────────▼─────────┐ │ Router │ Intent classification
│ Agent Loop Intent routing └────┬────┘
└────┬──────────┬───┘
│ │ ┌──────────▼──────────┐
┌──────────▼────┐ ┌──▼───────────────┐ │ Scheduler Parallel job management
│ Scheduler │ Routines Engine └──────────┬──────────┘
│(parallel jobs)│ │(cron, event, wh) │
──────────────┘ └─────────────────┘ ──────────────────────────────
┌─────────────┼──────────────────── │ ┌─────────┐ ┌─────────┐ ┌─────────
│ │ │ Worker │ │ Worker │ Worker LLM reasoning
──────── ┌────▼──────────────── ──────── ────────┘ └────────
│ Local │ Orchestrator │ └───────────────┼───────────────┘
│Workers ┌───────────────┐ │
│(in-proc)│ │ Docker Sandbox│ ┌──────────▼──────────┐
└───┬─────┘ Containers Tool Registry
┌─────────── │ │ ┌───────────────┐
│ │ │Worker / CC │ │ │ Built-in │ │ │
│ │ └───────────┘ │ │ │ MCP │
└───────────────┘ │ │ │ │ WASM Sandbox │ │
└────────┬──────────┘ │ └───────────────┘
│ └────────────────── └─────────────────────┘
│ │ │ └─────────────────────────────────────────────────────────────────┘
│ ┌───────────▼──────────┐ │
│ │ Tool Registry │ │
│ │ Built-in, MCP, WASM │ │
│ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
``` ```
### Core Components ### Core Components
@@ -227,9 +184,6 @@ External content passes through multiple security layers:
| **Router** | Classifies user intent (command, query, task) | | **Router** | Classifies user intent (command, query, task) |
| **Scheduler** | Manages parallel job execution with priorities | | **Scheduler** | Manages parallel job execution with priorities |
| **Worker** | Executes jobs with LLM reasoning and tool calls | | **Worker** | Executes jobs with LLM reasoning and tool calls |
| **Orchestrator** | Container lifecycle, LLM proxying, per-job auth |
| **Web Gateway** | Browser UI with chat, memory, jobs, logs, extensions, routines |
| **Routines Engine** | Scheduled (cron) and reactive (event, webhook) background tasks |
| **Workspace** | Persistent memory with hybrid search | | **Workspace** | Persistent memory with hybrid search |
| **Safety Layer** | Prompt injection defense and content sanitization | | **Safety Layer** | Prompt injection defense and content sanitization |
@@ -237,7 +191,7 @@ External content passes through multiple security layers:
```bash ```bash
# First-time setup (configures database, auth, etc.) # First-time setup (configures database, auth, etc.)
ironclaw onboard ironclaw setup
# Start interactive REPL # Start interactive REPL
cargo run cargo run
@@ -256,16 +210,12 @@ cargo fmt
cargo clippy --all --benches --tests --examples --all-features cargo clippy --all --benches --tests --examples --all-features
# Run tests # Run tests
createdb ironclaw_test
cargo test cargo test
# Run specific test # Run specific test
cargo test test_name cargo test test_name
``` ```
- **Telegram channel**: See [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) for setup and DM pairing.
- **Changing channel sources**: Run `./channels-src/telegram/build.sh` before `cargo build` so the updated WASM is bundled.
## OpenClaw Heritage ## OpenClaw Heritage
IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix. IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix.
-106
View File
@@ -1,106 +0,0 @@
//! Build script: compile Telegram channel WASM from source.
//!
//! Do not commit compiled WASM binaries — they are a supply chain risk.
//! This script builds telegram.wasm from channels-src/telegram before the main crate compiles.
//!
//! Reproducible build:
//! cargo build --release
//! (build.rs invokes the channel build automatically)
//!
//! Prerequisites: rustup target add wasm32-wasip2, cargo install wasm-tools
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let root = PathBuf::from(&manifest_dir);
let channel_dir = root.join("channels-src/telegram");
let wasm_out = channel_dir.join("telegram.wasm");
// Rerun when channel source or build script changes
println!("cargo:rerun-if-changed=channels-src/telegram/src");
println!("cargo:rerun-if-changed=channels-src/telegram/Cargo.toml");
println!("cargo:rerun-if-changed=wit/channel.wit");
if !channel_dir.is_dir() {
return;
}
// Build WASM module
let status = match Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip2",
"--manifest-path",
channel_dir.join("Cargo.toml").to_str().unwrap(),
])
.current_dir(&root)
.status()
{
Ok(s) => s,
Err(_) => {
eprintln!(
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
);
return;
}
};
if !status.success() {
eprintln!(
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
);
return;
}
let raw_wasm = channel_dir.join("target/wasm32-wasip2/release/telegram_channel.wasm");
if !raw_wasm.exists() {
eprintln!(
"cargo:warning=Telegram WASM output not found at {:?}",
raw_wasm
);
return;
}
// Convert to component and strip (wasm-tools)
let component_ok = Command::new("wasm-tools")
.args([
"component",
"new",
raw_wasm.to_str().unwrap(),
"-o",
wasm_out.to_str().unwrap(),
])
.current_dir(&root)
.status()
.map(|s| s.success())
.unwrap_or(false);
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");
}
} 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(),
])
.current_dir(&root)
.status()
.map(|s| s.success())
.unwrap_or(false);
if strip_ok {
let _ = std::fs::rename(&stripped, &wasm_out);
}
}
}
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "discord-channel"
version = "0.1.0"
edition = "2021"
description = "Discord channel for IronClaw"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wit-bindgen = "0.41.0"
[lib]
crate-type = ["cdylib"]
[profile.release]
strip = true
opt-level = "s"
lto = true
codegen-units = 1
-121
View File
@@ -1,121 +0,0 @@
# Discord Channel for IronClaw
WASM channel for Discord integration - handle slash commands and button interactions via webhooks.
## Features
- **Slash Commands** - Process Discord slash commands
- **Button Interactions** - Handle button clicks
- **Thread Support** - Respond in threads
- **DM Support** - Handle direct messages
## Setup
1. Create a Discord Application at <https://discord.com/developers/applications>
2. Create a Bot and get the token
3. Set up Interactions URL to point to your IronClaw instance
4. Copy the Application ID and Public Key
5. Store in IronClaw secrets:
```bash
ironclaw secret set discord_bot_token YOUR_BOT_TOKEN
```
**Note:** The `discord_bot_token` secret is the only value read directly by this
Discord channel WASM component. The `discord_app_id` and `discord_public_key`
secrets are used by the IronClaw host (for example, to verify Discord
interaction signatures and manage slash command registration) and are not
accessed from the WASM module itself.
## Discord Configuration
### Register Slash Commands
```bash
curl -X POST \
-H "Authorization: Bot YOUR_BOT_TOKEN" \
-H "Content-Type: application/json" \
https://discord.com/api/v10/applications/YOUR_APP_ID/commands \
-d '{
"name": "ask",
"description": "Ask the AI agent",
"options": [{
"name": "question",
"description": "Your question",
"type": 3,
"required": true
}]
}'
```
### Set Interactions Endpoint
In your Discord app settings, set:
- Interactions Endpoint URL: `https://your-ironclaw.com/webhook/discord`
## Usage Examples
### Slash Command
User types: `/ask question: What is the weather?`
The agent receives:
```text
User: @username
Content: /ask question: What is the weather?
```
### Button Click
When a user clicks a button in a message, the agent receives:
```text
User: @username
Content: [Button clicked] Original message content
```
## Error Handling
If an internal error occurs (e.g., metadata serialization failure), the tool attempts to send an ephemeral message to the user:
```text
❌ Internal Error: Failed to process command metadata.
```
Check the host logs for detailed error information.
## Advanced Usage
### Embeds
To send embeds, include an `embeds` array in the `metadata_json` field of the agent's response. The structure should match the Discord API `embed` object.
## Troubleshooting
### "Invalid Signature"
- Check that `discord_public_key` is set correctly in IronClaw secrets.
- This validation happens on the host before reaching the WASM.
### "401 Unauthorized"
- Check that `discord_bot_token` is set correctly in IronClaw secrets.
- Ensure the bot is added to the server.
### "Interaction Failed"
- The interaction might have timed out (Discord requires a response within 3 seconds).
- The `interactions_endpoint_url` might be unreachable.
## Building
```bash
cd channels-src/discord
cargo build --target wasm32-wasi --release
```
## License
MIT/Apache-2.0
@@ -1,39 +0,0 @@
{
"type": "channel",
"name": "discord",
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
"capabilities": {
"http": {
"allowlist": [
{ "host": "discord.com", "path_prefix": "/api/v10" }
],
"credentials": {
"discord_bot_token": {
"secret_name": "discord_bot_token",
"location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " },
"host_patterns": ["discord.com"]
}
},
"rate_limit": {
"requests_per_minute": 60,
"requests_per_hour": 3600
}
},
"secrets": {
"allowed_names": ["discord_bot_token", "discord_*"]
},
"channel": {
"allowed_paths": ["/webhook/discord"],
"allow_polling": false,
"callback_timeout_secs": 45,
"workspace_prefix": "channels/discord/",
"emit_rate_limit": {
"messages_per_minute": 100,
"messages_per_hour": 5000
}
}
},
"config": {
"require_signature_verification": true
}
}
-476
View File
@@ -1,476 +0,0 @@
//! Discord Gateway/Webhook channel for IronClaw.
//!
//! This WASM component implements the channel interface for handling Discord
//! interactions via webhooks and sending messages back to Discord.
//!
//! # Features
//!
//! - URL verification for Discord interactions
//! - Slash command handling
//! - Message event parsing (@mentions, DMs)
//! - Thread support for conversations
//! - Response posting via Discord Web API
//! - Automatic message truncation (> 2000 chars)
//!
//! # Security
//!
//! - Signature validation is handled by the host (webhook secrets)
//! - Bot token is injected by host during HTTP requests
//! - WASM never sees raw credentials
wit_bindgen::generate!({
world: "sandboxed-channel",
path: "../../wit/channel.wit",
});
use serde::{Deserialize, Serialize};
use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage};
/// Discord interaction wrapper.
#[derive(Debug, Deserialize)]
struct DiscordInteraction {
/// Interaction type (1=Ping, 2=ApplicationCommand, 3=MessageComponent)
#[serde(rename = "type")]
interaction_type: u8,
/// Interaction ID
id: String,
/// Application ID
application_id: String,
/// Guild ID (if in server)
#[allow(dead_code)] // Part of API payload, currently unused
guild_id: Option<String>,
/// Channel ID
channel_id: Option<String>,
/// Member info (if in server)
member: Option<DiscordMember>,
/// User info (if DM)
user: Option<DiscordUser>,
/// Command data (for slash commands)
data: Option<DiscordCommandData>,
/// Message (for component interactions)
message: Option<DiscordMessage>,
/// Token for responding
token: String,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordMember {
user: DiscordUser,
#[allow(dead_code)] // Part of API payload, currently unused
nick: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordUser {
id: String,
username: String,
global_name: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordCommandData {
#[allow(dead_code)] // Part of API payload, currently unused
id: String,
name: String,
options: Option<Vec<DiscordCommandOption>>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordCommandOption {
name: String,
value: serde_json::Value,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordMessage {
#[allow(dead_code)] // Part of API payload, currently unused
id: String,
content: String,
channel_id: String,
#[allow(dead_code)] // Part of API payload, currently unused
author: DiscordUser,
}
/// Metadata stored with emitted messages for response routing.
#[derive(Debug, Serialize, Deserialize)]
struct DiscordMessageMetadata {
/// Discord channel ID
channel_id: String,
/// Interaction ID for followups
interaction_id: String,
/// Interaction token for responding
token: String,
/// Application ID
application_id: String,
/// Thread ID (for forum threads)
thread_id: Option<String>,
}
struct DiscordChannel;
impl Guest for DiscordChannel {
fn on_start(_config_json: String) -> Result<ChannelConfig, String> {
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
Ok(ChannelConfig {
display_name: "Discord".to_string(),
http_endpoints: vec![HttpEndpointConfig {
path: "/webhook/discord".to_string(),
methods: vec!["POST".to_string()],
require_secret: true,
}],
poll: None,
})
}
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
let body_str = match std::str::from_utf8(&req.body) {
Ok(s) => s,
Err(_) => {
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
}
};
let interaction: DiscordInteraction = match serde_json::from_str(body_str) {
Ok(i) => i,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to parse Discord interaction: {}", e),
);
return json_response(400, serde_json::json!({"error": "Invalid interaction"}));
}
};
match interaction.interaction_type {
// Ping - Discord verification
1 => {
channel_host::log(channel_host::LogLevel::Info, "Responding to Discord ping");
json_response(200, serde_json::json!({"type": 1}))
}
// Application Command (slash command)
2 => {
handle_slash_command(&interaction);
json_response(
200,
serde_json::json!({
"type": 5,
"data": {
"content": "🤔 Thinking..."
}
}),
)
}
// Message Component (buttons, selects)
3 => {
if let Some(ref message) = interaction.message {
handle_message_component(&interaction, message);
}
json_response(200, serde_json::json!({"type": 6}))
}
_ => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Unknown Discord interaction type: {}",
interaction.interaction_type
),
);
json_response(200, serde_json::json!({"type": 6}))
}
}
}
fn on_poll() {}
fn on_respond(response: AgentResponse) -> Result<(), String> {
let metadata: DiscordMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
// Use webhook endpoint for followup
let url = format!(
"https://discord.com/api/v10/webhooks/{}/{}",
metadata.application_id, metadata.token
);
// Truncate content to 2000 characters to comply with Discord limits
let content = truncate_message(&response.content);
let mut payload = serde_json::json!({
"content": content,
});
// Check for embeds in metadata
if let Ok(meta_json) = serde_json::from_str::<serde_json::Value>(&response.metadata_json) {
if let Some(embeds) = meta_json.get("embeds") {
payload["embeds"] = embeds.clone();
}
}
let payload_bytes =
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
let headers = serde_json::json!({
"Content-Type": "application/json"
});
let result = channel_host::http_request(
"POST",
&url,
&headers.to_string(),
Some(&payload_bytes),
None,
);
match result {
Ok(http_response) => {
if http_response.status >= 200 && http_response.status < 300 {
channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord");
Ok(())
} else {
let body_str = String::from_utf8_lossy(&http_response.body);
Err(format!(
"Discord API error: {} - {}",
http_response.status, body_str
))
}
}
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}
fn on_status(_update: StatusUpdate) {}
fn on_shutdown() {
channel_host::log(
channel_host::LogLevel::Info,
"Discord channel shutting down",
);
}
}
fn handle_slash_command(interaction: &DiscordInteraction) {
let user = interaction
.member
.as_ref()
.map(|m| &m.user)
.or(interaction.user.as_ref());
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
let user_name = user
.map(|u| {
u.global_name
.as_ref()
.filter(|s| !s.is_empty())
.unwrap_or(&u.username)
.clone()
})
.unwrap_or_default();
let channel_id = interaction.channel_id.clone().unwrap_or_default();
let command_name = interaction
.data
.as_ref()
.map(|d| d.name.clone())
.unwrap_or_default();
let options = interaction.data.as_ref().and_then(|d| d.options.clone());
let content = if let Some(opts) = options {
let opt_str = opts
.iter()
.map(|o| format!("{}: {}", o.name, o.value))
.collect::<Vec<_>>()
.join(", ");
format!("/{} {}", command_name, opt_str)
} else {
format!("/{}", command_name)
};
let metadata = DiscordMessageMetadata {
channel_id: channel_id.clone(),
interaction_id: interaction.id.clone(),
token: interaction.token.clone(),
application_id: interaction.application_id.clone(),
thread_id: None,
};
let metadata_json = match serde_json::to_string(&metadata) {
Ok(json) => json,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to serialize metadata: {}", e),
);
// Attempt to notify user of internal error
let url = format!(
"https://discord.com/api/v10/webhooks/{}/{}",
interaction.application_id, interaction.token
);
let payload = serde_json::json!({
"content": "❌ Internal Error: Failed to process command metadata.",
"flags": 64 // Ephemeral
});
let _ = channel_host::http_request(
"POST",
&url,
&serde_json::json!({"Content-Type": "application/json"}).to_string(),
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
None,
);
return;
}
};
channel_host::emit_message(&EmittedMessage {
user_id,
user_name: Some(user_name),
content,
thread_id: None,
metadata_json,
});
}
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
// Check member first (for server contexts), then user (for DMs)
let user = interaction
.member
.as_ref()
.map(|m| &m.user)
.or(interaction.user.as_ref());
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
let user_name = user
.map(|u| {
u.global_name
.as_ref()
.filter(|s| !s.is_empty())
.unwrap_or(&u.username)
.clone()
})
.unwrap_or_default();
let channel_id = message.channel_id.clone();
let metadata = DiscordMessageMetadata {
channel_id: channel_id.clone(),
interaction_id: interaction.id.clone(),
token: interaction.token.clone(),
application_id: interaction.application_id.clone(),
thread_id: None,
};
let metadata_json = match serde_json::to_string(&metadata) {
Ok(json) => json,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to serialize metadata: {}", e),
);
return; // Don't emit message if metadata can't be serialized
}
};
channel_host::emit_message(&EmittedMessage {
user_id,
user_name: Some(user_name),
content: format!("[Button clicked] {}", message.content),
thread_id: None,
metadata_json,
});
}
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
let body = serde_json::to_vec(&value).unwrap_or_default();
let headers = serde_json::json!({"Content-Type": "application/json"});
OutgoingHttpResponse {
status,
headers_json: headers.to_string(),
body,
}
}
export!(DiscordChannel);
fn truncate_message(content: &str) -> String {
if content.len() <= 2000 {
content.to_string()
} else {
let max_bytes = 1990;
let cutoff = content
.char_indices()
.map(|(i, c)| i + c.len_utf8())
.take_while(|&end| end <= max_bytes)
.last()
.unwrap_or(0);
let mut truncated = content[..cutoff].to_string();
truncated.push_str("\n... (truncated)");
truncated
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_message() {
let short = "Hello world";
assert_eq!(truncate_message(short), short);
let long = "a".repeat(2005);
let truncated = truncate_message(&long);
assert_eq!(truncated.len(), 2006); // 1990 + 16 chars suffix
assert!(truncated.ends_with("\n... (truncated)"));
// Test with multibyte characters (Euro sign is 3 bytes)
// 1000 chars * 3 bytes = 3000 bytes
let multi = "".repeat(1000);
let truncated_multi = truncate_message(&multi);
// 1990 bytes limit. 1990 / 3 = 663 with remainder 1.
// Should truncate at 663 chars (1989 bytes).
// Suffix is 16 bytes. Total: 1989 + 16 = 2005 bytes.
assert!(truncated_multi.len() <= 2006);
assert!(truncated_multi.len() >= 2006 - 4); // Allow for max utf8 char width variance
assert!(truncated_multi.ends_with("\n... (truncated)"));
let content_part = &truncated_multi[..truncated_multi.len() - 16];
assert!(content_part.chars().all(|c| c == '€'));
}
#[test]
fn test_metadata_serialization() {
let metadata = DiscordMessageMetadata {
channel_id: "123".into(),
interaction_id: "456".into(),
token: "abc".into(),
application_id: "789".into(),
thread_id: None,
};
let json = serde_json::to_string(&metadata).unwrap();
let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.channel_id, "123");
assert_eq!(parsed.interaction_id, "456");
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ if [ -f "$WASM_PATH" ]; then
wasm-tools strip slack.wasm -o slack.wasm wasm-tools strip slack.wasm -o slack.wasm
echo "Built: slack.wasm ($(du -h slack.wasm | cut -f1))" echo "Built: slack.wasm ($(du -h slack.wasm | cut -f1))"
echo "Copy slack.wasm and slack.capabilities.json to ~/.ironclaw/channels/" echo "Copy slack.wasm and slack.capabilities.json to ~/.near-agent/channels/"
else else
echo "Error: WASM output not found at $WASM_PATH" echo "Error: WASM output not found at $WASM_PATH"
exit 1 exit 1
+23 -37
View File
@@ -108,10 +108,7 @@ struct SlackPostMessageResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct SlackConfig { struct SlackConfig {
/// Name of secret containing signing secret (for verification by host). /// Name of secret containing signing secret (for verification by host).
/// Parsed from config for forward compatibility; not yet used in WASM
/// (host handles signature verification).
#[serde(default = "default_signing_secret_name")] #[serde(default = "default_signing_secret_name")]
#[allow(dead_code)]
signing_secret_name: String, signing_secret_name: String,
} }
@@ -178,7 +175,11 @@ impl Guest for SlackChannel {
// Actual event callback // Actual event callback
"event_callback" => { "event_callback" => {
if let Some(event) = event_wrapper.event { if let Some(event) = event_wrapper.event {
handle_slack_event(event, event_wrapper.team_id, event_wrapper.event_id); handle_slack_event(
event,
event_wrapper.team_id,
event_wrapper.event_id,
);
} }
// Always respond 200 quickly to Slack (they have a 3s timeout) // Always respond 200 quickly to Slack (they have a 3s timeout)
json_response(200, serde_json::json!({"ok": true})) json_response(200, serde_json::json!({"ok": true}))
@@ -229,7 +230,6 @@ impl Guest for SlackChannel {
"https://slack.com/api/chat.postMessage", "https://slack.com/api/chat.postMessage",
&headers.to_string(), &headers.to_string(),
Some(&payload_bytes), Some(&payload_bytes),
None,
); );
match result { match result {
@@ -243,15 +243,14 @@ impl Guest for SlackChannel {
// Parse Slack response // Parse Slack response
let slack_response: SlackPostMessageResponse = let slack_response: SlackPostMessageResponse =
serde_json::from_slice(&http_response.body) serde_json::from_slice(&http_response.body).map_err(|e| {
.map_err(|e| format!("Failed to parse Slack response: {}", e))?; format!("Failed to parse Slack response: {}", e)
})?;
if !slack_response.ok { if !slack_response.ok {
return Err(format!( return Err(format!(
"Slack API error: {}", "Slack API error: {}",
slack_response slack_response.error.unwrap_or_else(|| "unknown".to_string())
.error
.unwrap_or_else(|| "unknown".to_string())
)); ));
} }
@@ -278,16 +277,17 @@ impl Guest for SlackChannel {
} }
/// Handle a Slack event and emit message if applicable. /// Handle a Slack event and emit message if applicable.
fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Option<String>) { fn handle_slack_event(
event: SlackEvent,
team_id: Option<String>,
_event_id: Option<String>,
) {
match event.event_type.as_str() { match event.event_type.as_str() {
// Direct mention of the bot // Direct mention of the bot
"app_mention" => { "app_mention" => {
if let (Some(user), Some(channel), Some(text), Some(ts)) = ( if let (Some(user), Some(channel), Some(text), Some(ts)) =
event.user, (event.user, event.channel.clone(), event.text, event.ts.clone())
event.channel.clone(), {
event.text,
event.ts.clone(),
) {
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id); emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
} }
} }
@@ -299,12 +299,9 @@ fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Opt
return; return;
} }
if let (Some(user), Some(channel), Some(text), Some(ts)) = ( if let (Some(user), Some(channel), Some(text), Some(ts)) =
event.user, (event.user, event.channel.clone(), event.text, event.ts.clone())
event.channel.clone(), {
event.text,
event.ts.clone(),
) {
// Only process DMs (channel IDs starting with D) // Only process DMs (channel IDs starting with D)
if channel.starts_with('D') { if channel.starts_with('D') {
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id); emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
@@ -338,13 +335,8 @@ fn emit_message(
team_id, team_id,
}; };
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|e| { let metadata_json =
channel_host::log( serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
channel_host::LogLevel::Error,
&format!("Failed to serialize Slack metadata: {}", e),
);
"{}".to_string()
});
// Strip @ mentions of the bot from the text for cleaner messages // Strip @ mentions of the bot from the text for cleaner messages
let cleaned_text = strip_bot_mention(&text); let cleaned_text = strip_bot_mention(&text);
@@ -372,13 +364,7 @@ fn strip_bot_mention(text: &str) -> String {
/// Create a JSON HTTP response. /// Create a JSON HTTP response.
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse { fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
let body = serde_json::to_vec(&value).unwrap_or_else(|e| { let body = serde_json::to_vec(&value).unwrap_or_default();
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to serialize JSON response: {}", e),
);
Vec::new()
});
let headers = serde_json::json!({"Content-Type": "application/json"}); let headers = serde_json::json!({"Content-Type": "application/json"});
OutgoingHttpResponse { OutgoingHttpResponse {
+2 -2
View File
@@ -32,8 +32,8 @@ if [ -f "$WASM_PATH" ]; then
echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))" echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))"
echo "" echo ""
echo "To install:" echo "To install:"
echo " mkdir -p ~/.ironclaw/channels" echo " mkdir -p ~/.near-agent/channels"
echo " cp telegram.wasm telegram.capabilities.json ~/.ironclaw/channels/" echo " cp telegram.wasm telegram.capabilities.json ~/.near-agent/channels/"
echo "" echo ""
echo "Then add your bot token to secrets:" echo "Then add your bot token to secrets:"
echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store" echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store"
+100 -392
View File
@@ -72,10 +72,6 @@ struct TelegramMessage {
/// Message text. /// Message text.
text: Option<String>, text: Option<String>,
/// Caption for media (photo, video, document, etc.).
#[serde(default)]
caption: Option<String>,
/// Original message if this is a reply. /// Original message if this is a reply.
reply_to_message: Option<Box<TelegramMessage>>, reply_to_message: Option<Box<TelegramMessage>>,
@@ -164,21 +160,6 @@ const POLLING_STATE_PATH: &str = "state/last_update_id";
/// Workspace path for persisting owner_id across WASM callbacks. /// Workspace path for persisting owner_id across WASM callbacks.
const OWNER_ID_PATH: &str = "state/owner_id"; const OWNER_ID_PATH: &str = "state/owner_id";
/// Workspace path for persisting dm_policy across WASM callbacks.
const DM_POLICY_PATH: &str = "state/dm_policy";
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
const ALLOW_FROM_PATH: &str = "state/allow_from";
/// Channel name for pairing store (used by pairing host APIs).
const CHANNEL_NAME: &str = "telegram";
/// Workspace path for persisting bot_username for mention detection in groups.
const BOT_USERNAME_PATH: &str = "state/bot_username";
/// Workspace path for persisting respond_to_all_group_messages flag.
const RESPOND_TO_ALL_GROUP_PATH: &str = "state/respond_to_all_group_messages";
// ============================================================================ // ============================================================================
// Channel Metadata // Channel Metadata
// ============================================================================ // ============================================================================
@@ -215,14 +196,6 @@ struct TelegramConfig {
#[serde(default)] #[serde(default)]
owner_id: Option<i64>, owner_id: Option<i64>,
/// DM policy: "pairing" (default), "allowlist", or "open".
#[serde(default)]
dm_policy: Option<String>,
/// Allowed sender IDs/usernames from config (merged with pairing-approved store).
#[serde(default)]
allow_from: Option<Vec<String>>,
/// Whether to respond to all group messages (not just mentions). /// Whether to respond to all group messages (not just mentions).
#[serde(default)] #[serde(default)]
respond_to_all_group_messages: bool, respond_to_all_group_messages: bool,
@@ -284,24 +257,6 @@ impl Guest for TelegramChannel {
); );
} }
// Persist dm_policy and allow_from for DM pairing in handle_message
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
.unwrap_or_else(|_| "[]".to_string());
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
// Persist bot_username and respond_to_all_group_messages for group handling
let _ = channel_host::workspace_write(
BOT_USERNAME_PATH,
&config.bot_username.unwrap_or_default(),
);
let _ = channel_host::workspace_write(
RESPOND_TO_ALL_GROUP_PATH,
&config.respond_to_all_group_messages.to_string(),
);
// Mode is determined by whether the host injected a tunnel_url // Mode is determined by whether the host injected a tunnel_url
// If tunnel is configured, use webhooks. Otherwise, use polling. // If tunnel is configured, use webhooks. Otherwise, use polling.
let webhook_mode = config.tunnel_url.is_some(); let webhook_mode = config.tunnel_url.is_some();
@@ -433,9 +388,7 @@ impl Guest for TelegramChannel {
let headers = serde_json::json!({}); let headers = serde_json::json!({});
// 35s HTTP timeout outlives Telegram's 30s server-side long-poll let result = channel_host::http_request("GET", &url, &headers.to_string(), None);
let result =
channel_host::http_request("GET", &url, &headers.to_string(), None, Some(35_000));
match result { match result {
Ok(response) => { Ok(response) => {
@@ -508,52 +461,72 @@ impl Guest for TelegramChannel {
} }
fn on_respond(response: AgentResponse) -> Result<(), String> { fn on_respond(response: AgentResponse) -> Result<(), String> {
// Parse metadata to get chat info
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json) let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?; .map_err(|e| format!("Failed to parse metadata: {}", e))?;
// Try sending with Markdown first; fall back to plain text if Telegram // Build sendMessage payload
// can't parse the entities (e.g. model leaked <tool_call> with underscores). let mut payload = serde_json::json!({
let result = send_message( "chat_id": metadata.chat_id,
metadata.chat_id, "text": response.content,
&response.content, "parse_mode": "Markdown",
metadata.message_id, });
Some("Markdown"),
// Reply to the original message for context
payload["reply_to_message_id"] = serde_json::Value::Number(metadata.message_id.into());
let payload_bytes = serde_json::to_vec(&payload)
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
// Make HTTP request to Telegram API
// The bot token is injected into the URL by the host
let headers = serde_json::json!({
"Content-Type": "application/json"
});
let result = channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
&headers.to_string(),
Some(&payload_bytes),
); );
match result { match result {
Ok(msg_id) => { Ok(http_response) => {
if http_response.status != 200 {
let body_str = String::from_utf8_lossy(&http_response.body);
return Err(format!(
"Telegram API returned status {}: {}",
http_response.status, body_str
));
}
// Parse Telegram response
let api_response: TelegramApiResponse<SentMessage> =
serde_json::from_slice(&http_response.body)
.map_err(|e| format!("Failed to parse Telegram response: {}", e))?;
if !api_response.ok {
return Err(format!(
"Telegram API error: {}",
api_response
.description
.unwrap_or_else(|| "unknown".to_string())
));
}
channel_host::log( channel_host::log(
channel_host::LogLevel::Debug, channel_host::LogLevel::Debug,
&format!( &format!(
"Sent message to chat {}: message_id={}", "Sent message to chat {}: message_id={}",
metadata.chat_id, msg_id metadata.chat_id,
api_response.result.map(|r| r.message_id).unwrap_or(0)
), ),
); );
Ok(())
}
Err(SendError::ParseEntities(detail)) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Markdown parse failed ({}), retrying as plain text", detail),
);
let msg_id = send_message(
metadata.chat_id,
&response.content,
metadata.message_id,
None,
)
.map_err(|e| format!("Plain-text retry also failed: {}", e))?;
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Sent plain-text message to chat {}: message_id={}",
metadata.chat_id, msg_id
),
);
Ok(()) Ok(())
} }
Err(e) => Err(e.to_string()), Err(e) => Err(format!("HTTP request failed: {}", e)),
} }
} }
@@ -595,7 +568,6 @@ impl Guest for TelegramChannel {
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction", "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction",
&headers.to_string(), &headers.to_string(),
Some(&payload_bytes), Some(&payload_bytes),
None,
); );
if let Err(e) = result { if let Err(e) = result {
@@ -614,101 +586,6 @@ impl Guest for TelegramChannel {
} }
} }
// ============================================================================
// Send Message Helper
// ============================================================================
/// Errors from send_message, split so callers can match on parse-entity failures.
enum SendError {
/// Telegram returned 400 with "can't parse entities" (Markdown issue).
ParseEntities(String),
/// Any other failure.
Other(String),
}
impl std::fmt::Display for SendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SendError::ParseEntities(detail) => write!(f, "parse entities error: {}", detail),
SendError::Other(msg) => write!(f, "{}", msg),
}
}
}
/// Send a message via the Telegram Bot API.
///
/// Returns the sent message_id on success. When `parse_mode` is set and
/// Telegram returns a 400 "can't parse entities" error, returns
/// `SendError::ParseEntities` so the caller can retry without formatting.
fn send_message(
chat_id: i64,
text: &str,
reply_to_message_id: i64,
parse_mode: Option<&str>,
) -> Result<i64, SendError> {
let mut payload = serde_json::json!({
"chat_id": chat_id,
"text": text,
"reply_to_message_id": reply_to_message_id,
});
if let Some(mode) = parse_mode {
payload["parse_mode"] = serde_json::Value::String(mode.to_string());
}
let payload_bytes = serde_json::to_vec(&payload)
.map_err(|e| SendError::Other(format!("Failed to serialize payload: {}", e)))?;
let headers = serde_json::json!({ "Content-Type": "application/json" });
let result = channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
&headers.to_string(),
Some(&payload_bytes),
None,
);
match result {
Ok(http_response) => {
if http_response.status == 400 {
let body_str = String::from_utf8_lossy(&http_response.body);
if body_str.contains("can't parse entities") {
return Err(SendError::ParseEntities(body_str.to_string()));
}
return Err(SendError::Other(format!(
"Telegram API returned 400: {}",
body_str
)));
}
if http_response.status != 200 {
let body_str = String::from_utf8_lossy(&http_response.body);
return Err(SendError::Other(format!(
"Telegram API returned status {}: {}",
http_response.status, body_str
)));
}
let api_response: TelegramApiResponse<SentMessage> =
serde_json::from_slice(&http_response.body)
.map_err(|e| SendError::Other(format!("Failed to parse response: {}", e)))?;
if !api_response.ok {
return Err(SendError::Other(format!(
"Telegram API error: {}",
api_response
.description
.unwrap_or_else(|| "unknown".to_string())
)));
}
Ok(api_response.result.map(|r| r.message_id).unwrap_or(0))
}
Err(e) => Err(SendError::Other(format!("HTTP request failed: {}", e))),
}
}
// ============================================================================ // ============================================================================
// Webhook Management // Webhook Management
// ============================================================================ // ============================================================================
@@ -727,7 +604,6 @@ fn delete_webhook() -> Result<(), String> {
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook", "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook",
&headers.to_string(), &headers.to_string(),
None, None,
None,
); );
match result { match result {
@@ -790,7 +666,6 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook", "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
&headers.to_string(), &headers.to_string(),
Some(&body_bytes), Some(&body_bytes),
None,
); );
match result { match result {
@@ -825,48 +700,6 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
} }
} }
// ============================================================================
// Pairing Reply
// ============================================================================
/// Send a pairing code message to a chat. Used when an unknown user DMs the bot.
fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
let payload = serde_json::json!({
"chat_id": chat_id,
"text": format!(
"To pair with this bot, run: `ironclaw pairing approve telegram {}`",
code
),
"parse_mode": "Markdown",
});
let payload_bytes =
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize payload: {}", e))?;
let headers = serde_json::json!({
"Content-Type": "application/json"
});
let result = channel_host::http_request(
"POST",
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
&headers.to_string(),
Some(&payload_bytes),
None,
);
match result {
Ok(response) => {
if response.status != 200 {
let body_str = String::from_utf8_lossy(&response.body);
return Err(format!("HTTP {}: {}", response.status, body_str));
}
Ok(())
}
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}
// ============================================================================ // ============================================================================
// Update Handling // Update Handling
// ============================================================================ // ============================================================================
@@ -886,16 +719,11 @@ fn handle_update(update: TelegramUpdate) {
/// Process a single message. /// Process a single message.
fn handle_message(message: TelegramMessage) { fn handle_message(message: TelegramMessage) {
// Use text or caption (for media messages) // Skip messages without text
let content = message let text = match message.text {
.text Some(t) if !t.is_empty() => t,
.filter(|t| !t.is_empty()) _ => return,
.or_else(|| message.caption.filter(|c| !c.is_empty())) };
.unwrap_or_default();
if content.is_empty() {
return;
}
// Skip messages without a sender (channel posts) // Skip messages without a sender (channel posts)
let from = match message.from { let from = match message.from {
@@ -908,105 +736,41 @@ fn handle_message(message: TelegramMessage) {
return; return;
} }
let is_private = message.chat.chat_type == "private"; // Owner validation: silently drop messages from non-owner users
if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) {
// Owner validation: when owner_id is set, only that user can message if !owner_id_str.is_empty() {
let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty()); if let Ok(owner_id) = owner_id_str.parse::<i64>() {
if from.id != owner_id {
if let Some(ref id_str) = owner_id_str { channel_host::log(
if let Ok(owner_id) = id_str.parse::<i64>() { channel_host::LogLevel::Debug,
if from.id != owner_id { &format!(
channel_host::log( "Dropping message from non-owner user {} (owner: {})",
channel_host::LogLevel::Debug, from.id, owner_id
&format!( ),
"Dropping message from non-owner user {} (owner: {})", );
from.id, owner_id return;
),
);
return;
}
}
} else if is_private {
// No owner_id: apply dm_policy for private chats
let dm_policy =
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
if dm_policy != "open" {
// Build effective allow list: config allow_from + pairing store
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
allowed.extend(store_allowed);
}
let id_str = from.id.to_string();
let username_opt = from.username.as_deref();
let is_allowed = allowed.contains(&"*".to_string())
|| allowed.contains(&id_str)
|| username_opt.map_or(false, |u| allowed.contains(&u.to_string()));
if !is_allowed {
if dm_policy == "pairing" {
// Upsert pairing request and send reply
let meta = serde_json::json!({
"chat_id": message.chat.id,
"user_id": from.id,
"username": username_opt,
})
.to_string();
match channel_host::pairing_upsert_request(CHANNEL_NAME, &id_str, &meta) {
Ok(result) => {
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"Pairing request for user {} (chat {}): code {}",
from.id, message.chat.id, result.code
),
);
if result.created {
let _ = send_pairing_reply(message.chat.id, &result.code);
}
}
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Pairing upsert failed: {}", e),
);
}
}
} }
return;
} }
} }
} }
// For group chats, only respond if bot was mentioned or respond_to_all is enabled let is_private = message.chat.chat_type == "private";
// For group chats, check if the bot was mentioned
// TODO: Read bot_username from config and check mentions
// For now, process all messages in private chats and groups
if !is_private { if !is_private {
let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH) // In groups, only respond if there's a bot mention or command
.as_deref() // This is a simplified check - proper implementation would use entities
.unwrap_or("false") let has_command = text.starts_with('/');
== "true"; let has_mention = text.contains('@');
if !respond_to_all { if !has_command && !has_mention {
let has_command = content.starts_with('/'); channel_host::log(
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default(); channel_host::LogLevel::Debug,
let has_bot_mention = if bot_username.is_empty() { &format!("Ignoring group message without mention: {}", text),
content.contains('@') );
} else { return;
let mention = format!("@{}", bot_username);
content.to_lowercase().contains(&mention.to_lowercase())
};
if !has_command && !has_bot_mention {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Ignoring group message without mention: {}", content),
);
return;
}
} }
} }
@@ -1028,30 +792,17 @@ fn handle_message(message: TelegramMessage) {
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
// Clean the message text (strip bot mentions and commands) // Clean the message text (strip bot mentions and commands)
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default(); let cleaned_text = clean_message_text(&text);
let cleaned_text = clean_message_text(
&content,
if bot_username.is_empty() {
None
} else {
Some(bot_username.as_str())
},
);
// For /start with no args, emit placeholder so agent can respond with welcome if cleaned_text.is_empty() {
let content_to_emit = if cleaned_text.is_empty() && content.trim().starts_with('/') {
"[User started the bot]".to_string()
} else if cleaned_text.is_empty() {
return; return;
} else { }
cleaned_text
};
// Emit the message to the agent // Emit the message to the agent
channel_host::emit_message(&EmittedMessage { channel_host::emit_message(&EmittedMessage {
user_id: from.id.to_string(), user_id: from.id.to_string(),
user_name: Some(user_name), user_name: Some(user_name),
content: content_to_emit, content: cleaned_text,
thread_id: None, // Telegram doesn't have threads in the same way thread_id: None, // Telegram doesn't have threads in the same way
metadata_json, metadata_json,
}); });
@@ -1066,8 +817,7 @@ fn handle_message(message: TelegramMessage) {
} }
/// Clean message text by removing bot commands and @mentions at the start. /// Clean message text by removing bot commands and @mentions at the start.
/// When bot_username is set, only strips that specific mention; otherwise strips any leading @mention. fn clean_message_text(text: &str) -> String {
fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
let mut result = text.trim().to_string(); let mut result = text.trim().to_string();
// Remove leading /command // Remove leading /command
@@ -1082,30 +832,11 @@ fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
// Remove leading @mention // Remove leading @mention
if result.starts_with('@') { if result.starts_with('@') {
if let Some(bot) = bot_username { if let Some(space_idx) = result.find(' ') {
let mention = format!("@{}", bot); result = result[space_idx..].trim_start().to_string();
let mention_lower = mention.to_lowercase();
let result_lower = result.to_lowercase();
if result_lower.starts_with(&mention_lower) {
let rest = result[mention.len()..].trim_start();
if rest.is_empty() {
return String::new();
}
result = rest.to_string();
} else if let Some(space_idx) = result.find(' ') {
// Different leading @mention - only strip if it's the bot
let first_word = &result[..space_idx];
if first_word.eq_ignore_ascii_case(&mention) {
result = result[space_idx..].trim_start().to_string();
}
}
} else { } else {
// No bot_username: strip any leading @mention // Just a mention with no text
if let Some(space_idx) = result.find(' ') { return String::new();
result = result[space_idx..].trim_start().to_string();
} else {
return String::new();
}
} }
} }
@@ -1141,22 +872,12 @@ mod tests {
#[test] #[test]
fn test_clean_message_text() { fn test_clean_message_text() {
// Without bot_username: strips any leading @mention assert_eq!(clean_message_text("/start hello"), "hello");
assert_eq!(clean_message_text("/start hello", None), "hello"); assert_eq!(clean_message_text("@bot hello world"), "hello world");
assert_eq!(clean_message_text("@bot hello world", None), "hello world"); assert_eq!(clean_message_text("/start"), "");
assert_eq!(clean_message_text("/start", None), ""); assert_eq!(clean_message_text("@botname"), "");
assert_eq!(clean_message_text("@botname", None), ""); assert_eq!(clean_message_text("just text"), "just text");
assert_eq!(clean_message_text("just text", None), "just text"); assert_eq!(clean_message_text(" spaced "), "spaced");
assert_eq!(clean_message_text(" spaced ", None), "spaced");
// With bot_username: only strips @MyBot, not @alice
assert_eq!(clean_message_text("@MyBot hello", Some("MyBot")), "hello");
assert_eq!(clean_message_text("@mybot hi", Some("MyBot")), "hi");
assert_eq!(
clean_message_text("@alice hello", Some("MyBot")),
"@alice hello"
);
assert_eq!(clean_message_text("@MyBot", Some("MyBot")), "");
} }
#[test] #[test]
@@ -1224,17 +945,4 @@ mod tests {
assert_eq!(from.id, 789); assert_eq!(from.id, 789);
assert_eq!(from.first_name, "John"); assert_eq!(from.first_name, "John");
} }
#[test]
fn test_parse_message_with_caption() {
let json = r#"{
"message_id": 1,
"from": {"id": 1, "is_bot": false, "first_name": "A"},
"chat": {"id": 1, "type": "private"},
"caption": "What's in this image?"
}"#;
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.text, None);
assert_eq!(msg.caption.as_deref(), Some("What's in this image?"));
}
} }
@@ -1 +1,43 @@
{"type":"channel","name":"telegram","description":"Telegram Bot API channel for receiving and responding to Telegram messages","capabilities":{"http":{"allowlist":[{"host":"api.telegram.org","path_prefix":"/bot"}],"credentials":{"telegram_bot":{"secret_name":"telegram_bot_token","location":{"type":"url_path","placeholder":"{TELEGRAM_BOT_TOKEN}"},"host_patterns":["api.telegram.org"]}},"rate_limit":{"requests_per_minute":30,"requests_per_hour":1000}},"secrets":{"allowed_names":["telegram_*"]},"channel":{"allowed_paths":["/webhook/telegram"],"allow_polling":true,"min_poll_interval_ms":30000,"workspace_prefix":"channels/telegram/","emit_rate_limit":{"messages_per_minute":100,"messages_per_hour":5000}}},"config":{"bot_username":null,"owner_id":null,"respond_to_all_group_messages":false,"polling_enabled":false,"poll_interval_ms":30000,"dm_policy":"pairing","allow_from":[]}} {
"type": "channel",
"name": "telegram",
"description": "Telegram Bot API channel for receiving and responding to Telegram messages",
"capabilities": {
"http": {
"allowlist": [
{ "host": "api.telegram.org", "path_prefix": "/bot" }
],
"credentials": {
"telegram_bot": {
"secret_name": "telegram_bot_token",
"location": { "type": "url_path", "placeholder": "{TELEGRAM_BOT_TOKEN}" },
"host_patterns": ["api.telegram.org"]
}
},
"rate_limit": {
"requests_per_minute": 30,
"requests_per_hour": 1000
}
},
"secrets": {
"allowed_names": ["telegram_*"]
},
"channel": {
"allowed_paths": ["/webhook/telegram"],
"allow_polling": true,
"min_poll_interval_ms": 30000,
"workspace_prefix": "channels/telegram/",
"emit_rate_limit": {
"messages_per_minute": 100,
"messages_per_hour": 5000
}
}
},
"config": {
"bot_username": null,
"owner_id": null,
"respond_to_all_group_messages": false,
"polling_enabled": false,
"poll_interval_ms": 30000
}
}
Binary file not shown.
+6 -24
View File
@@ -254,19 +254,10 @@ struct WhatsAppChannel;
impl Guest for WhatsAppChannel { impl Guest for WhatsAppChannel {
fn on_start(config_json: String) -> Result<ChannelConfig, String> { fn on_start(config_json: String) -> Result<ChannelConfig, String> {
let config: WhatsAppConfig = match serde_json::from_str(&config_json) { let config: WhatsAppConfig = serde_json::from_str(&config_json).unwrap_or(WhatsAppConfig {
Ok(c) => c, api_version: default_api_version(),
Err(e) => { reply_to_message: default_reply_to_message(),
channel_host::log( });
channel_host::LogLevel::Warn,
&format!("Failed to parse WhatsApp config, using defaults: {}", e),
);
WhatsAppConfig {
api_version: default_api_version(),
reply_to_message: default_reply_to_message(),
}
}
};
channel_host::log( channel_host::log(
channel_host::LogLevel::Info, channel_host::LogLevel::Info,
@@ -276,9 +267,6 @@ impl Guest for WhatsAppChannel {
), ),
); );
// Persist api_version in workspace so on_respond() can read it
let _ = channel_host::workspace_write("channels/whatsapp/api_version", &config.api_version);
// WhatsApp Cloud API is webhook-only, no polling available // WhatsApp Cloud API is webhook-only, no polling available
Ok(ChannelConfig { Ok(ChannelConfig {
display_name: "WhatsApp".to_string(), display_name: "WhatsApp".to_string(),
@@ -339,16 +327,11 @@ impl Guest for WhatsAppChannel {
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json) let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?; .map_err(|e| format!("Failed to parse metadata: {}", e))?;
// Read api_version from workspace (set during on_start), fallback to default
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "v18.0".to_string());
// Build WhatsApp API URL with token placeholder // Build WhatsApp API URL with token placeholder
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header // Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
let api_url = format!( let api_url = format!(
"https://graph.facebook.com/{}/{}/messages", "https://graph.facebook.com/v18.0/{}/messages",
api_version, metadata.phone_number_id metadata.phone_number_id
); );
// Build sendMessage payload // Build sendMessage payload
@@ -378,7 +361,6 @@ impl Guest for WhatsAppChannel {
&api_url, &api_url,
&headers.to_string(), &headers.to_string(),
Some(&payload_bytes), Some(&payload_bytes),
None,
); );
match result { match result {
-13
View File
@@ -1,13 +0,0 @@
[Unit]
Description=Cloud SQL Auth Proxy
After=network.target
[Service]
Type=simple
DynamicUser=yes
ExecStart=/usr/local/bin/cloud-sql-proxy ironclaw-prod:us-central1:ironclaw-db --port=5432
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
-27
View File
@@ -1,27 +0,0 @@
# WARNING: Replace all CHANGE_ME values before deploying.
# Do not use placeholder passwords in production.
DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw
# NEAR AI
NEARAI_SESSION_TOKEN=CHANGE_ME
NEARAI_MODEL=claude-3-5-sonnet-20241022
NEARAI_BASE_URL=https://cloud-api.near.ai
NEARAI_AUTH_URL=https://private.near.ai
NEARAI_API_MODE=chat_completions
# Agent
AGENT_NAME=ironclaw
CLI_ENABLED=false
# Web Gateway
GATEWAY_ENABLED=true
# 0.0.0.0 binds to all interfaces (required for Docker --network=host).
# Use 127.0.0.1 if running outside Docker or for local-only access.
GATEWAY_HOST=0.0.0.0
GATEWAY_PORT=3000
GATEWAY_AUTH_TOKEN=CHANGE_ME
# Disabled for initial deploy
SANDBOX_ENABLED=false
HEARTBEAT_ENABLED=false
EMBEDDING_ENABLED=false
-20
View File
@@ -1,20 +0,0 @@
[Unit]
Description=IronClaw AI Assistant
After=cloud-sql-proxy.service docker.service
Requires=cloud-sql-proxy.service
[Service]
Type=simple
ExecStartPre=/usr/bin/docker pull us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest
ExecStart=/usr/bin/docker run --rm \
--name ironclaw \
--env-file /opt/ironclaw/.env \
--network=host \
us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest \
--no-onboard
ExecStop=/usr/bin/docker stop ironclaw
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env bash
# VM bootstrap script for IronClaw on GCP Compute Engine.
#
# Run on a fresh Debian 12 VM after SSH:
# sudo bash setup.sh
#
# Prerequisites:
# - VM has the ironclaw-vm service account attached
# - Cloud SQL Auth Proxy accessible via IAM
# - Artifact Registry image pushed
set -euo pipefail
# Must run as root
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: This script must be run as root (sudo bash setup.sh)"
exit 1
fi
echo "==> Installing Docker"
apt-get update
apt-get install -y docker.io
systemctl enable docker
systemctl start docker
echo "==> Installing Cloud SQL Auth Proxy"
curl -fsSL -o /usr/local/bin/cloud-sql-proxy \
https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.14.3/cloud-sql-proxy.linux.amd64
chmod +x /usr/local/bin/cloud-sql-proxy
echo "==> Installing systemd services"
cp /tmp/deploy/cloud-sql-proxy.service /etc/systemd/system/
cp /tmp/deploy/ironclaw.service /etc/systemd/system/
systemctl daemon-reload
echo "==> Starting Cloud SQL Auth Proxy"
systemctl enable cloud-sql-proxy
systemctl start cloud-sql-proxy
echo "==> Configuring Docker registry auth"
# The VM service account provides Artifact Registry access
gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
echo "==> Creating config directory"
# Owned by root, readable only by root. Docker reads --env-file as root
# before dropping to uid 1000 (ironclaw) inside the container.
mkdir -p /opt/ironclaw
chmod 700 /opt/ironclaw
if [ ! -f /opt/ironclaw/.env ]; then
echo "WARNING: /opt/ironclaw/.env does not exist."
echo "Create it with your configuration before starting IronClaw."
echo "See deploy/env.example for the required variables."
echo ""
echo "Then run: systemctl enable ironclaw && systemctl start ironclaw"
else
chmod 600 /opt/ironclaw/.env
echo "==> Starting IronClaw"
systemctl enable ironclaw
systemctl start ironclaw
fi
echo "==> Setup complete"
echo ""
echo "Verify with:"
echo " systemctl status cloud-sql-proxy"
echo " systemctl status ironclaw"
echo " docker logs ironclaw"
-20
View File
@@ -1,20 +0,0 @@
# Local development only — do NOT use these credentials in production.
services:
postgres:
image: pgvector/pgvector:pg16
ports:
- "5432:5432"
environment:
POSTGRES_DB: ironclaw
POSTGRES_USER: ironclaw
POSTGRES_PASSWORD: ironclaw # dev-only, change for any non-local deployment
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ironclaw"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
+3 -36
View File
@@ -246,46 +246,13 @@ Create `my-channel.capabilities.json`:
## Building and Deploying ## Building and Deploying
### Supply Chain Security: No Committed Binaries
**Do not commit compiled WASM binaries.** They are a supply chain risk — the binary in a PR may not match the source. IronClaw builds channels from source:
- `cargo build` automatically builds `telegram.wasm` via `build.rs`
- The built binary is in `.gitignore` and is not committed
- CI should run `cargo build` (or `./scripts/build-all.sh`) to produce releases
**Reproducible build:**
```bash
cargo build --release
```
Prerequisites: `rustup target add wasm32-wasip2`, `cargo install wasm-tools` (optional; fallback copies raw WASM if unavailable).
### Telegram Channel (Manual Build)
```bash
# Add WASM target if needed
rustup target add wasm32-wasip2
# Build Telegram channel
./channels-src/telegram/build.sh
# Install (or use ironclaw onboard to install bundled channel)
mkdir -p ~/.ironclaw/channels
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
```
**Note**: The main IronClaw binary bundles `telegram.wasm` via `include_bytes!`. When modifying the Telegram channel source, run `./channels-src/telegram/build.sh` **before** building the main crate, so the updated WASM is included.
### Other Channels
```bash ```bash
# Build the WASM component # Build the WASM component
cd channels-src/my-channel cd channels/my-channel
cargo build --release --target wasm32-wasip2 cargo component build --release
# Deploy to ~/.ironclaw/channels/ # Deploy to ~/.ironclaw/channels/
cp target/wasm32-wasip2/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm cp target/wasm32-wasip1/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
cp my-channel.capabilities.json ~/.ironclaw/channels/ cp my-channel.capabilities.json ~/.ironclaw/channels/
``` ```
-135
View File
@@ -1,135 +0,0 @@
# Telegram Channel Setup
This guide covers configuring the Telegram channel for IronClaw, including DM pairing for access control.
## Overview
The Telegram channel lets you interact with IronClaw via Telegram DMs and groups. It supports:
- **Webhook mode** (recommended): Instant delivery via tunnel
- **Polling mode**: No tunnel required; ~30s delay
- **DM pairing**: Approve unknown users before they can message the agent
- **Group mentions**: `@YourBot` or `/command` to trigger in groups
## Prerequisites
- IronClaw installed and configured (`ironclaw onboard`)
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
## Quick Start
### 1. Create a Bot
1. Message [@BotFather](https://t.me/BotFather) on Telegram
2. Send `/newbot` and follow the prompts
3. Copy the bot token (e.g., `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
### 2. Configure via Setup Wizard
```bash
ironclaw onboard
```
When prompted, enable the Telegram channel and paste your bot token. The wizard will:
- Validate the token
- Optionally configure a webhook secret
- Set up tunnel (if you want webhook mode)
### 3. (Optional) Configure Tunnel for Webhooks
For instant message delivery, expose your agent via a tunnel:
```bash
# ngrok
ngrok http 8080
# Cloudflare
cloudflared tunnel --url http://localhost:8080
```
Set the tunnel URL in settings or via `TUNNEL_URL` env var. Without a tunnel, the channel uses polling (~30s delay).
## DM Pairing
When an unknown user DMs your bot, they receive a pairing code. You must approve them before they can message the agent.
### Flow
1. Unknown user sends a message to your bot
2. Bot replies: `To pair with this bot, run: ironclaw pairing approve telegram ABC12345`
3. You run: `ironclaw pairing approve telegram ABC12345`
4. User is added to the allow list; future messages are delivered
### Commands
```bash
# List pending pairing requests
ironclaw pairing list telegram
# List as JSON
ironclaw pairing list telegram --json
# Approve a user by code
ironclaw pairing approve telegram ABC12345
```
### Configuration
Edit `~/.ironclaw/channels/telegram.capabilities.json` (or the config injected by the host):
| Option | Values | Default | Description |
|--------|--------|---------|-------------|
| `dm_policy` | `open`, `allowlist`, `pairing` | `pairing` | `open` = allow all; `allowlist` = config + approved only; `pairing` = allowlist + send pairing reply to unknown |
| `allow_from` | `["user_id", "username", "*"]` | `[]` | Pre-approved IDs/usernames. `*` allows everyone. |
| `owner_id` | Telegram user ID | `null` | When set, only this user can message (overrides dm_policy) |
| `bot_username` | Bot username (no @) | `null` | Used for mention detection in groups; when set, only strips this mention from messages |
| `respond_to_all_group_messages` | `true`/`false` | `false` | When true, respond to all group messages; when false, only @mentions and /commands |
## Manual Installation
If the channel isn't installed via the wizard:
```bash
# Build the Telegram channel (requires wasm32-wasip2 target)
rustup target add wasm32-wasip2
./channels-src/telegram/build.sh
# Install
mkdir -p ~/.ironclaw/channels
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
```
## Secrets
The channel expects a secret named `telegram_bot_token`. Configure via:
- **Setup wizard**: Saves to encrypted secrets store
- **Environment**: `TELEGRAM_BOT_TOKEN=your_token`
- **Secrets store**: `ironclaw` CLI (if available)
## Webhook Secret (Optional)
For webhook validation, set `telegram_webhook_secret` in secrets. Telegram will send `X-Telegram-Bot-Api-Secret-Token` with each request; the host validates it before forwarding.
## Troubleshooting
### Messages not delivered
- **Polling mode**: Check logs for `getUpdates` errors. Ensure the bot token is valid.
- **Webhook mode**: Verify tunnel is running and `TUNNEL_URL` is correct. Telegram requires HTTPS.
### Pairing code not received
- Verify the channel can send messages (HTTP allowlist includes `api.telegram.org`)
- Check `dm_policy` is `pairing` (not `allowlist` which blocks without reply)
### Group mentions not working
- Set `bot_username` in config to your bot's username (e.g., `MyIronClawBot`)
- Ensure the message contains `@YourBot` or starts with `/`
### "Connection refused" when starting
- For webhook mode: Start your tunnel before `ironclaw run`
- For polling only: No tunnel needed; ignore tunnel-related warnings
-121
View File
@@ -1,121 +0,0 @@
//! Standalone heartbeat test.
//!
//! Exercises the heartbeat system in isolation: connects to the real
//! database, reads the real HEARTBEAT.md, calls the real LLM, and prints
//! every step so you can see exactly where it breaks.
//!
//! Usage:
//! cargo run --example test_heartbeat
use std::sync::Arc;
use ironclaw::{
agent::HeartbeatRunner,
config::Config,
history::Store,
llm::{SessionConfig, create_llm_provider, create_session_manager},
workspace::Workspace,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load .env and set up logging
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter("ironclaw=debug")
.init();
println!("=== Heartbeat Integration Test ===\n");
// 1. Load config
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!(
" heartbeat.interval_secs = {}",
config.heartbeat.interval_secs
);
println!(
" heartbeat.notify_channel = {:?}",
config.heartbeat.notify_channel
);
println!(
" heartbeat.notify_user = {:?}",
config.heartbeat.notify_user
);
// 2. Connect to database
let store = Store::new(&config.database).await?;
store.run_migrations().await?;
println!("[2/6] Database connected");
// 3. Create workspace
let workspace = Arc::new(Workspace::new("default", store.pool()));
println!("[3/6] Workspace created");
// 4. Read HEARTBEAT.md
let checklist = workspace.heartbeat_checklist().await;
match &checklist {
Ok(Some(content)) => {
let preview: String = content.chars().take(200).collect();
println!("[4/6] HEARTBEAT.md found ({} chars)", content.len());
println!(" Preview: {}...", preview);
}
Ok(None) => {
println!("[4/6] HEARTBEAT.md is None (no file, no seed fallback)");
println!(" Heartbeat will return Skipped.");
}
Err(e) => {
println!("[4/6] HEARTBEAT.md read error: {}", e);
}
}
// Check if the checklist would be considered "effectively empty"
if let Ok(Some(_)) = checklist {
println!(" (Will verify via runner below)");
}
// 5. Create LLM provider
let session = create_session_manager(SessionConfig {
auth_base_url: config.llm.nearai.auth_base_url.clone(),
session_path: config.llm.nearai.session_path.clone(),
})
.await;
let llm = create_llm_provider(&config.llm, session)?;
println!("[5/6] LLM provider created (model: {})", llm.model_name());
// 6. Run heartbeat check
println!("[6/6] Running check_heartbeat()...\n");
let hb_config = ironclaw::agent::HeartbeatConfig::default();
let runner = HeartbeatRunner::new(hb_config, workspace, llm);
let result = runner.check_heartbeat().await;
println!("=== Result ===\n");
match &result {
ironclaw::agent::HeartbeatResult::Ok => {
println!("HeartbeatResult::Ok");
println!(" LLM responded HEARTBEAT_OK, nothing needs attention.");
}
ironclaw::agent::HeartbeatResult::NeedsAttention(msg) => {
println!("HeartbeatResult::NeedsAttention");
println!(" Message:\n{}", msg);
}
ironclaw::agent::HeartbeatResult::Skipped => {
println!("HeartbeatResult::Skipped");
println!(" No checklist found, or checklist was effectively empty.");
println!(" This means the HEARTBEAT.md either:");
println!(" - Does not exist in the workspace database");
println!(" - Contains only headers, comments, and empty checkboxes");
}
ironclaw::agent::HeartbeatResult::Failed(err) => {
println!("HeartbeatResult::Failed");
println!(" Error: {}", err);
}
}
Ok(())
}
-10
View File
@@ -1,10 +0,0 @@
-- Add project_dir and user_id columns for sandbox job tracking.
-- user_id was previously hardcoded to "default" in the Rust layer;
-- now it's persisted so we can filter per-user.
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS project_dir TEXT;
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS user_id TEXT NOT NULL DEFAULT 'default';
CREATE INDEX IF NOT EXISTS idx_agent_jobs_source ON agent_jobs(source);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_user ON agent_jobs(user_id);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_created ON agent_jobs(created_at DESC);
-14
View File
@@ -1,14 +0,0 @@
-- Track which mode a sandbox job uses (worker vs claude_code).
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS job_mode TEXT NOT NULL DEFAULT 'worker';
-- Persist Claude Code streaming events so they survive restarts and can be
-- loaded when the frontend opens a job detail view after the fact.
CREATE TABLE IF NOT EXISTS claude_code_events (
id BIGSERIAL PRIMARY KEY,
job_id UUID NOT NULL REFERENCES agent_jobs(id),
event_type TEXT NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cc_events_job ON claude_code_events(job_id, id);
-73
View File
@@ -1,73 +0,0 @@
-- Routines: scheduled and reactive job system.
--
-- A routine is a named, persistent, user-owned task with a trigger and an action.
-- Triggers fire independently (cron, event, webhook, manual) so only the
-- relevant routine's prompt hits the LLM, not the whole checklist.
CREATE TABLE routines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
-- Trigger definition
trigger_type TEXT NOT NULL, -- 'cron', 'event', 'webhook', 'manual'
trigger_config JSONB NOT NULL, -- type-specific config (schedule, pattern, etc.)
-- Action definition
action_type TEXT NOT NULL, -- 'lightweight', 'full_job'
action_config JSONB NOT NULL, -- prompt, context_paths, max_tokens / title, max_iterations
-- Guardrails
cooldown_secs INTEGER NOT NULL DEFAULT 300,
max_concurrent INTEGER NOT NULL DEFAULT 1,
dedup_window_secs INTEGER, -- NULL = no dedup
-- Notification preferences
notify_channel TEXT, -- NULL = use default
notify_user TEXT NOT NULL DEFAULT 'default',
notify_on_success BOOLEAN NOT NULL DEFAULT false,
notify_on_failure BOOLEAN NOT NULL DEFAULT true,
notify_on_attention BOOLEAN NOT NULL DEFAULT true,
-- Runtime state (updated by engine)
state JSONB NOT NULL DEFAULT '{}',
last_run_at TIMESTAMPTZ,
next_fire_at TIMESTAMPTZ, -- pre-computed for cron triggers
run_count BIGINT NOT NULL DEFAULT 0,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, name)
);
-- Fast lookup: "which cron routines need to fire right now?"
CREATE INDEX idx_routines_next_fire
ON routines (next_fire_at)
WHERE enabled AND next_fire_at IS NOT NULL;
-- Fast lookup: event triggers for a user
CREATE INDEX idx_routines_event_triggers
ON routines (user_id)
WHERE enabled AND trigger_type = 'event';
-- Audit log of individual routine executions.
CREATE TABLE routine_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
routine_id UUID NOT NULL REFERENCES routines(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL,
trigger_detail TEXT, -- e.g. matched message preview, cron expression
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'running', -- running, ok, attention, failed
result_summary TEXT,
tokens_used INTEGER,
job_id UUID REFERENCES agent_jobs(id), -- non-NULL for full_job runs
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_routine_runs_routine ON routine_runs (routine_id);
CREATE INDEX idx_routine_runs_status ON routine_runs (status) WHERE status = 'running';
-3
View File
@@ -1,3 +0,0 @@
-- Rename claude_code_events to job_events (generic for all sandbox job types).
ALTER TABLE claude_code_events RENAME TO job_events;
ALTER INDEX idx_cc_events_job RENAME TO idx_job_events_job;
-16
View File
@@ -1,16 +0,0 @@
-- Settings table: key-value store for all user configuration.
--
-- Replaces ~/.ironclaw/settings.json, session.json, and mcp-servers.json.
-- Keys use dotted paths matching the existing Settings.get()/set() convention
-- (e.g., "agent.name", "sandbox.enabled", "mcp_servers").
-- One row per setting so individual values can be updated atomically.
CREATE TABLE IF NOT EXISTS settings (
user_id TEXT NOT NULL,
key TEXT NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, key)
);
CREATE INDEX IF NOT EXISTS idx_settings_user ON settings (user_id);
-2
View File
@@ -1,2 +0,0 @@
[workspace]
git_release_enable = false
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# Build IronClaw and all bundled channels.
#
# Run this before release or when channel sources have changed.
# The main binary bundles telegram.wasm via include_bytes!; it must exist.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "Building bundled channels..."
if [ -d "channels-src/telegram" ]; then
./channels-src/telegram/build.sh
fi
echo ""
echo "Building IronClaw..."
cargo build --release
echo ""
echo "Done. Binary: target/release/ironclaw"
+227 -854
View File
File diff suppressed because it is too large Load Diff
+3 -34
View File
@@ -29,7 +29,7 @@ use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::channels::OutgoingResponse; use crate::channels::OutgoingResponse;
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider}; use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
use crate::workspace::Workspace; use crate::workspace::Workspace;
/// Configuration for the heartbeat runner. /// Configuration for the heartbeat runner.
@@ -217,26 +217,9 @@ impl HeartbeatRunner {
] ]
}; };
// Use the model's context_length to set max_tokens. The API returns
// the total context window; we cap output at half of that (the rest is
// the prompt) with a floor of 4096.
let max_tokens = match self.llm.model_metadata().await {
Ok(meta) => {
let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(4096);
from_api.max(4096)
}
Err(e) => {
tracing::warn!(
"Could not fetch model metadata, using default max_tokens: {}",
e
);
4096
}
};
let request = CompletionRequest::new(messages) let request = CompletionRequest::new(messages)
.with_max_tokens(max_tokens) .with_max_tokens(1024)
.with_temperature(0.3); .with_temperature(0.3); // Lower temperature for more focused responses
let response = match self.llm.complete(request).await { let response = match self.llm.complete(request).await {
Ok(r) => r, Ok(r) => r,
@@ -245,20 +228,6 @@ impl HeartbeatRunner {
let content = response.content.trim(); let content = response.content.trim();
// Guard against empty content. Reasoning models (e.g. GLM-4.7) may
// burn all output tokens on chain-of-thought and return content: null.
if content.is_empty() {
return if response.finish_reason == FinishReason::Length {
HeartbeatResult::Failed(
"LLM response was truncated (finish_reason=length) with no content. \
The model may have exhausted its token budget on reasoning."
.to_string(),
)
} else {
HeartbeatResult::Failed("LLM returned empty content.".to_string())
};
}
// Check if nothing needs attention // Check if nothing needs attention
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") { if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
return HeartbeatResult::Ok; return HeartbeatResult::Ok;
-6
View File
@@ -6,7 +6,6 @@
//! - Tool invocation with safety //! - Tool invocation with safety
//! - Self-repair for stuck jobs //! - Self-repair for stuck jobs
//! - Proactive heartbeat execution //! - Proactive heartbeat execution
//! - Routine-based scheduled and reactive jobs
//! - Turn-based session management with undo //! - Turn-based session management with undo
//! - Context compaction for long conversations //! - Context compaction for long conversations
@@ -15,8 +14,6 @@ pub mod compaction;
pub mod context_monitor; pub mod context_monitor;
mod heartbeat; mod heartbeat;
mod router; mod router;
pub mod routine;
pub mod routine_engine;
mod scheduler; mod scheduler;
mod self_repair; mod self_repair;
pub mod session; pub mod session;
@@ -26,14 +23,11 @@ pub mod task;
pub mod undo; pub mod undo;
pub mod worker; pub mod worker;
pub(crate) use agent_loop::truncate_for_preview;
pub use agent_loop::{Agent, AgentDeps}; pub use agent_loop::{Agent, AgentDeps};
pub use compaction::{CompactionResult, ContextCompactor}; pub use compaction::{CompactionResult, ContextCompactor};
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor}; pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat}; pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
pub use router::{MessageIntent, Router}; pub use router::{MessageIntent, Router};
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
pub use routine_engine::RoutineEngine;
pub use scheduler::Scheduler; pub use scheduler::Scheduler;
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob}; pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState}; pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
-509
View File
@@ -1,509 +0,0 @@
//! Core types for the routines system.
//!
//! A routine is a named, persistent, user-owned task with a trigger and an action.
//! Each routine fires independently when its trigger condition is met, with only
//! that routine's prompt and context sent to the LLM.
//!
//! ```text
//! ┌──────────┐ ┌─────────┐ ┌──────────────────┐
//! │ Trigger │────▶│ Engine │────▶│ Execution Mode │
//! │ cron/event│ │guardrail│ │lightweight│full_job│
//! │ webhook │ │ check │ └──────────────────┘
//! │ manual │ └─────────┘ │
//! └──────────┘ ▼
//! ┌──────────────┐
//! │ Notify user │
//! │ if needed │
//! └──────────────┘
//! ```
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A routine is a named, persistent, user-owned task with a trigger and an action.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Routine {
pub id: Uuid,
pub name: String,
pub description: String,
pub user_id: String,
pub enabled: bool,
pub trigger: Trigger,
pub action: RoutineAction,
pub guardrails: RoutineGuardrails,
pub notify: NotifyConfig,
// Runtime state (DB-managed)
pub last_run_at: Option<DateTime<Utc>>,
pub next_fire_at: Option<DateTime<Utc>>,
pub run_count: u64,
pub consecutive_failures: u32,
pub state: serde_json::Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// When a routine should fire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Trigger {
/// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h").
Cron { schedule: String },
/// Fire when a channel message matches a pattern.
Event {
/// Optional channel filter (e.g. "telegram", "slack").
channel: Option<String>,
/// Regex pattern to match against message content.
pattern: String,
},
/// Fire on incoming webhook POST to /hooks/routine/{id}.
Webhook {
/// Optional webhook path suffix (defaults to routine id).
path: Option<String>,
/// Optional shared secret for HMAC validation.
secret: Option<String>,
},
/// Only fires via tool call or CLI.
Manual,
}
impl Trigger {
/// The string tag stored in the DB trigger_type column.
pub fn type_tag(&self) -> &'static str {
match self {
Trigger::Cron { .. } => "cron",
Trigger::Event { .. } => "event",
Trigger::Webhook { .. } => "webhook",
Trigger::Manual => "manual",
}
}
/// Parse a trigger from its DB representation.
pub fn from_db(trigger_type: &str, config: serde_json::Value) -> Result<Self, String> {
match trigger_type {
"cron" => {
let schedule = config
.get("schedule")
.and_then(|v| v.as_str())
.ok_or("cron trigger missing 'schedule'")?
.to_string();
Ok(Trigger::Cron { schedule })
}
"event" => {
let pattern = config
.get("pattern")
.and_then(|v| v.as_str())
.ok_or("event trigger missing 'pattern'")?
.to_string();
let channel = config
.get("channel")
.and_then(|v| v.as_str())
.map(String::from);
Ok(Trigger::Event { channel, pattern })
}
"webhook" => {
let path = config
.get("path")
.and_then(|v| v.as_str())
.map(String::from);
let secret = config
.get("secret")
.and_then(|v| v.as_str())
.map(String::from);
Ok(Trigger::Webhook { path, secret })
}
"manual" => Ok(Trigger::Manual),
other => Err(format!("unknown trigger type: {other}")),
}
}
/// Serialize trigger-specific config to JSON for DB storage.
pub fn to_config_json(&self) -> serde_json::Value {
match self {
Trigger::Cron { schedule } => serde_json::json!({ "schedule": schedule }),
Trigger::Event { channel, pattern } => serde_json::json!({
"pattern": pattern,
"channel": channel,
}),
Trigger::Webhook { path, secret } => serde_json::json!({
"path": path,
"secret": secret,
}),
Trigger::Manual => serde_json::json!({}),
}
}
}
/// What happens when a routine fires.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RoutineAction {
/// Single LLM call, no tools. Cheap and fast.
Lightweight {
/// The prompt sent to the LLM.
prompt: String,
/// Workspace paths to load as context (e.g. ["context/priorities.md"]).
#[serde(default)]
context_paths: Vec<String>,
/// Max output tokens (default: 4096).
#[serde(default = "default_max_tokens")]
max_tokens: u32,
},
/// Full multi-turn worker job with tool access.
FullJob {
/// Job title for the scheduler.
title: String,
/// Job description / initial prompt.
description: String,
/// Max reasoning iterations (default: 10).
#[serde(default = "default_max_iterations")]
max_iterations: u32,
},
}
fn default_max_tokens() -> u32 {
4096
}
fn default_max_iterations() -> u32 {
10
}
impl RoutineAction {
/// The string tag stored in the DB action_type column.
pub fn type_tag(&self) -> &'static str {
match self {
RoutineAction::Lightweight { .. } => "lightweight",
RoutineAction::FullJob { .. } => "full_job",
}
}
/// Parse an action from its DB representation.
pub fn from_db(action_type: &str, config: serde_json::Value) -> Result<Self, String> {
match action_type {
"lightweight" => {
let prompt = config
.get("prompt")
.and_then(|v| v.as_str())
.ok_or("lightweight action missing 'prompt'")?
.to_string();
let context_paths = config
.get("context_paths")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let max_tokens = config
.get("max_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(default_max_tokens() as u64) as u32;
Ok(RoutineAction::Lightweight {
prompt,
context_paths,
max_tokens,
})
}
"full_job" => {
let title = config
.get("title")
.and_then(|v| v.as_str())
.ok_or("full_job action missing 'title'")?
.to_string();
let description = config
.get("description")
.and_then(|v| v.as_str())
.ok_or("full_job action missing 'description'")?
.to_string();
let max_iterations = config
.get("max_iterations")
.and_then(|v| v.as_u64())
.unwrap_or(default_max_iterations() as u64)
as u32;
Ok(RoutineAction::FullJob {
title,
description,
max_iterations,
})
}
other => Err(format!("unknown action type: {other}")),
}
}
/// Serialize action config to JSON for DB storage.
pub fn to_config_json(&self) -> serde_json::Value {
match self {
RoutineAction::Lightweight {
prompt,
context_paths,
max_tokens,
} => serde_json::json!({
"prompt": prompt,
"context_paths": context_paths,
"max_tokens": max_tokens,
}),
RoutineAction::FullJob {
title,
description,
max_iterations,
} => serde_json::json!({
"title": title,
"description": description,
"max_iterations": max_iterations,
}),
}
}
}
/// Guardrails to prevent runaway execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutineGuardrails {
/// Minimum time between fires.
pub cooldown: Duration,
/// Max simultaneous runs of this routine.
pub max_concurrent: u32,
/// Window for content-hash dedup (event triggers). None = no dedup.
pub dedup_window: Option<Duration>,
}
impl Default for RoutineGuardrails {
fn default() -> Self {
Self {
cooldown: Duration::from_secs(300),
max_concurrent: 1,
dedup_window: None,
}
}
}
/// Notification preferences for a routine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotifyConfig {
/// Channel to notify on (None = default/broadcast all).
pub channel: Option<String>,
/// User to notify.
pub user: String,
/// Notify when routine produces actionable output.
pub on_attention: bool,
/// Notify when routine errors.
pub on_failure: bool,
/// Notify when routine runs with no findings.
pub on_success: bool,
}
impl Default for NotifyConfig {
fn default() -> Self {
Self {
channel: None,
user: "default".to_string(),
on_attention: true,
on_failure: true,
on_success: false,
}
}
}
/// Status of a routine run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Running,
Ok,
Attention,
Failed,
}
impl std::fmt::Display for RunStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RunStatus::Running => write!(f, "running"),
RunStatus::Ok => write!(f, "ok"),
RunStatus::Attention => write!(f, "attention"),
RunStatus::Failed => write!(f, "failed"),
}
}
}
impl FromStr for RunStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"running" => Ok(RunStatus::Running),
"ok" => Ok(RunStatus::Ok),
"attention" => Ok(RunStatus::Attention),
"failed" => Ok(RunStatus::Failed),
other => Err(format!("unknown run status: {other}")),
}
}
}
/// A single execution of a routine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutineRun {
pub id: Uuid,
pub routine_id: Uuid,
pub trigger_type: String,
pub trigger_detail: Option<String>,
pub started_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub status: RunStatus,
pub result_summary: Option<String>,
pub tokens_used: Option<i32>,
pub job_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
/// Compute a content hash for event dedup.
pub fn content_hash(content: &str) -> u64 {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
}
/// Parse a cron expression and compute the next fire time from now.
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, String> {
let cron_schedule =
cron::Schedule::from_str(schedule).map_err(|e| format!("invalid cron: {e}"))?;
Ok(cron_schedule.upcoming(Utc).next())
}
#[cfg(test)]
mod tests {
use crate::agent::routine::{
RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, next_cron_fire,
};
#[test]
fn test_trigger_roundtrip() {
let trigger = Trigger::Cron {
schedule: "0 9 * * MON-FRI".to_string(),
};
let json = trigger.to_config_json();
let parsed = Trigger::from_db("cron", json).expect("parse cron");
assert!(matches!(parsed, Trigger::Cron { schedule } if schedule == "0 9 * * MON-FRI"));
}
#[test]
fn test_event_trigger_roundtrip() {
let trigger = Trigger::Event {
channel: Some("telegram".to_string()),
pattern: r"deploy\s+\w+".to_string(),
};
let json = trigger.to_config_json();
let parsed = Trigger::from_db("event", json).expect("parse event");
assert!(matches!(parsed, Trigger::Event { channel, pattern }
if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+"));
}
#[test]
fn test_action_lightweight_roundtrip() {
let action = RoutineAction::Lightweight {
prompt: "Check PRs".to_string(),
context_paths: vec!["context/priorities.md".to_string()],
max_tokens: 2048,
};
let json = action.to_config_json();
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
assert!(
matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens }
if prompt == "Check PRs" && context_paths.len() == 1 && max_tokens == 2048)
);
}
#[test]
fn test_action_full_job_roundtrip() {
let action = RoutineAction::FullJob {
title: "Deploy review".to_string(),
description: "Review and deploy pending changes".to_string(),
max_iterations: 5,
};
let json = action.to_config_json();
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
assert!(
matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. }
if title == "Deploy review" && max_iterations == 5)
);
}
#[test]
fn test_run_status_display_parse() {
for status in [
RunStatus::Running,
RunStatus::Ok,
RunStatus::Attention,
RunStatus::Failed,
] {
let s = status.to_string();
let parsed: RunStatus = s.parse().expect("parse status");
assert_eq!(parsed, status);
}
}
#[test]
fn test_content_hash_deterministic() {
let h1 = content_hash("deploy production");
let h2 = content_hash("deploy production");
assert_eq!(h1, h2);
let h3 = content_hash("deploy staging");
assert_ne!(h1, h3);
}
#[test]
fn test_next_cron_fire_valid() {
// Every minute should always have a next fire
let next = next_cron_fire("* * * * * *").expect("valid cron");
assert!(next.is_some());
}
#[test]
fn test_next_cron_fire_invalid() {
let result = next_cron_fire("not a cron");
assert!(result.is_err());
}
#[test]
fn test_guardrails_default() {
let g = RoutineGuardrails::default();
assert_eq!(g.cooldown.as_secs(), 300);
assert_eq!(g.max_concurrent, 1);
assert!(g.dedup_window.is_none());
}
#[test]
fn test_trigger_type_tag() {
assert_eq!(
Trigger::Cron {
schedule: String::new()
}
.type_tag(),
"cron"
);
assert_eq!(
Trigger::Event {
channel: None,
pattern: String::new()
}
.type_tag(),
"event"
);
assert_eq!(
Trigger::Webhook {
path: None,
secret: None
}
.type_tag(),
"webhook"
);
assert_eq!(Trigger::Manual.type_tag(), "manual");
}
}
-601
View File
@@ -1,601 +0,0 @@
//! Routine execution engine.
//!
//! Handles loading routines, checking triggers, enforcing guardrails,
//! and executing both lightweight (single LLM call) and full-job routines.
//!
//! The engine runs two independent loops:
//! - A **cron ticker** that polls the DB every N seconds for due cron routines
//! - An **event matcher** called synchronously from the agent main loop
//!
//! Lightweight routines execute inline (single LLM call, no scheduler slot).
//! Full-job routines are delegated to the existing `Scheduler`.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use chrono::Utc;
use regex::Regex;
use tokio::sync::{RwLock, mpsc};
use uuid::Uuid;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
};
use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::config::RoutineConfig;
use crate::db::Database;
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
use crate::workspace::Workspace;
/// The routine execution engine.
pub struct RoutineEngine {
config: RoutineConfig,
store: Arc<dyn Database>,
llm: Arc<dyn LlmProvider>,
workspace: Arc<Workspace>,
/// Sender for notifications (routed to channel manager).
notify_tx: mpsc::Sender<OutgoingResponse>,
/// Currently running routine count (across all routines).
running_count: Arc<AtomicUsize>,
/// Compiled event regex cache: routine_id -> compiled regex.
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
}
impl RoutineEngine {
pub fn new(
config: RoutineConfig,
store: Arc<dyn Database>,
llm: Arc<dyn LlmProvider>,
workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>,
) -> Self {
Self {
config,
store,
llm,
workspace,
notify_tx,
running_count: Arc::new(AtomicUsize::new(0)),
event_cache: Arc::new(RwLock::new(Vec::new())),
}
}
/// Refresh the in-memory event trigger cache from DB.
pub async fn refresh_event_cache(&self) {
match self.store.list_event_routines().await {
Ok(routines) => {
let mut cache = Vec::new();
for routine in routines {
if let Trigger::Event { ref pattern, .. } = routine.trigger {
match Regex::new(pattern) {
Ok(re) => cache.push((routine.id, routine.clone(), re)),
Err(e) => {
tracing::warn!(
routine = %routine.name,
"Invalid event regex '{}': {}",
pattern, e
);
}
}
}
}
let count = cache.len();
*self.event_cache.write().await = cache;
tracing::debug!("Refreshed event cache: {} routines", count);
}
Err(e) => {
tracing::error!("Failed to refresh event cache: {}", e);
}
}
}
/// Check incoming message against event triggers. Returns number of routines fired.
///
/// Called synchronously from the main loop after handle_message(). The actual
/// execution is spawned async so this returns quickly.
pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize {
let cache = self.event_cache.read().await;
let mut fired = 0;
for (_, routine, re) in cache.iter() {
// Channel filter
if let Trigger::Event {
channel: Some(ch), ..
} = &routine.trigger
&& ch != &message.channel
{
continue;
}
// Regex match
if !re.is_match(&message.content) {
continue;
}
// Cooldown check
if !self.check_cooldown(routine) {
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
continue;
}
// Concurrent run check
if !self.check_concurrent(routine).await {
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
continue;
}
// Global capacity check
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached");
continue;
}
let detail = truncate(&message.content, 200);
self.spawn_fire(routine.clone(), "event", Some(detail));
fired += 1;
}
fired
}
/// Check all due cron routines and fire them. Called by the cron ticker.
pub async fn check_cron_triggers(&self) {
let routines = match self.store.list_due_cron_routines().await {
Ok(r) => r,
Err(e) => {
tracing::error!("Failed to load due cron routines: {}", e);
return;
}
};
for routine in routines {
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
tracing::warn!("Global max concurrent routines reached, skipping remaining");
break;
}
if !self.check_cooldown(&routine) {
continue;
}
if !self.check_concurrent(&routine).await {
continue;
}
let detail = if let Trigger::Cron { ref schedule } = routine.trigger {
Some(schedule.clone())
} else {
None
};
self.spawn_fire(routine, "cron", detail);
}
}
/// Fire a routine manually (from tool call or CLI).
pub async fn fire_manual(&self, routine_id: Uuid) -> Result<Uuid, String> {
let routine = self
.store
.get_routine(routine_id)
.await
.map_err(|e| format!("DB error: {e}"))?
.ok_or_else(|| format!("routine {routine_id} not found"))?;
if !routine.enabled {
return Err(format!("routine '{}' is disabled", routine.name));
}
if !self.check_concurrent(&routine).await {
return Err(format!(
"routine '{}' already at max concurrent runs",
routine.name
));
}
let run_id = Uuid::new_v4();
let run = RoutineRun {
id: run_id,
routine_id: routine.id,
trigger_type: "manual".to_string(),
trigger_detail: None,
started_at: Utc::now(),
completed_at: None,
status: RunStatus::Running,
result_summary: None,
tokens_used: None,
job_id: None,
created_at: Utc::now(),
};
if let Err(e) = self.store.create_routine_run(&run).await {
return Err(format!("failed to create run record: {e}"));
}
// Execute inline for manual triggers (caller wants to wait)
let engine = EngineContext {
store: self.store.clone(),
llm: self.llm.clone(),
workspace: self.workspace.clone(),
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
max_lightweight_tokens: self.config.max_lightweight_tokens,
};
tokio::spawn(async move {
execute_routine(engine, routine, run).await;
});
Ok(run_id)
}
/// Spawn a fire in a background task.
fn spawn_fire(&self, routine: Routine, trigger_type: &str, trigger_detail: Option<String>) {
let run = RoutineRun {
id: Uuid::new_v4(),
routine_id: routine.id,
trigger_type: trigger_type.to_string(),
trigger_detail,
started_at: Utc::now(),
completed_at: None,
status: RunStatus::Running,
result_summary: None,
tokens_used: None,
job_id: None,
created_at: Utc::now(),
};
let engine = EngineContext {
store: self.store.clone(),
llm: self.llm.clone(),
workspace: self.workspace.clone(),
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
max_lightweight_tokens: self.config.max_lightweight_tokens,
};
// Record the run in DB, then spawn execution
let store = self.store.clone();
tokio::spawn(async move {
if let Err(e) = store.create_routine_run(&run).await {
tracing::error!(routine = %routine.name, "Failed to record run: {}", e);
return;
}
execute_routine(engine, routine, run).await;
});
}
fn check_cooldown(&self, routine: &Routine) -> bool {
if let Some(last_run) = routine.last_run_at {
let elapsed = Utc::now().signed_duration_since(last_run);
let cooldown = chrono::Duration::from_std(routine.guardrails.cooldown)
.unwrap_or(chrono::Duration::seconds(300));
if elapsed < cooldown {
return false;
}
}
true
}
async fn check_concurrent(&self, routine: &Routine) -> bool {
match self.store.count_running_routine_runs(routine.id).await {
Ok(count) => count < routine.guardrails.max_concurrent as i64,
Err(e) => {
tracing::error!(
routine = %routine.name,
"Failed to check concurrent runs: {}", e
);
false
}
}
}
}
/// Shared context passed to the execution function.
struct EngineContext {
store: Arc<dyn Database>,
llm: Arc<dyn LlmProvider>,
workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>,
running_count: Arc<AtomicUsize>,
max_lightweight_tokens: u32,
}
/// Execute a routine run. Handles both lightweight and full_job modes.
async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) {
// Increment running count (atomic: survives panics in the execution below)
ctx.running_count.fetch_add(1, Ordering::Relaxed);
let result = match &routine.action {
RoutineAction::Lightweight {
prompt,
context_paths,
max_tokens,
} => execute_lightweight(&ctx, &routine, prompt, context_paths, *max_tokens).await,
RoutineAction::FullJob { description, .. } => {
// Full job mode: for now, execute as lightweight with the description
// as prompt. Full scheduler integration will come as a follow-up.
tracing::info!(
routine = %routine.name,
"FullJob mode executing as lightweight (scheduler integration pending)"
);
execute_lightweight(&ctx, &routine, description, &[], ctx.max_lightweight_tokens).await
}
};
// Decrement running count
ctx.running_count.fetch_sub(1, Ordering::Relaxed);
// Process result
let (status, summary, tokens) = match result {
Ok(execution) => execution,
Err(e) => {
tracing::error!(routine = %routine.name, "Execution failed: {}", e);
(RunStatus::Failed, Some(e), None)
}
};
// Complete the run record
if let Err(e) = ctx
.store
.complete_routine_run(run.id, status, summary.as_deref(), tokens)
.await
{
tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e);
}
// Update routine runtime state
let now = Utc::now();
let next_fire = if let Trigger::Cron { ref schedule } = routine.trigger {
next_cron_fire(schedule).unwrap_or(None)
} else {
None
};
let new_failures = if status == RunStatus::Failed {
routine.consecutive_failures + 1
} else {
0
};
if let Err(e) = ctx
.store
.update_routine_runtime(
routine.id,
now,
next_fire,
routine.run_count + 1,
new_failures,
&routine.state,
)
.await
{
tracing::error!(routine = %routine.name, "Failed to update runtime state: {}", e);
}
// Send notifications based on config
send_notification(
&ctx.notify_tx,
&routine.notify,
&routine.name,
status,
summary.as_deref(),
)
.await;
}
/// Execute a lightweight routine (single LLM call).
async fn execute_lightweight(
ctx: &EngineContext,
routine: &Routine,
prompt: &str,
context_paths: &[String],
max_tokens: u32,
) -> Result<(RunStatus, Option<String>, Option<i32>), String> {
// Load context from workspace
let mut context_parts = Vec::new();
for path in context_paths {
match ctx.workspace.read(path).await {
Ok(doc) => {
context_parts.push(format!("## {}\n\n{}", path, doc.content));
}
Err(e) => {
tracing::debug!(
routine = %routine.name,
"Failed to read context path {}: {}", path, e
);
}
}
}
// Load routine state from workspace
let state_path = format!("routines/{}/state.md", routine.name);
let state_content = match ctx.workspace.read(&state_path).await {
Ok(doc) => Some(doc.content),
Err(_) => None,
};
// Build the prompt
let mut full_prompt = String::new();
full_prompt.push_str(prompt);
if !context_parts.is_empty() {
full_prompt.push_str("\n\n---\n\n# Context\n\n");
full_prompt.push_str(&context_parts.join("\n\n"));
}
if let Some(state) = &state_content {
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
full_prompt.push_str(state);
}
full_prompt.push_str(
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
If something needs attention, provide a concise summary.",
);
// Get system prompt
let system_prompt = match ctx.workspace.system_prompt().await {
Ok(p) => p,
Err(e) => {
tracing::warn!(routine = %routine.name, "Failed to get system prompt: {}", e);
String::new()
}
};
let messages = if system_prompt.is_empty() {
vec![ChatMessage::user(&full_prompt)]
} else {
vec![
ChatMessage::system(&system_prompt),
ChatMessage::user(&full_prompt),
]
};
// Determine max_tokens from model metadata with fallback
let effective_max_tokens = match ctx.llm.model_metadata().await {
Ok(meta) => {
let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(max_tokens);
from_api.max(max_tokens)
}
Err(_) => max_tokens,
};
let request = CompletionRequest::new(messages)
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);
let response = ctx
.llm
.complete(request)
.await
.map_err(|e| format!("LLM call failed: {e}"))?;
let content = response.content.trim();
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
// Empty content guard (same as heartbeat)
if content.is_empty() {
return if response.finish_reason == FinishReason::Length {
Err(
"LLM response truncated (finish_reason=length) with no content. \
Model may have exhausted token budget on reasoning."
.to_string(),
)
} else {
Err("LLM returned empty content.".to_string())
};
}
// Check for the "nothing to do" sentinel
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
return Ok((RunStatus::Ok, None, tokens_used));
}
Ok((RunStatus::Attention, Some(content.to_string()), tokens_used))
}
/// Send a notification based on the routine's notify config and run status.
async fn send_notification(
tx: &mpsc::Sender<OutgoingResponse>,
notify: &NotifyConfig,
routine_name: &str,
status: RunStatus,
summary: Option<&str>,
) {
let should_notify = match status {
RunStatus::Ok => notify.on_success,
RunStatus::Attention => notify.on_attention,
RunStatus::Failed => notify.on_failure,
RunStatus::Running => false,
};
if !should_notify {
return;
}
let icon = match status {
RunStatus::Ok => "",
RunStatus::Attention => "🔔",
RunStatus::Failed => "",
RunStatus::Running => "",
};
let message = match summary {
Some(s) => format!("{} *Routine '{}'*: {}\n\n{}", icon, routine_name, status, s),
None => format!("{} *Routine '{}'*: {}", icon, routine_name, status),
};
let response = OutgoingResponse {
content: message,
thread_id: None,
metadata: serde_json::json!({
"source": "routine",
"routine_name": routine_name,
"status": status.to_string(),
}),
};
if let Err(e) = tx.send(response).await {
tracing::error!(routine = %routine_name, "Failed to send notification: {}", e);
}
}
/// Spawn the cron ticker background task.
pub fn spawn_cron_ticker(
engine: Arc<RoutineEngine>,
interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
// Skip immediate first tick
ticker.tick().await;
loop {
ticker.tick().await;
engine.check_cron_triggers().await;
}
})
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let end = crate::util::floor_char_boundary(s, max);
format!("{}...", &s[..end])
}
}
#[cfg(test)]
mod tests {
use crate::agent::routine::{NotifyConfig, RunStatus};
#[test]
fn test_notification_gating() {
let config = NotifyConfig {
on_success: false,
on_failure: true,
on_attention: true,
..Default::default()
};
// on_success = false means Ok status should not notify
assert!(!config.on_success);
assert!(config.on_failure);
assert!(config.on_attention);
}
#[test]
fn test_run_status_icons() {
// Just verify the mapping doesn't panic
for status in [
RunStatus::Ok,
RunStatus::Attention,
RunStatus::Failed,
RunStatus::Running,
] {
let _ = status.to_string();
}
}
}
+76 -76
View File
@@ -12,8 +12,8 @@ use crate::agent::task::{Task, TaskContext, TaskOutput};
use crate::agent::worker::{Worker, WorkerDeps}; use crate::agent::worker::{Worker, WorkerDeps};
use crate::config::AgentConfig; use crate::config::AgentConfig;
use crate::context::{ContextManager, JobContext, JobState}; use crate::context::{ContextManager, JobContext, JobState};
use crate::db::Database;
use crate::error::{Error, JobError}; use crate::error::{Error, JobError};
use crate::history::Store;
use crate::llm::LlmProvider; use crate::llm::LlmProvider;
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
@@ -48,7 +48,7 @@ pub struct Scheduler {
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>, safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
store: Option<Arc<dyn Database>>, store: Option<Arc<Store>>,
/// Running jobs (main LLM-driven jobs). /// Running jobs (main LLM-driven jobs).
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>, jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
/// Running sub-tasks (tool executions, background tasks). /// Running sub-tasks (tool executions, background tasks).
@@ -63,7 +63,7 @@ impl Scheduler {
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>, safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
store: Option<Arc<dyn Database>>, store: Option<Arc<Store>>,
) -> Self { ) -> Self {
Self { Self {
config, config,
@@ -79,64 +79,64 @@ impl Scheduler {
/// Schedule a job for execution. /// Schedule a job for execution.
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> { pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
// Hold write lock for the entire check-insert sequence to prevent // Check if already scheduled
// TOCTOU races where two concurrent calls both pass the checks. if self.jobs.read().await.contains_key(&job_id) {
{ return Ok(());
let mut jobs = self.jobs.write().await;
if jobs.contains_key(&job_id) {
return Ok(());
}
if jobs.len() >= self.config.max_parallel_jobs {
return Err(JobError::MaxJobsExceeded {
max: self.config.max_parallel_jobs,
});
}
// Transition job to in_progress
self.context_manager
.update_context(job_id, |ctx| {
ctx.transition_to(
JobState::InProgress,
Some("Scheduled for execution".to_string()),
)
})
.await?
.map_err(|s| JobError::ContextError {
id: job_id,
reason: s,
})?;
// Create worker channel
let (tx, rx) = mpsc::channel(16);
// Create worker with shared dependencies
let deps = WorkerDeps {
context_manager: self.context_manager.clone(),
llm: self.llm.clone(),
safety: self.safety.clone(),
tools: self.tools.clone(),
store: self.store.clone(),
timeout: self.config.job_timeout,
use_planning: self.config.use_planning,
};
let worker = Worker::new(job_id, deps);
// Spawn worker task
let handle = tokio::spawn(async move {
if let Err(e) = worker.run(rx).await {
tracing::error!("Worker for job {} failed: {}", job_id, e);
}
});
// Start the worker
let _ = tx.send(WorkerMessage::Start).await;
// Insert while still holding the write lock
jobs.insert(job_id, ScheduledJob { handle, tx });
} }
// Check capacity
let current_count = self.jobs.read().await.len();
if current_count >= self.config.max_parallel_jobs {
return Err(JobError::MaxJobsExceeded {
max: self.config.max_parallel_jobs,
});
}
// Transition job to in_progress
self.context_manager
.update_context(job_id, |ctx| {
ctx.transition_to(
JobState::InProgress,
Some("Scheduled for execution".to_string()),
)
})
.await?
.map_err(|s| JobError::ContextError {
id: job_id,
reason: s,
})?;
// Create worker channel
let (tx, rx) = mpsc::channel(16);
// Create worker with shared dependencies
let deps = WorkerDeps {
context_manager: self.context_manager.clone(),
llm: self.llm.clone(),
safety: self.safety.clone(),
tools: self.tools.clone(),
store: self.store.clone(),
timeout: self.config.job_timeout,
use_planning: self.config.use_planning,
};
let worker = Worker::new(job_id, deps);
// Spawn worker task
let handle = tokio::spawn(async move {
if let Err(e) = worker.run(rx).await {
tracing::error!("Worker for job {} failed: {}", job_id, e);
}
});
// Start the worker
let _ = tx.send(WorkerMessage::Start).await;
// Store the scheduled job
self.jobs
.write()
.await
.insert(job_id, ScheduledJob { handle, tx });
// Cleanup task for this job to avoid capacity leaks // Cleanup task for this job to avoid capacity leaks
let jobs = Arc::clone(&self.jobs); let jobs = Arc::clone(&self.jobs);
tokio::spawn(async move { tokio::spawn(async move {
@@ -373,23 +373,23 @@ impl Scheduler {
.into()); .into());
} }
// Execute with per-tool timeout // Execute with timeout
let tool_timeout = tool.execution_timeout(); let result = tokio::time::timeout(Duration::from_secs(60), async {
let result = tool.execute(params, &job_ctx).await
tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await }) })
.await .await
.map_err(|_| { .map_err(|_| {
Error::Tool(crate::error::ToolError::Timeout { Error::Tool(crate::error::ToolError::Timeout {
name: tool_name.to_string(), name: tool_name.to_string(),
timeout: tool_timeout, timeout: Duration::from_secs(60),
}) })
})? })?
.map_err(|e| { .map_err(|e| {
Error::Tool(crate::error::ToolError::ExecutionFailed { Error::Tool(crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(), name: tool_name.to_string(),
reason: e.to_string(), reason: e.to_string(),
}) })
})?; })?;
Ok(TaskOutput::new(result.result, start.elapsed())) Ok(TaskOutput::new(result.result, start.elapsed()))
} }
+21 -21
View File
@@ -8,8 +8,8 @@ use chrono::{DateTime, Utc};
use uuid::Uuid; use uuid::Uuid;
use crate::context::{ContextManager, JobState}; use crate::context::{ContextManager, JobState};
use crate::db::Database;
use crate::error::RepairError; use crate::error::RepairError;
use crate::history::Store;
use crate::tools::{BuildRequirement, Language, SoftwareBuilder, SoftwareType, ToolRegistry}; use crate::tools::{BuildRequirement, Language, SoftwareBuilder, SoftwareType, ToolRegistry};
/// A job that has been detected as stuck. /// A job that has been detected as stuck.
@@ -69,7 +69,7 @@ pub struct DefaultSelfRepair {
#[allow(dead_code)] // Will be used for time-based stuck detection #[allow(dead_code)] // Will be used for time-based stuck detection
stuck_threshold: Duration, stuck_threshold: Duration,
max_repair_attempts: u32, max_repair_attempts: u32,
store: Option<Arc<dyn Database>>, store: Option<Arc<Store>>,
builder: Option<Arc<dyn SoftwareBuilder>>, builder: Option<Arc<dyn SoftwareBuilder>>,
#[allow(dead_code)] // Will be used for tool hot-reload after repair #[allow(dead_code)] // Will be used for tool hot-reload after repair
tools: Option<Arc<ToolRegistry>>, tools: Option<Arc<ToolRegistry>>,
@@ -94,7 +94,7 @@ impl DefaultSelfRepair {
/// Add a Store for tool failure tracking. /// Add a Store for tool failure tracking.
#[allow(dead_code)] // Public API for configuring repair with persistence #[allow(dead_code)] // Public API for configuring repair with persistence
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self { pub fn with_store(mut self, store: Arc<Store>) -> Self {
self.store = Some(store); self.store = Some(store);
self self
} }
@@ -119,25 +119,25 @@ impl SelfRepair for DefaultSelfRepair {
let mut stuck_jobs = Vec::new(); let mut stuck_jobs = Vec::new();
for job_id in stuck_ids { for job_id in stuck_ids {
if let Ok(ctx) = self.context_manager.get_context(job_id).await if let Ok(ctx) = self.context_manager.get_context(job_id).await {
&& ctx.state == JobState::Stuck if ctx.state == JobState::Stuck {
{ let stuck_duration = ctx
let stuck_duration = ctx .started_at
.started_at .map(|start| {
.map(|start| { let now = Utc::now();
let now = Utc::now(); let duration = now.signed_duration_since(start);
let duration = now.signed_duration_since(start); Duration::from_secs(duration.num_seconds().max(0) as u64)
Duration::from_secs(duration.num_seconds().max(0) as u64) })
}) .unwrap_or_default();
.unwrap_or_default();
stuck_jobs.push(StuckJob { stuck_jobs.push(StuckJob {
job_id, job_id,
last_activity: ctx.started_at.unwrap_or(ctx.created_at), last_activity: ctx.started_at.unwrap_or(ctx.created_at),
stuck_duration, stuck_duration,
last_error: None, last_error: None,
repair_attempts: ctx.repair_attempts, repair_attempts: ctx.repair_attempts,
}); });
}
} }
} }
+5 -409
View File
@@ -173,10 +173,6 @@ pub struct Thread {
/// Pending auth token request (thread is in auth mode). /// Pending auth token request (thread is in auth mode).
#[serde(default)] #[serde(default)]
pub pending_auth: Option<PendingAuth>, pub pending_auth: Option<PendingAuth>,
/// Last NEAR AI response ID for response chaining. Persisted to DB
/// metadata so we can resume chaining across restarts.
#[serde(default)]
pub last_response_id: Option<String>,
} }
impl Thread { impl Thread {
@@ -193,24 +189,6 @@ impl Thread {
metadata: serde_json::Value::Null, metadata: serde_json::Value::Null,
pending_approval: None, pending_approval: None,
pending_auth: None, pending_auth: None,
last_response_id: None,
}
}
/// Create a thread with a specific ID (for DB hydration).
pub fn with_id(id: Uuid, session_id: Uuid) -> Self {
let now = Utc::now();
Self {
id,
session_id,
state: ThreadState::Idle,
turns: Vec::new(),
created_at: now,
updated_at: now,
metadata: serde_json::Value::Null,
pending_approval: None,
pending_auth: None,
last_response_id: None,
} }
} }
@@ -346,11 +324,11 @@ impl Thread {
let mut turn = Turn::new(turn_number, &msg.content); let mut turn = Turn::new(turn_number, &msg.content);
// Check if next is assistant response // Check if next is assistant response
if let Some(next) = iter.peek() if let Some(next) = iter.peek() {
&& next.role == crate::llm::Role::Assistant if next.role == crate::llm::Role::Assistant {
{ let response = iter.next().expect("peeked");
let response = iter.next().expect("peeked"); turn.complete(&response.content);
turn.complete(&response.content); }
} }
self.turns.push(turn); self.turns.push(turn);
@@ -615,386 +593,4 @@ mod tests {
let restored: Thread = serde_json::from_str(&json).expect("should deserialize"); let restored: Thread = serde_json::from_str(&json).expect("should deserialize");
assert!(restored.pending_auth.is_none()); assert!(restored.pending_auth.is_none());
} }
#[test]
fn test_thread_with_id() {
let specific_id = Uuid::new_v4();
let session_id = Uuid::new_v4();
let thread = Thread::with_id(specific_id, session_id);
assert_eq!(thread.id, specific_id);
assert_eq!(thread.session_id, session_id);
assert_eq!(thread.state, ThreadState::Idle);
assert!(thread.turns.is_empty());
}
#[test]
fn test_thread_with_id_restore_messages() {
let thread_id = Uuid::new_v4();
let session_id = Uuid::new_v4();
let mut thread = Thread::with_id(thread_id, session_id);
let messages = vec![
ChatMessage::user("Hello from DB"),
ChatMessage::assistant("Restored response"),
];
thread.restore_from_messages(messages);
assert_eq!(thread.id, thread_id);
assert_eq!(thread.turns.len(), 1);
assert_eq!(thread.turns[0].user_input, "Hello from DB");
assert_eq!(
thread.turns[0].response,
Some("Restored response".to_string())
);
}
#[test]
fn test_restore_from_messages_empty() {
let mut thread = Thread::new(Uuid::new_v4());
// Add a turn first, then restore with empty vec
thread.start_turn("hello");
thread.complete_turn("hi");
assert_eq!(thread.turns.len(), 1);
thread.restore_from_messages(Vec::new());
// Should clear all turns and stay idle
assert!(thread.turns.is_empty());
assert_eq!(thread.state, ThreadState::Idle);
}
#[test]
fn test_restore_from_messages_only_assistant_messages() {
let mut thread = Thread::new(Uuid::new_v4());
// Only assistant messages (no user messages to anchor turns)
let messages = vec![
ChatMessage::assistant("I'm here"),
ChatMessage::assistant("Still here"),
];
thread.restore_from_messages(messages);
// Assistant-only messages have no user turn to attach to, so
// they should be skipped entirely.
assert!(thread.turns.is_empty());
}
#[test]
fn test_restore_from_messages_multiple_user_messages_in_a_row() {
let mut thread = Thread::new(Uuid::new_v4());
// Two user messages with no assistant response between them
let messages = vec![
ChatMessage::user("first"),
ChatMessage::user("second"),
ChatMessage::assistant("reply to second"),
];
thread.restore_from_messages(messages);
// First user message becomes a turn with no response,
// second user message pairs with the assistant response.
assert_eq!(thread.turns.len(), 2);
assert_eq!(thread.turns[0].user_input, "first");
assert!(thread.turns[0].response.is_none());
assert_eq!(thread.turns[1].user_input, "second");
assert_eq!(
thread.turns[1].response,
Some("reply to second".to_string())
);
}
#[test]
fn test_thread_switch() {
let mut session = Session::new("user-1");
let t1_id = session.create_thread().id;
let t2_id = session.create_thread().id;
// After creating two threads, active should be the last one
assert_eq!(session.active_thread, Some(t2_id));
// Switch back to the first
assert!(session.switch_thread(t1_id));
assert_eq!(session.active_thread, Some(t1_id));
// Switching to a nonexistent thread should fail
let fake_id = Uuid::new_v4();
assert!(!session.switch_thread(fake_id));
// Active thread should remain unchanged
assert_eq!(session.active_thread, Some(t1_id));
}
#[test]
fn test_get_or_create_thread_idempotent() {
let mut session = Session::new("user-1");
let tid1 = session.get_or_create_thread().id;
let tid2 = session.get_or_create_thread().id;
// Should return the same thread (not create a new one each time)
assert_eq!(tid1, tid2);
assert_eq!(session.threads.len(), 1);
}
#[test]
fn test_truncate_turns() {
let mut thread = Thread::new(Uuid::new_v4());
for i in 0..5 {
thread.start_turn(format!("msg-{}", i));
thread.complete_turn(format!("resp-{}", i));
}
assert_eq!(thread.turns.len(), 5);
thread.truncate_turns(3);
assert_eq!(thread.turns.len(), 3);
// Should keep the most recent turns
assert_eq!(thread.turns[0].user_input, "msg-2");
assert_eq!(thread.turns[1].user_input, "msg-3");
assert_eq!(thread.turns[2].user_input, "msg-4");
// Turn numbers should be re-indexed
assert_eq!(thread.turns[0].turn_number, 0);
assert_eq!(thread.turns[1].turn_number, 1);
assert_eq!(thread.turns[2].turn_number, 2);
}
#[test]
fn test_truncate_turns_noop_when_fewer() {
let mut thread = Thread::new(Uuid::new_v4());
thread.start_turn("only one");
thread.complete_turn("response");
thread.truncate_turns(10);
assert_eq!(thread.turns.len(), 1);
assert_eq!(thread.turns[0].user_input, "only one");
}
#[test]
fn test_thread_interrupt_and_resume() {
let mut thread = Thread::new(Uuid::new_v4());
thread.start_turn("do something");
assert_eq!(thread.state, ThreadState::Processing);
thread.interrupt();
assert_eq!(thread.state, ThreadState::Interrupted);
let last_turn = thread.last_turn().unwrap();
assert_eq!(last_turn.state, TurnState::Interrupted);
assert!(last_turn.completed_at.is_some());
thread.resume();
assert_eq!(thread.state, ThreadState::Idle);
}
#[test]
fn test_resume_only_from_interrupted() {
let mut thread = Thread::new(Uuid::new_v4());
// Idle thread: resume should be a no-op
assert_eq!(thread.state, ThreadState::Idle);
thread.resume();
assert_eq!(thread.state, ThreadState::Idle);
// Processing thread: resume should not change state
thread.start_turn("work");
assert_eq!(thread.state, ThreadState::Processing);
thread.resume();
assert_eq!(thread.state, ThreadState::Processing);
}
#[test]
fn test_turn_fail() {
let mut thread = Thread::new(Uuid::new_v4());
thread.start_turn("risky operation");
thread.fail_turn("connection timed out");
assert_eq!(thread.state, ThreadState::Idle);
let turn = thread.last_turn().unwrap();
assert_eq!(turn.state, TurnState::Failed);
assert_eq!(turn.error, Some("connection timed out".to_string()));
assert!(turn.response.is_none());
assert!(turn.completed_at.is_some());
}
#[test]
fn test_messages_with_incomplete_last_turn() {
let mut thread = Thread::new(Uuid::new_v4());
thread.start_turn("first");
thread.complete_turn("first reply");
thread.start_turn("second (in progress)");
let messages = thread.messages();
// Should have 3 messages: user, assistant, user (no assistant for in-progress)
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].content, "first");
assert_eq!(messages[1].content, "first reply");
assert_eq!(messages[2].content, "second (in progress)");
}
#[test]
fn test_thread_serialization_round_trip() {
let mut thread = Thread::new(Uuid::new_v4());
thread.start_turn("hello");
thread.complete_turn("world");
thread.last_response_id = Some("resp_abc123".to_string());
let json = serde_json::to_string(&thread).unwrap();
let restored: Thread = serde_json::from_str(&json).unwrap();
assert_eq!(restored.id, thread.id);
assert_eq!(restored.session_id, thread.session_id);
assert_eq!(restored.turns.len(), 1);
assert_eq!(restored.turns[0].user_input, "hello");
assert_eq!(restored.turns[0].response, Some("world".to_string()));
assert_eq!(restored.last_response_id, Some("resp_abc123".to_string()));
}
#[test]
fn test_session_serialization_round_trip() {
let mut session = Session::new("user-ser");
session.create_thread();
session.auto_approve_tool("echo");
let json = serde_json::to_string(&session).unwrap();
let restored: Session = serde_json::from_str(&json).unwrap();
assert_eq!(restored.user_id, "user-ser");
assert_eq!(restored.threads.len(), 1);
assert!(restored.is_tool_auto_approved("echo"));
assert!(!restored.is_tool_auto_approved("shell"));
}
#[test]
fn test_auto_approved_tools() {
let mut session = Session::new("user-1");
assert!(!session.is_tool_auto_approved("shell"));
session.auto_approve_tool("shell");
assert!(session.is_tool_auto_approved("shell"));
// Idempotent
session.auto_approve_tool("shell");
assert_eq!(session.auto_approved_tools.len(), 1);
}
#[test]
fn test_turn_tool_call_error() {
let mut turn = Turn::new(0, "test");
turn.record_tool_call("http", serde_json::json!({"url": "example.com"}));
turn.record_tool_error("timeout");
assert_eq!(turn.tool_calls.len(), 1);
assert_eq!(turn.tool_calls[0].error, Some("timeout".to_string()));
assert!(turn.tool_calls[0].result.is_none());
}
#[test]
fn test_turn_number_increments() {
let mut thread = Thread::new(Uuid::new_v4());
// Before any turns, turn_number() is 1 (1-indexed for display)
assert_eq!(thread.turn_number(), 1);
thread.start_turn("first");
thread.complete_turn("done");
assert_eq!(thread.turn_number(), 2);
thread.start_turn("second");
assert_eq!(thread.turn_number(), 3);
}
#[test]
fn test_complete_turn_on_empty_thread() {
let mut thread = Thread::new(Uuid::new_v4());
// Completing a turn when there are no turns should be a safe no-op
thread.complete_turn("phantom response");
assert_eq!(thread.state, ThreadState::Idle);
assert!(thread.turns.is_empty());
}
#[test]
fn test_fail_turn_on_empty_thread() {
let mut thread = Thread::new(Uuid::new_v4());
// Failing a turn when there are no turns should be a safe no-op
thread.fail_turn("phantom error");
assert_eq!(thread.state, ThreadState::Idle);
assert!(thread.turns.is_empty());
}
#[test]
fn test_pending_approval_flow() {
let mut thread = Thread::new(Uuid::new_v4());
let approval = PendingApproval {
request_id: Uuid::new_v4(),
tool_name: "shell".to_string(),
parameters: serde_json::json!({"command": "rm -rf /"}),
description: "dangerous command".to_string(),
tool_call_id: "call_123".to_string(),
context_messages: vec![ChatMessage::user("do it")],
};
thread.await_approval(approval);
assert_eq!(thread.state, ThreadState::AwaitingApproval);
assert!(thread.pending_approval.is_some());
let taken = thread.take_pending_approval();
assert!(taken.is_some());
assert_eq!(taken.unwrap().tool_name, "shell");
assert!(thread.pending_approval.is_none());
}
#[test]
fn test_clear_pending_approval() {
let mut thread = Thread::new(Uuid::new_v4());
let approval = PendingApproval {
request_id: Uuid::new_v4(),
tool_name: "http".to_string(),
parameters: serde_json::json!({}),
description: "test".to_string(),
tool_call_id: "call_456".to_string(),
context_messages: vec![],
};
thread.await_approval(approval);
thread.clear_pending_approval();
assert_eq!(thread.state, ThreadState::Idle);
assert!(thread.pending_approval.is_none());
}
#[test]
fn test_active_thread_accessors() {
let mut session = Session::new("user-1");
assert!(session.active_thread().is_none());
assert!(session.active_thread_mut().is_none());
let tid = session.create_thread().id;
assert!(session.active_thread().is_some());
assert_eq!(session.active_thread().unwrap().id, tid);
// Mutably modify through accessor
session.active_thread_mut().unwrap().start_turn("test");
assert_eq!(
session.active_thread().unwrap().state,
ThreadState::Processing
);
}
} }
+4 -379
View File
@@ -110,41 +110,6 @@ impl SessionManager {
(session, thread_id) (session, thread_id)
} }
/// Register a hydrated thread so subsequent `resolve_thread` calls find it.
///
/// Inserts into the thread_map and creates an undo manager for the thread.
pub async fn register_thread(
&self,
user_id: &str,
channel: &str,
thread_id: Uuid,
session: Arc<Mutex<Session>>,
) {
let key = ThreadKey {
user_id: user_id.to_string(),
channel: channel.to_string(),
external_thread_id: Some(thread_id.to_string()),
};
{
let mut thread_map = self.thread_map.write().await;
thread_map.insert(key, thread_id);
}
{
let mut undo_managers = self.undo_managers.write().await;
undo_managers
.entry(thread_id)
.or_insert_with(|| Arc::new(Mutex::new(UndoManager::new())));
}
// Ensure the session is tracked
{
let mut sessions = self.sessions.write().await;
sessions.entry(user_id.to_string()).or_insert(session);
}
}
/// Get undo manager for a thread. /// Get undo manager for a thread.
pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc<Mutex<UndoManager>> { pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc<Mutex<UndoManager>> {
// Fast path // Fast path
@@ -199,10 +164,10 @@ impl SessionManager {
{ {
let sessions = self.sessions.read().await; let sessions = self.sessions.read().await;
for user_id in &stale_users { for user_id in &stale_users {
if let Some(session) = sessions.get(user_id) if let Some(session) = sessions.get(user_id) {
&& let Ok(sess) = session.try_lock() if let Ok(sess) = session.try_lock() {
{ stale_thread_ids.extend(sess.threads.keys());
stale_thread_ids.extend(sess.threads.keys()); }
} }
} }
} }
@@ -331,344 +296,4 @@ mod tests {
.await; .await;
assert_eq!(pruned, 0); assert_eq!(pruned, 0);
} }
#[tokio::test]
async fn test_register_thread() {
use crate::agent::session::{Session, Thread};
let manager = SessionManager::new();
let thread_id = Uuid::new_v4();
// Create a session with a hydrated thread
let session = Arc::new(Mutex::new(Session::new("user-hydrate")));
{
let mut sess = session.lock().await;
let thread = Thread::with_id(thread_id, sess.id);
sess.threads.insert(thread_id, thread);
sess.active_thread = Some(thread_id);
}
// Register the thread
manager
.register_thread("user-hydrate", "gateway", thread_id, Arc::clone(&session))
.await;
// resolve_thread should find it (using the UUID as external_thread_id)
let (resolved_session, resolved_tid) = manager
.resolve_thread("user-hydrate", "gateway", Some(&thread_id.to_string()))
.await;
assert_eq!(resolved_tid, thread_id);
// Should be the same session object
let sess = resolved_session.lock().await;
assert!(sess.threads.contains_key(&thread_id));
}
#[tokio::test]
async fn test_resolve_thread_with_explicit_external_id() {
let manager = SessionManager::new();
// Two calls with the same explicit external thread ID should resolve
// to the same internal thread.
let (_, t1) = manager
.resolve_thread("user-1", "gateway", Some("ext-abc"))
.await;
let (_, t2) = manager
.resolve_thread("user-1", "gateway", Some("ext-abc"))
.await;
assert_eq!(t1, t2);
// A different external ID on the same channel/user gets a new thread.
let (_, t3) = manager
.resolve_thread("user-1", "gateway", Some("ext-xyz"))
.await;
assert_ne!(t1, t3);
}
#[tokio::test]
async fn test_resolve_thread_none_vs_some_external_id() {
let manager = SessionManager::new();
// None external_thread_id is a distinct key from Some("ext-1").
let (_, t_none) = manager.resolve_thread("user-1", "cli", None).await;
let (_, t_some) = manager.resolve_thread("user-1", "cli", Some("ext-1")).await;
assert_ne!(t_none, t_some);
}
#[tokio::test]
async fn test_resolve_thread_different_users_isolated() {
let manager = SessionManager::new();
let (_, t1) = manager
.resolve_thread("user-a", "gateway", Some("same-ext"))
.await;
let (_, t2) = manager
.resolve_thread("user-b", "gateway", Some("same-ext"))
.await;
// Same channel + same external ID but different users = different threads
assert_ne!(t1, t2);
}
#[tokio::test]
async fn test_resolve_thread_different_channels_isolated() {
let manager = SessionManager::new();
let (_, t1) = manager
.resolve_thread("user-1", "gateway", Some("thread-x"))
.await;
let (_, t2) = manager
.resolve_thread("user-1", "telegram", Some("thread-x"))
.await;
// Same user + same external ID but different channels = different threads
assert_ne!(t1, t2);
}
#[tokio::test]
async fn test_resolve_thread_stale_mapping_creates_new_thread() {
let manager = SessionManager::new();
// Create a thread normally
let (session, original_tid) = manager
.resolve_thread("user-1", "gateway", Some("ext-1"))
.await;
// Simulate the thread being removed from the session (e.g. pruned)
{
let mut sess = session.lock().await;
sess.threads.remove(&original_tid);
}
// Next resolve should detect the stale mapping and create a fresh thread
let (_, new_tid) = manager
.resolve_thread("user-1", "gateway", Some("ext-1"))
.await;
assert_ne!(original_tid, new_tid);
// The new thread should actually exist in the session
let sess = session.lock().await;
assert!(sess.threads.contains_key(&new_tid));
}
#[tokio::test]
async fn test_register_thread_preserves_uuid_on_resolve() {
use crate::agent::session::{Session, Thread};
let manager = SessionManager::new();
let known_uuid = Uuid::new_v4();
let session = Arc::new(Mutex::new(Session::new("user-web")));
let session_id = {
let sess = session.lock().await;
sess.id
};
// Simulate hydration: create thread with a known UUID
{
let mut sess = session.lock().await;
let thread = Thread::with_id(known_uuid, session_id);
sess.threads.insert(known_uuid, thread);
}
// Register it
manager
.register_thread("user-web", "gateway", known_uuid, Arc::clone(&session))
.await;
// resolve_thread with UUID as external_thread_id MUST return the same UUID,
// not mint a new one (this was the root cause of the "wrong conversation" bug)
let (_, resolved) = manager
.resolve_thread("user-web", "gateway", Some(&known_uuid.to_string()))
.await;
assert_eq!(resolved, known_uuid);
}
#[tokio::test]
async fn test_register_thread_idempotent() {
use crate::agent::session::{Session, Thread};
let manager = SessionManager::new();
let tid = Uuid::new_v4();
let session = Arc::new(Mutex::new(Session::new("user-idem")));
{
let mut sess = session.lock().await;
let thread = Thread::with_id(tid, sess.id);
sess.threads.insert(tid, thread);
}
// Register twice
manager
.register_thread("user-idem", "gateway", tid, Arc::clone(&session))
.await;
manager
.register_thread("user-idem", "gateway", tid, Arc::clone(&session))
.await;
// Should still resolve to the same thread
let (_, resolved) = manager
.resolve_thread("user-idem", "gateway", Some(&tid.to_string()))
.await;
assert_eq!(resolved, tid);
}
#[tokio::test]
async fn test_register_thread_creates_undo_manager() {
use crate::agent::session::{Session, Thread};
let manager = SessionManager::new();
let tid = Uuid::new_v4();
let session = Arc::new(Mutex::new(Session::new("user-undo")));
{
let mut sess = session.lock().await;
let thread = Thread::with_id(tid, sess.id);
sess.threads.insert(tid, thread);
}
manager
.register_thread("user-undo", "gateway", tid, Arc::clone(&session))
.await;
// Undo manager should exist for the registered thread
let undo = manager.get_undo_manager(tid).await;
let undo2 = manager.get_undo_manager(tid).await;
assert!(Arc::ptr_eq(&undo, &undo2));
}
#[tokio::test]
async fn test_register_thread_stores_session() {
use crate::agent::session::{Session, Thread};
let manager = SessionManager::new();
let tid = Uuid::new_v4();
let session = Arc::new(Mutex::new(Session::new("user-new")));
{
let mut sess = session.lock().await;
let thread = Thread::with_id(tid, sess.id);
sess.threads.insert(tid, thread);
}
// The user has no session yet in the manager
{
let sessions = manager.sessions.read().await;
assert!(!sessions.contains_key("user-new"));
}
manager
.register_thread("user-new", "gateway", tid, Arc::clone(&session))
.await;
// Now the session should be tracked
{
let sessions = manager.sessions.read().await;
assert!(sessions.contains_key("user-new"));
}
}
#[tokio::test]
async fn test_multiple_threads_per_user() {
let manager = SessionManager::new();
let (_, t1) = manager
.resolve_thread("user-1", "gateway", Some("thread-a"))
.await;
let (_, t2) = manager
.resolve_thread("user-1", "gateway", Some("thread-b"))
.await;
let (session, t3) = manager
.resolve_thread("user-1", "gateway", Some("thread-c"))
.await;
// All three should be distinct
assert_ne!(t1, t2);
assert_ne!(t2, t3);
assert_ne!(t1, t3);
// All three should exist in the same session
let sess = session.lock().await;
assert!(sess.threads.contains_key(&t1));
assert!(sess.threads.contains_key(&t2));
assert!(sess.threads.contains_key(&t3));
}
#[tokio::test]
async fn test_prune_cleans_thread_map_and_undo_managers() {
let manager = SessionManager::new();
let (stale_session, stale_tid) = manager.resolve_thread("user-stale", "cli", None).await;
// Backdate the session
{
let mut sess = stale_session.lock().await;
sess.last_active_at = chrono::Utc::now() - chrono::TimeDelta::seconds(86400 * 30);
}
// Verify thread_map and undo_managers have entries
{
let tm = manager.thread_map.read().await;
assert!(!tm.is_empty());
}
{
let um = manager.undo_managers.read().await;
assert!(um.contains_key(&stale_tid));
}
let pruned = manager
.prune_stale_sessions(std::time::Duration::from_secs(86400 * 7))
.await;
assert_eq!(pruned, 1);
// Thread map and undo managers should be cleaned up
{
let tm = manager.thread_map.read().await;
assert!(tm.is_empty());
}
{
let um = manager.undo_managers.read().await;
assert!(!um.contains_key(&stale_tid));
}
}
#[tokio::test]
async fn test_resolve_thread_active_thread_set() {
let manager = SessionManager::new();
let (session, thread_id) = manager
.resolve_thread("user-1", "gateway", Some("ext-1"))
.await;
// The resolved thread should be set as the active thread
let sess = session.lock().await;
assert_eq!(sess.active_thread, Some(thread_id));
}
#[tokio::test]
async fn test_register_then_resolve_different_channel_creates_new() {
use crate::agent::session::{Session, Thread};
let manager = SessionManager::new();
let tid = Uuid::new_v4();
let session = Arc::new(Mutex::new(Session::new("user-cross")));
{
let mut sess = session.lock().await;
let thread = Thread::with_id(tid, sess.id);
sess.threads.insert(tid, thread);
}
// Register on "gateway" channel
manager
.register_thread("user-cross", "gateway", tid, Arc::clone(&session))
.await;
// Resolve on a different channel with the same UUID string should NOT
// find the registered thread (channel is part of the key)
let (_, resolved) = manager
.resolve_thread("user-cross", "telegram", Some(&tid.to_string()))
.await;
assert_ne!(resolved, tid);
}
} }
+14 -144
View File
@@ -43,49 +43,6 @@ impl SubmissionParser {
if lower == "/thread new" || lower == "/new" { if lower == "/thread new" || lower == "/new" {
return Submission::NewThread; return Submission::NewThread;
} }
// System commands (bypass thread-state checks)
if lower == "/help" || lower == "/?" {
return Submission::SystemCommand {
command: "help".to_string(),
args: vec![],
};
}
if lower == "/version" {
return Submission::SystemCommand {
command: "version".to_string(),
args: vec![],
};
}
if lower == "/tools" {
return Submission::SystemCommand {
command: "tools".to_string(),
args: vec![],
};
}
if lower == "/ping" {
return Submission::SystemCommand {
command: "ping".to_string(),
args: vec![],
};
}
if lower == "/debug" {
return Submission::SystemCommand {
command: "debug".to_string(),
args: vec![],
};
}
if lower.starts_with("/model") {
let args: Vec<String> = trimmed
.split_whitespace()
.skip(1)
.map(|s| s.to_string())
.collect();
return Submission::SystemCommand {
command: "model".to_string(),
args,
};
}
if lower == "/quit" || lower == "/exit" || lower == "/shutdown" { if lower == "/quit" || lower == "/exit" || lower == "/shutdown" {
return Submission::Quit; return Submission::Quit;
} }
@@ -93,26 +50,27 @@ impl SubmissionParser {
// /thread <uuid> - switch thread // /thread <uuid> - switch thread
if let Some(rest) = lower.strip_prefix("/thread ") { if let Some(rest) = lower.strip_prefix("/thread ") {
let rest = rest.trim(); let rest = rest.trim();
if rest != "new" if rest != "new" {
&& let Ok(id) = Uuid::parse_str(rest) if let Ok(id) = Uuid::parse_str(rest) {
{ return Submission::SwitchThread { thread_id: id };
return Submission::SwitchThread { thread_id: id }; }
} }
} }
// /resume <uuid> - resume from checkpoint // /resume <uuid> - resume from checkpoint
if let Some(rest) = lower.strip_prefix("/resume ") if let Some(rest) = lower.strip_prefix("/resume ") {
&& let Ok(id) = Uuid::parse_str(rest.trim()) if let Ok(id) = Uuid::parse_str(rest.trim()) {
{ return Submission::Resume { checkpoint_id: id };
return Submission::Resume { checkpoint_id: id }; }
} }
// Try structured JSON approval (from web gateway's /api/chat/approval endpoint) // Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
if trimmed.starts_with('{') if trimmed.starts_with('{') {
&& let Ok(submission) = serde_json::from_str::<Submission>(trimmed) if let Ok(submission) = serde_json::from_str::<Submission>(trimmed) {
&& matches!(submission, Submission::ExecApproval { .. }) if matches!(submission, Submission::ExecApproval { .. }) {
{ return submission;
return submission; }
}
} }
// Approval responses (simple yes/no/always for pending approvals) // Approval responses (simple yes/no/always for pending approvals)
@@ -214,15 +172,6 @@ pub enum Submission {
/// Quit the agent. Bypasses thread-state checks. /// Quit the agent. Bypasses thread-state checks.
Quit, Quit,
/// System command (help, model, version, tools, ping, debug).
/// Bypasses thread-state checks and safety validation.
SystemCommand {
/// The command name (e.g. "help", "model", "version").
command: String,
/// Arguments to the command.
args: Vec<String>,
},
} }
impl Submission { impl Submission {
@@ -289,7 +238,6 @@ impl Submission {
| Self::Heartbeat | Self::Heartbeat
| Self::Summarize | Self::Summarize
| Self::Suggest | Self::Suggest
| Self::SystemCommand { .. }
) )
} }
} }
@@ -556,84 +504,6 @@ mod tests {
); );
} }
#[test]
fn test_parser_system_command_help() {
let submission = SubmissionParser::parse("/help");
assert!(
matches!(submission, Submission::SystemCommand { command, args } if command == "help" && args.is_empty())
);
let submission = SubmissionParser::parse("/?");
assert!(
matches!(submission, Submission::SystemCommand { command, .. } if command == "help")
);
let submission = SubmissionParser::parse("/HELP");
assert!(
matches!(submission, Submission::SystemCommand { command, .. } if command == "help")
);
}
#[test]
fn test_parser_system_command_model() {
// No args: show current model
let submission = SubmissionParser::parse("/model");
assert!(
matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args.is_empty())
);
// With args: switch model
let submission = SubmissionParser::parse("/model gpt-4o");
assert!(
matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["gpt-4o"])
);
// Case insensitive command, preserves arg case
let submission = SubmissionParser::parse("/MODEL Claude-3.5");
assert!(
matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["Claude-3.5"])
);
}
#[test]
fn test_parser_system_command_version() {
let submission = SubmissionParser::parse("/version");
assert!(
matches!(submission, Submission::SystemCommand { command, args } if command == "version" && args.is_empty())
);
}
#[test]
fn test_parser_system_command_tools() {
let submission = SubmissionParser::parse("/tools");
assert!(
matches!(submission, Submission::SystemCommand { command, args } if command == "tools" && args.is_empty())
);
}
#[test]
fn test_parser_system_command_ping() {
let submission = SubmissionParser::parse("/ping");
assert!(
matches!(submission, Submission::SystemCommand { command, args } if command == "ping" && args.is_empty())
);
}
#[test]
fn test_parser_system_command_debug() {
let submission = SubmissionParser::parse("/debug");
assert!(
matches!(submission, Submission::SystemCommand { command, args } if command == "debug" && args.is_empty())
);
}
#[test]
fn test_parser_system_command_is_control() {
let submission = SubmissionParser::parse("/help");
assert!(submission.is_control());
assert!(!submission.starts_turn());
}
#[test] #[test]
fn test_parser_quit() { fn test_parser_quit() {
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit)); assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
+35 -162
View File
@@ -10,8 +10,8 @@ use uuid::Uuid;
use crate::agent::scheduler::WorkerMessage; use crate::agent::scheduler::WorkerMessage;
use crate::agent::task::TaskOutput; use crate::agent::task::TaskOutput;
use crate::context::{ContextManager, JobState}; use crate::context::{ContextManager, JobState};
use crate::db::Database;
use crate::error::Error; use crate::error::Error;
use crate::history::Store;
use crate::llm::{ use crate::llm::{
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection, ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
}; };
@@ -28,7 +28,7 @@ pub struct WorkerDeps {
pub llm: Arc<dyn LlmProvider>, pub llm: Arc<dyn LlmProvider>,
pub safety: Arc<SafetyLayer>, pub safety: Arc<SafetyLayer>,
pub tools: Arc<ToolRegistry>, pub tools: Arc<ToolRegistry>,
pub store: Option<Arc<dyn Database>>, pub store: Option<Arc<Store>>,
pub timeout: Duration, pub timeout: Duration,
pub use_planning: bool, pub use_planning: bool,
} }
@@ -67,7 +67,7 @@ impl Worker {
&self.deps.tools &self.deps.tools
} }
fn store(&self) -> Option<&Arc<dyn Database>> { fn store(&self) -> Option<&Arc<Store>> {
self.deps.store.as_ref() self.deps.store.as_ref()
} }
@@ -227,11 +227,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
} }
// Check for cancellation // Check for cancellation
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await if let Ok(ctx) = self.context_manager().get_context(self.job_id).await {
&& ctx.state == JobState::Cancelled if ctx.state == JobState::Cancelled {
{ tracing::info!("Worker for job {} detected cancellation", self.job_id);
tracing::info!("Worker for job {} detected cancellation", self.job_id); return Ok(());
return Ok(()); }
} }
iteration += 1; iteration += 1;
@@ -248,15 +248,16 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
if selections.is_empty() { if selections.is_empty() {
// No tools from select_tools, ask LLM directly (may still return tool calls) // No tools from select_tools, ask LLM directly (may still return tool calls)
let respond_output = reasoning.respond_with_tools(reason_ctx).await?; let respond_result = reasoning.respond_with_tools(reason_ctx).await?;
match respond_output.result { match respond_result {
RespondResult::Text(response) => { RespondResult::Text(response) => {
// Check for explicit completion phrases. Use word-boundary // Check for completion keywords
// aware checks to avoid false positives like "incomplete", let response_lower = response.to_lowercase();
// "not done", or "unfinished". Only the LLM's own response if response_lower.contains("complete")
// (not tool output) can trigger this. || response_lower.contains("finished")
if crate::util::llm_signals_completion(&response) { || response_lower.contains("done")
{
self.mark_completed().await?; self.mark_completed().await?;
return Ok(()); return Ok(());
} }
@@ -271,10 +272,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
)); ));
} }
} }
RespondResult::ToolCalls { RespondResult::ToolCalls(tool_calls) => {
tool_calls,
content,
} => {
// Model returned tool calls - execute them // Model returned tool calls - execute them
tracing::debug!( tracing::debug!(
"Job {} respond_with_tools returned {} tool calls", "Job {} respond_with_tools returned {} tool calls",
@@ -282,14 +280,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
tool_calls.len() tool_calls.len()
); );
// Add assistant message with tool_calls (OpenAI protocol)
reason_ctx
.messages
.push(ChatMessage::assistant_with_tool_calls(
content,
tool_calls.clone(),
));
for tc in tool_calls { for tc in tool_calls {
let result = self.execute_tool(&tc.name, &tc.arguments).await; let result = self.execute_tool(&tc.name, &tc.arguments).await;
@@ -299,7 +289,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
parameters: tc.arguments.clone(), parameters: tc.arguments.clone(),
reasoning: String::new(), reasoning: String::new(),
alternatives: vec![], alternatives: vec![],
tool_call_id: tc.id.clone(),
}; };
self.process_tool_result(reason_ctx, &selection, result) self.process_tool_result(reason_ctx, &selection, result)
@@ -382,7 +371,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
context_manager: Arc<ContextManager>, context_manager: Arc<ContextManager>,
safety: Arc<SafetyLayer>, safety: Arc<SafetyLayer>,
store: Option<Arc<dyn Database>>, store: Option<Arc<Store>>,
job_id: Uuid, job_id: Uuid,
tool_name: &str, tool_name: &str,
params: &serde_json::Value, params: &serde_json::Value,
@@ -428,51 +417,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.into()); .into());
} }
tracing::debug!( // Execute with timeout and timing
tool = %tool_name,
params = %params,
job = %job_id,
"Tool call started"
);
// Execute with per-tool timeout and timing
let tool_timeout = tool.execution_timeout();
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let result = tokio::time::timeout(tool_timeout, async { let result = tokio::time::timeout(Duration::from_secs(60), async {
tool.execute(params.clone(), &job_ctx).await tool.execute(params.clone(), &job_ctx).await
}) })
.await; .await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
match &result {
Ok(Ok(output)) => {
let result_str = serde_json::to_string(&output.result)
.unwrap_or_else(|_| "<serialize error>".to_string());
tracing::debug!(
tool = %tool_name,
elapsed_ms = elapsed.as_millis() as u64,
result = %result_str,
"Tool call succeeded"
);
}
Ok(Err(e)) => {
tracing::debug!(
tool = %tool_name,
elapsed_ms = elapsed.as_millis() as u64,
error = %e,
"Tool call failed"
);
}
Err(_) => {
tracing::debug!(
tool = %tool_name,
elapsed_ms = elapsed.as_millis() as u64,
timeout_secs = tool_timeout.as_secs(),
"Tool call timed out"
);
}
}
// Record action in memory and get the ActionRecord for persistence // Record action in memory and get the ActionRecord for persistence
let action = match &result { let action = match &result {
Ok(Ok(output)) => { Ok(Ok(output)) => {
@@ -527,7 +479,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
let output = result let output = result
.map_err(|_| crate::error::ToolError::Timeout { .map_err(|_| crate::error::ToolError::Timeout {
name: tool_name.to_string(), name: tool_name.to_string(),
timeout: tool_timeout, timeout: Duration::from_secs(60),
})? })?
.map_err(|e| crate::error::ToolError::ExecutionFailed { .map_err(|e| crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(), name: tool_name.to_string(),
@@ -566,14 +518,17 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
); );
reason_ctx.messages.push(ChatMessage::tool_result( reason_ctx.messages.push(ChatMessage::tool_result(
&selection.tool_call_id, "tool_call_id",
&selection.tool_name, &selection.tool_name,
wrapped, wrapped,
)); ));
// Tool output never drives job completion. A malicious tool could // Check if job is complete
// emit "TASK_COMPLETE" to force premature completion. Only the LLM's if output.contains("TASK_COMPLETE") || output.contains("JOB_DONE") {
// own structured response (in execution_loop) can mark a job done. self.mark_completed().await?;
return Ok(true);
}
Ok(false) Ok(false)
} }
Err(e) => { Err(e) => {
@@ -598,7 +553,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
} }
reason_ctx.messages.push(ChatMessage::tool_result( reason_ctx.messages.push(ChatMessage::tool_result(
&selection.tool_call_id, "tool_call_id",
&selection.tool_name, &selection.tool_name,
format!("Error: {}", e), format!("Error: {}", e),
)); ));
@@ -648,15 +603,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.execute_tool(&action.tool_name, &action.parameters) .execute_tool(&action.tool_name, &action.parameters)
.await; .await;
// Create a synthetic ToolSelection for process_tool_result. // Create a synthetic ToolSelection for process_tool_result
// Plan actions don't originate from an LLM tool_call response so
// there is no real tool_call_id; generate a unique one.
let selection = ToolSelection { let selection = ToolSelection {
tool_name: action.tool_name.clone(), tool_name: action.tool_name.clone(),
parameters: action.parameters.clone(), parameters: action.parameters.clone(),
reasoning: action.reasoning.clone(), reasoning: action.reasoning.clone(),
alternatives: vec![], alternatives: vec![],
tool_call_id: format!("plan_{}_{}", self.job_id, i),
}; };
// Process the result // Process the result
@@ -680,7 +632,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
let response = reasoning.respond(reason_ctx).await?; let response = reasoning.respond(reason_ctx).await?;
reason_ctx.messages.push(ChatMessage::assistant(&response)); reason_ctx.messages.push(ChatMessage::assistant(&response));
if crate::util::llm_signals_completion(&response) { let response_lower = response.to_lowercase();
if response_lower.contains("complete")
|| response_lower.contains("finished")
|| response_lower.contains("done")
{
self.mark_completed().await?; self.mark_completed().await?;
} else { } else {
// Job not complete, could re-plan or fall back to direct selection // Job not complete, could re-plan or fall back to direct selection
@@ -775,86 +731,3 @@ impl From<TaskOutput> for Result<String, Error> {
}) })
} }
} }
#[cfg(test)]
mod tests {
use crate::llm::ToolSelection;
use crate::util::llm_signals_completion;
#[test]
fn test_tool_selection_preserves_call_id() {
let selection = ToolSelection {
tool_name: "memory_search".to_string(),
parameters: serde_json::json!({"query": "test"}),
reasoning: "Need to search memory".to_string(),
alternatives: vec![],
tool_call_id: "call_abc123".to_string(),
};
assert_eq!(selection.tool_call_id, "call_abc123");
assert_ne!(
selection.tool_call_id, "tool_call_id",
"tool_call_id must not be the hardcoded placeholder string"
);
}
#[test]
fn test_completion_positive_signals() {
assert!(llm_signals_completion("The job is complete."));
assert!(llm_signals_completion(
"I have completed the task successfully."
));
assert!(llm_signals_completion("The task is done."));
assert!(llm_signals_completion("The task is finished."));
assert!(llm_signals_completion(
"All steps are complete and verified."
));
assert!(llm_signals_completion(
"I've done all the work. The work is done."
));
assert!(llm_signals_completion(
"Successfully completed the migration."
));
}
#[test]
fn test_completion_negative_signals_block_false_positives() {
// These contain completion keywords but also negation, should NOT trigger.
assert!(!llm_signals_completion("The task is not complete yet."));
assert!(!llm_signals_completion("This is not done."));
assert!(!llm_signals_completion("The work is incomplete."));
assert!(!llm_signals_completion(
"The migration is not yet finished."
));
assert!(!llm_signals_completion("The job isn't done yet."));
assert!(!llm_signals_completion("This remains unfinished."));
}
#[test]
fn test_completion_does_not_match_bare_substrings() {
// Bare words embedded in other text should NOT trigger completion.
assert!(!llm_signals_completion(
"I need to complete more work first."
));
assert!(!llm_signals_completion(
"Let me finish the remaining steps."
));
assert!(!llm_signals_completion(
"I'm done analyzing, now let me fix it."
));
assert!(!llm_signals_completion(
"I completed step 1 but step 2 remains."
));
}
#[test]
fn test_completion_tool_output_injection() {
// A malicious tool output echoed by the LLM should not trigger
// completion unless it forms a genuine completion phrase.
assert!(!llm_signals_completion("TASK_COMPLETE"));
assert!(!llm_signals_completion("JOB_DONE"));
assert!(!llm_signals_completion(
"The tool returned: TASK_COMPLETE signal"
));
}
}
-464
View File
@@ -1,464 +0,0 @@
//! Bootstrap helpers for IronClaw.
//!
//! The only setting that truly needs disk persistence before the database is
//! available is `DATABASE_URL` (chicken-and-egg: can't connect to DB without
//! it). Everything else is auto-detected or read from env vars.
//!
//! File: `~/.ironclaw/.env` (standard dotenvy format)
use std::path::PathBuf;
/// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`.
pub fn ironclaw_env_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
.join(".env")
}
/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`).
///
/// Call this **after** `dotenvy::dotenv()` so that the standard `./.env`
/// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites
/// existing env vars, so the effective priority is:
///
/// explicit env vars > `./.env` > `~/.ironclaw/.env`
///
/// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does,
/// extracts `DATABASE_URL` from it and writes the `.env` file (one-time
/// upgrade from the old config format).
pub fn load_ironclaw_env() {
let path = ironclaw_env_path();
if !path.exists() {
// One-time upgrade: extract DATABASE_URL from legacy bootstrap.json
migrate_bootstrap_json_to_env(&path);
}
if path.exists() {
let _ = dotenvy::from_path(&path);
}
}
/// If `bootstrap.json` exists, pull `database_url` out of it and write `.env`.
fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
let ironclaw_dir = env_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let bootstrap_path = ironclaw_dir.join("bootstrap.json");
if !bootstrap_path.exists() {
return;
}
let content = match std::fs::read_to_string(&bootstrap_path) {
Ok(c) => c,
Err(_) => return,
};
// Minimal parse: just grab database_url from the JSON
let parsed: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return,
};
if let Some(url) = parsed.get("database_url").and_then(|v| v.as_str()) {
if let Some(parent) = env_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("Warning: failed to create {}: {}", parent.display(), e);
return;
}
if let Err(e) = std::fs::write(env_path, format!("DATABASE_URL=\"{}\"\n", url)) {
eprintln!("Warning: failed to migrate bootstrap.json to .env: {}", e);
return;
}
rename_to_migrated(&bootstrap_path);
eprintln!(
"Migrated DATABASE_URL from bootstrap.json to {}",
env_path.display()
);
}
}
/// Write database bootstrap vars to `~/.ironclaw/.env`.
///
/// These settings form the chicken-and-egg layer: they must be available
/// from the filesystem (env vars) BEFORE any database connection, because
/// they determine which database to connect to. Everything else is stored
/// in the database itself.
///
/// Creates the parent directory if it doesn't exist.
/// Values are double-quoted so that `#` (common in URL-encoded passwords)
/// and other shell-special characters are preserved by dotenvy.
pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
let path = ironclaw_env_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut content = String::new();
for (key, value) in vars {
content.push_str(&format!("{}=\"{}\"\n", key, value));
}
std::fs::write(&path, content)
}
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
///
/// Convenience wrapper around `save_bootstrap_env` for single-value migration
/// paths. Prefer `save_bootstrap_env` for new code.
pub fn save_database_url(url: &str) -> std::io::Result<()> {
save_bootstrap_env(&[("DATABASE_URL", url)])
}
/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
///
/// Only runs when a `settings.json` exists on disk AND the DB has no settings
/// yet. After the wizard writes directly to the DB, this path is only hit by
/// users upgrading from the old disk-only configuration.
///
/// After syncing, renames `settings.json` to `.migrated` so it won't trigger again.
pub async fn migrate_disk_to_db(
store: &dyn crate::db::Database,
user_id: &str,
) -> Result<(), MigrationError> {
let ironclaw_dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw");
let legacy_settings_path = ironclaw_dir.join("settings.json");
if !legacy_settings_path.exists() {
tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration");
return Ok(());
}
// If DB already has settings, this is not a first boot, the wizard already
// wrote directly to the DB. Just clean up the stale file.
let has_settings = store.has_settings(user_id).await.map_err(|e| {
MigrationError::Database(format!("Failed to check existing settings: {}", e))
})?;
if has_settings {
tracing::info!("DB already has settings, renaming stale settings.json");
rename_to_migrated(&legacy_settings_path);
return Ok(());
}
tracing::info!("Migrating disk settings to database...");
// 1. Load and migrate settings.json
let settings = crate::settings::Settings::load_from(&legacy_settings_path);
let db_map = settings.to_db_map();
if !db_map.is_empty() {
store
.set_all_settings(user_id, &db_map)
.await
.map_err(|e| {
MigrationError::Database(format!("Failed to write settings to DB: {}", e))
})?;
tracing::info!("Migrated {} settings to database", db_map.len());
}
// 2. Write DATABASE_URL to ~/.ironclaw/.env
if let Some(ref url) = settings.database_url {
save_database_url(url)
.map_err(|e| MigrationError::Io(format!("Failed to write .env: {}", e)))?;
tracing::info!("Wrote DATABASE_URL to {}", ironclaw_env_path().display());
}
// 3. Migrate mcp-servers.json if it exists
let mcp_path = ironclaw_dir.join("mcp-servers.json");
if mcp_path.exists() {
match std::fs::read_to_string(&mcp_path) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => {
store
.set_setting(user_id, "mcp_servers", &value)
.await
.map_err(|e| {
MigrationError::Database(format!(
"Failed to write MCP servers to DB: {}",
e
))
})?;
tracing::info!("Migrated mcp-servers.json to database");
rename_to_migrated(&mcp_path);
}
Err(e) => {
tracing::warn!("Failed to parse mcp-servers.json: {}", e);
}
},
Err(e) => {
tracing::warn!("Failed to read mcp-servers.json: {}", e);
}
}
}
// 4. Migrate session.json if it exists
let session_path = ironclaw_dir.join("session.json");
if session_path.exists() {
match std::fs::read_to_string(&session_path) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => {
store
.set_setting(user_id, "nearai.session_token", &value)
.await
.map_err(|e| {
MigrationError::Database(format!(
"Failed to write session to DB: {}",
e
))
})?;
tracing::info!("Migrated session.json to database");
rename_to_migrated(&session_path);
}
Err(e) => {
tracing::warn!("Failed to parse session.json: {}", e);
}
},
Err(e) => {
tracing::warn!("Failed to read session.json: {}", e);
}
}
}
// 5. Rename settings.json to .migrated (don't delete, safety net)
rename_to_migrated(&legacy_settings_path);
// 6. Clean up old bootstrap.json if it exists (superseded by .env)
let old_bootstrap = ironclaw_dir.join("bootstrap.json");
if old_bootstrap.exists() {
rename_to_migrated(&old_bootstrap);
tracing::info!("Renamed old bootstrap.json to .migrated");
}
tracing::info!("Disk-to-DB migration complete");
Ok(())
}
/// Rename a file to `<name>.migrated` as a safety net.
fn rename_to_migrated(path: &std::path::Path) {
let mut migrated = path.as_os_str().to_owned();
migrated.push(".migrated");
if let Err(e) = std::fs::rename(path, &migrated) {
tracing::warn!("Failed to rename {} to .migrated: {}", path.display(), e);
}
}
/// Errors that can occur during disk-to-DB migration.
#[derive(Debug, thiserror::Error)]
pub enum MigrationError {
#[error("Database error: {0}")]
Database(String),
#[error("IO error: {0}")]
Io(String),
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_save_and_load_database_url() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
// Write in the quoted format that save_database_url uses
let url = "postgres://localhost:5432/ironclaw_test";
std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();
// Verify the content is a valid dotenv line (quoted)
let content = std::fs::read_to_string(&env_path).unwrap();
assert_eq!(
content,
"DATABASE_URL=\"postgres://localhost:5432/ironclaw_test\"\n"
);
// Verify dotenvy can parse it (strips quotes automatically)
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].0, "DATABASE_URL");
assert_eq!(parsed[0].1, url);
}
#[test]
fn test_save_database_url_with_hash_in_password() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
// URLs with # in the password are common (URL-encoded special chars).
// Without quoting, dotenvy treats # as a comment delimiter.
let url = "postgres://user:p%23ss@localhost:5432/ironclaw";
std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].0, "DATABASE_URL");
assert_eq!(parsed[0].1, url);
}
#[test]
fn test_save_database_url_creates_parent_dirs() {
let dir = tempdir().unwrap();
let nested = dir.path().join("deep").join("nested");
let env_path = nested.join(".env");
// Parent doesn't exist yet
assert!(!nested.exists());
// The global function uses a fixed path, so we test the logic directly
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(&env_path, "DATABASE_URL=postgres://test\n").unwrap();
assert!(env_path.exists());
let content = std::fs::read_to_string(&env_path).unwrap();
assert!(content.contains("DATABASE_URL=postgres://test"));
}
#[test]
fn test_ironclaw_env_path() {
let path = ironclaw_env_path();
assert!(path.ends_with(".ironclaw/.env"));
}
#[test]
fn test_migrate_bootstrap_json_to_env() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
let bootstrap_path = dir.path().join("bootstrap.json");
// Write a legacy bootstrap.json
let bootstrap_json = serde_json::json!({
"database_url": "postgres://localhost/ironclaw_upgrade",
"database_pool_size": 5,
"secrets_master_key_source": "keychain",
"onboard_completed": true
});
std::fs::write(
&bootstrap_path,
serde_json::to_string_pretty(&bootstrap_json).unwrap(),
)
.unwrap();
assert!(!env_path.exists());
assert!(bootstrap_path.exists());
// Run the migration
migrate_bootstrap_json_to_env(&env_path);
// .env should now exist with DATABASE_URL
assert!(env_path.exists());
let content = std::fs::read_to_string(&env_path).unwrap();
assert_eq!(
content,
"DATABASE_URL=\"postgres://localhost/ironclaw_upgrade\"\n"
);
// bootstrap.json should be renamed to .migrated
assert!(!bootstrap_path.exists());
assert!(dir.path().join("bootstrap.json.migrated").exists());
}
#[test]
fn test_migrate_bootstrap_json_no_database_url() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
let bootstrap_path = dir.path().join("bootstrap.json");
// bootstrap.json with no database_url
let bootstrap_json = serde_json::json!({
"onboard_completed": false
});
std::fs::write(
&bootstrap_path,
serde_json::to_string_pretty(&bootstrap_json).unwrap(),
)
.unwrap();
migrate_bootstrap_json_to_env(&env_path);
// .env should NOT be created
assert!(!env_path.exists());
// bootstrap.json should remain (no migration happened)
assert!(bootstrap_path.exists());
}
#[test]
fn test_migrate_bootstrap_json_missing() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
// No bootstrap.json at all
migrate_bootstrap_json_to_env(&env_path);
// Nothing should happen
assert!(!env_path.exists());
}
#[test]
fn test_save_bootstrap_env_multiple_vars() {
let dir = tempdir().unwrap();
let env_path = dir.path().join("nested").join(".env");
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
let vars = [
("DATABASE_BACKEND", "libsql"),
("LIBSQL_PATH", "/home/user/.ironclaw/ironclaw.db"),
];
// Write manually to the temp path (save_bootstrap_env uses the global path)
let mut content = String::new();
for (key, value) in &vars {
content.push_str(&format!("{}=\"{}\"\n", key, value));
}
std::fs::write(&env_path, &content).unwrap();
// Verify dotenvy can parse all entries
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(parsed.len(), 2);
assert_eq!(
parsed[0],
("DATABASE_BACKEND".to_string(), "libsql".to_string())
);
assert_eq!(
parsed[1],
(
"LIBSQL_PATH".to_string(),
"/home/user/.ironclaw/ironclaw.db".to_string()
)
);
}
#[test]
fn test_save_bootstrap_env_overwrites_previous() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
// Write initial content
std::fs::write(&env_path, "DATABASE_URL=\"postgres://old\"\n").unwrap();
// Overwrite with new vars (simulating save_bootstrap_env behavior)
let content = "DATABASE_BACKEND=\"libsql\"\nLIBSQL_PATH=\"/new/path.db\"\n";
std::fs::write(&env_path, content).unwrap();
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
// Old DATABASE_URL should be gone
assert_eq!(parsed.len(), 2);
assert!(parsed.iter().all(|(k, _)| k != "DATABASE_URL"));
}
}
-19
View File
@@ -114,12 +114,6 @@ pub enum StatusUpdate {
StreamChunk(String), StreamChunk(String),
/// General status message. /// General status message.
Status(String), Status(String),
/// A sandbox job has started (shown as a clickable card in the UI).
JobStarted {
job_id: String,
title: String,
browse_url: String,
},
/// Tool requires user approval before execution. /// Tool requires user approval before execution.
ApprovalNeeded { ApprovalNeeded {
request_id: String, request_id: String,
@@ -127,19 +121,6 @@ pub enum StatusUpdate {
description: String, description: String,
parameters: serde_json::Value, parameters: serde_json::Value,
}, },
/// Extension needs user authentication (token or OAuth).
AuthRequired {
extension_name: String,
instructions: Option<String>,
auth_url: Option<String>,
setup_url: Option<String>,
},
/// Extension authentication completed.
AuthCompleted {
extension_name: String,
success: bool,
message: String,
},
} }
/// Trait for message channels. /// Trait for message channels.
+9 -69
View File
@@ -33,41 +33,21 @@ use termimad::MadSkin;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use crate::agent::truncate_for_preview;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError; use crate::error::ChannelError;
/// Max characters for tool result previews in the terminal.
const CLI_TOOL_RESULT_MAX: usize = 200;
/// Max characters for thinking/status messages in the terminal.
const CLI_STATUS_MAX: usize = 200;
/// Slash commands available in the REPL. /// Slash commands available in the REPL.
const SLASH_COMMANDS: &[&str] = &[ const SLASH_COMMANDS: &[&str] = &[
"/help", "/help",
"/quit", "/quit",
"/exit", "/exit",
"/debug", "/debug",
"/model",
"/undo", "/undo",
"/redo", "/redo",
"/clear", "/clear",
"/compact", "/compact",
"/new", "/new",
"/interrupt", "/interrupt",
"/version",
"/tools",
"/ping",
"/job",
"/status",
"/cancel",
"/list",
"/heartbeat",
"/summarize",
"/suggest",
"/thread",
"/resume",
]; ];
/// Rustyline helper for slash-command tab completion. /// Rustyline helper for slash-command tab completion.
@@ -268,7 +248,7 @@ impl Channel for ReplChannel {
std::thread::spawn(move || { std::thread::spawn(move || {
// Single message mode: send it and return // Single message mode: send it and return
if let Some(msg) = single_message { if let Some(msg) = single_message {
let incoming = IncomingMessage::new("repl", "default", &msg); let incoming = IncomingMessage::new("repl", "user", &msg);
let _ = tx.blocking_send(incoming); let _ = tx.blocking_send(incoming);
return; return;
} }
@@ -315,11 +295,10 @@ impl Channel for ReplChannel {
continue; continue;
} }
// Handle local REPL commands (only commands that need // Handle local REPL commands
// immediate local handling stay here)
match line.to_lowercase().as_str() { match line.to_lowercase().as_str() {
"/quit" | "/exit" => break, "/quit" | "/exit" => break,
"/help" => { "/help" | "/?" => {
print_help(); print_help();
continue; continue;
} }
@@ -336,21 +315,21 @@ impl Channel for ReplChannel {
_ => {} _ => {}
} }
let msg = IncomingMessage::new("repl", "default", line); let msg = IncomingMessage::new("repl", "user", line);
if tx.blocking_send(msg).is_err() { if tx.blocking_send(msg).is_err() {
break; break;
} }
} }
Err(ReadlineError::Interrupted) => { Err(ReadlineError::Interrupted) => {
// Ctrl+C: send /interrupt // Ctrl+C: send /interrupt
let msg = IncomingMessage::new("repl", "default", "/interrupt"); let msg = IncomingMessage::new("repl", "user", "/interrupt");
if tx.blocking_send(msg).is_err() { if tx.blocking_send(msg).is_err() {
break; break;
} }
} }
Err(ReadlineError::Eof) => { Err(ReadlineError::Eof) => {
// Ctrl+D: send /quit so the agent loop runs graceful shutdown // Ctrl+D: send /quit so the agent loop runs graceful shutdown
let msg = IncomingMessage::new("repl", "default", "/quit"); let msg = IncomingMessage::new("repl", "user", "/quit");
let _ = tx.blocking_send(msg); let _ = tx.blocking_send(msg);
break; break;
} }
@@ -407,8 +386,7 @@ impl Channel for ReplChannel {
match status { match status {
StatusUpdate::Thinking(msg) => { StatusUpdate::Thinking(msg) => {
let display = truncate_for_preview(&msg, CLI_STATUS_MAX); eprintln!(" \x1b[90m\u{25CB} {msg}\x1b[0m");
eprintln!(" \x1b[90m\u{25CB} {display}\x1b[0m");
} }
StatusUpdate::ToolStarted { name } => { StatusUpdate::ToolStarted { name } => {
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m"); eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
@@ -421,8 +399,7 @@ impl Channel for ReplChannel {
} }
} }
StatusUpdate::ToolResult { name: _, preview } => { StatusUpdate::ToolResult { name: _, preview } => {
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX); eprintln!(" \x1b[90m{preview}\x1b[0m");
eprintln!(" \x1b[90m{display}\x1b[0m");
} }
StatusUpdate::StreamChunk(chunk) => { StatusUpdate::StreamChunk(chunk) => {
// Print separator on the false-to-true transition // Print separator on the false-to-true transition
@@ -436,19 +413,9 @@ impl Channel for ReplChannel {
print!("{chunk}"); print!("{chunk}");
let _ = io::stdout().flush(); let _ = io::stdout().flush();
} }
StatusUpdate::JobStarted {
job_id,
title,
browse_url,
} => {
eprintln!(
" \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m"
);
}
StatusUpdate::Status(msg) => { StatusUpdate::Status(msg) => {
if debug || msg.contains("approval") || msg.contains("Approval") { if debug || msg.contains("approval") || msg.contains("Approval") {
let display = truncate_for_preview(&msg, CLI_STATUS_MAX); eprintln!(" \x1b[90m{msg}\x1b[0m");
eprintln!(" \x1b[90m{display}\x1b[0m");
} }
} }
StatusUpdate::ApprovalNeeded { StatusUpdate::ApprovalNeeded {
@@ -505,33 +472,6 @@ impl Channel for ReplChannel {
eprintln!(" {bot_border}"); eprintln!(" {bot_border}");
eprintln!(); eprintln!();
} }
StatusUpdate::AuthRequired {
extension_name,
instructions,
setup_url,
..
} => {
eprintln!();
eprintln!("\x1b[33m Authentication required for {extension_name}\x1b[0m");
if let Some(ref instr) = instructions {
eprintln!(" {instr}");
}
if let Some(ref url) = setup_url {
eprintln!(" \x1b[4m{url}\x1b[0m");
}
eprintln!();
}
StatusUpdate::AuthCompleted {
extension_name,
success,
message,
} => {
if success {
eprintln!("\x1b[32m {extension_name}: {message}\x1b[0m");
} else {
eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m");
}
}
} }
Ok(()) Ok(())
} }
+48 -109
View File
@@ -1,125 +1,68 @@
//! Known WASM channels that can be installed from build artifacts. //! Bundled WASM channels that can be installed locally.
//!
//! Instead of embedding WASM binaries in the host binary via include_bytes!,
//! channels are compiled separately and installed from their build output
//! directories during onboarding.
//!
//! Channel source layout:
//! channels-src/<name>/
//! target/wasm32-wasip2/release/<name>_channel.wasm
//! <name>.capabilities.json
use std::path::{Path, PathBuf}; use std::path::Path;
use tokio::fs; use tokio::fs;
/// Compile-time project root, used to locate channels-src/ in dev builds. #[derive(Clone, Copy)]
const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); struct BundledChannel {
name: &'static str,
/// Known channel names and their crate names (for locating build artifacts). wasm: &'static [u8],
const KNOWN_CHANNELS: &[(&str, &str)] = &[ capabilities: &'static [u8],
("telegram", "telegram_channel"),
("slack", "slack_channel"),
("whatsapp", "whatsapp_channel"),
];
/// Names of known channels that can be installed.
pub fn bundled_channel_names() -> Vec<&'static str> {
KNOWN_CHANNELS.iter().map(|(name, _)| *name).collect()
} }
/// Resolve the channels source directory. /// Names of bundled channels shipped with IronClaw.
/// pub fn bundled_channel_names() -> &'static [&'static str] {
/// Checks (in order): &["telegram"]
/// 1. `IRONCLAW_CHANNELS_SRC` env var
/// 2. `<CARGO_MANIFEST_DIR>/channels-src/` (dev builds)
fn channels_src_dir() -> PathBuf {
if let Ok(dir) = std::env::var("IRONCLAW_CHANNELS_SRC") {
return PathBuf::from(dir);
}
PathBuf::from(CARGO_MANIFEST_DIR).join("channels-src")
} }
/// Locate the build artifacts for a channel. /// Install a bundled channel into a channels directory.
///
/// Returns (wasm_path, capabilities_path) or an error if files are missing.
fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> {
let (_, crate_name) = KNOWN_CHANNELS
.iter()
.find(|(n, _)| *n == name)
.ok_or_else(|| format!("Unknown channel '{}'", name))?;
let src_dir = channels_src_dir();
let channel_dir = src_dir.join(name);
let wasm_path = channel_dir
.join("target/wasm32-wasip2/release")
.join(format!("{}.wasm", crate_name));
let caps_path = channel_dir.join(format!("{}.capabilities.json", name));
if !wasm_path.exists() {
return Err(format!(
"Channel '{}' WASM not found at {}. Build it first:\n \
cd {} && cargo build --target wasm32-wasip2 --release",
name,
wasm_path.display(),
channel_dir.display()
));
}
if !caps_path.exists() {
return Err(format!(
"Channel '{}' capabilities not found at {}",
name,
caps_path.display()
));
}
Ok((wasm_path, caps_path))
}
/// Install a channel from build artifacts into the channels directory.
pub async fn install_bundled_channel( pub async fn install_bundled_channel(
name: &str, name: &str,
target_dir: &Path, target_dir: &Path,
force: bool, force: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let (wasm_src, caps_src) = locate_channel_artifacts(name)?; let channel = bundled_channel(name)
.ok_or_else(|| format!("Unknown bundled channel '{}'", name.to_lowercase()))?;
fs::create_dir_all(target_dir) fs::create_dir_all(target_dir)
.await .await
.map_err(|e| format!("Failed to create channels directory: {}", e))?; .map_err(|e| format!("Failed to create channels directory: {}", e))?;
let wasm_dst = target_dir.join(format!("{}.wasm", name)); let wasm_path = target_dir.join(format!("{}.wasm", channel.name));
let caps_dst = target_dir.join(format!("{}.capabilities.json", name)); let caps_path = target_dir.join(format!("{}.capabilities.json", channel.name));
let has_existing = wasm_dst.exists() || caps_dst.exists(); let has_existing = wasm_path.exists() || caps_path.exists();
if has_existing && !force { if has_existing && !force {
return Err(format!( return Err(format!(
"Channel '{}' already exists at {}", "Channel '{}' already exists at {}",
name, channel.name,
target_dir.display() target_dir.display()
)); ));
} }
fs::copy(&wasm_src, &wasm_dst) fs::write(&wasm_path, channel.wasm)
.await .await
.map_err(|e| format!("Failed to copy {}: {}", wasm_src.display(), e))?; .map_err(|e| format!("Failed to write {}: {}", wasm_path.display(), e))?;
fs::copy(&caps_src, &caps_dst) fs::write(&caps_path, channel.capabilities)
.await .await
.map_err(|e| format!("Failed to copy {}: {}", caps_src.display(), e))?; .map_err(|e| format!("Failed to write {}: {}", caps_path.display(), e))?;
Ok(()) Ok(())
} }
/// Check which known channels have build artifacts available. fn bundled_channel(name: &str) -> Option<BundledChannel> {
pub fn available_channel_names() -> Vec<&'static str> { if name.eq_ignore_ascii_case("telegram") {
KNOWN_CHANNELS Some(BundledChannel {
.iter() name: "telegram",
.filter(|(name, _)| locate_channel_artifacts(name).is_ok()) wasm: include_bytes!("../../../channels-src/telegram/telegram.wasm"),
.map(|(name, _)| *name) capabilities: include_bytes!(
.collect() "../../../channels-src/telegram/telegram.capabilities.json"
),
})
} else {
None
}
} }
#[cfg(test)] #[cfg(test)]
@@ -130,35 +73,31 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_known_channels_includes_all_three() { fn test_bundled_channel_names_contains_telegram() {
let names = bundled_channel_names(); assert!(bundled_channel_names().contains(&"telegram"));
assert!(names.contains(&"telegram"));
assert!(names.contains(&"slack"));
assert!(names.contains(&"whatsapp"));
}
#[test]
fn test_channels_src_dir_default() {
let dir = channels_src_dir();
assert!(dir.ends_with("channels-src"));
}
#[test]
fn test_locate_unknown_channel_errors() {
assert!(locate_channel_artifacts("nonexistent").is_err());
} }
#[tokio::test] #[tokio::test]
async fn test_install_refuses_overwrite_without_force() { async fn test_install_bundled_channel_writes_files() {
let dir = tempdir().unwrap();
install_bundled_channel("telegram", dir.path(), false)
.await
.unwrap();
assert!(dir.path().join("telegram.wasm").exists());
assert!(dir.path().join("telegram.capabilities.json").exists());
}
#[tokio::test]
async fn test_install_bundled_channel_refuses_overwrite_without_force() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let wasm_path = dir.path().join("telegram.wasm"); let wasm_path = dir.path().join("telegram.wasm");
fs::write(&wasm_path, b"custom").await.unwrap(); fs::write(&wasm_path, b"custom").await.unwrap();
let result = install_bundled_channel("telegram", dir.path(), false).await; let result = install_bundled_channel("telegram", dir.path(), false).await;
// Either fails because artifacts missing OR because file exists
assert!(result.is_err()); assert!(result.is_err());
// Original file should be untouched
let existing = fs::read(&wasm_path).await.unwrap(); let existing = fs::read(&wasm_path).await.unwrap();
assert_eq!(existing, b"custom"); assert_eq!(existing, b"custom");
} }
+5 -17
View File
@@ -16,21 +16,16 @@ use crate::channels::wasm::error::WasmChannelError;
use crate::channels::wasm::runtime::WasmChannelRuntime; use crate::channels::wasm::runtime::WasmChannelRuntime;
use crate::channels::wasm::schema::ChannelCapabilitiesFile; use crate::channels::wasm::schema::ChannelCapabilitiesFile;
use crate::channels::wasm::wrapper::WasmChannel; use crate::channels::wasm::wrapper::WasmChannel;
use crate::pairing::PairingStore;
/// Loads WASM channels from the filesystem. /// Loads WASM channels from the filesystem.
pub struct WasmChannelLoader { pub struct WasmChannelLoader {
runtime: Arc<WasmChannelRuntime>, runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
} }
impl WasmChannelLoader { impl WasmChannelLoader {
/// Create a new loader with the given runtime and pairing store. /// Create a new loader with the given runtime.
pub fn new(runtime: Arc<WasmChannelRuntime>, pairing_store: Arc<PairingStore>) -> Self { pub fn new(runtime: Arc<WasmChannelRuntime>) -> Self {
Self { Self { runtime }
runtime,
pairing_store,
}
} }
/// Load a single WASM channel from a file pair. /// Load a single WASM channel from a file pair.
@@ -119,13 +114,7 @@ impl WasmChannelLoader {
.await?; .await?;
// Create the channel // Create the channel
let channel = WasmChannel::new( let channel = WasmChannel::new(self.runtime.clone(), prepared, capabilities, config_json);
self.runtime.clone(),
prepared,
capabilities,
config_json,
self.pairing_store.clone(),
);
tracing::info!( tracing::info!(
name = name, name = name,
@@ -363,7 +352,6 @@ mod tests {
use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels}; use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels};
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig}; use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
use crate::pairing::PairingStore;
use std::sync::Arc; use std::sync::Arc;
#[tokio::test] #[tokio::test]
@@ -420,7 +408,7 @@ mod tests {
async fn test_loader_invalid_name() { async fn test_loader_invalid_name() {
let config = WasmChannelRuntimeConfig::for_testing(); let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new())); let loader = WasmChannelLoader::new(runtime);
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("test.wasm"); let wasm_path = dir.path().join("test.wasm");
+1 -1
View File
@@ -89,7 +89,7 @@ mod schema;
mod wrapper; mod wrapper;
// Core types // Core types
pub use bundled::{available_channel_names, bundled_channel_names, install_bundled_channel}; pub use bundled::{bundled_channel_names, install_bundled_channel};
pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig}; pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig};
pub use error::WasmChannelError; pub use error::WasmChannelError;
pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage}; pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
-2
View File
@@ -478,7 +478,6 @@ mod tests {
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
}; };
use crate::channels::wasm::wrapper::WasmChannel; use crate::channels::wasm::wrapper::WasmChannel;
use crate::pairing::PairingStore;
use crate::tools::wasm::ResourceLimits; use crate::tools::wasm::ResourceLimits;
fn create_test_channel(name: &str) -> Arc<WasmChannel> { fn create_test_channel(name: &str) -> Arc<WasmChannel> {
@@ -500,7 +499,6 @@ mod tests {
prepared, prepared,
capabilities, capabilities,
"{}".to_string(), "{}".to_string(),
Arc::new(PairingStore::new()),
)) ))
} }
+53 -399
View File
@@ -48,7 +48,6 @@ use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
use crate::channels::wasm::schema::ChannelConfig; use crate::channels::wasm::schema::ChannelConfig;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError; use crate::error::ChannelError;
use crate::pairing::PairingStore;
use crate::safety::LeakDetector; use crate::safety::LeakDetector;
use crate::tools::wasm::LogLevel; use crate::tools::wasm::LogLevel;
use crate::tools::wasm::WasmResourceLimiter; use crate::tools::wasm::WasmResourceLimiter;
@@ -74,11 +73,6 @@ struct ChannelStoreData {
/// Injected credentials for URL substitution (e.g., bot tokens). /// Injected credentials for URL substitution (e.g., bot tokens).
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN". /// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
credentials: HashMap<String, String>, credentials: HashMap<String, String>,
/// Pairing store for DM pairing (guest access control).
pairing_store: Arc<PairingStore>,
/// Dedicated tokio runtime for HTTP requests, lazily initialized.
/// Reused across multiple `http_request` calls within one execution.
http_runtime: Option<tokio::runtime::Runtime>,
} }
impl ChannelStoreData { impl ChannelStoreData {
@@ -87,7 +81,6 @@ impl ChannelStoreData {
channel_name: &str, channel_name: &str,
capabilities: ChannelCapabilities, capabilities: ChannelCapabilities,
credentials: HashMap<String, String>, credentials: HashMap<String, String>,
pairing_store: Arc<PairingStore>,
) -> Self { ) -> Self {
// Create a minimal WASI context (no filesystem, no env vars for security) // Create a minimal WASI context (no filesystem, no env vars for security)
let wasi = WasiCtxBuilder::new().build(); let wasi = WasiCtxBuilder::new().build();
@@ -98,8 +91,6 @@ impl ChannelStoreData {
wasi, wasi,
table: ResourceTable::new(), table: ResourceTable::new(),
credentials, credentials,
pairing_store,
http_runtime: None,
} }
} }
@@ -138,34 +129,18 @@ impl ChannelStoreData {
if result.contains('{') && result.contains('}') { if result.contains('{') && result.contains('}') {
// Only warn if it looks like an unresolved placeholder (not JSON braces) // Only warn if it looks like an unresolved placeholder (not JSON braces)
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok(); let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
if let Some(re) = brace_pattern if let Some(re) = brace_pattern {
&& re.is_match(&result) if re.is_match(&result) {
{ tracing::warn!(
tracing::warn!( context = %context,
context = %context, "String may contain unresolved credential placeholders"
"String may contain unresolved credential placeholders" );
); }
} }
} }
result result
} }
/// Replace injected credential values with `[REDACTED]` in text.
///
/// Prevents credentials from leaking through error messages, logs, or
/// return values to WASM. reqwest::Error includes the full URL in its
/// Display output, so any error from an injected-URL request will
/// contain the raw credential unless we scrub it.
fn redact_credentials(&self, text: &str) -> String {
let mut result = text.to_string();
for (name, value) in &self.credentials {
if !value.is_empty() {
result = result.replace(value, &format!("[REDACTED:{}]", name));
}
}
result
}
} }
// Implement WasiView to provide WASI context and resource table // Implement WasiView to provide WASI context and resource table
@@ -212,7 +187,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
url: String, url: String,
headers_json: String, headers_json: String,
body: Option<Vec<u8>>, body: Option<Vec<u8>>,
timeout_ms: Option<u32>,
) -> Result<near::agent::channel_host::HttpResponse, String> { ) -> Result<near::agent::channel_host::HttpResponse, String> {
tracing::info!( tracing::info!(
method = %method, method = %method,
@@ -277,35 +251,10 @@ impl near::agent::channel_host::Host for ChannelStoreData {
.scan_http_request(&url, &header_vec, body.as_deref()) .scan_http_request(&url, &header_vec, body.as_deref())
.map_err(|e| format!("Potential secret leak blocked: {}", e))?; .map_err(|e| format!("Potential secret leak blocked: {}", e))?;
// Get the max response size from capabilities (default 10MB). // Make the HTTP request using blocking I/O
let max_response_bytes = self // We're already in a spawn_blocking context, so we can use block_on
.host_state let result = tokio::runtime::Handle::current().block_on(async {
.capabilities() let client = reqwest::Client::new();
.tool_capabilities
.http
.as_ref()
.map(|h| h.max_response_bytes)
.unwrap_or(10 * 1024 * 1024);
// Make the HTTP request using a dedicated single-threaded runtime.
// We're inside spawn_blocking, so we can't rely on the main runtime's
// I/O driver (it may be busy with WASM compilation or other startup work).
// A dedicated runtime gives us our own I/O driver and avoids contention.
// The runtime is lazily created and reused across calls within one execution.
if self.http_runtime.is_none() {
self.http_runtime = Some(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("Failed to create HTTP runtime: {e}"))?,
);
}
let rt = self.http_runtime.as_ref().expect("just initialized");
let result = rt.block_on(async {
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let mut request = match method.to_uppercase().as_str() { let mut request = match method.to_uppercase().as_str() {
"GET" => client.get(&url), "GET" => client.get(&url),
@@ -327,21 +276,12 @@ impl near::agent::channel_host::Host for ChannelStoreData {
request = request.body(body_bytes); request = request.body(body_bytes);
} }
// Send request with caller-specified timeout (default 30s, max 5min). // Send request with timeout
let timeout_ms = timeout_ms.unwrap_or(30_000).min(300_000) as u64; let response = request
let timeout = std::time::Duration::from_millis(timeout_ms); .timeout(std::time::Duration::from_secs(30))
let response = request.timeout(timeout).send().await.map_err(|e| { .send()
// Walk the full error chain so we get the actual root cause .await
// (DNS, TLS, connection refused, etc.) instead of just .map_err(|e| format!("HTTP request failed: {}", e))?;
// "error sending request for url (...)".
let mut chain = format!("HTTP request failed: {}", e);
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
chain.push_str(&format!(" -> {}", cause));
source = cause.source();
}
chain
})?;
let status = response.status().as_u16(); let status = response.status().as_u16();
let response_headers: std::collections::HashMap<String, String> = response let response_headers: std::collections::HashMap<String, String> = response
@@ -354,29 +294,11 @@ impl near::agent::channel_host::Host for ChannelStoreData {
}) })
.collect(); .collect();
let headers_json = serde_json::to_string(&response_headers).unwrap_or_default(); let headers_json = serde_json::to_string(&response_headers).unwrap_or_default();
// Enforce max response body size to prevent memory exhaustion.
let max_response = max_response_bytes;
if let Some(cl) = response.content_length()
&& cl as usize > max_response
{
return Err(format!(
"Response body too large: {} bytes exceeds limit of {} bytes",
cl, max_response
));
}
let body = response let body = response
.bytes() .bytes()
.await .await
.map_err(|e| format!("Failed to read response body: {}", e))?; .map_err(|e| format!("Failed to read response body: {}", e))?
if body.len() > max_response { .to_vec();
return Err(format!(
"Response body too large: {} bytes exceeds limit of {} bytes",
body.len(),
max_response
));
}
let body = body.to_vec();
tracing::info!( tracing::info!(
status = status, status = status,
@@ -408,11 +330,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
}) })
}); });
// Scrub credential values from error messages before logging or returning
// to WASM. reqwest::Error includes the full URL (with injected credentials)
// in its Display output.
let result = result.map_err(|e| self.redact_credentials(&e));
match &result { match &result {
Ok(resp) => { Ok(resp) => {
tracing::info!(status = resp.status, "http_request completed successfully"); tracing::info!(status = resp.status, "http_request completed successfully");
@@ -455,43 +372,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
} }
} }
} }
fn pairing_upsert_request(
&mut self,
channel: String,
id: String,
meta_json: String,
) -> Result<near::agent::channel_host::PairingUpsertResult, String> {
let meta = if meta_json.is_empty() {
None
} else {
serde_json::from_str(&meta_json).ok()
};
match self.pairing_store.upsert_request(&channel, &id, meta) {
Ok(r) => Ok(near::agent::channel_host::PairingUpsertResult {
code: r.code,
created: r.created,
}),
Err(e) => Err(e.to_string()),
}
}
fn pairing_is_allowed(
&mut self,
channel: String,
id: String,
username: Option<String>,
) -> Result<bool, String> {
self.pairing_store
.is_sender_allowed(&channel, &id, username.as_deref())
.map_err(|e| e.to_string())
}
fn pairing_read_allow_from(&mut self, channel: String) -> Result<Vec<String>, String> {
self.pairing_store
.read_allow_from(&channel)
.map_err(|e| e.to_string())
}
} }
/// A WASM-based channel implementing the Channel trait. /// A WASM-based channel implementing the Channel trait.
@@ -544,9 +424,6 @@ pub struct WasmChannel {
/// Background task that repeats typing indicators every 4 seconds. /// Background task that repeats typing indicators every 4 seconds.
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it. /// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>, typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
/// Pairing store for DM pairing (guest access control).
pairing_store: Arc<PairingStore>,
} }
impl WasmChannel { impl WasmChannel {
@@ -556,7 +433,6 @@ impl WasmChannel {
prepared: Arc<PreparedChannelModule>, prepared: Arc<PreparedChannelModule>,
capabilities: ChannelCapabilities, capabilities: ChannelCapabilities,
config_json: String, config_json: String,
pairing_store: Arc<PairingStore>,
) -> Self { ) -> Self {
let name = prepared.name.clone(); let name = prepared.name.clone();
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone()); let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
@@ -576,7 +452,6 @@ impl WasmChannel {
endpoints: RwLock::new(Vec::new()), endpoints: RwLock::new(Vec::new()),
credentials: Arc::new(RwLock::new(HashMap::new())), credentials: Arc::new(RwLock::new(HashMap::new())),
typing_task: RwLock::new(None), typing_task: RwLock::new(None),
pairing_store,
} }
} }
@@ -658,7 +533,6 @@ impl WasmChannel {
prepared: &PreparedChannelModule, prepared: &PreparedChannelModule,
capabilities: &ChannelCapabilities, capabilities: &ChannelCapabilities,
credentials: HashMap<String, String>, credentials: HashMap<String, String>,
pairing_store: Arc<PairingStore>,
) -> Result<Store<ChannelStoreData>, WasmChannelError> { ) -> Result<Store<ChannelStoreData>, WasmChannelError> {
let engine = runtime.engine(); let engine = runtime.engine();
let limits = &prepared.limits; let limits = &prepared.limits;
@@ -669,7 +543,6 @@ impl WasmChannel {
&prepared.name, &prepared.name,
capabilities.clone(), capabilities.clone(),
credentials, credentials,
pairing_store,
); );
let mut store = Store::new(engine, store_data); let mut store = Store::new(engine, store_data);
@@ -770,18 +643,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Execute in blocking task with timeout // Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = Self::create_store( let mut store =
&runtime, Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_start using the generated typed interface // Call on_start using the generated typed interface
@@ -814,21 +681,7 @@ impl WasmChannel {
.await; .await;
match result { match result {
Ok(Ok((config, mut host_state))) => { Ok(Ok((config, _host_state))) => {
// Surface WASM guest logs (errors/warnings from webhook setup, etc.)
for entry in host_state.take_logs() {
match entry.level {
crate::tools::wasm::LogLevel::Error => {
tracing::error!(channel = %self.name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Warn => {
tracing::warn!(channel = %self.name, "{}", entry.message);
}
_ => {
tracing::debug!(channel = %self.name, "{}", entry.message);
}
}
}
tracing::info!( tracing::info!(
channel = %self.name, channel = %self.name,
display_name = %config.display_name, display_name = %config.display_name,
@@ -900,7 +753,6 @@ impl WasmChannel {
let capabilities = self.capabilities.clone(); let capabilities = self.capabilities.clone();
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Prepare request data // Prepare request data
let method = method.to_string(); let method = method.to_string();
@@ -914,13 +766,8 @@ impl WasmChannel {
// Execute in blocking task with timeout // Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = Self::create_store( let mut store =
&runtime, Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Build the WIT request type // Build the WIT request type
@@ -993,18 +840,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Execute in blocking task with timeout // Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = Self::create_store( let mut store =
&runtime, Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_poll using the generated typed interface // Call on_poll using the generated typed interface
@@ -1088,7 +929,6 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Prepare response data // Prepare response data
let message_id_str = message_id.to_string(); let message_id_str = message_id.to_string();
@@ -1102,13 +942,8 @@ impl WasmChannel {
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
tracing::info!("Creating WASM store for on_respond"); tracing::info!("Creating WASM store for on_respond");
let mut store = Self::create_store( let mut store =
&runtime, Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
tracing::info!("Instantiating WASM component for on_respond"); tracing::info!("Instantiating WASM component for on_respond");
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
@@ -1201,19 +1036,13 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
let wit_update = status_to_wit(status, metadata); let wit_update = status_to_wit(status, metadata);
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = Self::create_store( let mut store =
&runtime, Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
let channel_iface = instance.near_agent_channel(); let channel_iface = instance.near_agent_channel();
@@ -1251,14 +1080,12 @@ impl WasmChannel {
/// ///
/// Static method for use by the background typing repeat task (which /// Static method for use by the background typing repeat task (which
/// doesn't have access to `&self`). /// doesn't have access to `&self`).
#[allow(clippy::too_many_arguments)]
async fn execute_status( async fn execute_status(
channel_name: &str, channel_name: &str,
runtime: &Arc<WasmChannelRuntime>, runtime: &Arc<WasmChannelRuntime>,
prepared: &Arc<PreparedChannelModule>, prepared: &Arc<PreparedChannelModule>,
capabilities: &ChannelCapabilities, capabilities: &ChannelCapabilities,
credentials: &RwLock<HashMap<String, String>>, credentials: &RwLock<HashMap<String, String>>,
pairing_store: Arc<PairingStore>,
timeout: Duration, timeout: Duration,
wit_update: wit_channel::StatusUpdate, wit_update: wit_channel::StatusUpdate,
) -> Result<(), WasmChannelError> { ) -> Result<(), WasmChannelError> {
@@ -1274,13 +1101,8 @@ impl WasmChannel {
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = Self::create_store( let mut store =
&runtime, Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
&prepared,
&capabilities,
credentials_snapshot,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
let channel_iface = instance.near_agent_channel(); let channel_iface = instance.near_agent_channel();
@@ -1348,7 +1170,6 @@ impl WasmChannel {
let prepared = Arc::clone(&self.prepared); let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone(); let capabilities = self.capabilities.clone();
let credentials = self.credentials.clone(); let credentials = self.credentials.clone();
let pairing_store = self.pairing_store.clone();
let callback_timeout = self.runtime.config().callback_timeout; let callback_timeout = self.runtime.config().callback_timeout;
let wit_update = status_to_wit(&status, metadata); let wit_update = status_to_wit(&status, metadata);
@@ -1368,7 +1189,6 @@ impl WasmChannel {
&prepared, &prepared,
&capabilities, &capabilities,
&credentials, &credentials,
pairing_store.clone(),
callback_timeout, callback_timeout,
wit_update_clone, wit_update_clone,
) )
@@ -1499,7 +1319,6 @@ impl WasmChannel {
let message_tx = self.message_tx.clone(); let message_tx = self.message_tx.clone();
let rate_limiter = self.rate_limiter.clone(); let rate_limiter = self.rate_limiter.clone();
let credentials = self.credentials.clone(); let credentials = self.credentials.clone();
let pairing_store = self.pairing_store.clone();
let callback_timeout = self.runtime.config().callback_timeout; let callback_timeout = self.runtime.config().callback_timeout;
tokio::spawn(async move { tokio::spawn(async move {
@@ -1521,15 +1340,14 @@ impl WasmChannel {
&prepared, &prepared,
&capabilities, &capabilities,
&credentials, &credentials,
pairing_store.clone(),
callback_timeout, callback_timeout,
).await; ).await;
match result { match result {
Ok(emitted_messages) => { Ok(emitted_messages) => {
// Process any emitted messages // Process any emitted messages
if !emitted_messages.is_empty() if !emitted_messages.is_empty() {
&& let Err(e) = Self::dispatch_emitted_messages( if let Err(e) = Self::dispatch_emitted_messages(
&channel_name, &channel_name,
emitted_messages, emitted_messages,
&message_tx, &message_tx,
@@ -1541,6 +1359,7 @@ impl WasmChannel {
"Failed to dispatch emitted messages from poll" "Failed to dispatch emitted messages from poll"
); );
} }
}
} }
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
@@ -1572,7 +1391,6 @@ impl WasmChannel {
prepared: &Arc<PreparedChannelModule>, prepared: &Arc<PreparedChannelModule>,
capabilities: &ChannelCapabilities, capabilities: &ChannelCapabilities,
credentials: &RwLock<HashMap<String, String>>, credentials: &RwLock<HashMap<String, String>>,
pairing_store: Arc<PairingStore>,
timeout: Duration, timeout: Duration,
) -> Result<Vec<EmittedMessage>, WasmChannelError> { ) -> Result<Vec<EmittedMessage>, WasmChannelError> {
// Skip if no WASM bytes (testing mode) // Skip if no WASM bytes (testing mode)
@@ -1593,13 +1411,8 @@ impl WasmChannel {
// Execute in blocking task with timeout // Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move { let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let mut store = Self::create_store( let mut store =
&runtime, Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
&prepared,
&capabilities,
credentials_snapshot,
pairing_store,
)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_poll using the generated typed interface // Call on_poll using the generated typed interface
@@ -1770,22 +1583,22 @@ impl Channel for WasmChannel {
*self.endpoints.write().await = endpoints; *self.endpoints.write().await = endpoints;
// Start polling if configured // Start polling if configured
if let Some(poll_config) = &config.poll if let Some(poll_config) = &config.poll {
&& poll_config.enabled if poll_config.enabled {
{ let interval = self
let interval = self .capabilities
.capabilities .validate_poll_interval(poll_config.interval_ms)
.validate_poll_interval(poll_config.interval_ms) .map_err(|e| ChannelError::StartupFailed {
.map_err(|e| ChannelError::StartupFailed { name: self.name.clone(),
name: self.name.clone(), reason: e,
reason: e, })?;
})?;
// Create shutdown channel for polling and store the sender to keep it alive // Create shutdown channel for polling and store the sender to keep it alive
let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel(); let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
*self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx); *self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx);
self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx); self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx);
}
} }
tracing::info!( tracing::info!(
@@ -2045,29 +1858,6 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
message: format!("Approval needed: {} - {}", tool_name, description), message: format!("Approval needed: {} - {}", tool_name, description),
metadata_json, metadata_json,
}, },
StatusUpdate::JobStarted { job_id, title, .. } => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: format!("Job started: {} ({})", title, job_id),
metadata_json,
},
StatusUpdate::AuthRequired { extension_name, .. } => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: format!("Auth required: {}", extension_name),
metadata_json,
},
StatusUpdate::AuthCompleted {
extension_name,
success,
..
} => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: format!(
"Auth {}: {}",
if *success { "completed" } else { "failed" },
extension_name
),
metadata_json,
},
} }
} }
@@ -2139,7 +1929,6 @@ mod tests {
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
}; };
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel}; use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
use crate::pairing::PairingStore;
use crate::tools::wasm::ResourceLimits; use crate::tools::wasm::ResourceLimits;
fn create_test_channel() -> WasmChannel { fn create_test_channel() -> WasmChannel {
@@ -2155,13 +1944,7 @@ mod tests {
let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test"); let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test");
WasmChannel::new( WasmChannel::new(runtime, prepared, capabilities, "{}".to_string())
runtime,
prepared,
capabilities,
"{}".to_string(),
Arc::new(PairingStore::new()),
)
} }
#[test] #[test]
@@ -2236,7 +2019,6 @@ mod tests {
&prepared, &prepared,
&capabilities, &capabilities,
&credentials, &credentials,
Arc::new(PairingStore::new()),
timeout, timeout,
) )
.await; .await;
@@ -2330,13 +2112,7 @@ mod tests {
.with_path("/webhook/poll") .with_path("/webhook/poll")
.with_polling(1000); .with_polling(1000);
let channel = WasmChannel::new( let channel = WasmChannel::new(runtime, prepared, capabilities, "{}".to_string());
runtime,
prepared,
capabilities,
"{}".to_string(),
Arc::new(PairingStore::new()),
);
// Start the channel // Start the channel
let _stream = channel.start().await.expect("Channel should start"); let _stream = channel.start().await.expect("Channel should start");
@@ -2574,126 +2350,4 @@ mod tests {
assert_eq!(cloned.message, "hello"); assert_eq!(cloned.message, "hello");
assert_eq!(cloned.metadata_json, "{\"a\":1}"); assert_eq!(cloned.metadata_json, "{\"a\":1}");
} }
#[test]
fn test_redact_credentials_replaces_values() {
use super::ChannelStoreData;
let mut creds = std::collections::HashMap::new();
creds.insert(
"TELEGRAM_BOT_TOKEN".to_string(),
"8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(),
);
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
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)";
let redacted = store.redact_credentials(error);
assert!(
!redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"),
"credential value should be redacted"
);
assert!(
redacted.contains("[REDACTED:TELEGRAM_BOT_TOKEN]"),
"redacted text should contain placeholder name"
);
assert!(
!redacted.contains("s3cret"),
"other credentials should also be redacted"
);
}
#[test]
fn test_redact_credentials_no_op_without_credentials() {
use super::ChannelStoreData;
let store = ChannelStoreData::new(
1024 * 1024,
"test",
ChannelCapabilities::default(),
std::collections::HashMap::new(),
Arc::new(PairingStore::new()),
);
let input = "some error message";
assert_eq!(store.redact_credentials(input), input);
}
#[test]
fn test_redact_credentials_skips_empty_values() {
use super::ChannelStoreData;
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,
Arc::new(PairingStore::new()),
);
let input = "should not match anything";
assert_eq!(store.redact_credentials(input), input);
}
/// Verify that WASM HTTP host functions work using a dedicated
/// current-thread runtime inside spawn_blocking.
#[tokio::test]
async fn test_dedicated_runtime_inside_spawn_blocking() {
let result = tokio::task::spawn_blocking(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build runtime");
rt.block_on(async { 42 })
})
.await
.expect("spawn_blocking panicked");
assert_eq!(result, 42);
}
/// Verify a real HTTP request works using the dedicated-runtime pattern.
/// This catches DNS, TLS, and I/O driver issues that trivial tests miss.
#[tokio::test]
#[ignore] // requires network
async fn test_dedicated_runtime_real_http() {
let result = tokio::task::spawn_blocking(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build runtime");
rt.block_on(async {
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.expect("failed to build client");
let resp = client
.get("https://api.telegram.org/bot000/getMe")
.timeout(std::time::Duration::from_secs(10))
.send()
.await;
match resp {
Ok(r) => r.status().as_u16(),
Err(e) if e.is_timeout() => panic!("request timed out: {e}"),
Err(e) => panic!("unexpected error: {e}"),
}
})
})
.await
.expect("spawn_blocking panicked");
// 404 because "000" is not a valid bot token
assert_eq!(result, 404);
}
} }
+14 -13
View File
@@ -6,7 +6,6 @@ use axum::{
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use subtle::ConstantTimeEq;
/// Shared auth state injected via axum middleware state. /// Shared auth state injected via axum middleware state.
#[derive(Clone)] #[derive(Clone)]
@@ -24,22 +23,24 @@ pub async fn auth_middleware(
request: Request, request: Request,
next: Next, next: Next,
) -> Response { ) -> Response {
// Try Authorization header first (constant-time comparison) // Try Authorization header first
if let Some(auth_header) = headers.get("authorization") if let Some(auth_header) = headers.get("authorization") {
&& let Ok(value) = auth_header.to_str() if let Ok(value) = auth_header.to_str() {
&& let Some(token) = value.strip_prefix("Bearer ") if let Some(token) = value.strip_prefix("Bearer ") {
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) if token == auth.token {
{ return next.run(request).await;
return next.run(request).await; }
}
}
} }
// Fall back to query parameter for SSE EventSource (constant-time comparison) // Fall back to query parameter (for SSE EventSource)
if let Some(query) = request.uri().query() { if let Some(query) = request.uri().query() {
for pair in query.split('&') { for pair in query.split('&') {
if let Some(token) = pair.strip_prefix("token=") if let Some(token) = pair.strip_prefix("token=") {
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) if token == auth.token {
{ return next.run(request).await;
return next.run(request).await; }
} }
} }
} }
+1 -43
View File
@@ -24,8 +24,6 @@ use tokio::sync::broadcast;
use tracing::field::{Field, Visit}; use tracing::field::{Field, Visit};
use tracing_subscriber::Layer; use tracing_subscriber::Layer;
use crate::safety::LeakDetector;
/// Maximum number of recent log entries kept for late-joining SSE subscribers. /// Maximum number of recent log entries kept for late-joining SSE subscribers.
const HISTORY_CAP: usize = 500; const HISTORY_CAP: usize = 500;
@@ -48,8 +46,6 @@ pub struct LogEntry {
pub struct LogBroadcaster { pub struct LogBroadcaster {
tx: broadcast::Sender<LogEntry>, tx: broadcast::Sender<LogEntry>,
recent: Mutex<VecDeque<LogEntry>>, recent: Mutex<VecDeque<LogEntry>>,
/// Scrubs secrets from log messages before broadcasting to SSE clients.
leak_detector: LeakDetector,
} }
impl LogBroadcaster { impl LogBroadcaster {
@@ -58,19 +54,10 @@ impl LogBroadcaster {
Self { Self {
tx, tx,
recent: Mutex::new(VecDeque::with_capacity(HISTORY_CAP)), recent: Mutex::new(VecDeque::with_capacity(HISTORY_CAP)),
leak_detector: LeakDetector::new(),
} }
} }
pub fn send(&self, mut entry: LogEntry) { pub fn send(&self, entry: LogEntry) {
// Scrub secrets from the message before it reaches any subscriber.
// This is defense-in-depth: even if code elsewhere accidentally logs
// a secret, it won't be broadcast to SSE clients.
entry.message = self
.leak_detector
.scan_and_clean(&entry.message)
.unwrap_or_else(|_| "[log message redacted: contained blocked secret]".to_string());
// Stash in ring buffer (for late joiners) // Stash in ring buffer (for late joiners)
if let Ok(mut buf) = self.recent.lock() { if let Ok(mut buf) = self.recent.lock() {
if buf.len() >= HISTORY_CAP { if buf.len() >= HISTORY_CAP {
@@ -158,9 +145,6 @@ impl Visit for MessageVisitor {
/// ///
/// Only forwards DEBUG and above. Attach to the tracing subscriber /// Only forwards DEBUG and above. Attach to the tracing subscriber
/// alongside the existing fmt layer. /// alongside the existing fmt layer.
///
/// Log messages are scrubbed through `LeakDetector` in `LogBroadcaster::send()`
/// (the single funnel point for all log output, including late-joiner history).
pub struct WebLogLayer { pub struct WebLogLayer {
broadcaster: Arc<LogBroadcaster>, broadcaster: Arc<LogBroadcaster>,
} }
@@ -194,7 +178,6 @@ impl<S: tracing::Subscriber> Layer<S> for WebLogLayer {
timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
}; };
// LeakDetector scrubbing happens inside broadcaster.send()
self.broadcaster.send(entry); self.broadcaster.send(entry);
} }
} }
@@ -330,29 +313,4 @@ mod tests {
let v = MessageVisitor::new(); let v = MessageVisitor::new();
assert_eq!(v.finish(), ""); assert_eq!(v.finish(), "");
} }
#[test]
fn test_broadcaster_has_leak_detector() {
let broadcaster = LogBroadcaster::new();
// Verify the leak detector is initialized with default patterns
assert!(broadcaster.leak_detector.pattern_count() > 0);
}
#[test]
fn test_leak_detector_scrubs_api_key_in_log() {
let detector = crate::safety::LeakDetector::new();
let msg = "Connecting with token sk-proj-test1234567890abcdefghij";
let result = detector.scan_and_clean(msg);
// Should be blocked (OpenAI key pattern)
assert!(result.is_err());
}
#[test]
fn test_leak_detector_passes_clean_log() {
let detector = crate::safety::LeakDetector::new();
let msg = "Request completed status=200 url=https://api.example.com/data";
let result = detector.scan_and_clean(msg);
assert!(result.is_ok());
assert_eq!(result.unwrap(), msg);
}
} }
+24 -109
View File
@@ -10,13 +10,12 @@
//! ◄── GET /api/chat/events ── SSE stream //! ◄── GET /api/chat/events ── SSE stream
//! ─── GET /api/chat/ws ─────► WebSocket (bidirectional) //! ─── GET /api/chat/ws ─────► WebSocket (bidirectional)
//! ─── GET /api/memory/* ────► Workspace //! ─── GET /api/memory/* ────► Workspace
//! ─── GET /api/jobs/* ──────► Database //! ─── GET /api/jobs/* ──────► ContextManager
//! ◄── GET / ───────────────── Static HTML/CSS/JS //! ◄── GET / ───────────────── Static HTML/CSS/JS
//! ``` //! ```
pub mod auth; pub mod auth;
pub mod log_layer; pub mod log_layer;
pub mod openai_compat;
pub mod server; pub mod server;
pub mod sse; pub mod sse;
pub mod types; pub mod types;
@@ -32,10 +31,9 @@ use tokio_stream::wrappers::ReceiverStream;
use crate::agent::SessionManager; use crate::agent::SessionManager;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::config::GatewayConfig; use crate::config::GatewayConfig;
use crate::db::Database; use crate::context::ContextManager;
use crate::error::ChannelError; use crate::error::ChannelError;
use crate::extensions::ExtensionManager; use crate::extensions::ExtensionManager;
use crate::orchestrator::job_manager::ContainerJobManager;
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
use crate::workspace::Workspace; use crate::workspace::Workspace;
@@ -72,18 +70,14 @@ impl GatewayChannel {
msg_tx: tokio::sync::RwLock::new(None), msg_tx: tokio::sync::RwLock::new(None),
sse: SseManager::new(), sse: SseManager::new(),
workspace: None, workspace: None,
context_manager: None,
session_manager: None, session_manager: None,
log_broadcaster: None, log_broadcaster: None,
extension_manager: None, extension_manager: None,
tool_registry: None, tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
user_id: config.user_id.clone(), user_id: config.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())), ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
llm_provider: None,
chat_rate_limiter: server::RateLimiter::new(30, 60),
}); });
Self { Self {
@@ -99,18 +93,14 @@ impl GatewayChannel {
msg_tx: tokio::sync::RwLock::new(None), msg_tx: tokio::sync::RwLock::new(None),
sse: SseManager::new(), sse: SseManager::new(),
workspace: self.state.workspace.clone(), workspace: self.state.workspace.clone(),
context_manager: self.state.context_manager.clone(),
session_manager: self.state.session_manager.clone(), session_manager: self.state.session_manager.clone(),
log_broadcaster: self.state.log_broadcaster.clone(), log_broadcaster: self.state.log_broadcaster.clone(),
extension_manager: self.state.extension_manager.clone(), extension_manager: self.state.extension_manager.clone(),
tool_registry: self.state.tool_registry.clone(), tool_registry: self.state.tool_registry.clone(),
store: self.state.store.clone(),
job_manager: self.state.job_manager.clone(),
prompt_queue: self.state.prompt_queue.clone(),
user_id: self.state.user_id.clone(), user_id: self.state.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: self.state.ws_tracker.clone(), ws_tracker: self.state.ws_tracker.clone(),
llm_provider: self.state.llm_provider.clone(),
chat_rate_limiter: server::RateLimiter::new(30, 60),
}; };
mutate(&mut new_state); mutate(&mut new_state);
self.state = Arc::new(new_state); self.state = Arc::new(new_state);
@@ -122,6 +112,12 @@ impl GatewayChannel {
self self
} }
/// Inject the context manager for the jobs API.
pub fn with_context_manager(mut self, cm: Arc<ContextManager>) -> Self {
self.rebuild_state(|s| s.context_manager = Some(cm));
self
}
/// Inject the session manager for thread/session info. /// Inject the session manager for thread/session info.
pub fn with_session_manager(mut self, sm: Arc<SessionManager>) -> Self { pub fn with_session_manager(mut self, sm: Arc<SessionManager>) -> Self {
self.rebuild_state(|s| s.session_manager = Some(sm)); self.rebuild_state(|s| s.session_manager = Some(sm));
@@ -146,40 +142,6 @@ impl GatewayChannel {
self self
} }
/// Inject the database store for sandbox job persistence.
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
self.rebuild_state(|s| s.store = Some(store));
self
}
/// Inject the container job manager for sandbox operations.
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
self.rebuild_state(|s| s.job_manager = Some(jm));
self
}
/// Inject the prompt queue for Claude Code follow-up prompts.
pub fn with_prompt_queue(
mut self,
pq: Arc<
tokio::sync::Mutex<
std::collections::HashMap<
uuid::Uuid,
std::collections::VecDeque<crate::orchestrator::api::PendingPrompt>,
>,
>,
>,
) -> Self {
self.rebuild_state(|s| s.prompt_queue = Some(pq));
self
}
/// Inject the LLM provider for OpenAI-compatible API proxy.
pub fn with_llm_provider(mut self, llm: Arc<dyn crate::llm::LlmProvider>) -> Self {
self.rebuild_state(|s| s.llm_provider = Some(llm));
self
}
/// Get the auth token (for printing to console on startup). /// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str { pub fn auth_token(&self) -> &str {
&self.auth_token &self.auth_token
@@ -211,7 +173,11 @@ impl Channel for GatewayChannel {
), ),
})?; })?;
server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?; let bound_addr =
server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?;
tracing::info!("Web gateway listening on http://{}", bound_addr);
tracing::info!("Auth token: {}", self.auth_token);
Ok(Box::pin(ReceiverStream::new(rx))) Ok(Box::pin(ReceiverStream::new(rx)))
} }
@@ -234,48 +200,17 @@ impl Channel for GatewayChannel {
async fn send_status( async fn send_status(
&self, &self,
status: StatusUpdate, status: StatusUpdate,
metadata: &serde_json::Value, _metadata: &serde_json::Value,
) -> Result<(), ChannelError> { ) -> Result<(), ChannelError> {
let thread_id = metadata
.get("thread_id")
.and_then(|v| v.as_str())
.map(String::from);
let event = match status { let event = match status {
StatusUpdate::Thinking(msg) => SseEvent::Thinking { StatusUpdate::Thinking(msg) => SseEvent::Thinking { message: msg },
message: msg, StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted { name },
thread_id: thread_id.clone(), StatusUpdate::ToolCompleted { name, success } => {
}, SseEvent::ToolCompleted { name, success }
StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted { }
name, StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult { name, preview },
thread_id: thread_id.clone(), StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk { content },
}, StatusUpdate::Status(msg) => SseEvent::Status { message: msg },
StatusUpdate::ToolCompleted { name, success } => SseEvent::ToolCompleted {
name,
success,
thread_id: thread_id.clone(),
},
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult {
name,
preview,
thread_id: thread_id.clone(),
},
StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk {
content,
thread_id: thread_id.clone(),
},
StatusUpdate::Status(msg) => SseEvent::Status {
message: msg,
thread_id: thread_id.clone(),
},
StatusUpdate::JobStarted {
job_id,
title,
browse_url,
} => SseEvent::JobStarted {
job_id,
title,
browse_url,
},
StatusUpdate::ApprovalNeeded { StatusUpdate::ApprovalNeeded {
request_id, request_id,
tool_name, tool_name,
@@ -288,26 +223,6 @@ impl Channel for GatewayChannel {
parameters: serde_json::to_string_pretty(&parameters) parameters: serde_json::to_string_pretty(&parameters)
.unwrap_or_else(|_| parameters.to_string()), .unwrap_or_else(|_| parameters.to_string()),
}, },
StatusUpdate::AuthRequired {
extension_name,
instructions,
auth_url,
setup_url,
} => SseEvent::AuthRequired {
extension_name,
instructions,
auth_url,
setup_url,
},
StatusUpdate::AuthCompleted {
extension_name,
success,
message,
} => SseEvent::AuthCompleted {
extension_name,
success,
message,
},
}; };
self.state.sse.broadcast(event); self.state.sse.broadcast(event);
File diff suppressed because it is too large Load Diff
+102 -1553
View File
File diff suppressed because it is too large Load Diff
+14 -71
View File
@@ -13,15 +13,10 @@ use tokio_stream::wrappers::BroadcastStream;
use crate::channels::web::types::SseEvent; use crate::channels::web::types::SseEvent;
/// Maximum number of concurrent SSE/WebSocket connections.
/// Prevents resource exhaustion from connection flooding.
const MAX_CONNECTIONS: u64 = 100;
/// Manages SSE broadcast to all connected browser tabs. /// Manages SSE broadcast to all connected browser tabs.
pub struct SseManager { pub struct SseManager {
tx: broadcast::Sender<SseEvent>, tx: broadcast::Sender<SseEvent>,
connection_count: Arc<AtomicU64>, connection_count: Arc<AtomicU64>,
max_connections: u64,
} }
impl SseManager { impl SseManager {
@@ -32,7 +27,6 @@ impl SseManager {
Self { Self {
tx, tx,
connection_count: Arc::new(AtomicU64::new(0)), connection_count: Arc::new(AtomicU64::new(0)),
max_connections: MAX_CONNECTIONS,
} }
} }
@@ -51,50 +45,25 @@ impl SseManager {
/// ///
/// Returns a stream of `SseEvent` values and increments/decrements the /// Returns a stream of `SseEvent` values and increments/decrements the
/// connection counter on creation/drop, just like `subscribe()` does for SSE. /// connection counter on creation/drop, just like `subscribe()` does for SSE.
/// pub fn subscribe_raw(&self) -> impl Stream<Item = SseEvent> + Send + 'static + use<> {
/// Returns `None` if the maximum connection limit has been reached.
pub fn subscribe_raw(&self) -> Option<impl Stream<Item = SseEvent> + Send + 'static + use<>> {
// Atomically increment only if below the limit. This prevents
// concurrent callers from overshooting max_connections.
let counter = Arc::clone(&self.connection_count); let counter = Arc::clone(&self.connection_count);
let max = self.max_connections; counter.fetch_add(1, Ordering::Relaxed);
counter
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
if current < max {
Some(current + 1)
} else {
None
}
})
.ok()?;
let rx = self.tx.subscribe(); let rx = self.tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(|result| result.ok()); let stream = BroadcastStream::new(rx).filter_map(|result| result.ok());
Some(CountedStream { CountedStream {
inner: stream, inner: stream,
counter, counter,
}) }
} }
/// Create a new SSE stream for a client connection. /// Create a new SSE stream for a client connection.
///
/// Returns `None` if the maximum connection limit has been reached.
pub fn subscribe( pub fn subscribe(
&self, &self,
) -> Option<Sse<impl Stream<Item = Result<Event, Infallible>> + Send + 'static + use<>>> { ) -> Sse<impl Stream<Item = Result<Event, Infallible>> + Send + 'static + use<>> {
// Atomically increment only if below the limit.
let counter = Arc::clone(&self.connection_count); let counter = Arc::clone(&self.connection_count);
let max = self.max_connections; counter.fetch_add(1, Ordering::Relaxed);
counter
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
if current < max {
Some(current + 1)
} else {
None
}
})
.ok()?;
let rx = self.tx.subscribe(); let rx = self.tx.subscribe();
let stream = BroadcastStream::new(rx) let stream = BroadcastStream::new(rx)
@@ -110,15 +79,7 @@ impl SseManager {
SseEvent::StreamChunk { .. } => "stream_chunk", SseEvent::StreamChunk { .. } => "stream_chunk",
SseEvent::Status { .. } => "status", SseEvent::Status { .. } => "status",
SseEvent::ApprovalNeeded { .. } => "approval_needed", SseEvent::ApprovalNeeded { .. } => "approval_needed",
SseEvent::AuthRequired { .. } => "auth_required",
SseEvent::AuthCompleted { .. } => "auth_completed",
SseEvent::Error { .. } => "error", SseEvent::Error { .. } => "error",
SseEvent::JobStarted { .. } => "job_started",
SseEvent::JobMessage { .. } => "job_message",
SseEvent::JobToolUse { .. } => "job_tool_use",
SseEvent::JobToolResult { .. } => "job_tool_result",
SseEvent::JobStatus { .. } => "job_status",
SseEvent::JobResult { .. } => "job_result",
SseEvent::Heartbeat => "heartbeat", SseEvent::Heartbeat => "heartbeat",
}; };
Ok(Event::default().event(event_type).data(data)) Ok(Event::default().event(event_type).data(data))
@@ -130,10 +91,8 @@ impl SseManager {
counter, counter,
}; };
Some( Sse::new(counted_stream)
Sse::new(counted_stream) .keep_alive(KeepAlive::new().interval(Duration::from_secs(30)).text(""))
.keep_alive(KeepAlive::new().interval(Duration::from_secs(30)).text("")),
)
} }
} }
@@ -193,14 +152,13 @@ mod tests {
manager.broadcast(SseEvent::Status { manager.broadcast(SseEvent::Status {
message: "test".to_string(), message: "test".to_string(),
thread_id: None,
}); });
let event = rx.next().await; let event = rx.next().await;
assert!(event.is_some()); assert!(event.is_some());
let event = event.unwrap().unwrap(); let event = event.unwrap().unwrap();
match event { match event {
SseEvent::Status { message, .. } => assert_eq!(message, "test"), SseEvent::Status { message } => assert_eq!(message, "test"),
_ => panic!("unexpected event type"), _ => panic!("unexpected event type"),
} }
} }
@@ -208,18 +166,17 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_subscribe_raw_receives_events() { async fn test_subscribe_raw_receives_events() {
let manager = SseManager::new(); let manager = SseManager::new();
let mut stream = Box::pin(manager.subscribe_raw().expect("should subscribe")); let mut stream = Box::pin(manager.subscribe_raw());
assert_eq!(manager.connection_count(), 1); assert_eq!(manager.connection_count(), 1);
manager.broadcast(SseEvent::Thinking { manager.broadcast(SseEvent::Thinking {
message: "working".to_string(), message: "working".to_string(),
thread_id: None,
}); });
let event = stream.next().await.unwrap(); let event = stream.next().await.unwrap();
match event { match event {
SseEvent::Thinking { message, .. } => assert_eq!(message, "working"), SseEvent::Thinking { message } => assert_eq!(message, "working"),
_ => panic!("Expected Thinking event"), _ => panic!("Expected Thinking event"),
} }
} }
@@ -228,7 +185,7 @@ mod tests {
async fn test_subscribe_raw_decrements_on_drop() { async fn test_subscribe_raw_decrements_on_drop() {
let manager = SseManager::new(); let manager = SseManager::new();
{ {
let _stream = Box::pin(manager.subscribe_raw().expect("should subscribe")); let _stream = Box::pin(manager.subscribe_raw());
assert_eq!(manager.connection_count(), 1); assert_eq!(manager.connection_count(), 1);
} }
// Stream dropped, counter should decrement // Stream dropped, counter should decrement
@@ -238,8 +195,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_subscribe_raw_multiple_subscribers() { async fn test_subscribe_raw_multiple_subscribers() {
let manager = SseManager::new(); let manager = SseManager::new();
let mut s1 = Box::pin(manager.subscribe_raw().expect("should subscribe")); let mut s1 = Box::pin(manager.subscribe_raw());
let mut s2 = Box::pin(manager.subscribe_raw().expect("should subscribe")); let mut s2 = Box::pin(manager.subscribe_raw());
assert_eq!(manager.connection_count(), 2); assert_eq!(manager.connection_count(), 2);
manager.broadcast(SseEvent::Heartbeat); manager.broadcast(SseEvent::Heartbeat);
@@ -254,18 +211,4 @@ mod tests {
drop(s2); drop(s2);
assert_eq!(manager.connection_count(), 0); assert_eq!(manager.connection_count(), 0);
} }
#[tokio::test]
async fn test_subscribe_raw_rejects_over_limit() {
let mut manager = SseManager::new();
manager.max_connections = 2; // Low limit for testing
let _s1 = Box::pin(manager.subscribe_raw().expect("first should succeed"));
let _s2 = Box::pin(manager.subscribe_raw().expect("second should succeed"));
assert_eq!(manager.connection_count(), 2);
// Third should be rejected
assert!(manager.subscribe_raw().is_none());
assert!(manager.subscribe().is_none());
}
} }
File diff suppressed because it is too large Load Diff
+7 -82
View File
@@ -10,19 +10,12 @@
<body> <body>
<!-- Auth Screen --> <!-- Auth Screen -->
<div id="auth-screen"> <div id="auth-screen">
<div class="auth-card-login"> <h1>IronClaw</h1>
<div class="auth-brand"> <div class="auth-form">
<h1>IronClaw</h1> <input type="password" id="token-input" placeholder="Auth token" autofocus>
<p class="auth-tagline">Secure AI Assistant</p> <button onclick="authenticate()">Connect</button>
</div>
<div class="auth-form">
<label for="token-input">Gateway Token</label>
<input type="password" id="token-input" placeholder="Paste your auth token" autofocus>
<button onclick="authenticate()">Connect</button>
</div>
<div id="auth-error"></div>
<p class="auth-hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
</div> </div>
<div id="auth-error"></div>
</div> </div>
<!-- Main App (hidden until authenticated) --> <!-- Main App (hidden until authenticated) -->
@@ -33,33 +26,16 @@
<button data-tab="memory">Memory</button> <button data-tab="memory">Memory</button>
<button data-tab="jobs">Jobs</button> <button data-tab="jobs">Jobs</button>
<button data-tab="logs">Logs</button> <button data-tab="logs">Logs</button>
<button data-tab="routines">Routines</button>
<button data-tab="extensions">Extensions</button> <button data-tab="extensions">Extensions</button>
<div class="spacer"></div> <div class="spacer"></div>
<div class="status" id="gateway-status-trigger"> <div class="status">
<div class="dot" id="sse-dot"></div> <div class="dot" id="sse-dot"></div>
<span id="sse-status">Connected</span> <span id="sse-status">Connected</span>
<div class="gateway-popover" id="gateway-popover"></div>
</div> </div>
</div> </div>
<!-- Chat Tab --> <!-- Chat Tab -->
<div class="tab-panel active" id="tab-chat"> <div class="tab-panel active" id="tab-chat">
<div class="thread-sidebar" id="thread-sidebar">
<div class="thread-sidebar-header">
<span>Threads</span>
<button class="thread-new-btn" onclick="createNewThread()" title="New thread (Ctrl/Cmd+N)">+</button>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" title="Toggle sidebar">&laquo;</button>
</div>
<div class="assistant-item" id="assistant-thread" onclick="switchToAssistant()">
<span class="assistant-label">Assistant</span>
<span class="assistant-meta" id="assistant-meta"></span>
</div>
<div class="threads-section-header">
<span>Conversations</span>
</div>
<div class="thread-list" id="thread-list"></div>
</div>
<div class="chat-container"> <div class="chat-container">
<div class="chat-messages" id="chat-messages"></div> <div class="chat-messages" id="chat-messages"></div>
<div class="chat-status" id="chat-status"></div> <div class="chat-status" id="chat-status"></div>
@@ -80,20 +56,10 @@
<div class="memory-tree" id="memory-tree"></div> <div class="memory-tree" id="memory-tree"></div>
</div> </div>
<div class="memory-content"> <div class="memory-content">
<div class="memory-breadcrumb" id="memory-breadcrumb"> <div class="memory-breadcrumb" id="memory-breadcrumb">workspace /</div>
<span id="memory-breadcrumb-path">workspace /</span>
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()">Edit</button>
</div>
<div class="memory-viewer" id="memory-viewer"> <div class="memory-viewer" id="memory-viewer">
<div class="empty">Select a file to view its contents</div> <div class="empty">Select a file to view its contents</div>
</div> </div>
<div class="memory-editor" id="memory-editor" style="display:none">
<textarea id="memory-edit-textarea"></textarea>
<div class="memory-editor-actions">
<button class="btn-save" onclick="saveMemoryEdit()">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()">Cancel</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -107,7 +73,6 @@
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Title</th> <th>Title</th>
<th>Source</th>
<th>Status</th> <th>Status</th>
<th>Created</th> <th>Created</th>
<th>Actions</th> <th>Actions</th>
@@ -139,48 +104,9 @@
</div> </div>
</div> </div>
<!-- Routines Tab -->
<div class="tab-panel" id="tab-routines">
<div class="routines-container">
<div class="routines-summary" id="routines-summary"></div>
<table class="routines-table" id="routines-table">
<thead>
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Action</th>
<th>Last Run</th>
<th>Next Run</th>
<th>Runs</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="routines-tbody"></tbody>
</table>
<div class="empty-state" id="routines-empty" style="display:none">
No routines configured. Ask the assistant to create one.
</div>
<div class="routine-detail" id="routine-detail" style="display:none"></div>
</div>
</div>
<!-- Extensions Tab --> <!-- Extensions Tab -->
<div class="tab-panel" id="tab-extensions"> <div class="tab-panel" id="tab-extensions">
<div class="extensions-container"> <div class="extensions-container">
<div class="extensions-section">
<h3>Install Extension</h3>
<div class="ext-install-form" id="ext-install-form">
<input type="text" id="ext-install-name" placeholder="Extension name (required)">
<input type="text" id="ext-install-url" placeholder="URL (optional)">
<select id="ext-install-kind">
<option value="mcp_server">MCP Server</option>
<option value="wasm_tool">WASM Tool</option>
<option value="wasm_channel">WASM Channel</option>
</select>
<button onclick="installExtension()">Install</button>
</div>
</div>
<div class="extensions-section"> <div class="extensions-section">
<h3>Installed Extensions</h3> <h3>Installed Extensions</h3>
<div class="extensions-list" id="extensions-list"> <div class="extensions-list" id="extensions-list">
@@ -204,7 +130,6 @@
</div> </div>
</div> </div>
<div id="toasts"></div>
<script src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
+10 -414
View File
@@ -24,17 +24,10 @@ pub struct ThreadInfo {
pub turn_count: usize, pub turn_count: usize,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_type: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ThreadListResponse { pub struct ThreadListResponse {
/// The pinned assistant thread (always present after first load).
pub assistant_thread: Option<ThreadInfo>,
/// Regular conversation threads.
pub threads: Vec<ThreadInfo>, pub threads: Vec<ThreadInfo>,
pub active_thread: Option<Uuid>, pub active_thread: Option<Uuid>,
} }
@@ -61,12 +54,6 @@ pub struct ToolCallInfo {
pub struct HistoryResponse { pub struct HistoryResponse {
pub thread_id: Uuid, pub thread_id: Uuid,
pub turns: Vec<TurnInfo>, pub turns: Vec<TurnInfo>,
/// Whether there are older messages available.
#[serde(default)]
pub has_more: bool,
/// Cursor for the next page (ISO8601 timestamp of the oldest message returned).
#[serde(skip_serializing_if = "Option::is_none")]
pub oldest_timestamp: Option<String>,
} }
// --- Approval --- // --- Approval ---
@@ -76,8 +63,6 @@ pub struct ApprovalRequest {
pub request_id: String, pub request_id: String,
/// "approve", "always", or "deny" /// "approve", "always", or "deny"
pub action: String, pub action: String,
/// Thread that owns the pending approval (so the agent loop finds the right session).
pub thread_id: Option<String>,
} }
// --- SSE Event Types --- // --- SSE Event Types ---
@@ -88,49 +73,17 @@ pub enum SseEvent {
#[serde(rename = "response")] #[serde(rename = "response")]
Response { content: String, thread_id: String }, Response { content: String, thread_id: String },
#[serde(rename = "thinking")] #[serde(rename = "thinking")]
Thinking { Thinking { message: String },
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "tool_started")] #[serde(rename = "tool_started")]
ToolStarted { ToolStarted { name: String },
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "tool_completed")] #[serde(rename = "tool_completed")]
ToolCompleted { ToolCompleted { name: String, success: bool },
name: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "tool_result")] #[serde(rename = "tool_result")]
ToolResult { ToolResult { name: String, preview: String },
name: String,
preview: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "stream_chunk")] #[serde(rename = "stream_chunk")]
StreamChunk { StreamChunk { content: String },
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "status")] #[serde(rename = "status")]
Status { Status { message: String },
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "job_started")]
JobStarted {
job_id: String,
title: String,
browse_url: String,
},
#[serde(rename = "approval_needed")] #[serde(rename = "approval_needed")]
ApprovalNeeded { ApprovalNeeded {
request_id: String, request_id: String,
@@ -138,59 +91,10 @@ pub enum SseEvent {
description: String, description: String,
parameters: String, parameters: String,
}, },
#[serde(rename = "auth_required")]
AuthRequired {
extension_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
auth_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
setup_url: Option<String>,
},
#[serde(rename = "auth_completed")]
AuthCompleted {
extension_name: String,
success: bool,
message: String,
},
#[serde(rename = "error")] #[serde(rename = "error")]
Error { Error { message: String },
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "heartbeat")] #[serde(rename = "heartbeat")]
Heartbeat, Heartbeat,
// Sandbox job streaming events (worker + Claude Code bridge)
#[serde(rename = "job_message")]
JobMessage {
job_id: String,
role: String,
content: String,
},
#[serde(rename = "job_tool_use")]
JobToolUse {
job_id: String,
tool_name: String,
input: serde_json::Value,
},
#[serde(rename = "job_tool_result")]
JobToolResult {
job_id: String,
tool_name: String,
output: String,
},
#[serde(rename = "job_status")]
JobStatus { job_id: String, message: String },
#[serde(rename = "job_result")]
JobResult {
job_id: String,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
},
} }
// --- Memory --- // --- Memory ---
@@ -284,54 +188,6 @@ pub struct JobSummaryResponse {
pub stuck: usize, pub stuck: usize,
} }
#[derive(Debug, Serialize)]
pub struct JobDetailResponse {
pub id: Uuid,
pub title: String,
pub description: String,
pub state: String,
pub user_id: String,
pub created_at: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub elapsed_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub browse_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_mode: Option<String>,
pub transitions: Vec<TransitionInfo>,
}
// --- Project Files ---
#[derive(Debug, Serialize)]
pub struct ProjectFileEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
}
#[derive(Debug, Serialize)]
pub struct ProjectFilesResponse {
pub entries: Vec<ProjectFileEntry>,
}
#[derive(Debug, Serialize)]
pub struct ProjectFileReadResponse {
pub path: String,
pub content: String,
}
#[derive(Debug, Serialize)]
pub struct TransitionInfo {
pub from: String,
pub to: String,
pub timestamp: String,
pub reason: Option<String>,
}
// --- Extensions --- // --- Extensions ---
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -406,21 +262,6 @@ impl ActionResponse {
} }
} }
// --- Auth Token ---
/// Request to submit an auth token for an extension (dedicated endpoint).
#[derive(Debug, Deserialize)]
pub struct AuthTokenRequest {
pub extension_name: String,
pub token: String,
}
/// Request to cancel an in-progress auth flow.
#[derive(Debug, Deserialize)]
pub struct AuthCancelRequest {
pub extension_name: String,
}
// --- WebSocket --- // --- WebSocket ---
/// Message sent by a WebSocket client to the server. /// Message sent by a WebSocket client to the server.
@@ -439,18 +280,7 @@ pub enum WsClientMessage {
request_id: String, request_id: String,
/// "approve", "always", or "deny" /// "approve", "always", or "deny"
action: String, action: String,
/// Thread that owns the pending approval.
thread_id: Option<String>,
}, },
/// Submit an auth token for an extension (bypasses message pipeline).
#[serde(rename = "auth_token")]
AuthToken {
extension_name: String,
token: String,
},
/// Cancel an in-progress auth flow.
#[serde(rename = "auth_cancel")]
AuthCancel { extension_name: String },
/// Client heartbeat ping. /// Client heartbeat ping.
#[serde(rename = "ping")] #[serde(rename = "ping")]
Ping, Ping,
@@ -484,20 +314,12 @@ impl WsServerMessage {
SseEvent::Thinking { .. } => "thinking", SseEvent::Thinking { .. } => "thinking",
SseEvent::ToolStarted { .. } => "tool_started", SseEvent::ToolStarted { .. } => "tool_started",
SseEvent::ToolCompleted { .. } => "tool_completed", SseEvent::ToolCompleted { .. } => "tool_completed",
SseEvent::ToolResult { .. } => "tool_result",
SseEvent::StreamChunk { .. } => "stream_chunk", SseEvent::StreamChunk { .. } => "stream_chunk",
SseEvent::Status { .. } => "status", SseEvent::Status { .. } => "status",
SseEvent::JobStarted { .. } => "job_started",
SseEvent::ApprovalNeeded { .. } => "approval_needed", SseEvent::ApprovalNeeded { .. } => "approval_needed",
SseEvent::AuthRequired { .. } => "auth_required",
SseEvent::AuthCompleted { .. } => "auth_completed",
SseEvent::Error { .. } => "error", SseEvent::Error { .. } => "error",
SseEvent::ToolResult { .. } => "tool_result",
SseEvent::Heartbeat => "heartbeat", SseEvent::Heartbeat => "heartbeat",
SseEvent::JobMessage { .. } => "job_message",
SseEvent::JobToolUse { .. } => "job_tool_use",
SseEvent::JobToolResult { .. } => "job_tool_result",
SseEvent::JobStatus { .. } => "job_status",
SseEvent::JobResult { .. } => "job_result",
}; };
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null); let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
WsServerMessage::Event { WsServerMessage::Event {
@@ -507,96 +329,6 @@ impl WsServerMessage {
} }
} }
// --- Routines ---
#[derive(Debug, Serialize)]
pub struct RoutineInfo {
pub id: Uuid,
pub name: String,
pub description: String,
pub enabled: bool,
pub trigger_type: String,
pub trigger_summary: String,
pub action_type: String,
pub last_run_at: Option<String>,
pub next_fire_at: Option<String>,
pub run_count: u64,
pub consecutive_failures: u32,
pub status: String,
}
#[derive(Debug, Serialize)]
pub struct RoutineListResponse {
pub routines: Vec<RoutineInfo>,
}
#[derive(Debug, Serialize)]
pub struct RoutineSummaryResponse {
pub total: u64,
pub enabled: u64,
pub disabled: u64,
pub failing: u64,
pub runs_today: u64,
}
#[derive(Debug, Serialize)]
pub struct RoutineDetailResponse {
pub id: Uuid,
pub name: String,
pub description: String,
pub enabled: bool,
pub trigger: serde_json::Value,
pub action: serde_json::Value,
pub guardrails: serde_json::Value,
pub notify: serde_json::Value,
pub last_run_at: Option<String>,
pub next_fire_at: Option<String>,
pub run_count: u64,
pub consecutive_failures: u32,
pub created_at: String,
pub recent_runs: Vec<RoutineRunInfo>,
}
#[derive(Debug, Serialize)]
pub struct RoutineRunInfo {
pub id: Uuid,
pub trigger_type: String,
pub started_at: String,
pub completed_at: Option<String>,
pub status: String,
pub result_summary: Option<String>,
pub tokens_used: Option<i32>,
}
// --- Settings ---
#[derive(Debug, Serialize)]
pub struct SettingResponse {
pub key: String,
pub value: serde_json::Value,
pub updated_at: String,
}
#[derive(Debug, Serialize)]
pub struct SettingsListResponse {
pub settings: Vec<SettingResponse>,
}
#[derive(Debug, Deserialize)]
pub struct SettingWriteRequest {
pub value: serde_json::Value,
}
#[derive(Debug, Deserialize)]
pub struct SettingsImportRequest {
pub settings: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct SettingsExportResponse {
pub settings: std::collections::HashMap<String, serde_json::Value>,
}
// --- Health --- // --- Health ---
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -639,36 +371,12 @@ mod tests {
#[test] #[test]
fn test_ws_client_approval_parse() { fn test_ws_client_approval_parse() {
let json = let json = r#"{"type":"approval","request_id":"abc-123","action":"approve"}"#;
r#"{"type":"approval","request_id":"abc-123","action":"approve","thread_id":"t1"}"#;
let msg: WsClientMessage = serde_json::from_str(json).unwrap(); let msg: WsClientMessage = serde_json::from_str(json).unwrap();
match msg { match msg {
WsClientMessage::Approval { WsClientMessage::Approval { request_id, action } => {
request_id,
action,
thread_id,
} => {
assert_eq!(request_id, "abc-123"); assert_eq!(request_id, "abc-123");
assert_eq!(action, "approve"); assert_eq!(action, "approve");
assert_eq!(thread_id.as_deref(), Some("t1"));
}
_ => panic!("Expected Approval variant"),
}
}
#[test]
fn test_ws_client_approval_parse_no_thread() {
let json = r#"{"type":"approval","request_id":"abc-123","action":"deny"}"#;
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
match msg {
WsClientMessage::Approval {
request_id,
action,
thread_id,
} => {
assert_eq!(request_id, "abc-123");
assert_eq!(action, "deny");
assert!(thread_id.is_none());
} }
_ => panic!("Expected Approval variant"), _ => panic!("Expected Approval variant"),
} }
@@ -729,7 +437,6 @@ mod tests {
fn test_ws_server_from_sse_thinking() { fn test_ws_server_from_sse_thinking() {
let sse = SseEvent::Thinking { let sse = SseEvent::Thinking {
message: "reasoning...".to_string(), message: "reasoning...".to_string(),
thread_id: None,
}; };
let ws = WsServerMessage::from_sse_event(&sse); let ws = WsServerMessage::from_sse_event(&sse);
match ws { match ws {
@@ -770,115 +477,4 @@ mod tests {
_ => panic!("Expected Event variant"), _ => panic!("Expected Event variant"),
} }
} }
// ---- Auth type tests ----
#[test]
fn test_ws_client_auth_token_parse() {
let json = r#"{"type":"auth_token","extension_name":"notion","token":"sk-123"}"#;
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
match msg {
WsClientMessage::AuthToken {
extension_name,
token,
} => {
assert_eq!(extension_name, "notion");
assert_eq!(token, "sk-123");
}
_ => panic!("Expected AuthToken variant"),
}
}
#[test]
fn test_ws_client_auth_cancel_parse() {
let json = r#"{"type":"auth_cancel","extension_name":"notion"}"#;
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
match msg {
WsClientMessage::AuthCancel { extension_name } => {
assert_eq!(extension_name, "notion");
}
_ => panic!("Expected AuthCancel variant"),
}
}
#[test]
fn test_sse_auth_required_serialize() {
let event = SseEvent::AuthRequired {
extension_name: "notion".to_string(),
instructions: Some("Get your token from...".to_string()),
auth_url: None,
setup_url: Some("https://notion.so/integrations".to_string()),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"], "auth_required");
assert_eq!(parsed["extension_name"], "notion");
assert_eq!(parsed["instructions"], "Get your token from...");
assert!(parsed.get("auth_url").is_none());
assert_eq!(parsed["setup_url"], "https://notion.so/integrations");
}
#[test]
fn test_sse_auth_completed_serialize() {
let event = SseEvent::AuthCompleted {
extension_name: "notion".to_string(),
success: true,
message: "notion authenticated (3 tools loaded)".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"], "auth_completed");
assert_eq!(parsed["extension_name"], "notion");
assert_eq!(parsed["success"], true);
}
#[test]
fn test_ws_server_from_sse_auth_required() {
let sse = SseEvent::AuthRequired {
extension_name: "openai".to_string(),
instructions: Some("Enter API key".to_string()),
auth_url: None,
setup_url: None,
};
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
WsServerMessage::Event { event_type, data } => {
assert_eq!(event_type, "auth_required");
assert_eq!(data["extension_name"], "openai");
}
_ => panic!("Expected Event variant"),
}
}
#[test]
fn test_ws_server_from_sse_auth_completed() {
let sse = SseEvent::AuthCompleted {
extension_name: "slack".to_string(),
success: false,
message: "Invalid token".to_string(),
};
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
WsServerMessage::Event { event_type, data } => {
assert_eq!(event_type, "auth_completed");
assert_eq!(data["success"], false);
}
_ => panic!("Expected Event variant"),
}
}
#[test]
fn test_auth_token_request_deserialize() {
let json = r#"{"extension_name":"telegram","token":"bot12345"}"#;
let req: AuthTokenRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.extension_name, "telegram");
assert_eq!(req.token, "bot12345");
}
#[test]
fn test_auth_cancel_request_deserialize() {
let json = r#"{"extension_name":"telegram"}"#;
let req: AuthCancelRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.extension_name, "telegram");
}
} }
+5 -86
View File
@@ -71,17 +71,8 @@ pub async fn handle_ws_connection(socket: WebSocket, state: Arc<GatewayState>) {
} }
let tracker_for_drop = state.ws_tracker.clone(); let tracker_for_drop = state.ws_tracker.clone();
// Subscribe to broadcast events (same source as SSE). // Subscribe to broadcast events (same source as SSE)
// Reject if we've hit the connection limit. let mut event_stream = Box::pin(state.sse.subscribe_raw());
let Some(raw_stream) = state.sse.subscribe_raw() else {
tracing::warn!("WebSocket rejected: too many connections");
// Decrement the WS tracker we already incremented above.
if let Some(ref tracker) = tracker_for_drop {
tracker.decrement();
}
return;
};
let mut event_stream = Box::pin(raw_stream);
// Channel for the sender task to receive messages from both // Channel for the sender task to receive messages from both
// the broadcast stream and any direct sends (like Pong) // the broadcast stream and any direct sends (like Pong)
@@ -179,11 +170,7 @@ async fn handle_client_message(
.await; .await;
} }
} }
WsClientMessage::Approval { WsClientMessage::Approval { request_id, action } => {
request_id,
action,
thread_id,
} => {
let (approved, always) = match action.as_str() { let (approved, always) = match action.as_str() {
"approve" => (true, false), "approve" => (true, false),
"always" => (true, true), "always" => (true, true),
@@ -227,71 +214,12 @@ async fn handle_client_message(
} }
}; };
let mut msg = IncomingMessage::new("gateway", user_id, content); let msg = IncomingMessage::new("gateway", user_id, content);
if let Some(ref tid) = thread_id {
msg = msg.with_thread(tid);
}
let tx_guard = state.msg_tx.read().await; let tx_guard = state.msg_tx.read().await;
if let Some(ref tx) = *tx_guard { if let Some(ref tx) = *tx_guard {
let _ = tx.send(msg).await; let _ = tx.send(msg).await;
} }
} }
WsClientMessage::AuthToken {
extension_name,
token,
} => {
if let Some(ref ext_mgr) = state.extension_manager {
match ext_mgr.auth(&extension_name, Some(&token)).await {
Ok(result) if result.status == "authenticated" => {
let msg = match ext_mgr.activate(&extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
extension_name, e
),
};
crate::channels::web::server::clear_auth_mode(state).await;
state
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthCompleted {
extension_name,
success: true,
message: msg,
});
}
Ok(result) => {
state
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
extension_name,
instructions: result.instructions,
auth_url: result.auth_url,
setup_url: result.setup_url,
});
}
Err(e) => {
let _ = direct_tx
.send(WsServerMessage::Error {
message: format!("Auth failed: {}", e),
})
.await;
}
}
} else {
let _ = direct_tx
.send(WsServerMessage::Error {
message: "Extension manager not available".to_string(),
})
.await;
}
}
WsClientMessage::AuthCancel { .. } => {
crate::channels::web::server::clear_auth_mode(state).await;
}
WsClientMessage::Ping => { WsClientMessage::Ping => {
let _ = direct_tx.send(WsServerMessage::Pong).await; let _ = direct_tx.send(WsServerMessage::Pong).await;
} }
@@ -400,7 +328,6 @@ mod tests {
WsClientMessage::Approval { WsClientMessage::Approval {
request_id: request_id.to_string(), request_id: request_id.to_string(),
action: "approve".to_string(), action: "approve".to_string(),
thread_id: Some("thread-42".to_string()),
}, },
&state, &state,
"user1", "user1",
@@ -411,8 +338,6 @@ mod tests {
let incoming = agent_rx.recv().await.unwrap(); let incoming = agent_rx.recv().await.unwrap();
// The content should be a serialized ExecApproval // The content should be a serialized ExecApproval
assert!(incoming.content.contains("ExecApproval")); assert!(incoming.content.contains("ExecApproval"));
// Thread should be forwarded onto the IncomingMessage.
assert_eq!(incoming.thread_id.as_deref(), Some("thread-42"));
} }
#[tokio::test] #[tokio::test]
@@ -424,7 +349,6 @@ mod tests {
WsClientMessage::Approval { WsClientMessage::Approval {
request_id: Uuid::new_v4().to_string(), request_id: Uuid::new_v4().to_string(),
action: "maybe".to_string(), action: "maybe".to_string(),
thread_id: None,
}, },
&state, &state,
"user1", "user1",
@@ -450,7 +374,6 @@ mod tests {
WsClientMessage::Approval { WsClientMessage::Approval {
request_id: "not-a-uuid".to_string(), request_id: "not-a-uuid".to_string(),
action: "approve".to_string(), action: "approve".to_string(),
thread_id: None,
}, },
&state, &state,
"user1", "user1",
@@ -475,18 +398,14 @@ mod tests {
msg_tx: tokio::sync::RwLock::new(msg_tx), msg_tx: tokio::sync::RwLock::new(msg_tx),
sse: SseManager::new(), sse: SseManager::new(),
workspace: None, workspace: None,
context_manager: None,
session_manager: None, session_manager: None,
log_broadcaster: None, log_broadcaster: None,
extension_manager: None, extension_manager: None,
tool_registry: None, tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
user_id: "test".to_string(), user_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())), ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
} }
} }
} }
+58 -96
View File
@@ -1,9 +1,6 @@
//! Configuration management CLI commands. //! Configuration management CLI commands.
//! //!
//! Commands for viewing and modifying settings. //! Commands for viewing and modifying settings.
//! Settings are stored in the database (env > DB > default).
use std::sync::Arc;
use clap::Subcommand; use clap::Subcommand;
@@ -39,81 +36,41 @@ pub enum ConfigCommand {
path: String, path: String,
}, },
/// Show the settings storage info /// Show the settings file path
Path, Path,
} }
/// Run a config command. /// Run a config command.
/// pub fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
/// Connects to the database to read/write settings. Falls back to disk
/// if the database is not available.
pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
// Try to connect to the DB for settings access
let db: Option<Arc<dyn crate::db::Database>> = match connect_db().await {
Ok(d) => Some(d),
Err(e) => {
eprintln!(
"Warning: Could not connect to database ({}), using disk fallback",
e
);
None
}
};
let db_ref = db.as_deref();
match cmd { match cmd {
ConfigCommand::List { filter } => list_settings(db_ref, filter).await, ConfigCommand::List { filter } => list_settings(filter),
ConfigCommand::Get { path } => get_setting(db_ref, &path).await, ConfigCommand::Get { path } => get_setting(&path),
ConfigCommand::Set { path, value } => set_setting(db_ref, &path, &value).await, ConfigCommand::Set { path, value } => set_setting(&path, &value),
ConfigCommand::Reset { path } => reset_setting(db_ref, &path).await, ConfigCommand::Reset { path } => reset_setting(&path),
ConfigCommand::Path => show_path(db_ref.is_some()), ConfigCommand::Path => show_path(),
} }
} }
/// Bootstrap a DB connection for config commands (backend-agnostic).
async fn connect_db() -> anyhow::Result<Arc<dyn crate::db::Database>> {
let config = crate::config::Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
crate::db::connect_from_config(&config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))
}
const DEFAULT_USER_ID: &str = "default";
/// Load settings: DB if available, else disk.
async fn load_settings(store: Option<&dyn crate::db::Database>) -> Settings {
if let Some(store) = store {
match store.get_all_settings(DEFAULT_USER_ID).await {
Ok(map) if !map.is_empty() => return Settings::from_db_map(&map),
_ => {}
}
}
Settings::default()
}
/// List all settings. /// List all settings.
async fn list_settings( fn list_settings(filter: Option<String>) -> anyhow::Result<()> {
store: Option<&dyn crate::db::Database>, let settings = Settings::load();
filter: Option<String>,
) -> anyhow::Result<()> {
let settings = load_settings(store).await;
let all = settings.list(); let all = settings.list();
// Find the longest key for alignment
let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0); let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
let source = if store.is_some() { "database" } else { "disk" }; println!("Settings:");
println!("Settings (source: {}):", source);
println!(); println!();
for (key, value) in all { for (key, value) in all {
if let Some(ref f) = filter // Skip if filter is set and doesn't match
&& !key.starts_with(f) if let Some(ref f) = filter {
{ if !key.starts_with(f) {
continue; continue;
}
} }
// Truncate long values for display
let display_value = if value.len() > 60 { let display_value = if value.len() > 60 {
format!("{}...", &value[..57]) format!("{}...", &value[..57])
} else { } else {
@@ -127,8 +84,8 @@ async fn list_settings(
} }
/// Get a specific setting. /// Get a specific setting.
async fn get_setting(store: Option<&dyn crate::db::Database>, path: &str) -> anyhow::Result<()> { fn get_setting(path: &str) -> anyhow::Result<()> {
let settings = load_settings(store).await; let settings = Settings::load();
match settings.get(path) { match settings.get(path) {
Some(value) => { Some(value) => {
@@ -142,63 +99,68 @@ async fn get_setting(store: Option<&dyn crate::db::Database>, path: &str) -> any
} }
/// Set a setting value. /// Set a setting value.
async fn set_setting( fn set_setting(path: &str, value: &str) -> anyhow::Result<()> {
store: Option<&dyn crate::db::Database>, let mut settings = Settings::load();
path: &str,
value: &str,
) -> anyhow::Result<()> {
let mut settings = load_settings(store).await;
// Try to set the value
settings settings
.set(path, value) .set(path, value)
.map_err(|e| anyhow::anyhow!("{}", e))?; .map_err(|e| anyhow::anyhow!("{}", e))?;
let store = store.ok_or_else(|| { // Save to disk
anyhow::anyhow!("Database connection required to save settings. Check DATABASE_URL.") settings.save()?;
})?;
let json_value = match serde_json::from_str::<serde_json::Value>(value) {
Ok(v) => v,
Err(_) => serde_json::Value::String(value.to_string()),
};
store
.set_setting(DEFAULT_USER_ID, path, &json_value)
.await
.map_err(|e| anyhow::anyhow!("Failed to save to database: {}", e))?;
println!("Set {} = {}", path, value); println!("Set {} = {}", path, value);
Ok(()) Ok(())
} }
/// Reset a setting to default. /// Reset a setting to default.
async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> anyhow::Result<()> { fn reset_setting(path: &str) -> anyhow::Result<()> {
let mut settings = Settings::load();
// Get the default value for display
let default = Settings::default(); let default = Settings::default();
let default_value = default let default_value = default
.get(path) .get(path)
.ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?; .ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
let store = store.ok_or_else(|| { // Reset it
anyhow::anyhow!("Database connection required to reset settings. Check DATABASE_URL.") settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?;
})?;
store // Save to disk
.delete_setting(DEFAULT_USER_ID, path) settings.save()?;
.await
.map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?;
println!("Reset {} to default: {}", path, default_value); println!("Reset {} to default: {}", path, default_value);
Ok(()) Ok(())
} }
/// Show the settings storage info. /// Show the settings file path.
fn show_path(has_db: bool) -> anyhow::Result<()> { fn show_path() -> anyhow::Result<()> {
if has_db { let path = Settings::default_path();
println!("Settings stored in: database (settings table)"); println!("{}", path.display());
if path.exists() {
let metadata = std::fs::metadata(&path)?;
println!(" Size: {} bytes", metadata.len());
if let Ok(modified) = metadata.modified() {
use std::time::SystemTime;
let duration = SystemTime::now()
.duration_since(modified)
.unwrap_or_default();
let secs = duration.as_secs();
if secs < 60 {
println!(" Modified: {} seconds ago", secs);
} else if secs < 3600 {
println!(" Modified: {} minutes ago", secs / 60);
} else if secs < 86400 {
println!(" Modified: {} hours ago", secs / 3600);
} else {
println!(" Modified: {} days ago", secs / 86400);
}
}
} else { } else {
println!("Settings stored in: PostgreSQL (not connected, using defaults)"); println!(" (does not exist, using defaults)");
} }
println!(
"Env config: {}",
crate::bootstrap::ironclaw_env_path().display()
);
Ok(()) Ok(())
} }
+745
View File
@@ -0,0 +1,745 @@
//! NEAR key management CLI commands.
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use clap::Subcommand;
use tokio::fs;
use crate::config::Config;
use crate::history::Store;
use crate::keys::KeyManager;
use crate::keys::policy::{ChainSigRule, FunctionCallRule, PolicyConfig, SignatureDomain};
use crate::keys::types::{
AccessKeyPermission, NearAccountId, NearNetwork, format_yocto, parse_near_amount,
};
use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore};
/// Default policy config path.
fn default_policy_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".ironclaw").join("key_policy.json"))
.unwrap_or_else(|| PathBuf::from(".ironclaw/key_policy.json"))
}
#[derive(Subcommand, Debug, Clone)]
pub enum KeyCommand {
/// Generate a new ed25519 keypair
Generate {
/// Label for the key (used to reference it later)
label: String,
/// NEAR account ID this key belongs to
#[arg(long)]
account: String,
/// Permission level: "full-access" or "function-call"
#[arg(long, default_value = "function-call")]
permission: String,
/// Contract to scope function-call keys to
#[arg(long)]
receiver: Option<String>,
/// Comma-separated method names (empty = all methods on contract)
#[arg(long)]
methods: Option<String>,
/// Allowance in NEAR (e.g., "1.5")
#[arg(long)]
allowance: Option<String>,
/// Network: mainnet, testnet, or RPC URL
#[arg(long, default_value = "testnet")]
network: String,
},
/// Import an existing secret key
Import {
/// Label for the key
label: String,
/// NEAR account ID
#[arg(long)]
account: String,
/// Permission level
#[arg(long, default_value = "function-call")]
permission: String,
/// Contract to scope function-call keys to
#[arg(long)]
receiver: Option<String>,
/// Comma-separated method names
#[arg(long)]
methods: Option<String>,
/// Allowance in NEAR
#[arg(long)]
allowance: Option<String>,
/// Network
#[arg(long, default_value = "testnet")]
network: String,
},
/// List all stored keys
List {
/// Show verbose details
#[arg(short, long)]
verbose: bool,
},
/// Show information about a key
Info {
/// Key label
label: String,
},
/// Remove a key
Remove {
/// Key label
label: String,
},
/// Export public key (NEVER exports private key)
Export {
/// Key label
label: String,
},
/// Manage transaction approval policy
#[command(subcommand)]
Policy(PolicyCommand),
/// Create encrypted backup of all keys
Backup {
/// Output file path
#[arg(long)]
output: PathBuf,
/// List keys in a backup without restoring (still needs passphrase)
#[arg(long)]
list: bool,
},
/// Restore keys from encrypted backup
Restore {
/// Backup file path
path: PathBuf,
},
}
#[derive(Subcommand, Debug, Clone)]
pub enum PolicyCommand {
/// Show current policy configuration
Show,
/// Set auto-approve transfer limit
SetTransferLimit {
/// Max NEAR amount for auto-approved transfers (e.g., "1.5")
amount: String,
},
/// Whitelist an account for transfers
WhitelistAccount {
/// Account ID to whitelist
account: String,
/// Max transfer amount in NEAR
#[arg(long)]
max_transfer: Option<String>,
},
/// Whitelist a validator for staking
WhitelistValidator {
/// Validator account ID
validator: String,
/// Max stake amount in NEAR
#[arg(long)]
max_stake: Option<String>,
},
/// Add a function call rule for a contract
AddContractRule {
/// Contract account ID
contract: String,
/// Comma-separated method names (empty = all)
#[arg(long)]
methods: Option<String>,
/// Max deposit in NEAR
#[arg(long, default_value = "0")]
max_deposit: String,
/// Auto-approve matching calls
#[arg(long)]
auto_approve: bool,
},
/// Add a chain signature rule
AddChainSigRule {
/// Derivation path pattern (supports * glob)
path_pattern: String,
/// Signature domain: secp256k1 or ed25519
#[arg(long, default_value = "secp256k1")]
domain: String,
/// Max payload size in bytes
#[arg(long, default_value = "4096")]
max_payload: usize,
/// Auto-approve matching requests
#[arg(long)]
auto_approve: bool,
},
/// Set daily cumulative spend limit
SetDailyLimit {
/// Max NEAR amount per day
amount: String,
},
/// Set per-transaction auto-approve limit
SetTxLimit {
/// Max NEAR amount per transaction
amount: String,
},
}
/// Run a key management command.
pub async fn run_key_command(cmd: KeyCommand) -> anyhow::Result<()> {
match cmd {
KeyCommand::Generate {
label,
account,
permission,
receiver,
methods,
allowance,
network,
} => {
let manager = create_key_manager().await?;
let account_id = NearAccountId::new(&account)?;
let network: NearNetwork = network.parse()?;
let perm = parse_permission(&permission, receiver, methods, allowance)?;
let metadata = manager
.generate_key(&label, &account_id, perm.clone(), network)
.await?;
println!("Key generated successfully:");
println!(" Label: {}", metadata.label);
println!(" Account: {}", metadata.account_id);
println!(" Public key: {}", metadata.public_key);
println!(" Permission: {}", perm);
println!(" Network: {}", metadata.network);
if matches!(perm, AccessKeyPermission::FullAccess) {
println!();
println!(
" WARNING: This is a FULL ACCESS key for {}.",
metadata.account_id
);
println!(" If this is the ONLY full-access key for this account and you lose it,");
println!(" the account becomes permanently inaccessible.");
println!();
println!(" Create a backup: ironclaw key backup --output <file>");
}
Ok(())
}
KeyCommand::Import {
label,
account,
permission,
receiver,
methods,
allowance,
network,
} => {
let manager = create_key_manager().await?;
let account_id = NearAccountId::new(&account)?;
let network: NearNetwork = network.parse()?;
let perm = parse_permission(&permission, receiver, methods, allowance)?;
// Read secret key from stdin (hidden)
print!("Paste secret key (ed25519:...): ");
std::io::stdout().flush()?;
let secret_key = read_hidden_line()?;
println!();
if secret_key.is_empty() {
anyhow::bail!("No secret key provided");
}
let metadata = manager
.import_key(&label, &account_id, &secret_key, perm.clone(), network)
.await?;
println!("Key imported successfully:");
println!(" Label: {}", metadata.label);
println!(" Account: {}", metadata.account_id);
println!(" Public key: {}", metadata.public_key);
println!(" Permission: {}", perm);
if matches!(perm, AccessKeyPermission::FullAccess) {
println!();
println!(" WARNING: Full-access key imported. Back it up!");
println!(" ironclaw key backup --output <file>");
}
Ok(())
}
KeyCommand::List { verbose } => {
let manager = create_key_manager().await?;
let keys = manager.list_keys().await?;
if keys.is_empty() {
println!("No keys stored.");
println!("Generate one: ironclaw key generate <label> --account <id>");
return Ok(());
}
println!("Stored keys:");
println!();
for key in keys {
if verbose {
println!(" {} ({})", key.label, key.network);
println!(" Account: {}", key.account_id);
println!(" Public key: {}", key.public_key);
println!(" Permission: {}", key.permission);
println!(
" Created: {}",
key.created_at.format("%Y-%m-%d %H:%M UTC")
);
println!();
} else {
println!(
" {} | {} | {} | {}",
key.label, key.account_id, key.permission, key.network
);
}
}
Ok(())
}
KeyCommand::Info { label } => {
let manager = create_key_manager().await?;
let key = manager.get_key(&label).await?;
println!("Key: {}", key.label);
println!(" Account: {}", key.account_id);
println!(" Public key: {}", key.public_key);
println!(" Permission: {}", key.permission);
println!(" Network: {}", key.network);
println!(
" Created: {}",
key.created_at.format("%Y-%m-%d %H:%M UTC")
);
Ok(())
}
KeyCommand::Remove { label } => {
let manager = create_key_manager().await?;
manager.remove_key(&label).await?;
println!("Key '{}' removed.", label);
Ok(())
}
KeyCommand::Export { label } => {
let manager = create_key_manager().await?;
let pubkey = manager.export_public_key(&label).await?;
println!("{}", pubkey.to_near_format());
Ok(())
}
KeyCommand::Policy(policy_cmd) => run_policy_command(policy_cmd).await,
KeyCommand::Backup { output, list } => {
if list {
// List keys in backup
let data = fs::read(&output).await?;
print!("Backup passphrase: ");
std::io::stdout().flush()?;
let passphrase = read_hidden_line()?;
println!();
// We need to decrypt to list, so restore to a temp manager
// and just display, not actually import
let plaintext = crate::keys::decrypt_backup(&passphrase, &data)?;
let backup: serde_json::Value = serde_json::from_slice(&plaintext)?;
if let Some(keys) = backup.get("keys").and_then(|k| k.as_array()) {
println!("Keys in backup ({}):", output.display());
for key in keys {
let label = key.get("label").and_then(|l| l.as_str()).unwrap_or("?");
let account = key
.get("account_id")
.and_then(|a| a.as_str())
.unwrap_or("?");
println!(" {} ({})", label, account);
}
}
return Ok(());
}
let manager = create_key_manager().await?;
print!("Backup passphrase: ");
std::io::stdout().flush()?;
let passphrase = read_hidden_line()?;
println!();
print!("Confirm passphrase: ");
std::io::stdout().flush()?;
let confirm = read_hidden_line()?;
println!();
if passphrase != confirm {
anyhow::bail!("Passphrases do not match");
}
if passphrase.len() < 8 {
anyhow::bail!("Passphrase must be at least 8 characters");
}
let backup_data = manager.create_backup(&passphrase).await?;
fs::write(&output, &backup_data).await?;
println!(
"Backup created: {} ({} bytes)",
output.display(),
backup_data.len()
);
println!("Store this file securely. You'll need the passphrase to restore.");
Ok(())
}
KeyCommand::Restore { path } => {
let manager = create_key_manager().await?;
let data = fs::read(&path).await?;
print!("Backup passphrase: ");
std::io::stdout().flush()?;
let passphrase = read_hidden_line()?;
println!();
let restored = manager.restore_backup(&data, &passphrase).await?;
if restored.is_empty() {
println!("No new keys to restore (all already exist).");
} else {
println!("Restored {} keys:", restored.len());
for label in &restored {
println!(" {}", label);
}
}
Ok(())
}
}
}
async fn run_policy_command(cmd: PolicyCommand) -> anyhow::Result<()> {
let policy_path = default_policy_path();
match cmd {
PolicyCommand::Show => {
let policy = load_policy(&policy_path).await?;
let json = serde_json::to_string_pretty(&policy)?;
println!("{}", json);
Ok(())
}
PolicyCommand::SetTransferLimit { amount } => {
let yocto = parse_near_amount(&amount)?;
let mut policy = load_policy(&policy_path).await?;
policy.transfer_auto_approve_max_yocto = yocto;
save_policy(&policy_path, &policy).await?;
println!("Transfer auto-approve limit set to {}", format_yocto(yocto));
Ok(())
}
PolicyCommand::WhitelistAccount {
account,
max_transfer,
} => {
let mut policy = load_policy(&policy_path).await?;
if !policy.transfer_whitelist.contains(&account) {
policy.transfer_whitelist.push(account.clone());
}
if let Some(max) = max_transfer {
policy.transfer_whitelist_max_yocto = parse_near_amount(&max)?;
}
save_policy(&policy_path, &policy).await?;
println!("Account '{}' added to transfer whitelist", account);
Ok(())
}
PolicyCommand::WhitelistValidator {
validator,
max_stake,
} => {
let mut policy = load_policy(&policy_path).await?;
if !policy.stake_validator_whitelist.contains(&validator) {
policy.stake_validator_whitelist.push(validator.clone());
}
if let Some(max) = max_stake {
policy.stake_auto_approve_max_yocto = parse_near_amount(&max)?;
}
save_policy(&policy_path, &policy).await?;
println!("Validator '{}' added to staking whitelist", validator);
Ok(())
}
PolicyCommand::AddContractRule {
contract,
methods,
max_deposit,
auto_approve,
} => {
let mut policy = load_policy(&policy_path).await?;
let deposit = parse_near_amount(&max_deposit)?;
let method_list = methods
.map(|m| m.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
policy.function_call_rules.push(FunctionCallRule {
receiver_id: contract.clone(),
allowed_methods: method_list,
max_deposit_yocto: deposit,
max_gas: None,
auto_approve,
});
save_policy(&policy_path, &policy).await?;
println!(
"Contract rule added for '{}' (auto_approve={})",
contract, auto_approve
);
Ok(())
}
PolicyCommand::AddChainSigRule {
path_pattern,
domain,
max_payload,
auto_approve,
} => {
let domain = match domain.to_lowercase().as_str() {
"secp256k1" => SignatureDomain::Secp256k1,
"ed25519" => SignatureDomain::Ed25519,
other => anyhow::bail!("Unknown domain '{}', expected secp256k1 or ed25519", other),
};
let mut policy = load_policy(&policy_path).await?;
policy.chain_sig_rules.push(ChainSigRule {
allowed_paths: vec![path_pattern.clone()],
allowed_domains: vec![domain],
max_payload_bytes: max_payload,
auto_approve,
});
save_policy(&policy_path, &policy).await?;
println!(
"Chain signature rule added for '{}' (auto_approve={})",
path_pattern, auto_approve
);
Ok(())
}
PolicyCommand::SetDailyLimit { amount } => {
let yocto = parse_near_amount(&amount)?;
let mut policy = load_policy(&policy_path).await?;
policy.daily_spend_limit_yocto = Some(yocto);
save_policy(&policy_path, &policy).await?;
println!("Daily spend limit set to {}", format_yocto(yocto));
Ok(())
}
PolicyCommand::SetTxLimit { amount } => {
let yocto = parse_near_amount(&amount)?;
let mut policy = load_policy(&policy_path).await?;
policy.per_tx_auto_approve_max_yocto = yocto;
save_policy(&policy_path, &policy).await?;
println!(
"Per-transaction auto-approve limit set to {}",
format_yocto(yocto)
);
Ok(())
}
}
}
async fn load_policy(path: &PathBuf) -> anyhow::Result<PolicyConfig> {
if path.exists() {
let content = fs::read_to_string(path).await?;
Ok(serde_json::from_str(&content)?)
} else {
Ok(PolicyConfig::default())
}
}
async fn save_policy(path: &PathBuf, policy: &PolicyConfig) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let content = serde_json::to_string_pretty(policy)?;
fs::write(path, content).await?;
Ok(())
}
fn parse_permission(
permission: &str,
receiver: Option<String>,
methods: Option<String>,
allowance: Option<String>,
) -> anyhow::Result<AccessKeyPermission> {
match permission {
"full-access" | "FullAccess" => Ok(AccessKeyPermission::FullAccess),
"function-call" | "FunctionCall" => {
let receiver_id = receiver
.ok_or_else(|| anyhow::anyhow!("--receiver required for function-call keys"))?;
let method_names = methods
.map(|m| m.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let allowance_yocto = allowance
.map(|a| parse_near_amount(&a))
.transpose()
.map_err(|e| anyhow::anyhow!("invalid allowance: {}", e))?;
Ok(AccessKeyPermission::FunctionCall {
allowance: allowance_yocto,
receiver_id,
method_names,
})
}
other => Err(anyhow::anyhow!(
"unknown permission '{}', expected full-access or function-call",
other
)),
}
}
/// Create a KeyManager with the default secrets store.
async fn create_key_manager() -> anyhow::Result<KeyManager> {
let config = Config::from_env()?;
let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!(
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
)
})?;
let store = Store::new(&config.database).await?;
store.run_migrations().await?;
let crypto = SecretsCrypto::new(master_key.clone())?;
let secrets_store: Arc<dyn SecretsStore + Send + Sync> =
Arc::new(PostgresSecretsStore::new(store.pool(), Arc::new(crypto)));
let manager = KeyManager::new(secrets_store, "default".to_string());
// Load policy if it exists
let policy_path = default_policy_path();
if policy_path.exists() {
let content = fs::read_to_string(&policy_path).await?;
let policy: PolicyConfig = serde_json::from_str(&content)?;
Ok(manager.with_policy(policy))
} else {
Ok(manager)
}
}
/// Read a line of input with hidden characters.
fn read_hidden_line() -> anyhow::Result<String> {
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
terminal,
};
let mut input = String::new();
terminal::enable_raw_mode()?;
loop {
if let Event::Key(key_event) = event::read()? {
match key_event.code {
KeyCode::Enter => break,
KeyCode::Backspace => {
if !input.is_empty() {
input.pop();
print!("\x08 \x08");
std::io::stdout().flush()?;
}
}
KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
terminal::disable_raw_mode()?;
return Err(anyhow::anyhow!("Interrupted"));
}
KeyCode::Char(c) => {
input.push(c);
print!("*");
std::io::stdout().flush()?;
}
_ => {}
}
}
}
terminal::disable_raw_mode()?;
Ok(input)
}
#[cfg(test)]
mod tests {
use crate::cli::key::parse_permission;
use crate::keys::types::AccessKeyPermission;
#[test]
fn test_parse_full_access() {
let perm = parse_permission("full-access", None, None, None).unwrap();
assert!(matches!(perm, AccessKeyPermission::FullAccess));
}
#[test]
fn test_parse_function_call() {
let perm = parse_permission(
"function-call",
Some("contract.near".to_string()),
Some("deposit,withdraw".to_string()),
Some("1.5".to_string()),
)
.unwrap();
match perm {
AccessKeyPermission::FunctionCall {
receiver_id,
method_names,
allowance,
} => {
assert_eq!(receiver_id, "contract.near");
assert_eq!(method_names, vec!["deposit", "withdraw"]);
assert!(allowance.is_some());
}
_ => panic!("expected FunctionCall"),
}
}
#[test]
fn test_parse_function_call_missing_receiver() {
let result = parse_permission("function-call", None, None, None);
assert!(result.is_err());
}
}
+21 -117
View File
@@ -8,14 +8,14 @@ use std::sync::Arc;
use clap::Subcommand; use clap::Subcommand;
use crate::config::Config; use crate::config::Config;
use crate::db::Database; use crate::history::Store;
#[cfg(feature = "postgres")] use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore};
use crate::secrets::PostgresSecretsStore;
use crate::secrets::{SecretsCrypto, SecretsStore};
use crate::tools::mcp::{ use crate::tools::mcp::{
McpClient, McpServerConfig, McpSessionManager, OAuthConfig, McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
auth::{authorize_mcp_server, is_authenticated}, auth::{authorize_mcp_server, is_authenticated},
config::{self, McpServersFile}, config::{
add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server, save_mcp_servers,
},
}; };
#[derive(Subcommand, Debug, Clone)] #[derive(Subcommand, Debug, Clone)]
@@ -173,11 +173,8 @@ async fn add_server(
// Validate // Validate
config.validate()?; config.validate()?;
// Save (DB if available, else disk) // Save
let db = connect_db().await; add_mcp_server(config).await?;
let mut servers = load_servers(db.as_deref()).await?;
servers.upsert(config);
save_servers(db.as_deref(), &servers).await?;
println!(); println!();
println!(" ✓ Added MCP server '{}'", name); println!(" ✓ Added MCP server '{}'", name);
@@ -195,12 +192,7 @@ async fn add_server(
/// Remove an MCP server. /// Remove an MCP server.
async fn remove_server(name: String) -> anyhow::Result<()> { async fn remove_server(name: String) -> anyhow::Result<()> {
let db = connect_db().await; remove_mcp_server(&name).await?;
let mut servers = load_servers(db.as_deref()).await?;
if !servers.remove(&name) {
anyhow::bail!("Server '{}' not found", name);
}
save_servers(db.as_deref(), &servers).await?;
println!(); println!();
println!(" ✓ Removed MCP server '{}'", name); println!(" ✓ Removed MCP server '{}'", name);
@@ -211,8 +203,7 @@ async fn remove_server(name: String) -> anyhow::Result<()> {
/// List configured MCP servers. /// List configured MCP servers.
async fn list_servers(verbose: bool) -> anyhow::Result<()> { async fn list_servers(verbose: bool) -> anyhow::Result<()> {
let db = connect_db().await; let servers = load_mcp_servers().await?;
let servers = load_servers(db.as_deref()).await?;
if servers.servers.is_empty() { if servers.servers.is_empty() {
println!(); println!();
@@ -270,12 +261,7 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> {
/// Authenticate with an MCP server. /// Authenticate with an MCP server.
async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> { async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
// Get server config // Get server config
let db = connect_db().await; let server = get_mcp_server(&name).await?;
let servers = load_servers(db.as_deref()).await?;
let server = servers
.get(&name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?;
// Initialize secrets store // Initialize secrets store
let secrets = get_secrets_store().await?; let secrets = get_secrets_store().await?;
@@ -343,12 +329,7 @@ async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
/// Test connection to an MCP server. /// Test connection to an MCP server.
async fn test_server(name: String, user_id: String) -> anyhow::Result<()> { async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
// Get server config // Get server config
let db = connect_db().await; let server = get_mcp_server(&name).await?;
let servers = load_servers(db.as_deref()).await?;
let server = servers
.get(&name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?;
println!(); println!();
println!(" Testing connection to '{}'...", name); println!(" Testing connection to '{}'...", name);
@@ -439,8 +420,7 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
/// Toggle server enabled/disabled state. /// Toggle server enabled/disabled state.
async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> { async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> {
let db = connect_db().await; let mut servers = load_mcp_servers().await?;
let mut servers = load_servers(db.as_deref()).await?;
let server = servers let server = servers
.get_mut(&name) .get_mut(&name)
@@ -455,7 +435,7 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
}; };
server.enabled = new_state; server.enabled = new_state;
save_servers(db.as_deref(), &servers).await?; save_mcp_servers(&servers).await?;
let status = if new_state { "enabled" } else { "disabled" }; let status = if new_state { "enabled" } else { "disabled" };
println!(); println!();
@@ -465,38 +445,9 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
Ok(()) Ok(())
} }
const DEFAULT_USER_ID: &str = "default";
/// Try to connect to the database (backend-agnostic).
async fn connect_db() -> Option<Arc<dyn Database>> {
let config = Config::from_env().await.ok()?;
crate::db::connect_from_config(&config.database).await.ok()
}
/// Load MCP servers (DB if available, else disk).
async fn load_servers(db: Option<&dyn Database>) -> Result<McpServersFile, config::ConfigError> {
if let Some(db) = db {
config::load_mcp_servers_from_db(db, DEFAULT_USER_ID).await
} else {
config::load_mcp_servers().await
}
}
/// Save MCP servers (DB if available, else disk).
async fn save_servers(
db: Option<&dyn Database>,
servers: &McpServersFile,
) -> Result<(), config::ConfigError> {
if let Some(db) = db {
config::save_mcp_servers_to_db(db, DEFAULT_USER_ID, servers).await
} else {
config::save_mcp_servers(servers).await
}
}
/// Initialize and return the secrets store. /// Initialize and return the secrets store.
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> { async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
let config = Config::from_env().await?; let config = Config::from_env()?;
let master_key = config.secrets.master_key().ok_or_else(|| { let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
@@ -504,61 +455,14 @@ async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Syn
) )
})?; })?;
let store = Store::new(&config.database).await?;
store.run_migrations().await?;
let crypto = SecretsCrypto::new(master_key.clone())?; let crypto = SecretsCrypto::new(master_key.clone())?;
Ok(Arc::new(PostgresSecretsStore::new(
#[cfg(feature = "postgres")] store.pool(),
{ Arc::new(crypto),
let store = crate::history::Store::new(&config.database).await?; )))
store.run_migrations().await?;
Ok(Arc::new(PostgresSecretsStore::new(
store.pool(),
Arc::new(crypto),
)))
}
#[cfg(all(feature = "libsql", not(feature = "postgres")))]
{
use crate::db::Database as _;
use crate::db::libsql_backend::LibSqlBackend;
use secrecy::ExposeSecret as _;
let default_path = crate::config::default_libsql_path();
let db_path = config
.database
.libsql_path
.as_deref()
.unwrap_or(&default_path);
let backend = if let Some(ref url) = config.database.libsql_url {
let token = config.database.libsql_auth_token.as_ref().ok_or_else(|| {
anyhow::anyhow!("LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set")
})?;
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret())
.await
.map_err(|e| anyhow::anyhow!("{}", e))?
} else {
LibSqlBackend::new_local(db_path)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?
};
backend
.run_migrations()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
return Ok(Arc::new(crate::secrets::LibSqlSecretsStore::new(
backend.shared_db(),
Arc::new(crypto),
)));
}
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
{
let _ = crypto;
anyhow::bail!(
"No database backend available for secrets. Enable 'postgres' or 'libsql' feature."
);
}
} }
#[cfg(test)] #[cfg(test)]
+1 -26
View File
@@ -9,30 +9,6 @@ use clap::Subcommand;
use crate::workspace::{EmbeddingProvider, SearchConfig, Workspace}; use crate::workspace::{EmbeddingProvider, SearchConfig, Workspace};
/// Run a memory command using the Database trait (works with any backend).
pub async fn run_memory_command_with_db(
cmd: MemoryCommand,
db: std::sync::Arc<dyn crate::db::Database>,
embeddings: Option<Arc<dyn EmbeddingProvider>>,
) -> anyhow::Result<()> {
let mut workspace = Workspace::new_with_db("default", db);
if let Some(emb) = embeddings {
workspace = workspace.with_embeddings(emb);
}
match cmd {
MemoryCommand::Search { query, limit } => search(&workspace, &query, limit).await,
MemoryCommand::Read { path } => read(&workspace, &path).await,
MemoryCommand::Write {
path,
content,
append,
} => write(&workspace, &path, content, append).await,
MemoryCommand::Tree { path, depth } => tree(&workspace, &path, depth).await,
MemoryCommand::Status => status(&workspace).await,
}
}
#[derive(Subcommand, Debug, Clone)] #[derive(Subcommand, Debug, Clone)]
pub enum MemoryCommand { pub enum MemoryCommand {
/// Search workspace memory (hybrid full-text + semantic) /// Search workspace memory (hybrid full-text + semantic)
@@ -79,8 +55,7 @@ pub enum MemoryCommand {
Status, Status,
} }
/// Run a memory command (PostgreSQL backend). /// Run a memory command.
#[cfg(feature = "postgres")]
pub async fn run_memory_command( pub async fn run_memory_command(
cmd: MemoryCommand, cmd: MemoryCommand,
pool: deadpool_postgres::Pool, pool: deadpool_postgres::Pool,
+7 -47
View File
@@ -10,20 +10,16 @@
//! - Checking system health (`status`) //! - Checking system health (`status`)
mod config; mod config;
pub mod key;
mod mcp; mod mcp;
pub mod memory; pub mod memory;
pub mod oauth_defaults;
mod pairing;
pub mod status; pub mod status;
mod tool; mod tool;
pub use config::{ConfigCommand, run_config_command}; pub use config::{ConfigCommand, run_config_command};
pub use key::{KeyCommand, run_key_command};
pub use mcp::{McpCommand, run_mcp_command}; pub use mcp::{McpCommand, run_mcp_command};
pub use memory::MemoryCommand; pub use memory::{MemoryCommand, run_memory_command};
#[cfg(feature = "postgres")]
pub use memory::run_memory_command;
pub use memory::run_memory_command_with_db;
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
pub use status::run_status_command; pub use status::run_status_command;
pub use tool::{ToolCommand, run_tool_command}; pub use tool::{ToolCommand, run_tool_command};
@@ -84,6 +80,10 @@ pub enum Command {
#[command(subcommand)] #[command(subcommand)]
Tool(ToolCommand), Tool(ToolCommand),
/// Manage NEAR blockchain keys
#[command(subcommand)]
Key(KeyCommand),
/// Manage MCP servers (hosted tool providers) /// Manage MCP servers (hosted tool providers)
#[command(subcommand)] #[command(subcommand)]
Mcp(McpCommand), Mcp(McpCommand),
@@ -92,48 +92,8 @@ pub enum Command {
#[command(subcommand)] #[command(subcommand)]
Memory(MemoryCommand), Memory(MemoryCommand),
/// DM pairing (approve inbound requests from unknown senders)
#[command(subcommand)]
Pairing(PairingCommand),
/// Show system health and diagnostics /// Show system health and diagnostics
Status, Status,
/// Run as a sandboxed worker inside a Docker container (internal use).
/// This is invoked automatically by the orchestrator, not by users directly.
Worker {
/// Job ID to execute.
#[arg(long)]
job_id: uuid::Uuid,
/// URL of the orchestrator's internal API.
#[arg(long, default_value = "http://host.docker.internal:50051")]
orchestrator_url: String,
/// Maximum iterations before stopping.
#[arg(long, default_value = "50")]
max_iterations: u32,
},
/// Run as a Claude Code bridge inside a Docker container (internal use).
/// Spawns the `claude` CLI and streams output back to the orchestrator.
ClaudeBridge {
/// Job ID to execute.
#[arg(long)]
job_id: uuid::Uuid,
/// URL of the orchestrator's internal API.
#[arg(long, default_value = "http://host.docker.internal:50051")]
orchestrator_url: String,
/// Maximum agentic turns for Claude Code.
#[arg(long, default_value = "50")]
max_turns: u32,
/// Claude model to use (e.g. "sonnet", "opus").
#[arg(long, default_value = "sonnet")]
model: String,
},
} }
impl Cli { impl Cli {
-342
View File
@@ -1,342 +0,0 @@
//! Shared OAuth infrastructure: built-in credentials, callback server, landing pages.
//!
//! Every OAuth flow in the codebase (WASM tool auth, MCP server auth, NEAR AI login)
//! uses the same callback port, landing page, and listener logic from this module.
//!
//! # Built-in Credentials
//!
//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials
//! so users don't need to register their own OAuth app. Google explicitly
//! documents that client_secret for "Desktop App" / "Installed App" types
//! is NOT actually secret.
//!
//! Default credentials are hardcoded below. They can be overridden at:
//!
//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
//! env vars before building to replace the hardcoded defaults.
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
//! env vars, which take priority over built-in defaults.
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
// ── Built-in credentials ────────────────────────────────────────────────
pub struct OAuthCredentials {
pub client_id: &'static str,
pub client_secret: &'static str,
}
/// Google OAuth "Desktop App" credentials, shared across all Google tools.
/// Compile-time env vars override the hardcoded defaults below.
const GOOGLE_CLIENT_ID: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_ID") {
Some(v) => v,
None => "564604149681-efo25d43rs85v0tibdepsmdv5dsrhhr0.apps.googleusercontent.com",
};
const GOOGLE_CLIENT_SECRET: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_SECRET") {
Some(v) => v,
None => "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",
};
/// Returns built-in OAuth credentials for a provider, keyed by secret_name.
///
/// The secret_name comes from the tool's capabilities.json `auth.secret_name` field.
/// Returns `None` if no built-in credentials are configured for that provider.
pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
match secret_name {
"google_oauth_token" => Some(OAuthCredentials {
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
}),
_ => None,
}
}
// ── Shared callback server ──────────────────────────────────────────────
/// Fixed port for all OAuth callbacks.
///
/// Every redirect URI registered with providers must use this port:
/// `http://localhost:9876/callback` (or `/auth/callback` for NEAR AI).
pub const OAUTH_CALLBACK_PORT: u16 = 9876;
/// Error from the OAuth callback listener.
#[derive(Debug, thiserror::Error)]
pub enum OAuthCallbackError {
#[error("Port {0} is in use (another auth flow running?): {1}")]
PortInUse(u16, String),
#[error("Authorization denied by user")]
Denied,
#[error("Timed out waiting for authorization")]
Timeout,
#[error("IO error: {0}")]
Io(String),
}
/// Bind the OAuth callback listener on the fixed port.
///
/// Binds to IPv4 `127.0.0.1` first because callback URLs use `127.0.0.1`
/// explicitly (e.g., NEAR AI redirects to `http://127.0.0.1:9876/auth/callback`).
/// Falls back to IPv6 `[::1]` only if IPv4 binding fails for a reason other
/// than `AddrInUse`. If the port is already occupied, fails immediately.
pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError> {
let ipv4_addr = format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT);
match TcpListener::bind(&ipv4_addr).await {
Ok(listener) => return Ok(listener),
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
return Err(OAuthCallbackError::PortInUse(
OAUTH_CALLBACK_PORT,
e.to_string(),
));
}
Err(_) => {
// IPv4 not available, fall back to IPv6
}
}
TcpListener::bind(format!("[::1]:{}", OAUTH_CALLBACK_PORT))
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::AddrInUse {
OAuthCallbackError::PortInUse(OAUTH_CALLBACK_PORT, e.to_string())
} else {
OAuthCallbackError::Io(e.to_string())
}
})
}
/// Wait for an OAuth callback and extract a query parameter value.
///
/// Listens for a GET request matching `path_prefix` (e.g., "/callback" or "/auth/callback"),
/// extracts the value of `param_name` (e.g., "code" or "token"), and shows a branded
/// landing page using `display_name` (e.g., "Google", "Notion", "NEAR AI").
///
/// Times out after 5 minutes.
pub async fn wait_for_callback(
listener: TcpListener,
path_prefix: &str,
param_name: &str,
display_name: &str,
) -> Result<String, OAuthCallbackError> {
let path_prefix = path_prefix.to_string();
let param_name = param_name.to_string();
let display_name = display_name.to_string();
tokio::time::timeout(Duration::from_secs(300), async move {
loop {
let (mut socket, _) = listener
.accept()
.await
.map_err(|e| OAuthCallbackError::Io(e.to_string()))?;
let mut reader = BufReader::new(&mut socket);
let mut request_line = String::new();
reader
.read_line(&mut request_line)
.await
.map_err(|e| OAuthCallbackError::Io(e.to_string()))?;
if let Some(path) = request_line.split_whitespace().nth(1)
&& path.starts_with(&path_prefix)
&& let Some(query) = path.split('?').nth(1)
{
// Check for error first
if query.contains("error=") {
let html = landing_html(&display_name, false);
let response = format!(
"HTTP/1.1 400 Bad Request\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Connection: close\r\n\
\r\n\
{}",
html
);
let _ = socket.write_all(response.as_bytes()).await;
return Err(OAuthCallbackError::Denied);
}
// Look for the target parameter
for param in query.split('&') {
let parts: Vec<&str> = param.splitn(2, '=').collect();
if parts.len() == 2 && parts[0] == param_name {
let value = urlencoding::decode(parts[1])
.unwrap_or_else(|_| parts[1].into())
.into_owned();
let html = landing_html(&display_name, true);
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Connection: close\r\n\
\r\n\
{}",
html
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.shutdown().await;
return Ok(value);
}
}
}
// Not the callback we're looking for
let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
let _ = socket.write_all(response.as_bytes()).await;
}
})
.await
.map_err(|_| OAuthCallbackError::Timeout)?
}
/// Escape a string for safe interpolation into HTML content.
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#x27;"),
_ => out.push(c),
}
}
out
}
/// HTML landing page shown in the browser after an OAuth redirect.
pub fn landing_html(provider_name: &str, success: bool) -> String {
let safe_name = html_escape(provider_name);
let (icon, heading, subtitle, accent) = if success {
(
r##"<div style="width:64px;height:64px;border-radius:50%;background:#22c55e;display:flex;align-items:center;justify-content:center;margin:0 auto 24px">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>"##,
format!("{} Connected", safe_name),
"You can close this window and return to your terminal.",
"#22c55e",
)
} else {
(
r##"<div style="width:64px;height:64px;border-radius:50%;background:#ef4444;display:flex;align-items:center;justify-content:center;margin:0 auto 24px">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</div>"##,
"Authorization Failed".to_string(),
"The request was denied. You can close this window and try again.",
"#ef4444",
)
};
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>IronClaw - {heading}</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}}
.card {{
text-align: center;
padding: 48px 40px;
max-width: 420px;
border: 1px solid #262626;
border-radius: 16px;
background: #141414;
}}
h1 {{
font-size: 22px;
font-weight: 600;
margin-bottom: 8px;
color: #fafafa;
}}
p {{
font-size: 14px;
color: #a3a3a3;
line-height: 1.5;
}}
.accent {{ color: {accent}; }}
.brand {{
margin-top: 32px;
font-size: 12px;
color: #525252;
letter-spacing: 0.5px;
text-transform: uppercase;
}}
</style>
</head>
<body>
<div class="card">
{icon}
<h1>{heading}</h1>
<p>{subtitle}</p>
<div class="brand">IronClaw</div>
</div>
</body>
</html>"#,
heading = heading,
icon = icon,
subtitle = subtitle,
accent = accent,
)
}
#[cfg(test)]
mod tests {
use crate::cli::oauth_defaults::{builtin_credentials, landing_html};
#[test]
fn test_unknown_provider_returns_none() {
assert!(builtin_credentials("unknown_token").is_none());
}
#[test]
fn test_google_returns_based_on_compile_env() {
let creds = builtin_credentials("google_oauth_token");
assert!(creds.is_some());
let creds = creds.unwrap();
assert!(!creds.client_id.is_empty());
assert!(!creds.client_secret.is_empty());
}
#[test]
fn test_landing_html_success_contains_key_elements() {
let html = landing_html("Google", true);
assert!(html.contains("Google Connected"));
assert!(html.contains("charset"));
assert!(html.contains("IronClaw"));
assert!(html.contains("#22c55e")); // green accent
assert!(!html.contains("Failed"));
}
#[test]
fn test_landing_html_escapes_provider_name() {
let html = landing_html("<script>alert(1)</script>", true);
assert!(!html.contains("<script>"));
assert!(html.contains("&lt;script&gt;"));
}
#[test]
fn test_landing_html_error_contains_key_elements() {
let html = landing_html("Notion", false);
assert!(html.contains("Authorization Failed"));
assert!(html.contains("charset"));
assert!(html.contains("IronClaw"));
assert!(html.contains("#ef4444")); // red accent
assert!(!html.contains("Connected"));
}
}
-187
View File
@@ -1,187 +0,0 @@
//! DM pairing CLI commands.
//!
//! Manage pairing requests for channels (Telegram, Slack, etc.).
use clap::Subcommand;
use crate::pairing::PairingStore;
/// Pairing subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum PairingCommand {
/// List pending pairing requests
List {
/// Channel name (e.g., telegram, slack)
#[arg(required = true)]
channel: String,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Approve a pairing request by code
Approve {
/// Channel name (e.g., telegram, slack)
#[arg(required = true)]
channel: String,
/// Pairing code (e.g., ABC12345)
#[arg(required = true)]
code: String,
},
}
/// Run pairing CLI command.
pub fn run_pairing_command(cmd: PairingCommand) -> Result<(), String> {
run_pairing_command_with_store(&PairingStore::new(), cmd)
}
/// Run pairing CLI command with a given store (for testing).
pub fn run_pairing_command_with_store(
store: &PairingStore,
cmd: PairingCommand,
) -> Result<(), String> {
match cmd {
PairingCommand::List { channel, json } => run_list(store, &channel, json),
PairingCommand::Approve { channel, code } => run_approve(store, &channel, &code),
}
}
fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), String> {
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())?
);
return Ok(());
}
if requests.is_empty() {
println!("No pending {} pairing requests.", channel);
return Ok(());
}
println!("Pairing requests ({}):", requests.len());
for r in &requests {
let meta = r
.meta
.as_ref()
.and_then(|m| m.as_object())
.map(|o| {
o.iter()
.filter_map(|(k, v)| v.as_str().map(|s| format!("{}={}", k, s)))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
println!(" {} {} {} {}", r.code, r.id, meta, r.created_at);
}
Ok(())
}
fn run_approve(store: &PairingStore, channel: &str, code: &str) -> Result<(), String> {
match store.approve(channel, code) {
Ok(Some(entry)) => {
println!("Approved {} sender {}.", channel, entry.id);
Ok(())
}
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(),
),
Err(e) => Err(e.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_store() -> (PairingStore, TempDir) {
let dir = TempDir::new().unwrap();
let store = PairingStore::with_base_dir(dir.path().to_path_buf());
(store, dir)
}
#[test]
fn test_list_empty_returns_ok() {
let (store, _) = test_store();
let result = run_pairing_command_with_store(
&store,
PairingCommand::List {
channel: "telegram".to_string(),
json: false,
},
);
assert!(result.is_ok());
}
#[test]
fn test_list_json_empty_returns_ok() {
let (store, _) = test_store();
let result = run_pairing_command_with_store(
&store,
PairingCommand::List {
channel: "telegram".to_string(),
json: true,
},
);
assert!(result.is_ok());
}
#[test]
fn test_approve_invalid_code_returns_err() {
let (store, _) = test_store();
// Create a pending request so the pairing file exists, then approve with wrong code
store.upsert_request("telegram", "user1", None).unwrap();
let result = run_pairing_command_with_store(
&store,
PairingCommand::Approve {
channel: "telegram".to_string(),
code: "BADCODE1".to_string(),
},
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("No pending pairing request"));
}
#[test]
fn test_approve_valid_code_returns_ok() {
let (store, _) = test_store();
let r = store.upsert_request("telegram", "user1", None).unwrap();
assert!(r.created);
let result = run_pairing_command_with_store(
&store,
PairingCommand::Approve {
channel: "telegram".to_string(),
code: r.code,
},
);
assert!(result.is_ok());
}
#[test]
fn test_list_with_pending_returns_ok() {
let (store, _) = test_store();
store.upsert_request("telegram", "user1", None).unwrap();
let result = run_pairing_command_with_store(
&store,
PairingCommand::List {
channel: "telegram".to_string(),
json: false,
},
);
assert!(result.is_ok());
}
}
+24 -51
View File
@@ -9,7 +9,7 @@ use crate::settings::Settings;
/// Run the status command, printing system health info. /// Run the status command, printing system health info.
pub async fn run_status_command() -> anyhow::Result<()> { pub async fn run_status_command() -> anyhow::Result<()> {
let settings = Settings::default(); let settings = Settings::load();
println!("IronClaw Status"); println!("IronClaw Status");
println!("===============\n"); println!("===============\n");
@@ -22,36 +22,16 @@ pub async fn run_status_command() -> anyhow::Result<()> {
); );
// Database // Database
let db_url_set = settings.database_url.is_some() || std::env::var("DATABASE_URL").is_ok();
print!(" Database: "); print!(" Database: ");
let db_backend = std::env::var("DATABASE_BACKEND") if db_url_set {
.ok() // Try to connect
.unwrap_or_else(|| "postgres".to_string()); match check_database().await {
match db_backend.as_str() { Ok(()) => println!("connected"),
"libsql" | "turso" | "sqlite" => { Err(e) => println!("error ({})", e),
let path = std::env::var("LIBSQL_PATH")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| crate::config::default_libsql_path());
if path.exists() {
let turso = if std::env::var("LIBSQL_URL").is_ok() {
" + Turso sync"
} else {
""
};
println!("libSQL ({}{})", path.display(), turso);
} else {
println!("libSQL (file missing: {})", path.display());
}
}
_ => {
if std::env::var("DATABASE_URL").is_ok() {
match check_database().await {
Ok(()) => println!("connected (PostgreSQL)"),
Err(e) => println!("error ({})", e),
}
} else {
println!("not configured");
}
} }
} else {
println!("not configured");
} }
// Session / Auth // Session / Auth
@@ -63,17 +43,15 @@ pub async fn run_status_command() -> anyhow::Result<()> {
println!("not found (run `ironclaw onboard`)"); println!("not found (run `ironclaw onboard`)");
} }
// Secrets (auto-detect from env only; skip keychain probe to avoid // Secrets
// triggering macOS system password dialogs on a simple status check)
print!(" Secrets: "); print!(" Secrets: ");
if std::env::var("SECRETS_MASTER_KEY").is_ok() { let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None
println!("configured (env)"); || std::env::var("SECRETS_MASTER_KEY").is_ok()
|| crate::secrets::keychain::has_master_key();
if secrets_configured {
println!("configured ({:?})", settings.secrets_master_key_source);
} else { } else {
// We don't probe the keychain here because get_generic_password() println!("not configured");
// triggers macOS unlock+authorization dialogs, which is bad UX for
// a read-only status command. If onboarding completed with keychain
// storage, the key is there; we just can't cheaply verify it.
println!("env not set (keychain may be configured)");
} }
// Embeddings // Embeddings
@@ -151,18 +129,19 @@ pub async fn run_status_command() -> anyhow::Result<()> {
Err(_) => println!("none configured"), Err(_) => println!("none configured"),
} }
// Config path // Settings path
println!( println!("\n Settings: {}", Settings::default_path().display());
"\n Config: {}",
crate::bootstrap::ironclaw_env_path().display()
);
Ok(()) Ok(())
} }
#[cfg(feature = "postgres")]
async fn check_database() -> anyhow::Result<()> { async fn check_database() -> anyhow::Result<()> {
let url = std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL not set"))?; let _ = dotenvy::dotenv();
let settings = Settings::load();
let url = std::env::var("DATABASE_URL")
.ok()
.or(settings.database_url)
.ok_or_else(|| anyhow::anyhow!("no URL"))?;
let config: deadpool_postgres::Config = deadpool_postgres::Config { let config: deadpool_postgres::Config = deadpool_postgres::Config {
url: Some(url), url: Some(url),
@@ -188,12 +167,6 @@ async fn check_database() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
#[cfg(not(feature = "postgres"))]
async fn check_database() -> anyhow::Result<()> {
// For non-postgres backends, just report configured
Ok(())
}
fn count_wasm_files(dir: &std::path::Path) -> usize { fn count_wasm_files(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir) std::fs::read_dir(dir)
.map(|entries| { .map(|entries| {
+164 -236
View File
@@ -11,11 +11,8 @@ use clap::Subcommand;
use tokio::fs; use tokio::fs;
use crate::config::Config; use crate::config::Config;
#[allow(unused_imports)] use crate::history::Store;
use crate::db::Database; use crate::secrets::{CreateSecretParams, PostgresSecretsStore, SecretsCrypto, SecretsStore};
#[cfg(feature = "postgres")]
use crate::secrets::PostgresSecretsStore;
use crate::secrets::{CreateSecretParams, SecretsCrypto, SecretsStore};
use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash}; use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
/// Default tools directory. /// Default tools directory.
@@ -423,11 +420,11 @@ async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result<String> {
// Simple TOML parsing for [package] name // Simple TOML parsing for [package] name
for line in content.lines() { for line in content.lines() {
let line = line.trim(); let line = line.trim();
if line.starts_with("name") if line.starts_with("name") {
&& let Some((_, value)) = line.split_once('=') if let Some((_, value)) = line.split_once('=') {
{ let name = value.trim().trim_matches('"').trim_matches('\'');
let name = value.trim().trim_matches('"').trim_matches('\''); return Ok(name.to_string());
return Ok(name.to_string()); }
} }
} }
@@ -491,10 +488,10 @@ async fn list_tools(dir: Option<PathBuf>, verbose: bool) -> anyhow::Result<()> {
if has_caps { if has_caps {
let caps_path = path.with_extension("capabilities.json"); let caps_path = path.with_extension("capabilities.json");
if let Ok(content) = fs::read_to_string(&caps_path).await if let Ok(content) = fs::read_to_string(&caps_path).await {
&& let Ok(caps) = CapabilitiesFile::from_json(&content) if let Ok(caps) = CapabilitiesFile::from_json(&content) {
{ print_capabilities_summary(&caps);
print_capabilities_summary(&caps); }
} }
} }
println!(); println!();
@@ -607,16 +604,16 @@ fn print_capabilities_summary(caps: &CapabilitiesFile) {
} }
} }
if let Some(ref secrets) = caps.secrets if let Some(ref secrets) = caps.secrets {
&& !secrets.allowed_names.is_empty() if !secrets.allowed_names.is_empty() {
{ parts.push(format!("secrets: {}", secrets.allowed_names.len()));
parts.push(format!("secrets: {}", secrets.allowed_names.len())); }
} }
if let Some(ref ws) = caps.workspace if let Some(ref ws) = caps.workspace {
&& !ws.allowed_prefixes.is_empty() if !ws.allowed_prefixes.is_empty() {
{ parts.push("workspace: read".to_string());
parts.push("workspace: read".to_string()); }
} }
if !parts.is_empty() { if !parts.is_empty() {
@@ -653,30 +650,30 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
} }
} }
if let Some(ref secrets) = caps.secrets if let Some(ref secrets) = caps.secrets {
&& !secrets.allowed_names.is_empty() if !secrets.allowed_names.is_empty() {
{ println!(" Secrets (existence check only):");
println!(" Secrets (existence check only):"); for name in &secrets.allowed_names {
for name in &secrets.allowed_names { println!(" {}", name);
println!(" {}", name); }
} }
} }
if let Some(ref tool_invoke) = caps.tool_invoke if let Some(ref tool_invoke) = caps.tool_invoke {
&& !tool_invoke.aliases.is_empty() if !tool_invoke.aliases.is_empty() {
{ println!(" Tool aliases:");
println!(" Tool aliases:"); for (alias, real_name) in &tool_invoke.aliases {
for (alias, real_name) in &tool_invoke.aliases { println!(" {} -> {}", alias, real_name);
println!(" {} -> {}", alias, real_name); }
} }
} }
if let Some(ref ws) = caps.workspace if let Some(ref ws) = caps.workspace {
&& !ws.allowed_prefixes.is_empty() if !ws.allowed_prefixes.is_empty() {
{ println!(" Workspace read prefixes:");
println!(" Workspace read prefixes:"); for prefix in &ws.allowed_prefixes {
for prefix in &ws.allowed_prefixes { println!(" {}", prefix);
println!(" {}", prefix); }
} }
} }
} }
@@ -718,65 +715,18 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
println!(); println!();
// Initialize secrets store // Initialize secrets store
let config = Config::from_env().await?; let config = Config::from_env()?;
let master_key = config.secrets.master_key().ok_or_else(|| { let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env" "SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
) )
})?; })?;
let store = Store::new(&config.database).await?;
store.run_migrations().await?;
let crypto = SecretsCrypto::new(master_key.clone())?; let crypto = SecretsCrypto::new(master_key.clone())?;
let secrets_store = Arc::new(PostgresSecretsStore::new(store.pool(), Arc::new(crypto)));
let secrets_store: Arc<dyn SecretsStore + Send + Sync> = {
#[cfg(feature = "postgres")]
{
let store = crate::history::Store::new(&config.database).await?;
store.run_migrations().await?;
Arc::new(PostgresSecretsStore::new(store.pool(), Arc::new(crypto)))
}
#[cfg(all(feature = "libsql", not(feature = "postgres")))]
{
use crate::db::Database as _;
use crate::db::libsql_backend::LibSqlBackend;
use secrecy::ExposeSecret as _;
let default_path = crate::config::default_libsql_path();
let db_path = config
.database
.libsql_path
.as_deref()
.unwrap_or(&default_path);
let backend = if let Some(ref url) = config.database.libsql_url {
let token = config.database.libsql_auth_token.as_ref().ok_or_else(|| {
anyhow::anyhow!("LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set")
})?;
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret())
.await
.map_err(|e| anyhow::anyhow!("{}", e))?
} else {
LibSqlBackend::new_local(db_path)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?
};
backend
.run_migrations()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
Arc::new(crate::secrets::LibSqlSecretsStore::new(
backend.shared_db(),
Arc::new(crypto),
))
}
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
{
let _ = crypto;
anyhow::bail!(
"No database backend available for secrets. Enable 'postgres' or 'libsql' feature."
);
}
};
// Check if already configured // Check if already configured
let already_configured = secrets_store let already_configured = secrets_store
@@ -802,103 +752,51 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
} }
// Check for environment variable // Check for environment variable
if let Some(ref env_var) = auth.env_var if let Some(ref env_var) = auth.env_var {
&& let Ok(token) = std::env::var(env_var) if let Ok(token) = std::env::var(env_var) {
&& !token.is_empty() if !token.is_empty() {
{ println!(" Found {} in environment.", env_var);
println!(" Found {} in environment.", env_var); println!();
println!();
// Validate if endpoint is provided // Validate if endpoint is provided
if let Some(ref validation) = auth.validation_endpoint { if let Some(ref validation) = auth.validation_endpoint {
print!(" Validating token..."); print!(" Validating token...");
std::io::stdout().flush()?; std::io::stdout().flush()?;
match validate_token(&token, validation, &auth.secret_name).await { match validate_token(&token, validation, &auth.secret_name).await {
Ok(()) => { Ok(()) => {
println!(""); println!("");
} }
Err(e) => { Err(e) => {
println!(""); println!("");
println!(" Validation failed: {}", e); println!(" Validation failed: {}", e);
println!(); println!();
println!(" Falling back to manual entry..."); println!(" Falling back to manual entry...");
return auth_tool_manual(secrets_store.as_ref(), &user_id, &auth).await; return auth_tool_manual(&secrets_store, &user_id, &auth).await;
}
}
} }
// Save the token
save_token(&secrets_store, &user_id, &auth, &token).await?;
print_success(display_name);
return Ok(());
} }
} }
// Save the token
save_token(secrets_store.as_ref(), &user_id, &auth, &token, None, None).await?;
print_success(display_name);
return Ok(());
} }
// Check for OAuth configuration // Check for OAuth configuration
if let Some(ref oauth) = auth.oauth { if let Some(ref oauth) = auth.oauth {
// For providers with shared tokens (e.g., all Google tools share google_oauth_token), return auth_tool_oauth(&secrets_store, &user_id, &auth, oauth).await;
// combine scopes from all installed tools so one auth covers everything.
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
if combined.scopes.len() > oauth.scopes.len() {
let extra = combined.scopes.len() - oauth.scopes.len();
println!(
" Including scopes from {} other installed tool(s) sharing this credential.",
extra
);
println!();
}
return auth_tool_oauth(secrets_store.as_ref(), &user_id, &auth, &combined).await;
} }
// Fall back to manual entry // Fall back to manual entry
auth_tool_manual(secrets_store.as_ref(), &user_id, &auth).await auth_tool_manual(&secrets_store, &user_id, &auth).await
}
/// Scan the tools directory for all capabilities files sharing the same secret_name
/// and combine their OAuth scopes. This way, authing any Google tool requests scopes
/// for ALL installed Google tools, so one login covers everything.
async fn combine_provider_scopes(
tools_dir: &Path,
secret_name: &str,
base_oauth: &crate::tools::wasm::OAuthConfigSchema,
) -> crate::tools::wasm::OAuthConfigSchema {
let mut all_scopes: std::collections::HashSet<String> =
base_oauth.scopes.iter().cloned().collect();
if let Ok(mut entries) = tokio::fs::read_dir(tools_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if !name.ends_with(".capabilities.json") {
continue;
}
if let Ok(content) = tokio::fs::read_to_string(&path).await
&& let Ok(caps) = CapabilitiesFile::from_json(&content)
&& let Some(auth) = &caps.auth
&& auth.secret_name == secret_name
&& let Some(oauth) = &auth.oauth
{
all_scopes.extend(oauth.scopes.iter().cloned());
}
}
}
let mut combined = base_oauth.clone();
combined.scopes = all_scopes.into_iter().collect();
combined.scopes.sort(); // deterministic ordering
combined
} }
/// OAuth browser-based login flow. /// OAuth browser-based login flow.
async fn auth_tool_oauth( async fn auth_tool_oauth(
store: &(dyn SecretsStore + Send + Sync), store: &PostgresSecretsStore,
user_id: &str, user_id: &str,
auth: &crate::tools::wasm::AuthCapabilitySchema, auth: &crate::tools::wasm::AuthCapabilitySchema,
oauth: &crate::tools::wasm::OAuthConfigSchema, oauth: &crate::tools::wasm::OAuthConfigSchema,
@@ -906,14 +804,12 @@ async fn auth_tool_oauth(
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore; use rand::RngCore;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT}; use tokio::net::TcpListener;
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name); let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
// Get client_id: capabilities file > runtime env var > built-in defaults // Get client_id from config or env
let builtin = oauth_defaults::builtin_credentials(&auth.secret_name);
let client_id = oauth let client_id = oauth
.client_id .client_id
.clone() .clone()
@@ -923,32 +819,41 @@ async fn auth_tool_oauth(
.as_ref() .as_ref()
.and_then(|env| std::env::var(env).ok()) .and_then(|env| std::env::var(env).ok())
}) })
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
.ok_or_else(|| { .ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"OAuth client_id not configured.\n\ "OAuth client_id not configured.\n\
Set {} env var, or build with IRONCLAW_GOOGLE_CLIENT_ID.", Set it in the capabilities file or via environment variable."
oauth.client_id_env.as_deref().unwrap_or("the client_id")
) )
})?; })?;
// Get client_secret: capabilities file > runtime env var > built-in defaults // Get client_secret if provided
let client_secret = oauth let client_secret = oauth.client_secret.clone().or_else(|| {
.client_secret oauth
.clone() .client_secret_env
.or_else(|| { .as_ref()
oauth .and_then(|env| std::env::var(env).ok())
.client_secret_env });
.as_ref()
.and_then(|env| std::env::var(env).ok())
})
.or_else(|| builtin.as_ref().map(|c| c.client_secret.to_string()));
println!(" Starting OAuth authentication..."); println!(" Starting OAuth authentication...");
println!(); println!();
let listener = oauth_defaults::bind_callback_listener().await?; // Find an available port for the callback
let redirect_uri = format!("http://localhost:{}/callback", OAUTH_CALLBACK_PORT); let mut listener = None;
let mut port = 0;
for p in 9876..=9886 {
match TcpListener::bind(format!("127.0.0.1:{}", p)).await {
Ok(l) => {
listener = Some(l);
port = p;
break;
}
Err(_) => continue,
}
}
let listener = listener.ok_or_else(|| anyhow::anyhow!("Could not find available port"))?;
let redirect_uri = format!("http://localhost:{}/callback", port);
// Generate PKCE verifier and challenge // Generate PKCE verifier and challenge
let (code_verifier, code_challenge) = if oauth.use_pkce { let (code_verifier, code_challenge) = if oauth.use_pkce {
@@ -1007,8 +912,65 @@ async fn auth_tool_oauth(
println!(" Waiting for authorization..."); println!(" Waiting for authorization...");
let code = // Wait for callback with timeout
oauth_defaults::wait_for_callback(listener, "/callback", "code", display_name).await?; let timeout = std::time::Duration::from_secs(300);
let code = tokio::time::timeout(timeout, async {
loop {
let (mut socket, _) = listener.accept().await?;
let mut reader = BufReader::new(&mut socket);
let mut request_line = String::new();
reader.read_line(&mut request_line).await?;
// Parse GET /callback?code=xxx HTTP/1.1
if let Some(path) = request_line.split_whitespace().nth(1) {
if path.starts_with("/callback") {
if let Some(query) = path.split('?').nth(1) {
for param in query.split('&') {
let parts: Vec<&str> = param.splitn(2, '=').collect();
if parts.len() == 2 && parts[0] == "code" {
let code = urlencoding::decode(parts[1])
.unwrap_or_else(|_| parts[1].into())
.into_owned();
// Send success response
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: text/html\r\n\
\r\n\
<!DOCTYPE html><html><body style=\"font-family: sans-serif; \
display: flex; justify-content: center; align-items: center; \
height: 100vh; margin: 0; background: #191919; color: white;\">\
<div style=\"text-align: center;\">\
<h1> {} Connected!</h1>\
<p>You can close this window.</p>\
</div></body></html>",
display_name
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.shutdown().await;
return Ok::<_, anyhow::Error>(code);
}
}
// Check for error
if query.contains("error=") {
let response =
"HTTP/1.1 400 Bad Request\r\n\r\nAuthorization denied";
let _ = socket.write_all(response.as_bytes()).await;
return Err(anyhow::anyhow!("Authorization denied by user"));
}
}
}
}
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
let _ = socket.write_all(response.as_bytes()).await;
}
})
.await
.map_err(|_| anyhow::anyhow!("Timed out waiting for authorization"))??;
println!(); println!();
println!(" Exchanging code for token..."); println!(" Exchanging code for token...");
@@ -1059,19 +1021,8 @@ async fn auth_tool_oauth(
) )
})?; })?;
let refresh_token = token_data.get("refresh_token").and_then(|v| v.as_str()); // Save the token
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64()); save_token(store, user_id, auth, access_token).await?;
// Save the token (with refresh token and expiry if provided)
save_token(
store,
user_id,
auth,
access_token,
refresh_token,
expires_in,
)
.await?;
// Extract any additional info for display // Extract any additional info for display
let workspace_name = token_data let workspace_name = token_data
@@ -1093,7 +1044,7 @@ async fn auth_tool_oauth(
/// Manual token entry flow. /// Manual token entry flow.
async fn auth_tool_manual( async fn auth_tool_manual(
store: &(dyn SecretsStore + Send + Sync), store: &PostgresSecretsStore,
user_id: &str, user_id: &str,
auth: &crate::tools::wasm::AuthCapabilitySchema, auth: &crate::tools::wasm::AuthCapabilitySchema,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
@@ -1173,8 +1124,8 @@ async fn auth_tool_manual(
} }
} }
// Save the token (manual path: no refresh token or expiry) // Save the token
save_token(store, user_id, auth, &token, None, None).await?; save_token(store, user_id, auth, &token).await?;
print_success(display_name); print_success(display_name);
Ok(()) Ok(())
} }
@@ -1265,16 +1216,11 @@ async fn validate_token(
} }
/// Save token to secrets store. /// Save token to secrets store.
///
/// Optionally stores a refresh token (as `{secret_name}_refresh_token`) and
/// sets `expires_at` on the access token so the runtime can auto-refresh.
async fn save_token( async fn save_token(
store: &(dyn SecretsStore + Send + Sync), store: &PostgresSecretsStore,
user_id: &str, user_id: &str,
auth: &crate::tools::wasm::AuthCapabilitySchema, auth: &crate::tools::wasm::AuthCapabilitySchema,
token: &str, token: &str,
refresh_token: Option<&str>,
expires_in: Option<u64>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut params = CreateSecretParams::new(&auth.secret_name, token); let mut params = CreateSecretParams::new(&auth.secret_name, token);
@@ -1282,29 +1228,11 @@ async fn save_token(
params = params.with_provider(provider); params = params.with_provider(provider);
} }
if let Some(secs) = expires_in {
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(secs as i64);
params = params.with_expiry(expires_at);
}
store store
.create(user_id, params) .create(user_id, params)
.await .await
.map_err(|e| anyhow::anyhow!("Failed to save token: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to save token: {}", e))?;
// Store refresh token separately (no expiry, it's long-lived)
if let Some(rt) = refresh_token {
let refresh_name = format!("{}_refresh_token", auth.secret_name);
let mut refresh_params = CreateSecretParams::new(&refresh_name, rt);
if let Some(ref provider) = auth.provider {
refresh_params = refresh_params.with_provider(provider);
}
store
.create(user_id, refresh_params)
.await
.map_err(|e| anyhow::anyhow!("Failed to save refresh token: {}", e))?;
}
Ok(()) Ok(())
} }
+173 -638
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -45,21 +45,20 @@ impl ContextManager {
title: impl Into<String>, title: impl Into<String>,
description: impl Into<String>, description: impl Into<String>,
) -> Result<Uuid, JobError> { ) -> Result<Uuid, JobError> {
// Hold write lock for the entire check-insert to prevent TOCTOU races let contexts = self.contexts.read().await;
// where two concurrent calls both pass the active_count check.
let mut contexts = self.contexts.write().await;
let active_count = contexts.values().filter(|c| c.state.is_active()).count(); let active_count = contexts.values().filter(|c| c.state.is_active()).count();
if active_count >= self.max_jobs { if active_count >= self.max_jobs {
return Err(JobError::MaxJobsExceeded { max: self.max_jobs }); return Err(JobError::MaxJobsExceeded { max: self.max_jobs });
} }
drop(contexts);
let context = JobContext::with_user(user_id, title, description); let context = JobContext::with_user(user_id, title, description);
let job_id = context.job_id; let job_id = context.job_id;
contexts.insert(job_id, context);
drop(contexts);
let memory = Memory::new(job_id); let memory = Memory::new(job_id);
self.contexts.write().await.insert(job_id, context);
self.memories.write().await.insert(job_id, memory); self.memories.write().await.insert(job_id, memory);
Ok(job_id) Ok(job_id)
-88
View File
@@ -119,10 +119,6 @@ pub struct JobContext {
pub estimated_duration: Option<Duration>, pub estimated_duration: Option<Duration>,
/// Actual cost so far. /// Actual cost so far.
pub actual_cost: Decimal, pub actual_cost: Decimal,
/// Total tokens consumed by LLM calls in this job.
pub total_tokens_used: u64,
/// Maximum tokens allowed per job (0 = unlimited).
pub max_tokens: u64,
/// When the job was created. /// When the job was created.
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
/// When the job was started. /// When the job was started.
@@ -163,8 +159,6 @@ impl JobContext {
estimated_cost: None, estimated_cost: None,
estimated_duration: None, estimated_duration: None,
actual_cost: Decimal::ZERO, actual_cost: Decimal::ZERO,
total_tokens_used: 0,
max_tokens: 0,
created_at: Utc::now(), created_at: Utc::now(),
started_at: None, started_at: None,
completed_at: None, completed_at: None,
@@ -195,14 +189,6 @@ impl JobContext {
}; };
self.transitions.push(transition); self.transitions.push(transition);
// Cap transition history to prevent unbounded memory growth
const MAX_TRANSITIONS: usize = 200;
if self.transitions.len() > MAX_TRANSITIONS {
let drain_count = self.transitions.len() - MAX_TRANSITIONS;
self.transitions.drain(..drain_count);
}
self.state = new_state; self.state = new_state;
// Update timestamps // Update timestamps
@@ -224,29 +210,6 @@ impl JobContext {
self.actual_cost += cost; self.actual_cost += cost;
} }
/// Record token usage from an LLM call. Returns an error string if the
/// token budget has been exceeded after this addition.
pub fn add_tokens(&mut self, tokens: u64) -> Result<(), String> {
self.total_tokens_used += tokens;
if self.max_tokens > 0 && self.total_tokens_used > self.max_tokens {
Err(format!(
"Token budget exceeded: used {} of {} allowed tokens",
self.total_tokens_used, self.max_tokens
))
} else {
Ok(())
}
}
/// Check whether the monetary budget has been exceeded.
pub fn budget_exceeded(&self) -> bool {
if let Some(ref budget) = self.budget {
self.actual_cost > *budget
} else {
false
}
}
/// Get the duration since the job started. /// Get the duration since the job started.
pub fn elapsed(&self) -> Option<Duration> { pub fn elapsed(&self) -> Option<Duration> {
self.started_at.map(|start| { self.started_at.map(|start| {
@@ -311,57 +274,6 @@ mod tests {
assert_eq!(ctx.state, JobState::Completed); assert_eq!(ctx.state, JobState::Completed);
} }
#[test]
fn test_transition_history_capped() {
let mut ctx = JobContext::new("Test", "Transition cap test");
// Cycle through Pending -> InProgress -> Stuck -> InProgress -> Stuck ...
ctx.transition_to(JobState::InProgress, None).unwrap();
for i in 0..250 {
ctx.mark_stuck(format!("stuck {}", i)).unwrap();
ctx.attempt_recovery().unwrap();
}
// 1 initial + 250*2 = 501 transitions, should be capped at 200
assert!(
ctx.transitions.len() <= 200,
"transitions should be capped at 200, got {}",
ctx.transitions.len()
);
}
#[test]
fn test_add_tokens_enforces_budget() {
let mut ctx = JobContext::new("Test", "Budget test");
ctx.max_tokens = 1000;
assert!(ctx.add_tokens(500).is_ok());
assert_eq!(ctx.total_tokens_used, 500);
assert!(ctx.add_tokens(600).is_err());
assert_eq!(ctx.total_tokens_used, 1100); // tokens still recorded
}
#[test]
fn test_add_tokens_unlimited() {
let mut ctx = JobContext::new("Test", "No budget");
// max_tokens = 0 means unlimited
assert!(ctx.add_tokens(1_000_000).is_ok());
}
#[test]
fn test_budget_exceeded() {
let mut ctx = JobContext::new("Test", "Money test");
ctx.budget = Some(Decimal::new(100, 0)); // $100
assert!(!ctx.budget_exceeded());
ctx.add_cost(Decimal::new(50, 0));
assert!(!ctx.budget_exceeded());
ctx.add_cost(Decimal::new(60, 0));
assert!(ctx.budget_exceeded());
}
#[test]
fn test_budget_exceeded_none() {
let ctx = JobContext::new("Test", "No budget");
assert!(!ctx.budget_exceeded()); // No budget = never exceeded
}
#[test] #[test]
fn test_stuck_recovery() { fn test_stuck_recovery() {
let mut ctx = JobContext::new("Test", "Test job"); let mut ctx = JobContext::new("Test", "Test job");
File diff suppressed because it is too large Load Diff
-549
View File
@@ -1,549 +0,0 @@
//! SQLite-dialect migrations for the libSQL/Turso backend.
//!
//! Consolidates all PostgreSQL migrations (V1-V8) into a single SQLite-compatible
//! schema. Run once on database creation; idempotent via `IF NOT EXISTS`.
/// Consolidated schema for libSQL.
///
/// Translates PostgreSQL types and features:
/// - `UUID` -> `TEXT` (store as hex string)
/// - `TIMESTAMPTZ` -> `TEXT` (ISO-8601)
/// - `JSONB` -> `TEXT` (JSON encoded)
/// - `BYTEA` -> `BLOB`
/// - `NUMERIC` -> `TEXT` (preserve precision for rust_decimal)
/// - `TEXT[]` -> `TEXT` (JSON array)
/// - `VECTOR(1536)` -> `F32_BLOB(1536)` (libsql native)
/// - `TSVECTOR` -> FTS5 virtual table
/// - `BIGSERIAL` -> `INTEGER PRIMARY KEY AUTOINCREMENT`
/// - PL/pgSQL functions -> SQLite triggers
pub const SCHEMA: &str = r#"
-- ==================== Migration tracking ====================
CREATE TABLE IF NOT EXISTS _migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ==================== Conversations ====================
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
channel TEXT NOT NULL,
user_id TEXT NOT NULL,
thread_id TEXT,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
last_activity TEXT NOT NULL DEFAULT (datetime('now')),
metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_conversations_channel ON conversations(channel);
CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id);
CREATE INDEX IF NOT EXISTS idx_conversations_last_activity ON conversations(last_activity);
CREATE TABLE IF NOT EXISTS conversation_messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_conversation_messages_conversation
ON conversation_messages(conversation_id);
-- ==================== Agent Jobs ====================
CREATE TABLE IF NOT EXISTS agent_jobs (
id TEXT PRIMARY KEY,
marketplace_job_id TEXT,
conversation_id TEXT REFERENCES conversations(id),
title TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT,
status TEXT NOT NULL,
source TEXT NOT NULL,
user_id TEXT NOT NULL DEFAULT 'default',
project_dir TEXT,
job_mode TEXT NOT NULL DEFAULT 'worker',
budget_amount TEXT,
budget_token TEXT,
bid_amount TEXT,
estimated_cost TEXT,
estimated_time_secs INTEGER,
estimated_value TEXT,
actual_cost TEXT,
actual_time_secs INTEGER,
success INTEGER,
failure_reason TEXT,
stuck_since TEXT,
repair_attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_status ON agent_jobs(status);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_marketplace ON agent_jobs(marketplace_job_id);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_conversation ON agent_jobs(conversation_id);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_source ON agent_jobs(source);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_user ON agent_jobs(user_id);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_created ON agent_jobs(created_at DESC);
CREATE TABLE IF NOT EXISTS job_actions (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES agent_jobs(id) ON DELETE CASCADE,
sequence_num INTEGER NOT NULL,
tool_name TEXT NOT NULL,
input TEXT NOT NULL,
output_raw TEXT,
output_sanitized TEXT,
sanitization_warnings TEXT,
cost TEXT,
duration_ms INTEGER,
success INTEGER NOT NULL,
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(job_id, sequence_num)
);
CREATE INDEX IF NOT EXISTS idx_job_actions_job_id ON job_actions(job_id);
CREATE INDEX IF NOT EXISTS idx_job_actions_tool ON job_actions(tool_name);
-- ==================== Dynamic Tools ====================
CREATE TABLE IF NOT EXISTS dynamic_tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
parameters_schema TEXT NOT NULL,
code TEXT NOT NULL,
sandbox_config TEXT NOT NULL,
created_by_job_id TEXT REFERENCES agent_jobs(id),
success_count INTEGER NOT NULL DEFAULT 0,
failure_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_dynamic_tools_status ON dynamic_tools(status);
CREATE INDEX IF NOT EXISTS idx_dynamic_tools_name ON dynamic_tools(name);
-- ==================== LLM Calls ====================
CREATE TABLE IF NOT EXISTS llm_calls (
id TEXT PRIMARY KEY,
job_id TEXT REFERENCES agent_jobs(id) ON DELETE CASCADE,
conversation_id TEXT REFERENCES conversations(id),
provider TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cost TEXT NOT NULL,
purpose TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_llm_calls_job ON llm_calls(job_id);
CREATE INDEX IF NOT EXISTS idx_llm_calls_conversation ON llm_calls(conversation_id);
CREATE INDEX IF NOT EXISTS idx_llm_calls_provider ON llm_calls(provider);
-- ==================== Estimation ====================
CREATE TABLE IF NOT EXISTS estimation_snapshots (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES agent_jobs(id) ON DELETE CASCADE,
category TEXT NOT NULL,
tool_names TEXT NOT NULL DEFAULT '[]',
estimated_cost TEXT NOT NULL,
actual_cost TEXT,
estimated_time_secs INTEGER NOT NULL,
actual_time_secs INTEGER,
estimated_value TEXT NOT NULL,
actual_value TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_estimation_category ON estimation_snapshots(category);
CREATE INDEX IF NOT EXISTS idx_estimation_job ON estimation_snapshots(job_id);
-- ==================== Self Repair ====================
CREATE TABLE IF NOT EXISTS repair_attempts (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
diagnosis TEXT NOT NULL,
action_taken TEXT NOT NULL,
success INTEGER NOT NULL,
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_repair_attempts_target ON repair_attempts(target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_repair_attempts_created ON repair_attempts(created_at);
-- ==================== Workspace: Memory Documents ====================
CREATE TABLE IF NOT EXISTS memory_documents (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
agent_id TEXT,
path TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
metadata TEXT NOT NULL DEFAULT '{}',
UNIQUE (user_id, agent_id, path)
);
CREATE INDEX IF NOT EXISTS idx_memory_documents_user ON memory_documents(user_id);
CREATE INDEX IF NOT EXISTS idx_memory_documents_path ON memory_documents(user_id, path);
CREATE INDEX IF NOT EXISTS idx_memory_documents_updated ON memory_documents(updated_at DESC);
-- Trigger to auto-update updated_at on memory_documents
CREATE TRIGGER IF NOT EXISTS update_memory_documents_updated_at
AFTER UPDATE ON memory_documents
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE memory_documents SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- ==================== Workspace: Memory Chunks ====================
CREATE TABLE IF NOT EXISTS memory_chunks (
_rowid INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding F32_BLOB(1536),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (document_id, chunk_index)
);
CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
-- Vector index for semantic search (libSQL native)
CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedding
ON memory_chunks (libsql_vector_idx(embedding));
-- FTS5 virtual table for full-text search
CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
content,
content='memory_chunks',
content_rowid='_rowid'
);
-- Triggers to keep FTS5 in sync with memory_chunks
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_insert AFTER INSERT ON memory_chunks BEGIN
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_delete AFTER DELETE ON memory_chunks BEGIN
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
VALUES ('delete', old._rowid, old.content);
END;
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chunks BEGIN
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
VALUES ('delete', old._rowid, old.content);
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
END;
-- ==================== Workspace: Heartbeat State ====================
CREATE TABLE IF NOT EXISTS heartbeat_state (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
agent_id TEXT,
last_run TEXT,
next_run TEXT,
interval_seconds INTEGER NOT NULL DEFAULT 1800,
enabled INTEGER NOT NULL DEFAULT 1,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_checks TEXT NOT NULL DEFAULT '{}',
UNIQUE (user_id, agent_id)
);
CREATE INDEX IF NOT EXISTS idx_heartbeat_user ON heartbeat_state(user_id);
-- ==================== Secrets ====================
CREATE TABLE IF NOT EXISTS secrets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
encrypted_value BLOB NOT NULL,
key_salt BLOB NOT NULL,
provider TEXT,
expires_at TEXT,
last_used_at TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_secrets_user ON secrets(user_id);
-- ==================== WASM Tools ====================
CREATE TABLE IF NOT EXISTS wasm_tools (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '1.0.0',
description TEXT NOT NULL,
wasm_binary BLOB NOT NULL,
binary_hash BLOB NOT NULL,
parameters_schema TEXT NOT NULL,
source_url TEXT,
trust_level TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, name, version)
);
CREATE INDEX IF NOT EXISTS idx_wasm_tools_user ON wasm_tools(user_id);
CREATE INDEX IF NOT EXISTS idx_wasm_tools_name ON wasm_tools(user_id, name);
CREATE INDEX IF NOT EXISTS idx_wasm_tools_status ON wasm_tools(status);
-- ==================== Tool Capabilities ====================
CREATE TABLE IF NOT EXISTS tool_capabilities (
id TEXT PRIMARY KEY,
wasm_tool_id TEXT NOT NULL REFERENCES wasm_tools(id) ON DELETE CASCADE,
http_allowlist TEXT NOT NULL DEFAULT '[]',
allowed_secrets TEXT NOT NULL DEFAULT '[]',
tool_aliases TEXT NOT NULL DEFAULT '{}',
requests_per_minute INTEGER NOT NULL DEFAULT 60,
requests_per_hour INTEGER NOT NULL DEFAULT 1000,
max_request_body_bytes INTEGER NOT NULL DEFAULT 1048576,
max_response_body_bytes INTEGER NOT NULL DEFAULT 10485760,
workspace_read_prefixes TEXT NOT NULL DEFAULT '[]',
http_timeout_secs INTEGER NOT NULL DEFAULT 30,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (wasm_tool_id)
);
-- ==================== Leak Detection Patterns ====================
CREATE TABLE IF NOT EXISTS leak_detection_patterns (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
pattern TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'high',
action TEXT NOT NULL DEFAULT 'block',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ==================== Rate Limit State ====================
CREATE TABLE IF NOT EXISTS tool_rate_limit_state (
id TEXT PRIMARY KEY,
wasm_tool_id TEXT NOT NULL REFERENCES wasm_tools(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
minute_window_start TEXT NOT NULL DEFAULT (datetime('now')),
minute_count INTEGER NOT NULL DEFAULT 0,
hour_window_start TEXT NOT NULL DEFAULT (datetime('now')),
hour_count INTEGER NOT NULL DEFAULT 0,
UNIQUE (wasm_tool_id, user_id)
);
-- ==================== Secret Usage Audit Log ====================
CREATE TABLE IF NOT EXISTS secret_usage_log (
id TEXT PRIMARY KEY,
secret_id TEXT NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
wasm_tool_id TEXT REFERENCES wasm_tools(id) ON DELETE SET NULL,
user_id TEXT NOT NULL,
target_host TEXT NOT NULL,
target_path TEXT,
success INTEGER NOT NULL,
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_secret_usage_user ON secret_usage_log(user_id);
-- ==================== Leak Detection Events ====================
CREATE TABLE IF NOT EXISTS leak_detection_events (
id TEXT PRIMARY KEY,
pattern_id TEXT REFERENCES leak_detection_patterns(id) ON DELETE SET NULL,
wasm_tool_id TEXT REFERENCES wasm_tools(id) ON DELETE SET NULL,
user_id TEXT NOT NULL,
source TEXT NOT NULL,
action_taken TEXT NOT NULL,
context_preview TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ==================== Tool Failures ====================
CREATE TABLE IF NOT EXISTS tool_failures (
id TEXT PRIMARY KEY,
tool_name TEXT NOT NULL UNIQUE,
error_message TEXT,
error_count INTEGER DEFAULT 1,
first_failure TEXT DEFAULT (datetime('now')),
last_failure TEXT DEFAULT (datetime('now')),
last_build_result TEXT,
repaired_at TEXT,
repair_attempts INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_tool_failures_name ON tool_failures(tool_name);
-- ==================== Job Events ====================
CREATE TABLE IF NOT EXISTS job_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL REFERENCES agent_jobs(id),
event_type TEXT NOT NULL,
data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_job_events_job ON job_events(job_id, id);
-- ==================== Routines ====================
CREATE TABLE IF NOT EXISTS routines (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
trigger_type TEXT NOT NULL,
trigger_config TEXT NOT NULL,
action_type TEXT NOT NULL,
action_config TEXT NOT NULL,
cooldown_secs INTEGER NOT NULL DEFAULT 300,
max_concurrent INTEGER NOT NULL DEFAULT 1,
dedup_window_secs INTEGER,
notify_channel TEXT,
notify_user TEXT NOT NULL DEFAULT 'default',
notify_on_success INTEGER NOT NULL DEFAULT 0,
notify_on_failure INTEGER NOT NULL DEFAULT 1,
notify_on_attention INTEGER NOT NULL DEFAULT 1,
state TEXT NOT NULL DEFAULT '{}',
last_run_at TEXT,
next_fire_at TEXT,
run_count INTEGER NOT NULL DEFAULT 0,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_routines_user ON routines(user_id);
-- ==================== Routine Runs ====================
CREATE TABLE IF NOT EXISTS routine_runs (
id TEXT PRIMARY KEY,
routine_id TEXT NOT NULL REFERENCES routines(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL,
trigger_detail TEXT,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT,
status TEXT NOT NULL DEFAULT 'running',
result_summary TEXT,
tokens_used INTEGER,
job_id TEXT REFERENCES agent_jobs(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_routine_runs_routine ON routine_runs(routine_id);
-- ==================== Settings ====================
CREATE TABLE IF NOT EXISTS settings (
user_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, key)
);
CREATE INDEX IF NOT EXISTS idx_settings_user ON settings(user_id);
-- ==================== Missing indexes (parity with PostgreSQL) ====================
-- agent_jobs
CREATE INDEX IF NOT EXISTS idx_agent_jobs_stuck ON agent_jobs(stuck_since);
-- secrets
CREATE INDEX IF NOT EXISTS idx_secrets_provider ON secrets(provider);
CREATE INDEX IF NOT EXISTS idx_secrets_expires ON secrets(expires_at);
-- wasm_tools
CREATE INDEX IF NOT EXISTS idx_wasm_tools_trust ON wasm_tools(trust_level);
-- tool_capabilities
CREATE INDEX IF NOT EXISTS idx_tool_capabilities_tool ON tool_capabilities(wasm_tool_id);
-- leak_detection_patterns
CREATE INDEX IF NOT EXISTS idx_leak_patterns_enabled ON leak_detection_patterns(enabled);
-- tool_rate_limit_state
CREATE INDEX IF NOT EXISTS idx_rate_limit_tool ON tool_rate_limit_state(wasm_tool_id);
-- secret_usage_log
CREATE INDEX IF NOT EXISTS idx_secret_usage_secret ON secret_usage_log(secret_id);
CREATE INDEX IF NOT EXISTS idx_secret_usage_tool ON secret_usage_log(wasm_tool_id);
CREATE INDEX IF NOT EXISTS idx_secret_usage_created ON secret_usage_log(created_at DESC);
-- leak_detection_events
CREATE INDEX IF NOT EXISTS idx_leak_events_pattern ON leak_detection_events(pattern_id);
CREATE INDEX IF NOT EXISTS idx_leak_events_tool ON leak_detection_events(wasm_tool_id);
CREATE INDEX IF NOT EXISTS idx_leak_events_user ON leak_detection_events(user_id);
CREATE INDEX IF NOT EXISTS idx_leak_events_created ON leak_detection_events(created_at DESC);
-- tool_failures
CREATE INDEX IF NOT EXISTS idx_tool_failures_count ON tool_failures(error_count DESC);
CREATE INDEX IF NOT EXISTS idx_tool_failures_unrepaired ON tool_failures(tool_name);
-- routines
CREATE INDEX IF NOT EXISTS idx_routines_next_fire ON routines(next_fire_at);
CREATE INDEX IF NOT EXISTS idx_routines_event_triggers ON routines(user_id);
-- routine_runs
CREATE INDEX IF NOT EXISTS idx_routine_runs_status ON routine_runs(status);
-- heartbeat_state
CREATE INDEX IF NOT EXISTS idx_heartbeat_next_run ON heartbeat_state(next_run);
-- ==================== Seed data ====================
-- Pre-populate leak detection patterns (matches PostgreSQL V2 migration).
INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, action, enabled, created_at) VALUES
('550e8400-e29b-41d4-a716-446655440001', 'openai_api_key', 'sk-(?:proj-)?[a-zA-Z0-9]{20,}(?:T3BlbkFJ[a-zA-Z0-9_-]*)?', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440002', 'anthropic_api_key', 'sk-ant-api[a-zA-Z0-9_-]{90,}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440003', 'aws_access_key', 'AKIA[0-9A-Z]{16}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440004', 'aws_secret_key', '(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440005', 'github_token', 'gh[pousr]_[A-Za-z0-9_]{36,}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440006', 'github_fine_grained_pat', 'github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440007', 'stripe_api_key', 'sk_(?:live|test)_[a-zA-Z0-9]{24,}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440008', 'nearai_session', 'sess_[a-zA-Z0-9]{32,}', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440009', 'bearer_token', 'Bearer\s+[a-zA-Z0-9_-]{20,}', 'high', 'redact', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000a', 'pem_private_key', '-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000b', 'ssh_private_key', '-----BEGIN\s+(?:OPENSSH|EC|DSA)\s+PRIVATE\s+KEY-----', 'critical', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000c', 'google_api_key', 'AIza[0-9A-Za-z_-]{35}', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000d', 'slack_token', 'xox[baprs]-[0-9a-zA-Z-]{10,}', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000e', 'discord_token', '[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27}', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-44665544000f', 'twilio_api_key', 'SK[a-fA-F0-9]{32}', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440010', 'sendgrid_api_key', 'SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}', 'high', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, datetime('now')),
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, datetime('now'));
"#;
-538
View File
@@ -1,538 +0,0 @@
//! Database abstraction layer.
//!
//! Provides a backend-agnostic `Database` trait that unifies all persistence
//! operations. Two implementations exist behind feature flags:
//!
//! - `postgres` (default): Uses `deadpool-postgres` + `tokio-postgres`
//! - `libsql`: Uses libSQL (Turso's SQLite fork) for embedded/edge deployment
//!
//! The existing `Store`, `Repository`, `SecretsStore`, and `WasmToolStore`
//! types become thin wrappers that delegate to `Arc<dyn Database>`.
#[cfg(feature = "postgres")]
pub mod postgres;
#[cfg(feature = "libsql")]
pub mod libsql_backend;
#[cfg(feature = "libsql")]
pub mod libsql_migrations;
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use uuid::Uuid;
use crate::agent::BrokenTool;
use crate::agent::routine::{Routine, RoutineRun, RunStatus};
use crate::context::{ActionRecord, JobContext, JobState};
use crate::error::DatabaseError;
use crate::error::WorkspaceError;
use crate::history::{
ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
SandboxJobSummary, SettingRow,
};
use crate::workspace::{MemoryChunk, MemoryDocument, WorkspaceEntry};
use crate::workspace::{SearchConfig, SearchResult};
/// Create a database backend from configuration, run migrations, and return it.
///
/// This is the shared helper for CLI commands and other call sites that need
/// a simple `Arc<dyn Database>` without retaining backend-specific handles
/// (e.g., `pg_pool` or `libsql_conn` for the secrets store). The main agent
/// startup in `main.rs` uses its own initialization block because it also
/// captures those backend-specific handles.
pub async fn connect_from_config(
config: &crate::config::DatabaseConfig,
) -> Result<Arc<dyn Database>, DatabaseError> {
match config.backend {
#[cfg(feature = "libsql")]
crate::config::DatabaseBackend::LibSql => {
use secrecy::ExposeSecret as _;
let default_path = crate::config::default_libsql_path();
let db_path = config.libsql_path.as_deref().unwrap_or(&default_path);
let backend = if let Some(ref url) = config.libsql_url {
let token = config.libsql_auth_token.as_ref().ok_or_else(|| {
DatabaseError::Pool(
"LIBSQL_AUTH_TOKEN required when LIBSQL_URL is set".to_string(),
)
})?;
libsql_backend::LibSqlBackend::new_remote_replica(
db_path,
url,
token.expose_secret(),
)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?
} else {
libsql_backend::LibSqlBackend::new_local(db_path)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?
};
backend.run_migrations().await?;
Ok(Arc::new(backend))
}
#[cfg(feature = "postgres")]
_ => {
let pg = postgres::PgBackend::new(config)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
pg.run_migrations().await?;
Ok(Arc::new(pg))
}
#[cfg(not(feature = "postgres"))]
_ => Err(DatabaseError::Pool(
"No database backend available. Enable 'postgres' or 'libsql' feature.".to_string(),
)),
}
}
/// Backend-agnostic database trait.
///
/// Combines all persistence operations from Store, Repository, and related
/// stores into a single trait that can be implemented for different backends.
#[async_trait]
pub trait Database: Send + Sync {
/// Run schema migrations for this backend.
async fn run_migrations(&self) -> Result<(), DatabaseError>;
// ==================== Conversations ====================
/// Create a new conversation.
async fn create_conversation(
&self,
channel: &str,
user_id: &str,
thread_id: Option<&str>,
) -> Result<Uuid, DatabaseError>;
/// Update conversation last activity.
async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError>;
/// Add a message to a conversation.
async fn add_conversation_message(
&self,
conversation_id: Uuid,
role: &str,
content: &str,
) -> Result<Uuid, DatabaseError>;
/// Ensure a conversation row exists (upsert).
async fn ensure_conversation(
&self,
id: Uuid,
channel: &str,
user_id: &str,
thread_id: Option<&str>,
) -> Result<(), DatabaseError>;
/// List conversations with a title preview.
async fn list_conversations_with_preview(
&self,
user_id: &str,
channel: &str,
limit: i64,
) -> Result<Vec<ConversationSummary>, DatabaseError>;
/// Get or create the singleton assistant conversation.
async fn get_or_create_assistant_conversation(
&self,
user_id: &str,
channel: &str,
) -> Result<Uuid, DatabaseError>;
/// Create a conversation with specific metadata.
async fn create_conversation_with_metadata(
&self,
channel: &str,
user_id: &str,
metadata: &serde_json::Value,
) -> Result<Uuid, DatabaseError>;
/// Load messages with cursor-based pagination.
async fn list_conversation_messages_paginated(
&self,
conversation_id: Uuid,
before: Option<DateTime<Utc>>,
limit: i64,
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError>;
/// Merge a single key into conversation metadata.
async fn update_conversation_metadata_field(
&self,
id: Uuid,
key: &str,
value: &serde_json::Value,
) -> Result<(), DatabaseError>;
/// Read conversation metadata.
async fn get_conversation_metadata(
&self,
id: Uuid,
) -> Result<Option<serde_json::Value>, DatabaseError>;
/// Load all messages for a conversation.
async fn list_conversation_messages(
&self,
conversation_id: Uuid,
) -> Result<Vec<ConversationMessage>, DatabaseError>;
/// Check if a conversation belongs to a specific user.
async fn conversation_belongs_to_user(
&self,
conversation_id: Uuid,
user_id: &str,
) -> Result<bool, DatabaseError>;
// ==================== Jobs ====================
/// Save a job context.
async fn save_job(&self, ctx: &JobContext) -> Result<(), DatabaseError>;
/// Get a job by ID.
async fn get_job(&self, id: Uuid) -> Result<Option<JobContext>, DatabaseError>;
/// Update job status.
async fn update_job_status(
&self,
id: Uuid,
status: JobState,
failure_reason: Option<&str>,
) -> Result<(), DatabaseError>;
/// Mark job as stuck.
async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError>;
/// Get stuck jobs.
async fn get_stuck_jobs(&self) -> Result<Vec<Uuid>, DatabaseError>;
// ==================== Actions ====================
/// Save a job action.
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError>;
/// Get actions for a job.
async fn get_job_actions(&self, job_id: Uuid) -> Result<Vec<ActionRecord>, DatabaseError>;
// ==================== LLM Calls ====================
/// Record an LLM call.
async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result<Uuid, DatabaseError>;
// ==================== Estimation Snapshots ====================
/// Save an estimation snapshot.
async fn save_estimation_snapshot(
&self,
job_id: Uuid,
category: &str,
tool_names: &[String],
estimated_cost: Decimal,
estimated_time_secs: i32,
estimated_value: Decimal,
) -> Result<Uuid, DatabaseError>;
/// Update estimation snapshot with actual values.
async fn update_estimation_actuals(
&self,
id: Uuid,
actual_cost: Decimal,
actual_time_secs: i32,
actual_value: Option<Decimal>,
) -> Result<(), DatabaseError>;
// ==================== Sandbox Jobs ====================
/// Insert a new sandbox job.
async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError>;
/// Get a sandbox job by ID.
async fn get_sandbox_job(&self, id: Uuid) -> Result<Option<SandboxJobRecord>, DatabaseError>;
/// List all sandbox jobs, most recent first.
async fn list_sandbox_jobs(&self) -> Result<Vec<SandboxJobRecord>, DatabaseError>;
/// Update sandbox job status.
async fn update_sandbox_job_status(
&self,
id: Uuid,
status: &str,
success: Option<bool>,
message: Option<&str>,
started_at: Option<DateTime<Utc>>,
completed_at: Option<DateTime<Utc>>,
) -> Result<(), DatabaseError>;
/// Mark stale sandbox jobs as interrupted.
async fn cleanup_stale_sandbox_jobs(&self) -> Result<u64, DatabaseError>;
/// Get sandbox job summary.
async fn sandbox_job_summary(&self) -> Result<SandboxJobSummary, DatabaseError>;
/// List sandbox jobs for a specific user, most recent first.
async fn list_sandbox_jobs_for_user(
&self,
user_id: &str,
) -> Result<Vec<SandboxJobRecord>, DatabaseError>;
/// Get sandbox job summary for a specific user.
async fn sandbox_job_summary_for_user(
&self,
user_id: &str,
) -> Result<SandboxJobSummary, DatabaseError>;
/// Check if a sandbox job belongs to a specific user.
async fn sandbox_job_belongs_to_user(
&self,
job_id: Uuid,
user_id: &str,
) -> Result<bool, DatabaseError>;
/// Update sandbox job mode.
async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError>;
/// Get sandbox job mode.
async fn get_sandbox_job_mode(&self, id: Uuid) -> Result<Option<String>, DatabaseError>;
// ==================== Job Events ====================
/// Persist a job event.
async fn save_job_event(
&self,
job_id: Uuid,
event_type: &str,
data: &serde_json::Value,
) -> Result<(), DatabaseError>;
/// Load all job events.
async fn list_job_events(&self, job_id: Uuid) -> Result<Vec<JobEventRecord>, DatabaseError>;
// ==================== Routines ====================
/// Create a new routine.
async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError>;
/// Get a routine by ID.
async fn get_routine(&self, id: Uuid) -> Result<Option<Routine>, DatabaseError>;
/// Get a routine by user_id and name.
async fn get_routine_by_name(
&self,
user_id: &str,
name: &str,
) -> Result<Option<Routine>, DatabaseError>;
/// List routines for a user.
async fn list_routines(&self, user_id: &str) -> Result<Vec<Routine>, DatabaseError>;
/// List all enabled event routines.
async fn list_event_routines(&self) -> Result<Vec<Routine>, DatabaseError>;
/// List due cron routines.
async fn list_due_cron_routines(&self) -> Result<Vec<Routine>, DatabaseError>;
/// Update a routine.
async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError>;
/// Update runtime state after a routine fires.
async fn update_routine_runtime(
&self,
id: Uuid,
last_run_at: DateTime<Utc>,
next_fire_at: Option<DateTime<Utc>>,
run_count: u64,
consecutive_failures: u32,
state: &serde_json::Value,
) -> Result<(), DatabaseError>;
/// Delete a routine.
async fn delete_routine(&self, id: Uuid) -> Result<bool, DatabaseError>;
// ==================== Routine Runs ====================
/// Record a routine run starting.
async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError>;
/// Complete a routine run.
async fn complete_routine_run(
&self,
id: Uuid,
status: RunStatus,
result_summary: Option<&str>,
tokens_used: Option<i32>,
) -> Result<(), DatabaseError>;
/// List recent runs for a routine.
async fn list_routine_runs(
&self,
routine_id: Uuid,
limit: i64,
) -> Result<Vec<RoutineRun>, DatabaseError>;
/// Count currently running runs for a routine.
async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result<i64, DatabaseError>;
// ==================== Tool Failures ====================
/// Record a tool failure (upsert).
async fn record_tool_failure(
&self,
tool_name: &str,
error_message: &str,
) -> Result<(), DatabaseError>;
/// Get broken tools exceeding threshold.
async fn get_broken_tools(&self, threshold: i32) -> Result<Vec<BrokenTool>, DatabaseError>;
/// Mark a tool as repaired.
async fn mark_tool_repaired(&self, tool_name: &str) -> Result<(), DatabaseError>;
/// Increment repair attempts.
async fn increment_repair_attempts(&self, tool_name: &str) -> Result<(), DatabaseError>;
// ==================== Settings ====================
/// Get a single setting.
async fn get_setting(
&self,
user_id: &str,
key: &str,
) -> Result<Option<serde_json::Value>, DatabaseError>;
/// Get a single setting with metadata.
async fn get_setting_full(
&self,
user_id: &str,
key: &str,
) -> Result<Option<SettingRow>, DatabaseError>;
/// Set a single setting (upsert).
async fn set_setting(
&self,
user_id: &str,
key: &str,
value: &serde_json::Value,
) -> Result<(), DatabaseError>;
/// Delete a single setting.
async fn delete_setting(&self, user_id: &str, key: &str) -> Result<bool, DatabaseError>;
/// List all settings for a user.
async fn list_settings(&self, user_id: &str) -> Result<Vec<SettingRow>, DatabaseError>;
/// Get all settings as a flat map.
async fn get_all_settings(
&self,
user_id: &str,
) -> Result<HashMap<String, serde_json::Value>, DatabaseError>;
/// Bulk-write settings atomically.
async fn set_all_settings(
&self,
user_id: &str,
settings: &HashMap<String, serde_json::Value>,
) -> Result<(), DatabaseError>;
/// Check if settings exist for a user.
async fn has_settings(&self, user_id: &str) -> Result<bool, DatabaseError>;
// ==================== Workspace: Documents ====================
/// Get a document by path.
async fn get_document_by_path(
&self,
user_id: &str,
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError>;
/// Get a document by ID.
async fn get_document_by_id(&self, id: Uuid) -> Result<MemoryDocument, WorkspaceError>;
/// Get or create a document by path.
async fn get_or_create_document_by_path(
&self,
user_id: &str,
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError>;
/// Update a document's content.
async fn update_document(&self, id: Uuid, content: &str) -> Result<(), WorkspaceError>;
/// Delete a document by path.
async fn delete_document_by_path(
&self,
user_id: &str,
agent_id: Option<Uuid>,
path: &str,
) -> Result<(), WorkspaceError>;
/// List files and directories in a directory path.
async fn list_directory(
&self,
user_id: &str,
agent_id: Option<Uuid>,
directory: &str,
) -> Result<Vec<WorkspaceEntry>, WorkspaceError>;
/// List all file paths in the workspace.
async fn list_all_paths(
&self,
user_id: &str,
agent_id: Option<Uuid>,
) -> Result<Vec<String>, WorkspaceError>;
/// List all documents for a user.
async fn list_documents(
&self,
user_id: &str,
agent_id: Option<Uuid>,
) -> Result<Vec<MemoryDocument>, WorkspaceError>;
// ==================== Workspace: Chunks ====================
/// Delete all chunks for a document.
async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError>;
/// Insert a chunk.
async fn insert_chunk(
&self,
document_id: Uuid,
chunk_index: i32,
content: &str,
embedding: Option<&[f32]>,
) -> Result<Uuid, WorkspaceError>;
/// Update a chunk's embedding.
async fn update_chunk_embedding(
&self,
chunk_id: Uuid,
embedding: &[f32],
) -> Result<(), WorkspaceError>;
/// Get chunks without embeddings for backfilling.
async fn get_chunks_without_embeddings(
&self,
user_id: &str,
agent_id: Option<Uuid>,
limit: usize,
) -> Result<Vec<MemoryChunk>, WorkspaceError>;
// ==================== Workspace: Search ====================
/// Perform hybrid search combining FTS and vector similarity.
async fn hybrid_search(
&self,
user_id: &str,
agent_id: Option<Uuid>,
query: &str,
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError>;
}
-627
View File
@@ -1,627 +0,0 @@
//! PostgreSQL backend for the Database trait.
//!
//! Delegates to the existing `Store` (history) and `Repository` (workspace)
//! implementations, avoiding SQL duplication.
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use deadpool_postgres::Pool;
use rust_decimal::Decimal;
use uuid::Uuid;
use crate::agent::BrokenTool;
use crate::agent::routine::{Routine, RoutineRun, RunStatus};
use crate::config::DatabaseConfig;
use crate::context::{ActionRecord, JobContext, JobState};
use crate::db::Database;
use crate::error::{DatabaseError, WorkspaceError};
use crate::history::{
ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
SandboxJobSummary, SettingRow, Store,
};
use crate::workspace::{
MemoryChunk, MemoryDocument, Repository, SearchConfig, SearchResult, WorkspaceEntry,
};
/// PostgreSQL database backend.
///
/// Wraps the existing `Store` (for history/conversations/jobs/routines/settings)
/// and `Repository` (for workspace documents/chunks/search) to implement the
/// unified `Database` trait.
pub struct PgBackend {
store: Store,
repo: Repository,
}
impl PgBackend {
/// Create a new PostgreSQL backend from configuration.
pub async fn new(config: &DatabaseConfig) -> Result<Self, DatabaseError> {
let store = Store::new(config).await?;
let repo = Repository::new(store.pool());
Ok(Self { store, repo })
}
/// Get a clone of the connection pool.
///
/// Useful for sharing with components that still need raw pool access.
pub fn pool(&self) -> Pool {
self.store.pool()
}
}
#[async_trait]
impl Database for PgBackend {
async fn run_migrations(&self) -> Result<(), DatabaseError> {
self.store.run_migrations().await
}
// ==================== Conversations ====================
async fn create_conversation(
&self,
channel: &str,
user_id: &str,
thread_id: Option<&str>,
) -> Result<Uuid, DatabaseError> {
self.store
.create_conversation(channel, user_id, thread_id)
.await
}
async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
self.store.touch_conversation(id).await
}
async fn add_conversation_message(
&self,
conversation_id: Uuid,
role: &str,
content: &str,
) -> Result<Uuid, DatabaseError> {
self.store
.add_conversation_message(conversation_id, role, content)
.await
}
async fn ensure_conversation(
&self,
id: Uuid,
channel: &str,
user_id: &str,
thread_id: Option<&str>,
) -> Result<(), DatabaseError> {
self.store
.ensure_conversation(id, channel, user_id, thread_id)
.await
}
async fn list_conversations_with_preview(
&self,
user_id: &str,
channel: &str,
limit: i64,
) -> Result<Vec<ConversationSummary>, DatabaseError> {
self.store
.list_conversations_with_preview(user_id, channel, limit)
.await
}
async fn get_or_create_assistant_conversation(
&self,
user_id: &str,
channel: &str,
) -> Result<Uuid, DatabaseError> {
self.store
.get_or_create_assistant_conversation(user_id, channel)
.await
}
async fn create_conversation_with_metadata(
&self,
channel: &str,
user_id: &str,
metadata: &serde_json::Value,
) -> Result<Uuid, DatabaseError> {
self.store
.create_conversation_with_metadata(channel, user_id, metadata)
.await
}
async fn list_conversation_messages_paginated(
&self,
conversation_id: Uuid,
before: Option<DateTime<Utc>>,
limit: i64,
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> {
self.store
.list_conversation_messages_paginated(conversation_id, before, limit)
.await
}
async fn update_conversation_metadata_field(
&self,
id: Uuid,
key: &str,
value: &serde_json::Value,
) -> Result<(), DatabaseError> {
self.store
.update_conversation_metadata_field(id, key, value)
.await
}
async fn get_conversation_metadata(
&self,
id: Uuid,
) -> Result<Option<serde_json::Value>, DatabaseError> {
self.store.get_conversation_metadata(id).await
}
async fn list_conversation_messages(
&self,
conversation_id: Uuid,
) -> Result<Vec<ConversationMessage>, DatabaseError> {
self.store.list_conversation_messages(conversation_id).await
}
async fn conversation_belongs_to_user(
&self,
conversation_id: Uuid,
user_id: &str,
) -> Result<bool, DatabaseError> {
self.store
.conversation_belongs_to_user(conversation_id, user_id)
.await
}
// ==================== Jobs ====================
async fn save_job(&self, ctx: &JobContext) -> Result<(), DatabaseError> {
self.store.save_job(ctx).await
}
async fn get_job(&self, id: Uuid) -> Result<Option<JobContext>, DatabaseError> {
self.store.get_job(id).await
}
async fn update_job_status(
&self,
id: Uuid,
status: JobState,
failure_reason: Option<&str>,
) -> Result<(), DatabaseError> {
self.store
.update_job_status(id, status, failure_reason)
.await
}
async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError> {
self.store.mark_job_stuck(id).await
}
async fn get_stuck_jobs(&self) -> Result<Vec<Uuid>, DatabaseError> {
self.store.get_stuck_jobs().await
}
// ==================== Actions ====================
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError> {
self.store.save_action(job_id, action).await
}
async fn get_job_actions(&self, job_id: Uuid) -> Result<Vec<ActionRecord>, DatabaseError> {
self.store.get_job_actions(job_id).await
}
// ==================== LLM Calls ====================
async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result<Uuid, DatabaseError> {
self.store.record_llm_call(record).await
}
// ==================== Estimation Snapshots ====================
async fn save_estimation_snapshot(
&self,
job_id: Uuid,
category: &str,
tool_names: &[String],
estimated_cost: Decimal,
estimated_time_secs: i32,
estimated_value: Decimal,
) -> Result<Uuid, DatabaseError> {
self.store
.save_estimation_snapshot(
job_id,
category,
tool_names,
estimated_cost,
estimated_time_secs,
estimated_value,
)
.await
}
async fn update_estimation_actuals(
&self,
id: Uuid,
actual_cost: Decimal,
actual_time_secs: i32,
actual_value: Option<Decimal>,
) -> Result<(), DatabaseError> {
self.store
.update_estimation_actuals(id, actual_cost, actual_time_secs, actual_value)
.await
}
// ==================== Sandbox Jobs ====================
async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError> {
self.store.save_sandbox_job(job).await
}
async fn get_sandbox_job(&self, id: Uuid) -> Result<Option<SandboxJobRecord>, DatabaseError> {
self.store.get_sandbox_job(id).await
}
async fn list_sandbox_jobs(&self) -> Result<Vec<SandboxJobRecord>, DatabaseError> {
self.store.list_sandbox_jobs().await
}
async fn update_sandbox_job_status(
&self,
id: Uuid,
status: &str,
success: Option<bool>,
message: Option<&str>,
started_at: Option<DateTime<Utc>>,
completed_at: Option<DateTime<Utc>>,
) -> Result<(), DatabaseError> {
self.store
.update_sandbox_job_status(id, status, success, message, started_at, completed_at)
.await
}
async fn cleanup_stale_sandbox_jobs(&self) -> Result<u64, DatabaseError> {
self.store.cleanup_stale_sandbox_jobs().await
}
async fn sandbox_job_summary(&self) -> Result<SandboxJobSummary, DatabaseError> {
self.store.sandbox_job_summary().await
}
async fn list_sandbox_jobs_for_user(
&self,
user_id: &str,
) -> Result<Vec<SandboxJobRecord>, DatabaseError> {
self.store.list_sandbox_jobs_for_user(user_id).await
}
async fn sandbox_job_summary_for_user(
&self,
user_id: &str,
) -> Result<SandboxJobSummary, DatabaseError> {
self.store.sandbox_job_summary_for_user(user_id).await
}
async fn sandbox_job_belongs_to_user(
&self,
job_id: Uuid,
user_id: &str,
) -> Result<bool, DatabaseError> {
self.store
.sandbox_job_belongs_to_user(job_id, user_id)
.await
}
async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError> {
self.store.update_sandbox_job_mode(id, mode).await
}
async fn get_sandbox_job_mode(&self, id: Uuid) -> Result<Option<String>, DatabaseError> {
self.store.get_sandbox_job_mode(id).await
}
// ==================== Job Events ====================
async fn save_job_event(
&self,
job_id: Uuid,
event_type: &str,
data: &serde_json::Value,
) -> Result<(), DatabaseError> {
self.store.save_job_event(job_id, event_type, data).await
}
async fn list_job_events(&self, job_id: Uuid) -> Result<Vec<JobEventRecord>, DatabaseError> {
self.store.list_job_events(job_id).await
}
// ==================== Routines ====================
async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
self.store.create_routine(routine).await
}
async fn get_routine(&self, id: Uuid) -> Result<Option<Routine>, DatabaseError> {
self.store.get_routine(id).await
}
async fn get_routine_by_name(
&self,
user_id: &str,
name: &str,
) -> Result<Option<Routine>, DatabaseError> {
self.store.get_routine_by_name(user_id, name).await
}
async fn list_routines(&self, user_id: &str) -> Result<Vec<Routine>, DatabaseError> {
self.store.list_routines(user_id).await
}
async fn list_event_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
self.store.list_event_routines().await
}
async fn list_due_cron_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
self.store.list_due_cron_routines().await
}
async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
self.store.update_routine(routine).await
}
async fn update_routine_runtime(
&self,
id: Uuid,
last_run_at: DateTime<Utc>,
next_fire_at: Option<DateTime<Utc>>,
run_count: u64,
consecutive_failures: u32,
state: &serde_json::Value,
) -> Result<(), DatabaseError> {
self.store
.update_routine_runtime(
id,
last_run_at,
next_fire_at,
run_count,
consecutive_failures,
state,
)
.await
}
async fn delete_routine(&self, id: Uuid) -> Result<bool, DatabaseError> {
self.store.delete_routine(id).await
}
// ==================== Routine Runs ====================
async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError> {
self.store.create_routine_run(run).await
}
async fn complete_routine_run(
&self,
id: Uuid,
status: RunStatus,
result_summary: Option<&str>,
tokens_used: Option<i32>,
) -> Result<(), DatabaseError> {
self.store
.complete_routine_run(id, status, result_summary, tokens_used)
.await
}
async fn list_routine_runs(
&self,
routine_id: Uuid,
limit: i64,
) -> Result<Vec<RoutineRun>, DatabaseError> {
self.store.list_routine_runs(routine_id, limit).await
}
async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result<i64, DatabaseError> {
self.store.count_running_routine_runs(routine_id).await
}
// ==================== Tool Failures ====================
async fn record_tool_failure(
&self,
tool_name: &str,
error_message: &str,
) -> Result<(), DatabaseError> {
self.store
.record_tool_failure(tool_name, error_message)
.await
}
async fn get_broken_tools(&self, threshold: i32) -> Result<Vec<BrokenTool>, DatabaseError> {
self.store.get_broken_tools(threshold).await
}
async fn mark_tool_repaired(&self, tool_name: &str) -> Result<(), DatabaseError> {
self.store.mark_tool_repaired(tool_name).await
}
async fn increment_repair_attempts(&self, tool_name: &str) -> Result<(), DatabaseError> {
self.store.increment_repair_attempts(tool_name).await
}
// ==================== Settings ====================
async fn get_setting(
&self,
user_id: &str,
key: &str,
) -> Result<Option<serde_json::Value>, DatabaseError> {
self.store.get_setting(user_id, key).await
}
async fn get_setting_full(
&self,
user_id: &str,
key: &str,
) -> Result<Option<SettingRow>, DatabaseError> {
self.store.get_setting_full(user_id, key).await
}
async fn set_setting(
&self,
user_id: &str,
key: &str,
value: &serde_json::Value,
) -> Result<(), DatabaseError> {
self.store.set_setting(user_id, key, value).await
}
async fn delete_setting(&self, user_id: &str, key: &str) -> Result<bool, DatabaseError> {
self.store.delete_setting(user_id, key).await
}
async fn list_settings(&self, user_id: &str) -> Result<Vec<SettingRow>, DatabaseError> {
self.store.list_settings(user_id).await
}
async fn get_all_settings(
&self,
user_id: &str,
) -> Result<HashMap<String, serde_json::Value>, DatabaseError> {
self.store.get_all_settings(user_id).await
}
async fn set_all_settings(
&self,
user_id: &str,
settings: &HashMap<String, serde_json::Value>,
) -> Result<(), DatabaseError> {
self.store.set_all_settings(user_id, settings).await
}
async fn has_settings(&self, user_id: &str) -> Result<bool, DatabaseError> {
self.store.has_settings(user_id).await
}
// ==================== Workspace: Documents ====================
async fn get_document_by_path(
&self,
user_id: &str,
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError> {
self.repo
.get_document_by_path(user_id, agent_id, path)
.await
}
async fn get_document_by_id(&self, id: Uuid) -> Result<MemoryDocument, WorkspaceError> {
self.repo.get_document_by_id(id).await
}
async fn get_or_create_document_by_path(
&self,
user_id: &str,
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError> {
self.repo
.get_or_create_document_by_path(user_id, agent_id, path)
.await
}
async fn update_document(&self, id: Uuid, content: &str) -> Result<(), WorkspaceError> {
self.repo.update_document(id, content).await
}
async fn delete_document_by_path(
&self,
user_id: &str,
agent_id: Option<Uuid>,
path: &str,
) -> Result<(), WorkspaceError> {
self.repo
.delete_document_by_path(user_id, agent_id, path)
.await
}
async fn list_directory(
&self,
user_id: &str,
agent_id: Option<Uuid>,
directory: &str,
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
self.repo.list_directory(user_id, agent_id, directory).await
}
async fn list_all_paths(
&self,
user_id: &str,
agent_id: Option<Uuid>,
) -> Result<Vec<String>, WorkspaceError> {
self.repo.list_all_paths(user_id, agent_id).await
}
async fn list_documents(
&self,
user_id: &str,
agent_id: Option<Uuid>,
) -> Result<Vec<MemoryDocument>, WorkspaceError> {
self.repo.list_documents(user_id, agent_id).await
}
// ==================== Workspace: Chunks ====================
async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError> {
self.repo.delete_chunks(document_id).await
}
async fn insert_chunk(
&self,
document_id: Uuid,
chunk_index: i32,
content: &str,
embedding: Option<&[f32]>,
) -> Result<Uuid, WorkspaceError> {
self.repo
.insert_chunk(document_id, chunk_index, content, embedding)
.await
}
async fn update_chunk_embedding(
&self,
chunk_id: Uuid,
embedding: &[f32],
) -> Result<(), WorkspaceError> {
self.repo.update_chunk_embedding(chunk_id, embedding).await
}
async fn get_chunks_without_embeddings(
&self,
user_id: &str,
agent_id: Option<Uuid>,
limit: usize,
) -> Result<Vec<MemoryChunk>, WorkspaceError> {
self.repo
.get_chunks_without_embeddings(user_id, agent_id, limit)
.await
}
// ==================== Workspace: Search ====================
async fn hybrid_search(
&self,
user_id: &str,
agent_id: Option<Uuid>,
query: &str,
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
self.repo
.hybrid_search(user_id, agent_id, query, embedding, config)
.await
}
}
+2 -59
View File
@@ -40,11 +40,8 @@ pub enum Error {
#[error("Workspace error: {0}")] #[error("Workspace error: {0}")]
Workspace(#[from] WorkspaceError), Workspace(#[from] WorkspaceError),
#[error("Orchestrator error: {0}")] #[error("Key management error: {0}")]
Orchestrator(#[from] OrchestratorError), Key(#[from] crate::keys::KeyError),
#[error("Worker error: {0}")]
Worker(#[from] WorkerError),
} }
/// Configuration-related errors. /// Configuration-related errors.
@@ -87,21 +84,14 @@ pub enum DatabaseError {
#[error("Serialization error: {0}")] #[error("Serialization error: {0}")]
Serialization(String), Serialization(String),
#[cfg(feature = "postgres")]
#[error("PostgreSQL error: {0}")] #[error("PostgreSQL error: {0}")]
Postgres(#[from] tokio_postgres::Error), Postgres(#[from] tokio_postgres::Error),
#[cfg(feature = "postgres")]
#[error("Pool build error: {0}")] #[error("Pool build error: {0}")]
PoolBuild(#[from] deadpool_postgres::BuildError), PoolBuild(#[from] deadpool_postgres::BuildError),
#[cfg(feature = "postgres")]
#[error("Pool runtime error: {0}")] #[error("Pool runtime error: {0}")]
PoolRuntime(#[from] deadpool_postgres::PoolError), PoolRuntime(#[from] deadpool_postgres::PoolError),
#[cfg(feature = "libsql")]
#[error("LibSQL error: {0}")]
LibSql(#[from] libsql::Error),
} }
/// Channel-related errors. /// Channel-related errors.
@@ -321,52 +311,5 @@ pub enum WorkspaceError {
HeartbeatError { reason: String }, HeartbeatError { reason: String },
} }
/// Orchestrator errors (internal API, container management).
#[derive(Debug, thiserror::Error)]
pub enum OrchestratorError {
#[error("Container creation failed for job {job_id}: {reason}")]
ContainerCreationFailed { job_id: Uuid, reason: String },
#[error("Container not found for job {job_id}")]
ContainerNotFound { job_id: Uuid },
#[error("Container for job {job_id} is in unexpected state: {state}")]
InvalidContainerState { job_id: Uuid, state: String },
#[error("Worker authentication failed: {reason}")]
AuthFailed { reason: String },
#[error("Internal API error: {reason}")]
ApiError { reason: String },
#[error("Docker error: {reason}")]
Docker { reason: String },
#[error("Job {job_id} timed out in container")]
ContainerTimeout { job_id: Uuid },
}
/// Worker errors (container-side execution).
#[derive(Debug, thiserror::Error)]
pub enum WorkerError {
#[error("Failed to connect to orchestrator at {url}: {reason}")]
ConnectionFailed { url: String, reason: String },
#[error("LLM proxy request failed: {reason}")]
LlmProxyFailed { reason: String },
#[error("Secret resolution failed for {secret_name}: {reason}")]
SecretResolveFailed { secret_name: String, reason: String },
#[error("Orchestrator returned error for job {job_id}: {reason}")]
OrchestratorRejected { job_id: Uuid, reason: String },
#[error("Worker execution failed: {reason}")]
ExecutionFailed { reason: String },
#[error("Missing worker token (IRONCLAW_WORKER_TOKEN not set)")]
MissingToken,
}
/// Result type alias for the agent. /// Result type alias for the agent.
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
+5 -1
View File
@@ -20,6 +20,10 @@ impl CostEstimator {
// Default tool costs (in USD or equivalent) // Default tool costs (in USD or equivalent)
tool_costs.insert("http".to_string(), dec!(0.0001)); // API call tool_costs.insert("http".to_string(), dec!(0.0001)); // API call
tool_costs.insert("marketplace".to_string(), dec!(0.01)); // Gas costs
tool_costs.insert("ecommerce".to_string(), dec!(0.001)); // API call
tool_costs.insert("taskrabbit".to_string(), dec!(0.0)); // Cost comes from task itself
tool_costs.insert("restaurant".to_string(), dec!(0.001)); // API call
tool_costs.insert("echo".to_string(), dec!(0.0)); // Free tool_costs.insert("echo".to_string(), dec!(0.0)); // Free
tool_costs.insert("time".to_string(), dec!(0.0)); // Free tool_costs.insert("time".to_string(), dec!(0.0)); // Free
tool_costs.insert("json".to_string(), dec!(0.0)); // Free tool_costs.insert("json".to_string(), dec!(0.0)); // Free
@@ -70,7 +74,7 @@ mod tests {
let estimator = CostEstimator::new(); let estimator = CostEstimator::new();
assert_eq!(estimator.estimate_tool("echo"), dec!(0.0)); assert_eq!(estimator.estimate_tool("echo"), dec!(0.0));
assert_eq!(estimator.estimate_tool("http"), dec!(0.0001)); assert_eq!(estimator.estimate_tool("marketplace"), dec!(0.01));
assert!(estimator.estimate_tool("unknown") > dec!(0.0)); assert!(estimator.estimate_tool("unknown") > dec!(0.0));
} }
+4
View File
@@ -16,6 +16,10 @@ impl TimeEstimator {
// Default tool durations // Default tool durations
tool_durations.insert("http".to_string(), Duration::from_secs(5)); tool_durations.insert("http".to_string(), Duration::from_secs(5));
tool_durations.insert("marketplace".to_string(), Duration::from_secs(10));
tool_durations.insert("ecommerce".to_string(), Duration::from_secs(8));
tool_durations.insert("taskrabbit".to_string(), Duration::from_secs(30)); // Just API, not task itself
tool_durations.insert("restaurant".to_string(), Duration::from_secs(5));
tool_durations.insert("echo".to_string(), Duration::from_millis(10)); tool_durations.insert("echo".to_string(), Duration::from_millis(10));
tool_durations.insert("time".to_string(), Duration::from_millis(1)); tool_durations.insert("time".to_string(), Duration::from_millis(1));
tool_durations.insert("json".to_string(), Duration::from_millis(5)); tool_durations.insert("json".to_string(), Duration::from_millis(5));
+6 -5
View File
@@ -144,11 +144,12 @@ impl SuccessEvaluator for RuleBasedEvaluator {
// Check for critical errors // Check for critical errors
for action in actions.iter().filter(|a| !a.success) { for action in actions.iter().filter(|a| !a.success) {
if let Some(ref error) = action.error if let Some(ref error) = action.error {
&& (error.to_lowercase().contains("critical") if error.to_lowercase().contains("critical")
|| error.to_lowercase().contains("fatal")) || error.to_lowercase().contains("fatal")
{ {
issues.push(format!("Critical error in {}: {}", action.tool_name, error)); issues.push(format!("Critical error in {}: {}", action.tool_name, error));
}
} }
} }
+32 -121
View File
@@ -23,7 +23,9 @@ use crate::tools::mcp::auth::{
PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata, PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata,
find_available_port, is_authenticated, register_client, find_available_port, is_authenticated, register_client,
}; };
use crate::tools::mcp::config::McpServerConfig; use crate::tools::mcp::config::{
McpServerConfig, add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server,
};
use crate::tools::mcp::session::McpSessionManager; use crate::tools::mcp::session::McpSessionManager;
use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools}; use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools};
@@ -56,8 +58,6 @@ pub struct ExtensionManager {
/// Tunnel URL for remote OAuth callbacks (used in future iterations). /// Tunnel URL for remote OAuth callbacks (used in future iterations).
_tunnel_url: Option<String>, _tunnel_url: Option<String>,
user_id: String, user_id: String,
/// Optional database store for DB-backed MCP config.
store: Option<Arc<dyn crate::db::Database>>,
} }
impl ExtensionManager { impl ExtensionManager {
@@ -71,7 +71,6 @@ impl ExtensionManager {
wasm_channels_dir: PathBuf, wasm_channels_dir: PathBuf,
tunnel_url: Option<String>, tunnel_url: Option<String>,
user_id: String, user_id: String,
store: Option<Arc<dyn crate::db::Database>>,
) -> Self { ) -> Self {
Self { Self {
registry: ExtensionRegistry::new(), registry: ExtensionRegistry::new(),
@@ -86,7 +85,6 @@ impl ExtensionManager {
pending_auth: RwLock::new(HashMap::new()), pending_auth: RwLock::new(HashMap::new()),
_tunnel_url: tunnel_url, _tunnel_url: tunnel_url,
user_id, user_id,
store,
} }
} }
@@ -193,7 +191,7 @@ impl ExtensionManager {
// List MCP servers // List MCP servers
if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) { if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) {
match self.load_mcp_servers().await { match load_mcp_servers().await {
Ok(servers) => { Ok(servers) => {
for server in &servers.servers { for server in &servers.servers {
let authenticated = let authenticated =
@@ -306,7 +304,7 @@ impl ExtensionManager {
self.mcp_clients.write().await.remove(name); self.mcp_clients.write().await.remove(name);
// Remove from config // Remove from config
self.remove_mcp_server(name) remove_mcp_server(name)
.await .await
.map_err(|e| ExtensionError::Config(e.to_string()))?; .map_err(|e| ExtensionError::Config(e.to_string()))?;
@@ -344,56 +342,6 @@ impl ExtensionManager {
} }
} }
// ── MCP config helpers (DB with disk fallback) ─────────────────────
async fn load_mcp_servers(
&self,
) -> Result<crate::tools::mcp::config::McpServersFile, crate::tools::mcp::config::ConfigError>
{
if let Some(ref store) = self.store {
crate::tools::mcp::config::load_mcp_servers_from_db(store.as_ref(), &self.user_id).await
} else {
crate::tools::mcp::config::load_mcp_servers().await
}
}
async fn get_mcp_server(
&self,
name: &str,
) -> Result<McpServerConfig, crate::tools::mcp::config::ConfigError> {
let servers = self.load_mcp_servers().await?;
servers.get(name).cloned().ok_or_else(|| {
crate::tools::mcp::config::ConfigError::ServerNotFound {
name: name.to_string(),
}
})
}
async fn add_mcp_server(
&self,
config: McpServerConfig,
) -> Result<(), crate::tools::mcp::config::ConfigError> {
config.validate()?;
if let Some(ref store) = self.store {
crate::tools::mcp::config::add_mcp_server_db(store.as_ref(), &self.user_id, config)
.await
} else {
crate::tools::mcp::config::add_mcp_server(config).await
}
}
async fn remove_mcp_server(
&self,
name: &str,
) -> Result<(), crate::tools::mcp::config::ConfigError> {
if let Some(ref store) = self.store {
crate::tools::mcp::config::remove_mcp_server_db(store.as_ref(), &self.user_id, name)
.await
} else {
crate::tools::mcp::config::remove_mcp_server(name).await
}
}
// ── Private helpers ────────────────────────────────────────────────── // ── Private helpers ──────────────────────────────────────────────────
async fn install_from_entry( async fn install_from_entry(
@@ -433,7 +381,7 @@ impl ExtensionManager {
url: &str, url: &str,
) -> Result<InstallResult, ExtensionError> { ) -> Result<InstallResult, ExtensionError> {
// Check if already installed // Check if already installed
if self.get_mcp_server(name).await.is_ok() { if get_mcp_server(name).await.is_ok() {
return Err(ExtensionError::AlreadyInstalled(name.to_string())); return Err(ExtensionError::AlreadyInstalled(name.to_string()));
} }
@@ -442,7 +390,7 @@ impl ExtensionManager {
.validate() .validate()
.map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?; .map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?;
self.add_mcp_server(config) add_mcp_server(config)
.await .await
.map_err(|e| ExtensionError::Config(e.to_string()))?; .map_err(|e| ExtensionError::Config(e.to_string()))?;
@@ -463,16 +411,7 @@ impl ExtensionManager {
name: &str, name: &str,
url: &str, url: &str,
) -> Result<InstallResult, ExtensionError> { ) -> Result<InstallResult, ExtensionError> {
// Require HTTPS to prevent downgrade attacks // Download the WASM binary
if !url.starts_with("https://") {
return Err(ExtensionError::InstallFailed(
"Only HTTPS URLs are allowed for extension downloads".to_string(),
));
}
// 50 MB cap to prevent disk-fill DoS
const MAX_WASM_SIZE: usize = 50 * 1024 * 1024;
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60)) .timeout(std::time::Duration::from_secs(60))
.build() .build()
@@ -491,36 +430,11 @@ impl ExtensionManager {
))); )));
} }
// Check Content-Length header before downloading the full body
if let Some(len) = response.content_length()
&& len as usize > MAX_WASM_SIZE
{
return Err(ExtensionError::InstallFailed(format!(
"WASM binary too large ({} bytes, max {} bytes)",
len, MAX_WASM_SIZE
)));
}
let bytes = response let bytes = response
.bytes() .bytes()
.await .await
.map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?; .map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
if bytes.len() > MAX_WASM_SIZE {
return Err(ExtensionError::InstallFailed(format!(
"WASM binary too large ({} bytes, max {} bytes)",
bytes.len(),
MAX_WASM_SIZE
)));
}
// Basic WASM magic number check (\0asm)
if bytes.len() < 4 || &bytes[..4] != b"\0asm" {
return Err(ExtensionError::InstallFailed(
"Downloaded file is not a valid WASM binary (bad magic number)".to_string(),
));
}
// Ensure tools directory exists // Ensure tools directory exists
tokio::fs::create_dir_all(&self.wasm_tools_dir) tokio::fs::create_dir_all(&self.wasm_tools_dir)
.await .await
@@ -533,10 +447,9 @@ impl ExtensionManager {
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?; .map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
tracing::info!( tracing::info!(
"Installed WASM tool '{}' ({} bytes) from {} to {}", "Installed WASM tool '{}' ({} bytes) to {}",
name, name,
bytes.len(), bytes.len(),
url,
wasm_path.display() wasm_path.display()
); );
@@ -552,8 +465,7 @@ impl ExtensionManager {
name: &str, name: &str,
token: Option<&str>, token: Option<&str>,
) -> Result<AuthResult, ExtensionError> { ) -> Result<AuthResult, ExtensionError> {
let server = self let server = get_mcp_server(name)
.get_mcp_server(name)
.await .await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?; .map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
@@ -768,27 +680,27 @@ impl ExtensionManager {
}; };
// Check env var first // Check env var first
if let Some(ref env_var) = auth.env_var if let Some(ref env_var) = auth.env_var {
&& let Ok(value) = std::env::var(env_var) if let Ok(value) = std::env::var(env_var) {
{ // Store the env var value as a secret
// Store the env var value as a secret let params = CreateSecretParams::new(&auth.secret_name, &value)
let params = .with_provider(name.to_string());
CreateSecretParams::new(&auth.secret_name, &value).with_provider(name.to_string()); self.secrets
self.secrets .create(&self.user_id, params)
.create(&self.user_id, params) .await
.await .map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
return Ok(AuthResult { return Ok(AuthResult {
name: name.to_string(), name: name.to_string(),
kind: ExtensionKind::WasmTool, kind: ExtensionKind::WasmTool,
auth_url: None, auth_url: None,
callback_type: None, callback_type: None,
instructions: None, instructions: None,
setup_url: None, setup_url: None,
awaiting_token: false, awaiting_token: false,
status: "authenticated".to_string(), status: "authenticated".to_string(),
}); });
}
} }
// Check if already authenticated // Check if already authenticated
@@ -872,8 +784,7 @@ impl ExtensionManager {
} }
} }
let server = self let server = get_mcp_server(name)
.get_mcp_server(name)
.await .await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?; .map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
@@ -982,7 +893,7 @@ impl ExtensionManager {
/// Determine what kind of installed extension this is. /// Determine what kind of installed extension this is.
async fn determine_installed_kind(&self, name: &str) -> Result<ExtensionKind, ExtensionError> { async fn determine_installed_kind(&self, name: &str) -> Result<ExtensionKind, ExtensionError> {
// Check MCP servers first // Check MCP servers first
if self.get_mcp_server(name).await.is_ok() { if get_mcp_server(name).await.is_ok() {
return Ok(ExtensionKind::McpServer); return Ok(ExtensionKind::McpServer);
} }
+1 -8
View File
@@ -5,15 +5,8 @@
//! - Learning from past executions //! - Learning from past executions
//! - Analytics and metrics //! - Analytics and metrics
#[cfg(feature = "postgres")]
mod analytics; mod analytics;
mod store; mod store;
#[cfg(feature = "postgres")]
pub use analytics::{JobStats, ToolStats}; pub use analytics::{JobStats, ToolStats};
#[cfg(feature = "postgres")] pub use store::{LlmCallRecord, Store};
pub use store::Store;
pub use store::{
ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
SandboxJobSummary, SettingRow,
};
+6 -1247
View File
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
//! Cross-chain signing via v1.signer MPC contract.
//!
//! Enables signing payloads for other chains (Ethereum, Bitcoin, etc.)
//! using NEAR's chain signatures MPC network.
use crate::keys::KeyError;
use crate::keys::policy::SignatureDomain;
use crate::keys::transaction::{Action, FunctionCall, MAX_GAS, ONE_YOCTO};
/// The chain signatures MPC contract on mainnet.
pub const CHAIN_SIGNATURES_CONTRACT_MAINNET: &str = "v1.signer";
/// The chain signatures MPC contract on testnet.
pub const CHAIN_SIGNATURES_CONTRACT_TESTNET: &str = "v1.signer-prod.testnet";
/// Build a FunctionCall action for requesting a chain signature.
pub fn build_chain_signature_action(
payload: &[u8],
derivation_path: &str,
_domain: SignatureDomain,
) -> Result<Action, KeyError> {
let args = serde_json::json!({
"request": {
"payload": payload.iter().map(|b| *b as u32).collect::<Vec<u32>>(),
"path": derivation_path,
"key_version": 0,
},
});
let args_bytes = serde_json::to_vec(&args).map_err(|e| KeyError::ChainSignatureError {
reason: format!("failed to serialize chain sig args: {}", e),
})?;
Ok(Action::FunctionCall(FunctionCall {
method_name: "sign".to_string(),
args: args_bytes,
gas: MAX_GAS,
deposit: ONE_YOCTO,
}))
}
/// Parse the result of a chain signature request from the transaction outcome.
pub fn parse_chain_signature_result(
outcome: &serde_json::Value,
) -> Result<ChainSignatureResult, KeyError> {
// The result is in the SuccessValue field, base64-encoded
let success_value = outcome
.get("SuccessValue")
.and_then(|v| v.as_str())
.ok_or_else(|| KeyError::ChainSignatureError {
reason: "no SuccessValue in chain signature outcome".to_string(),
})?;
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, success_value)
.map_err(|e| KeyError::ChainSignatureError {
reason: format!("failed to decode chain sig result: {}", e),
})?;
let result_str = String::from_utf8(decoded).map_err(|e| KeyError::ChainSignatureError {
reason: format!("chain sig result is not UTF-8: {}", e),
})?;
let result_json: serde_json::Value =
serde_json::from_str(&result_str).map_err(|e| KeyError::ChainSignatureError {
reason: format!("failed to parse chain sig result JSON: {}", e),
})?;
// Extract big_r and s components
let big_r = result_json
.get("big_r")
.and_then(|v| v.get("affine_point"))
.and_then(|v| v.as_str())
.ok_or_else(|| KeyError::ChainSignatureError {
reason: "missing big_r.affine_point in chain sig result".to_string(),
})?
.to_string();
let s = result_json
.get("s")
.and_then(|v| v.get("scalar"))
.and_then(|v| v.as_str())
.ok_or_else(|| KeyError::ChainSignatureError {
reason: "missing s.scalar in chain sig result".to_string(),
})?
.to_string();
let recovery_id = result_json
.get("recovery_id")
.and_then(|v| v.as_u64())
.map(|v| v as u8);
Ok(ChainSignatureResult {
big_r,
s,
recovery_id,
})
}
/// Result from a chain signature request.
#[derive(Debug, Clone)]
pub struct ChainSignatureResult {
/// The R component (affine point, hex-encoded).
pub big_r: String,
/// The s component (scalar, hex-encoded).
pub s: String,
/// Recovery ID for ECDSA (relevant for Ethereum).
pub recovery_id: Option<u8>,
}
/// Get the chain signatures contract address for a network.
pub fn chain_sig_contract(network: &crate::keys::types::NearNetwork) -> &str {
match network {
crate::keys::types::NearNetwork::Mainnet => CHAIN_SIGNATURES_CONTRACT_MAINNET,
crate::keys::types::NearNetwork::Testnet => CHAIN_SIGNATURES_CONTRACT_TESTNET,
crate::keys::types::NearNetwork::Custom(_) => CHAIN_SIGNATURES_CONTRACT_TESTNET,
}
}
#[cfg(test)]
mod tests {
use crate::keys::chain_signatures::{
build_chain_signature_action, chain_sig_contract, parse_chain_signature_result,
};
use crate::keys::policy::SignatureDomain;
use crate::keys::transaction::{Action, MAX_GAS, ONE_YOCTO};
use crate::keys::types::NearNetwork;
#[test]
fn test_build_chain_signature_action() {
let payload = vec![0u8; 32];
let action =
build_chain_signature_action(&payload, "ethereum-1", SignatureDomain::Secp256k1)
.unwrap();
match action {
Action::FunctionCall(fc) => {
assert_eq!(fc.method_name, "sign");
assert_eq!(fc.gas, MAX_GAS);
assert_eq!(fc.deposit, ONE_YOCTO);
// Verify args parse correctly
let args: serde_json::Value = serde_json::from_slice(&fc.args).unwrap();
assert!(args.get("request").is_some());
let path = args["request"]["path"].as_str().unwrap();
assert_eq!(path, "ethereum-1");
}
_ => panic!("expected FunctionCall action"),
}
}
#[test]
fn test_parse_chain_signature_result() {
let result_json = serde_json::json!({
"big_r": {"affine_point": "02abc123"},
"s": {"scalar": "def456"},
"recovery_id": 0
});
let result_str = serde_json::to_string(&result_json).unwrap();
let encoded = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
result_str.as_bytes(),
);
let outcome = serde_json::json!({"SuccessValue": encoded});
let result = parse_chain_signature_result(&outcome).unwrap();
assert_eq!(result.big_r, "02abc123");
assert_eq!(result.s, "def456");
assert_eq!(result.recovery_id, Some(0));
}
#[test]
fn test_chain_sig_contract_addresses() {
assert_eq!(chain_sig_contract(&NearNetwork::Mainnet), "v1.signer");
assert_eq!(
chain_sig_contract(&NearNetwork::Testnet),
"v1.signer-prod.testnet"
);
}
}
+58
View File
@@ -0,0 +1,58 @@
//! Error types for NEAR key management.
use crate::secrets::SecretError;
/// Errors from NEAR key operations.
#[derive(Debug, thiserror::Error)]
pub enum KeyError {
#[error("Key not found: {label}")]
NotFound { label: String },
#[error("Key already exists: {label}")]
AlreadyExists { label: String },
#[error("Invalid key format: {reason}")]
InvalidKeyFormat { reason: String },
#[error("Invalid account ID: {reason}")]
InvalidAccountId { reason: String },
#[error("Signing failed: {reason}")]
SigningFailed { reason: String },
#[error("Serialization failed: {0}")]
SerializationFailed(String),
#[error("Approval required: {operation}")]
ApprovalRequired { operation: String },
#[error("Policy denied: {reason}")]
PolicyDenied { reason: String },
#[error("RPC error: {reason}")]
RpcError { reason: String },
#[error("Stale nonce: cached {cached}, chain {chain}")]
StaleNonce { cached: u64, chain: u64 },
#[error("Insufficient allowance: needed {needed}, available {available}")]
InsufficientAllowance { needed: u128, available: u128 },
#[error("Permission denied: {reason}")]
PermissionDenied { reason: String },
#[error("Chain signature error: {reason}")]
ChainSignatureError { reason: String },
#[error("Backup error: {reason}")]
BackupError { reason: String },
#[error("Secret store error: {0}")]
SecretStore(#[from] SecretError),
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
+183
View File
@@ -0,0 +1,183 @@
//! NEP-413 intent construction and signing.
//!
//! Provides types and signing for NEAR intents following the NEP-413 standard.
//! Intents are signed messages that authorize actions on a verifying contract.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::keys::KeyError;
use crate::keys::signer::sign_hash;
use crate::keys::types::NearPublicKey;
use crate::secrets::SecretsStore;
/// NEP-413 intent message to be signed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntentMessage {
/// Account signing the intent.
pub signer_id: String,
/// Contract that will verify the signature.
pub verifying_contract: String,
/// Deadline (block height or timestamp) after which the intent expires.
pub deadline: String,
/// Unique nonce to prevent replay.
pub nonce: String,
/// List of intent actions.
pub intents: Vec<IntentAction>,
}
/// An action within an intent.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum IntentAction {
/// Token difference (swap, deposit, etc.)
TokenDiff { token: String, amount: String },
/// Add a public key to the account.
AddPublicKey { public_key: String },
/// Custom action with arbitrary data.
Custom {
action_type: String,
data: serde_json::Value,
},
}
/// A signed NEP-413 intent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedIntent {
pub standard: String,
pub payload: IntentMessage,
pub public_key: String,
pub signature: String,
}
/// Construct the NEP-413 signing payload.
///
/// The payload is: SHA-256(tag + message_json + nonce + recipient)
/// where tag is the NEP-413 tag prefix.
pub fn nep413_signing_payload(message: &IntentMessage) -> Result<[u8; 32], KeyError> {
let message_json = serde_json::to_string(message).map_err(|e| {
KeyError::SerializationFailed(format!("failed to serialize intent message: {}", e))
})?;
// NEP-413 tag
const NEP413_TAG: u32 = 2147484061; // (1 << 31) + 413
let mut hasher = Sha256::new();
hasher.update(NEP413_TAG.to_le_bytes());
hasher.update(message_json.as_bytes());
Ok(hasher.finalize().into())
}
/// Sign an intent message using a key from the secrets store.
pub async fn sign_intent(
secrets_store: &dyn SecretsStore,
user_id: &str,
label: &str,
public_key: &NearPublicKey,
intent: IntentMessage,
) -> Result<SignedIntent, KeyError> {
let hash = nep413_signing_payload(&intent)?;
let signature_bytes = sign_hash(secrets_store, user_id, label, &hash).await?;
// Base64-encode the signature
let signature =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, signature_bytes);
Ok(SignedIntent {
standard: "nep413".to_string(),
payload: intent,
public_key: public_key.to_near_format(),
signature,
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use ed25519_dalek::SigningKey;
use secrecy::SecretString;
use crate::keys::intents::{IntentAction, IntentMessage, nep413_signing_payload, sign_intent};
use crate::keys::signer::public_key_from_secret;
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto, SecretsStore};
fn test_store() -> Arc<InMemorySecretsStore> {
let key = "0123456789abcdef0123456789abcdef";
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
Arc::new(InMemorySecretsStore::new(crypto))
}
fn test_intent() -> IntentMessage {
IntentMessage {
signer_id: "alice.near".to_string(),
verifying_contract: "intents.near".to_string(),
deadline: "100000000".to_string(),
nonce: "unique-nonce-123".to_string(),
intents: vec![IntentAction::TokenDiff {
token: "wrap.near".to_string(),
amount: "1000000".to_string(),
}],
}
}
#[test]
fn test_nep413_payload_deterministic() {
let intent = test_intent();
let hash1 = nep413_signing_payload(&intent).unwrap();
let hash2 = nep413_signing_payload(&intent).unwrap();
assert_eq!(hash1, hash2);
}
#[test]
fn test_nep413_payload_different_nonces() {
let mut intent1 = test_intent();
let mut intent2 = test_intent();
intent1.nonce = "nonce-1".to_string();
intent2.nonce = "nonce-2".to_string();
let hash1 = nep413_signing_payload(&intent1).unwrap();
let hash2 = nep413_signing_payload(&intent2).unwrap();
assert_ne!(hash1, hash2);
}
#[tokio::test]
async fn test_sign_intent_roundtrip() {
let store = test_store();
// Generate a key
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
let mut combined = Vec::with_capacity(64);
combined.extend_from_slice(signing_key.as_bytes());
combined.extend_from_slice(verifying_key.as_bytes());
let secret = format!("ed25519:{}", bs58::encode(&combined).into_string());
store
.create(
"user1",
CreateSecretParams::new("near_key:intent-signer", &secret)
.with_provider("near_keys"),
)
.await
.unwrap();
let public_key = public_key_from_secret(&secret).unwrap();
let intent = test_intent();
let signed = sign_intent(
store.as_ref(),
"user1",
"intent-signer",
&public_key,
intent,
)
.await
.unwrap();
assert_eq!(signed.standard, "nep413");
assert_eq!(signed.public_key, public_key.to_near_format());
assert!(!signed.signature.is_empty());
}
}
+994
View File
@@ -0,0 +1,994 @@
//! NEAR key management for IronClaw.
//!
//! Manages NEAR Protocol blockchain keys so the agent can sign transactions,
//! intents, and cross-chain signature requests.
//!
//! # Security Model
//!
//! Hybrid custody: the agent holds scoped function-call keys for routine
//! operations. High-value operations require explicit user approval.
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────────────────┐
//! │ Key Management │
//! │ │
//! │ KeyManager ──► SecretsStore (AES-256-GCM encrypted private keys) │
//! │ │ │
//! │ ├──► Signer (ed25519 sign, Zeroize on drop) │
//! │ ├──► Policy (analyze transaction, evaluate rules, approve/deny) │
//! │ ├──► SpendTracker (daily cumulative spend) │
//! │ └──► RPC Client (nonce, submit, status) │
//! │ │
//! │ INVARIANT: Private keys NEVER reach the LLM or WASM boundary. │
//! └─────────────────────────────────────────────────────────────────────────┘
//! ```
pub mod chain_signatures;
mod error;
pub mod intents;
pub mod policy;
pub mod rpc;
pub mod signer;
pub mod spending;
pub mod transaction;
pub mod types;
pub use error::KeyError;
use std::path::PathBuf;
use std::sync::Arc;
use chrono::Utc;
use ed25519_dalek::SigningKey;
use tokio::fs;
use zeroize::Zeroize;
use crate::keys::policy::{
ChainSigAnalysis, PolicyConfig, PolicyDecision, SignatureDomain, analyze_transaction,
infer_target_chain,
};
use crate::keys::rpc::NearRpcClient;
use crate::keys::signer::{public_key_from_secret, sign_hash};
use crate::keys::spending::SpendTracker;
use crate::keys::transaction::{BlockHash, Signature, SignedTransaction, Transaction};
use crate::keys::types::{
AccessKeyPermission, KeyMetadata, KeyStore, KeyType, NearAccountId, NearNetwork, NearPublicKey,
};
use crate::secrets::{CreateSecretParams, SecretsStore};
/// Result of a signing operation.
#[derive(Debug)]
pub enum SignResult {
/// Transaction was signed (policy auto-approved).
Signed {
transaction: SignedTransaction,
analysis: policy::TransactionAnalysis,
},
/// User must approve before signing can proceed.
ApprovalRequired {
analysis: policy::TransactionAnalysis,
reasons: Vec<String>,
},
}
/// Central key management struct.
pub struct KeyManager {
secrets_store: Arc<dyn SecretsStore + Send + Sync>,
metadata_path: PathBuf,
policy: PolicyConfig,
spend_tracker: SpendTracker,
user_id: String,
}
impl KeyManager {
/// Create a new KeyManager.
pub fn new(secrets_store: Arc<dyn SecretsStore + Send + Sync>, user_id: String) -> Self {
Self {
secrets_store,
metadata_path: default_keys_path(),
policy: PolicyConfig::default(),
spend_tracker: SpendTracker::new(SpendTracker::default_path()),
user_id,
}
}
/// Set a custom metadata path (for testing).
pub fn with_metadata_path(mut self, path: PathBuf) -> Self {
self.metadata_path = path;
self
}
/// Set the policy config.
pub fn with_policy(mut self, policy: PolicyConfig) -> Self {
self.policy = policy;
self
}
/// Set a custom spend tracker (for testing).
pub fn with_spend_tracker(mut self, tracker: SpendTracker) -> Self {
self.spend_tracker = tracker;
self
}
/// Get a reference to the current policy config.
pub fn policy(&self) -> &PolicyConfig {
&self.policy
}
/// Get a mutable reference to the policy config.
pub fn policy_mut(&mut self) -> &mut PolicyConfig {
&mut self.policy
}
// -- Key lifecycle --
/// Generate a new ed25519 keypair and store it.
pub async fn generate_key(
&self,
label: &str,
account_id: &NearAccountId,
permission: AccessKeyPermission,
network: NearNetwork,
) -> Result<KeyMetadata, KeyError> {
// Check for duplicates
let store = self.load_store().await?;
if store.keys.contains_key(label) {
return Err(KeyError::AlreadyExists {
label: label.to_string(),
});
}
// Generate keypair
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
// Build NEAR-format secret: ed25519:<base58(seed + pubkey)>
let mut combined = Vec::with_capacity(64);
combined.extend_from_slice(signing_key.as_bytes());
combined.extend_from_slice(verifying_key.as_bytes());
let secret_key = format!("ed25519:{}", bs58::encode(&combined).into_string());
combined.zeroize();
// signing_key drops here (Zeroize on drop)
let public_key = NearPublicKey {
key_type: KeyType::Ed25519,
data: verifying_key.to_bytes(),
};
// Store private key in secrets store
let secret_name = format!("near_key:{}", label);
self.secrets_store
.create(
&self.user_id,
CreateSecretParams::new(&secret_name, &secret_key).with_provider("near_keys"),
)
.await?;
// Build metadata
let metadata = KeyMetadata {
label: label.to_string(),
account_id: account_id.to_string(),
public_key: public_key.to_near_format(),
permission,
network,
created_at: Utc::now(),
cached_nonce: None,
};
// Save metadata
let mut store = self.load_store().await?;
store.keys.insert(label.to_string(), metadata.clone());
self.save_store(&store).await?;
Ok(metadata)
}
/// Import an existing key from a NEAR-format secret key string.
pub async fn import_key(
&self,
label: &str,
account_id: &NearAccountId,
secret_key: &str,
permission: AccessKeyPermission,
network: NearNetwork,
) -> Result<KeyMetadata, KeyError> {
// Check for duplicates
let store = self.load_store().await?;
if store.keys.contains_key(label) {
return Err(KeyError::AlreadyExists {
label: label.to_string(),
});
}
// Validate and derive public key
let public_key = public_key_from_secret(secret_key)?;
// Store private key in secrets store
let secret_name = format!("near_key:{}", label);
self.secrets_store
.create(
&self.user_id,
CreateSecretParams::new(&secret_name, secret_key).with_provider("near_keys"),
)
.await?;
// Build metadata
let metadata = KeyMetadata {
label: label.to_string(),
account_id: account_id.to_string(),
public_key: public_key.to_near_format(),
permission,
network,
created_at: Utc::now(),
cached_nonce: None,
};
// Save metadata
let mut store = self.load_store().await?;
store.keys.insert(label.to_string(), metadata.clone());
self.save_store(&store).await?;
Ok(metadata)
}
/// List all stored keys (metadata only).
pub async fn list_keys(&self) -> Result<Vec<KeyMetadata>, KeyError> {
let store = self.load_store().await?;
let mut keys: Vec<KeyMetadata> = store.keys.values().cloned().collect();
keys.sort_by(|a, b| a.label.cmp(&b.label));
Ok(keys)
}
/// Get metadata for a specific key.
pub async fn get_key(&self, label: &str) -> Result<KeyMetadata, KeyError> {
let store = self.load_store().await?;
store
.keys
.get(label)
.cloned()
.ok_or_else(|| KeyError::NotFound {
label: label.to_string(),
})
}
/// Remove a key (deletes from secrets store and metadata).
pub async fn remove_key(&self, label: &str) -> Result<(), KeyError> {
let mut store = self.load_store().await?;
if store.keys.remove(label).is_none() {
return Err(KeyError::NotFound {
label: label.to_string(),
});
}
// Delete from secrets store
let secret_name = format!("near_key:{}", label);
let _ = self.secrets_store.delete(&self.user_id, &secret_name).await;
self.save_store(&store).await?;
Ok(())
}
/// Export the public key (NEVER the private key).
pub async fn export_public_key(&self, label: &str) -> Result<NearPublicKey, KeyError> {
let metadata = self.get_key(label).await?;
NearPublicKey::from_near_format(&metadata.public_key)
}
// -- Transaction signing --
/// Sign a transaction with policy enforcement.
pub async fn sign_transaction(
&self,
label: &str,
receiver_id: &NearAccountId,
actions: Vec<transaction::Action>,
) -> Result<SignResult, KeyError> {
let metadata = self.get_key(label).await?;
// Analyze
let analysis = analyze_transaction(
receiver_id.as_str(),
&actions,
&metadata.permission,
&self.policy,
);
// Check spend
let daily_spend = self.spend_tracker.get_daily_spend().await?;
// Evaluate policy
let decision = self
.policy
.evaluate(&analysis, &metadata.permission, daily_spend);
match decision {
PolicyDecision::Deny { reason } => Err(KeyError::PolicyDenied { reason }),
PolicyDecision::RequireApproval { reasons } => {
Ok(SignResult::ApprovalRequired { analysis, reasons })
}
PolicyDecision::AutoApprove => {
let signed = self
.build_and_sign(label, &metadata, receiver_id, actions)
.await?;
// Record spend
if analysis.total_value_yocto > 0 {
let _ = self
.spend_tracker
.record_spend(analysis.total_value_yocto, analysis.summary.clone(), None)
.await;
}
Ok(SignResult::Signed {
transaction: signed,
analysis,
})
}
}
}
/// Request a chain signature via MPC.
pub async fn request_chain_signature(
&self,
label: &str,
payload: &[u8],
derivation_path: &str,
domain: SignatureDomain,
) -> Result<SignResult, KeyError> {
let metadata = self.get_key(label).await?;
// Build chain sig analysis
let chain_sig = ChainSigAnalysis {
derivation_path: derivation_path.to_string(),
domain,
target_chain: infer_target_chain(derivation_path),
payload_size: payload.len(),
risk_level: policy::RiskLevel::Medium,
};
let daily_spend = self.spend_tracker.get_daily_spend().await?;
let decision = self.policy.evaluate_chain_sig(&chain_sig, daily_spend);
// Build the function call action
let action =
chain_signatures::build_chain_signature_action(payload, derivation_path, domain)?;
let contract = chain_signatures::chain_sig_contract(&metadata.network);
let contract_id = NearAccountId::new(contract)?;
// Analyze the underlying transaction too
let analysis = analyze_transaction(
contract,
&[action.clone()],
&metadata.permission,
&self.policy,
);
match decision {
PolicyDecision::Deny { reason } => Err(KeyError::PolicyDenied { reason }),
PolicyDecision::RequireApproval { reasons } => {
Ok(SignResult::ApprovalRequired { analysis, reasons })
}
PolicyDecision::AutoApprove => {
let signed = self
.build_and_sign(label, &metadata, &contract_id, vec![action])
.await?;
Ok(SignResult::Signed {
transaction: signed,
analysis,
})
}
}
}
/// Build and sign a transaction (internal, after policy check passes).
async fn build_and_sign(
&self,
label: &str,
metadata: &KeyMetadata,
receiver_id: &NearAccountId,
actions: Vec<transaction::Action>,
) -> Result<SignedTransaction, KeyError> {
let public_key = NearPublicKey::from_near_format(&metadata.public_key)?;
// Get nonce and block hash from RPC
let rpc = NearRpcClient::new(&metadata.network);
let access_key = rpc
.view_access_key(&metadata.account_id, &metadata.public_key)
.await?;
let nonce = access_key.nonce + 1;
let block_hash = BlockHash::from_base58(&access_key.block_hash)?;
let signer_id = NearAccountId::new(&metadata.account_id)?;
let tx = Transaction {
signer_id,
public_key,
nonce,
receiver_id: receiver_id.clone(),
block_hash,
actions,
};
// Hash and sign
let hash = tx.hash_for_signing()?;
let sig_bytes = sign_hash(self.secrets_store.as_ref(), &self.user_id, label, &hash).await?;
Ok(SignedTransaction {
transaction: tx,
signature: Signature {
key_type: KeyType::Ed25519,
data: sig_bytes,
},
})
}
// -- Backup / Restore --
/// Create an encrypted backup of all keys.
pub async fn create_backup(&self, passphrase: &str) -> Result<Vec<u8>, KeyError> {
let store = self.load_store().await?;
let mut entries = Vec::new();
for (label, metadata) in &store.keys {
let secret_name = format!("near_key:{}", label);
let decrypted = self
.secrets_store
.get_decrypted(&self.user_id, &secret_name)
.await
.map_err(|e| KeyError::BackupError {
reason: format!("failed to decrypt key '{}': {}", label, e),
})?;
entries.push(KeyBackupEntry {
label: label.clone(),
account_id: metadata.account_id.clone(),
secret_key_near_format: decrypted.expose().to_string(),
permission: metadata.permission.clone(),
network: metadata.network.clone(),
});
}
let backup = KeyBackup {
version: 1,
created_at: Utc::now(),
keys: entries,
};
let plaintext = serde_json::to_vec(&backup).map_err(|e| KeyError::BackupError {
reason: format!("failed to serialize backup: {}", e),
})?;
encrypt_backup(passphrase, &plaintext)
}
/// Restore keys from an encrypted backup.
pub async fn restore_backup(
&self,
backup_data: &[u8],
passphrase: &str,
) -> Result<Vec<String>, KeyError> {
let plaintext = decrypt_backup(passphrase, backup_data)?;
let backup: KeyBackup =
serde_json::from_slice(&plaintext).map_err(|e| KeyError::BackupError {
reason: format!("failed to parse backup: {}", e),
})?;
let mut restored = Vec::new();
for entry in backup.keys {
// Validate the key
let _ = public_key_from_secret(&entry.secret_key_near_format)?;
let account_id = NearAccountId::new(&entry.account_id)?;
// Import (skip if already exists)
match self
.import_key(
&entry.label,
&account_id,
&entry.secret_key_near_format,
entry.permission,
entry.network,
)
.await
{
Ok(_) => restored.push(entry.label),
Err(KeyError::AlreadyExists { .. }) => {
// Skip existing keys
}
Err(e) => return Err(e),
}
}
// Update backup timestamp
let mut store = self.load_store().await?;
store.last_backup_at = Some(Utc::now());
self.save_store(&store).await?;
Ok(restored)
}
// -- Internal helpers --
async fn load_store(&self) -> Result<KeyStore, KeyError> {
if !self.metadata_path.exists() {
return Ok(KeyStore::default());
}
let content = fs::read_to_string(&self.metadata_path).await?;
serde_json::from_str(&content).map_err(|e| {
KeyError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("corrupt keys.json: {}", e),
))
})
}
async fn save_store(&self, store: &KeyStore) -> Result<(), KeyError> {
if let Some(parent) = self.metadata_path.parent() {
fs::create_dir_all(parent).await?;
}
let content = serde_json::to_string_pretty(store).map_err(|e| {
KeyError::SerializationFailed(format!("failed to serialize key store: {}", e))
})?;
fs::write(&self.metadata_path, content).await?;
Ok(())
}
}
/// Default path for keys metadata.
fn default_keys_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".ironclaw").join("keys.json"))
.unwrap_or_else(|| PathBuf::from(".ironclaw/keys.json"))
}
// -- Backup encryption --
/// Backup file magic bytes.
const BACKUP_MAGIC: &[u8; 4] = b"ICLK";
const BACKUP_VERSION: u32 = 1;
const ARGON2_SALT_LEN: usize = 32;
const AES_NONCE_LEN: usize = 12;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct KeyBackup {
version: u32,
created_at: chrono::DateTime<Utc>,
keys: Vec<KeyBackupEntry>,
}
#[derive(Serialize, Deserialize)]
struct KeyBackupEntry {
label: String,
account_id: String,
secret_key_near_format: String,
permission: AccessKeyPermission,
network: NearNetwork,
}
fn encrypt_backup(passphrase: &str, plaintext: &[u8]) -> Result<Vec<u8>, KeyError> {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use argon2::Argon2;
// Generate salt
let mut salt = [0u8; ARGON2_SALT_LEN];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
// Derive key with Argon2id
let mut derived_key = [0u8; 32];
Argon2::default()
.hash_password_into(passphrase.as_bytes(), &salt, &mut derived_key)
.map_err(|e| KeyError::BackupError {
reason: format!("Argon2 key derivation failed: {}", e),
})?;
// Encrypt with AES-256-GCM
let cipher = Aes256Gcm::new_from_slice(&derived_key).map_err(|e| KeyError::BackupError {
reason: format!("failed to create cipher: {}", e),
})?;
let mut nonce_bytes = [0u8; AES_NONCE_LEN];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| KeyError::BackupError {
reason: format!("encryption failed: {}", e),
})?;
// Assemble: magic + version + salt + nonce + ciphertext
let mut output = Vec::new();
output.extend_from_slice(BACKUP_MAGIC);
output.extend_from_slice(&BACKUP_VERSION.to_le_bytes());
output.extend_from_slice(&salt);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
derived_key.zeroize();
Ok(output)
}
pub(crate) fn decrypt_backup(passphrase: &str, data: &[u8]) -> Result<Vec<u8>, KeyError> {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use argon2::Argon2;
let header_len = 4 + 4 + ARGON2_SALT_LEN + AES_NONCE_LEN;
if data.len() < header_len {
return Err(KeyError::BackupError {
reason: "backup file too short".to_string(),
});
}
// Check magic
if &data[..4] != BACKUP_MAGIC {
return Err(KeyError::BackupError {
reason: "not a valid IronClaw backup file".to_string(),
});
}
// Check version
let version = u32::from_le_bytes(data[4..8].try_into().unwrap());
if version != BACKUP_VERSION {
return Err(KeyError::BackupError {
reason: format!("unsupported backup version: {}", version),
});
}
let salt = &data[8..8 + ARGON2_SALT_LEN];
let nonce_bytes = &data[8 + ARGON2_SALT_LEN..header_len];
let ciphertext = &data[header_len..];
// Derive key
let mut derived_key = [0u8; 32];
Argon2::default()
.hash_password_into(passphrase.as_bytes(), salt, &mut derived_key)
.map_err(|e| KeyError::BackupError {
reason: format!("Argon2 key derivation failed: {}", e),
})?;
// Decrypt
let cipher = Aes256Gcm::new_from_slice(&derived_key).map_err(|e| KeyError::BackupError {
reason: format!("failed to create cipher: {}", e),
})?;
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| KeyError::BackupError {
reason: "decryption failed (wrong passphrase?)".to_string(),
})?;
derived_key.zeroize();
Ok(plaintext)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use secrecy::SecretString;
use tempfile::TempDir;
use crate::keys::spending::SpendTracker;
use crate::keys::transaction::{Action, ONE_NEAR, Transfer};
use crate::keys::types::{AccessKeyPermission, NearAccountId, NearNetwork};
use crate::keys::{KeyManager, SignResult};
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
fn test_manager(dir: &TempDir) -> KeyManager {
let key = "0123456789abcdef0123456789abcdef";
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
let store: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(InMemorySecretsStore::new(crypto));
KeyManager::new(store, "test_user".to_string())
.with_metadata_path(dir.path().join("keys.json"))
.with_spend_tracker(SpendTracker::new(dir.path().join("spend.json")))
}
#[tokio::test]
async fn test_generate_key() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
let metadata = manager
.generate_key(
"test-key",
&account,
AccessKeyPermission::FunctionCall {
allowance: None,
receiver_id: "intents.near".to_string(),
method_names: vec![],
},
NearNetwork::Testnet,
)
.await
.unwrap();
assert_eq!(metadata.label, "test-key");
assert_eq!(metadata.account_id, "alice.testnet");
assert!(metadata.public_key.starts_with("ed25519:"));
}
#[tokio::test]
async fn test_generate_duplicate_key_fails() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
manager
.generate_key(
"dup",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let result = manager
.generate_key(
"dup",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await;
assert!(matches!(
result,
Err(crate::keys::KeyError::AlreadyExists { .. })
));
}
#[tokio::test]
async fn test_list_keys() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
assert_eq!(manager.list_keys().await.unwrap().len(), 0);
manager
.generate_key(
"key-1",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
manager
.generate_key(
"key-2",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let keys = manager.list_keys().await.unwrap();
assert_eq!(keys.len(), 2);
}
#[tokio::test]
async fn test_remove_key() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
manager
.generate_key(
"to-remove",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
manager.remove_key("to-remove").await.unwrap();
assert!(manager.get_key("to-remove").await.is_err());
}
#[tokio::test]
async fn test_export_public_key() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
let metadata = manager
.generate_key(
"export-test",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let pubkey = manager.export_public_key("export-test").await.unwrap();
assert_eq!(pubkey.to_near_format(), metadata.public_key);
}
#[tokio::test]
async fn test_import_key() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("bob.testnet").unwrap();
// Generate a test secret key
let signing_key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
let mut combined = Vec::with_capacity(64);
combined.extend_from_slice(signing_key.as_bytes());
combined.extend_from_slice(verifying_key.as_bytes());
let secret = format!("ed25519:{}", bs58::encode(&combined).into_string());
let metadata = manager
.import_key(
"imported",
&account,
&secret,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
assert_eq!(metadata.label, "imported");
assert!(metadata.public_key.starts_with("ed25519:"));
}
#[tokio::test]
async fn test_backup_and_restore_roundtrip() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
// Generate a key
manager
.generate_key(
"backup-test",
&account,
AccessKeyPermission::FunctionCall {
allowance: None,
receiver_id: "contract.near".to_string(),
method_names: vec!["deposit".to_string()],
},
NearNetwork::Testnet,
)
.await
.unwrap();
// Create backup
let backup_data = manager.create_backup("test-passphrase").await.unwrap();
assert!(!backup_data.is_empty());
// Restore into a fresh manager
let dir2 = TempDir::new().unwrap();
let manager2 = test_manager(&dir2);
let restored = manager2
.restore_backup(&backup_data, "test-passphrase")
.await
.unwrap();
assert_eq!(restored, vec!["backup-test"]);
// Verify the restored key
let keys = manager2.list_keys().await.unwrap();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].label, "backup-test");
}
#[tokio::test]
async fn test_backup_wrong_passphrase() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
manager
.generate_key(
"test",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let backup_data = manager.create_backup("correct").await.unwrap();
let dir2 = TempDir::new().unwrap();
let manager2 = test_manager(&dir2);
let result = manager2.restore_backup(&backup_data, "wrong").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_sign_transaction_policy_deny() {
let dir = TempDir::new().unwrap();
let key = "0123456789abcdef0123456789abcdef";
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
let store: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(InMemorySecretsStore::new(crypto));
let mut manager = KeyManager::new(store, "test_user".to_string())
.with_metadata_path(dir.path().join("keys.json"))
.with_spend_tracker(SpendTracker::new(dir.path().join("spend.json")));
// Deny full access operations
manager.policy_mut().deny_full_access_operations = true;
let account = NearAccountId::new("alice.testnet").unwrap();
manager
.generate_key(
"denied",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let receiver = NearAccountId::new("bob.testnet").unwrap();
let result = manager
.sign_transaction(
"denied",
&receiver,
vec![Action::Transfer(Transfer { deposit: 0 })],
)
.await;
assert!(matches!(
result,
Err(crate::keys::KeyError::PolicyDenied { .. })
));
}
#[tokio::test]
async fn test_sign_transaction_requires_approval() {
let dir = TempDir::new().unwrap();
let manager = test_manager(&dir);
let account = NearAccountId::new("alice.testnet").unwrap();
manager
.generate_key(
"signer",
&account,
AccessKeyPermission::FullAccess,
NearNetwork::Testnet,
)
.await
.unwrap();
let receiver = NearAccountId::new("unknown.testnet").unwrap();
let result = manager
.sign_transaction(
"signer",
&receiver,
vec![Action::Transfer(Transfer {
deposit: 100 * ONE_NEAR,
})],
)
.await
.unwrap();
// Default policy requires approval for any transfer
assert!(matches!(result, SignResult::ApprovalRequired { .. }));
}
}
+917
View File
@@ -0,0 +1,917 @@
//! Transaction analysis and policy engine for NEAR key operations.
//!
//! Every transaction is decomposed into a `TransactionAnalysis` before any
//! signing happens. The policy engine then evaluates the analysis against
//! a configurable ruleset. Most restrictive rule always wins.
//!
//! # Pipeline
//!
//! ```text
//! Transaction -> analyze_transaction() -> TransactionAnalysis
//! |
//! PolicyConfig.evaluate() <-------+
//! |
//! PolicyDecision { AutoApprove | RequireApproval | Deny }
//! ```
use serde::{Deserialize, Serialize};
use crate::keys::transaction::{Action, ONE_NEAR};
use crate::keys::types::{AccessKeyPermission, format_yocto};
/// Risk level for a single action within a transaction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
impl std::fmt::Display for RiskLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RiskLevel::Low => write!(f, "LOW"),
RiskLevel::Medium => write!(f, "MEDIUM"),
RiskLevel::High => write!(f, "HIGH"),
RiskLevel::Critical => write!(f, "CRITICAL"),
}
}
}
/// Category of a transaction action for policy evaluation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ActionCategory {
Transfer,
FunctionCall,
Stake,
AddKey { is_full_access: bool },
DeleteKey,
DeployContract,
CreateAccount,
DeleteAccount,
}
/// Analysis of a single action within a transaction.
#[derive(Debug, Clone)]
pub struct ActionAnalysis {
pub category: ActionCategory,
pub value_yocto: u128,
pub receiver: String,
pub method: Option<String>,
pub description: String,
pub risk_level: RiskLevel,
}
/// Complete analysis of a transaction.
#[derive(Debug, Clone)]
pub struct TransactionAnalysis {
pub actions: Vec<ActionAnalysis>,
pub total_value_yocto: u128,
pub receivers: Vec<String>,
pub uses_full_access_key: bool,
pub summary: String,
}
/// Analyze a transaction's actions for policy evaluation.
pub fn analyze_transaction(
receiver_id: &str,
actions: &[Action],
key_permission: &AccessKeyPermission,
policy: &PolicyConfig,
) -> TransactionAnalysis {
let uses_full_access_key = matches!(key_permission, AccessKeyPermission::FullAccess);
let mut action_analyses = Vec::new();
let mut total_value = 0u128;
for action in actions {
let analysis = analyze_action(action, receiver_id, policy);
total_value = total_value.saturating_add(analysis.value_yocto);
action_analyses.push(analysis);
}
let receivers: Vec<String> = action_analyses
.iter()
.map(|a| a.receiver.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let summary = build_summary(&action_analyses, total_value);
TransactionAnalysis {
actions: action_analyses,
total_value_yocto: total_value,
receivers,
uses_full_access_key,
summary,
}
}
fn analyze_action(action: &Action, receiver_id: &str, policy: &PolicyConfig) -> ActionAnalysis {
match action {
Action::Transfer(t) => {
let is_whitelisted = policy.transfer_whitelist.contains(&receiver_id.to_string());
let risk = if t.deposit == 0 || (t.deposit < ONE_NEAR && is_whitelisted) {
RiskLevel::Low
} else if t.deposit < policy.transfer_whitelist_max_yocto && is_whitelisted {
RiskLevel::Medium
} else {
RiskLevel::High
};
ActionAnalysis {
category: ActionCategory::Transfer,
value_yocto: t.deposit,
receiver: receiver_id.to_string(),
method: None,
description: format!("Transfer {} to {}", format_yocto(t.deposit), receiver_id),
risk_level: risk,
}
}
Action::FunctionCall(fc) => {
let has_matching_rule = policy
.function_call_rules
.iter()
.any(|r| r.receiver_id == receiver_id && fc.deposit <= r.max_deposit_yocto);
let risk = if fc.deposit == 0 && has_matching_rule {
RiskLevel::Low
} else if fc.deposit == 0 || has_matching_rule {
RiskLevel::Medium
} else {
RiskLevel::High
};
ActionAnalysis {
category: ActionCategory::FunctionCall,
value_yocto: fc.deposit,
receiver: receiver_id.to_string(),
method: Some(fc.method_name.clone()),
description: format!(
"FunctionCall {}::{}{}",
receiver_id,
fc.method_name,
if fc.deposit > 0 {
format!(" ({})", format_yocto(fc.deposit))
} else {
String::new()
}
),
risk_level: risk,
}
}
Action::Stake(s) => {
let risk = if policy
.stake_validator_whitelist
.contains(&receiver_id.to_string())
&& s.stake <= policy.stake_auto_approve_max_yocto
{
RiskLevel::Medium
} else {
RiskLevel::High
};
ActionAnalysis {
category: ActionCategory::Stake,
value_yocto: s.stake,
receiver: receiver_id.to_string(),
method: None,
description: format!("Stake {} with {}", format_yocto(s.stake), receiver_id),
risk_level: risk,
}
}
Action::AddKey(ak) => {
let is_full_access = borsh_permission_is_full_access(&ak.access_key.permission);
ActionAnalysis {
category: ActionCategory::AddKey { is_full_access },
value_yocto: 0,
receiver: receiver_id.to_string(),
method: None,
description: if is_full_access {
format!("AddKey (FullAccess) to {}", receiver_id)
} else {
format!("AddKey (FunctionCall) to {}", receiver_id)
},
risk_level: if is_full_access {
RiskLevel::Critical
} else {
RiskLevel::High
},
}
}
Action::DeleteKey(_) => ActionAnalysis {
category: ActionCategory::DeleteKey,
value_yocto: 0,
receiver: receiver_id.to_string(),
method: None,
description: format!("DeleteKey on {}", receiver_id),
risk_level: RiskLevel::High,
},
Action::DeployContract(_) => ActionAnalysis {
category: ActionCategory::DeployContract,
value_yocto: 0,
receiver: receiver_id.to_string(),
method: None,
description: format!("DeployContract to {}", receiver_id),
risk_level: RiskLevel::Critical,
},
Action::CreateAccount => ActionAnalysis {
category: ActionCategory::CreateAccount,
value_yocto: 0,
receiver: receiver_id.to_string(),
method: None,
description: format!("CreateAccount {}", receiver_id),
risk_level: RiskLevel::Medium,
},
Action::DeleteAccount(_) => ActionAnalysis {
category: ActionCategory::DeleteAccount,
value_yocto: 0,
receiver: receiver_id.to_string(),
method: None,
description: format!("DeleteAccount {}", receiver_id),
risk_level: RiskLevel::Critical,
},
}
}
fn borsh_permission_is_full_access(
perm: &crate::keys::transaction::AccessKeyPermissionBorsh,
) -> bool {
matches!(
perm,
crate::keys::transaction::AccessKeyPermissionBorsh::FullAccess
)
}
fn build_summary(actions: &[ActionAnalysis], total_value: u128) -> String {
let mut lines = Vec::new();
for (i, a) in actions.iter().enumerate() {
lines.push(format!(" {}. {} [{}]", i + 1, a.description, a.risk_level));
}
if total_value > 0 {
lines.push(format!(" Total value: {}", format_yocto(total_value)));
}
lines.join("\n")
}
/// Policy decision after evaluating a transaction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyDecision {
/// Transaction can proceed without user interaction.
AutoApprove,
/// User must approve before signing.
RequireApproval { reasons: Vec<String> },
/// Transaction is denied by policy (not even user can override).
Deny { reason: String },
}
/// Configurable policy rules for transaction approval.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyConfig {
// Transfer rules
pub transfer_auto_approve_max_yocto: u128,
pub transfer_whitelist_max_yocto: u128,
pub transfer_whitelist: Vec<String>,
// Function call rules
pub function_call_rules: Vec<FunctionCallRule>,
// Staking rules
pub stake_validator_whitelist: Vec<String>,
pub stake_auto_approve_max_yocto: u128,
// Key management rules
pub allow_add_scoped_keys_to: Vec<String>,
// Chain signature rules
pub chain_sig_rules: Vec<ChainSigRule>,
// Global limits
pub daily_spend_limit_yocto: Option<u128>,
pub per_tx_auto_approve_max_yocto: u128,
// Blanket denials
pub deny_full_access_operations: bool,
pub deny_delete_account: bool,
}
impl Default for PolicyConfig {
fn default() -> Self {
Self {
transfer_auto_approve_max_yocto: 0,
transfer_whitelist_max_yocto: ONE_NEAR,
transfer_whitelist: Vec::new(),
function_call_rules: Vec::new(),
stake_validator_whitelist: Vec::new(),
stake_auto_approve_max_yocto: 0,
allow_add_scoped_keys_to: Vec::new(),
chain_sig_rules: Vec::new(),
daily_spend_limit_yocto: None,
per_tx_auto_approve_max_yocto: 0,
deny_full_access_operations: false,
deny_delete_account: true,
}
}
}
/// A function call rule for policy evaluation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallRule {
pub receiver_id: String,
/// Empty = all methods on this contract.
pub allowed_methods: Vec<String>,
pub max_deposit_yocto: u128,
pub max_gas: Option<u64>,
pub auto_approve: bool,
}
/// Signature domain for chain signatures.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SignatureDomain {
Secp256k1 = 0,
Ed25519 = 1,
}
/// A chain signature rule for policy evaluation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainSigRule {
pub allowed_paths: Vec<String>,
pub allowed_domains: Vec<SignatureDomain>,
pub max_payload_bytes: usize,
pub auto_approve: bool,
}
/// Analysis specific to chain signature requests.
#[derive(Debug, Clone)]
pub struct ChainSigAnalysis {
pub derivation_path: String,
pub domain: SignatureDomain,
pub target_chain: Option<String>,
pub payload_size: usize,
pub risk_level: RiskLevel,
}
impl PolicyConfig {
/// Evaluate a transaction analysis against this policy.
///
/// Returns the most restrictive decision across all actions.
pub fn evaluate(
&self,
analysis: &TransactionAnalysis,
key_permission: &AccessKeyPermission,
daily_spend: u128,
) -> PolicyDecision {
let mut reasons = Vec::new();
// Blanket denials first
if self.deny_full_access_operations && analysis.uses_full_access_key {
return PolicyDecision::Deny {
reason: "full-access key operations are denied by policy".to_string(),
};
}
for action in &analysis.actions {
if self.deny_delete_account && matches!(action.category, ActionCategory::DeleteAccount)
{
return PolicyDecision::Deny {
reason: "account deletion is denied by policy".to_string(),
};
}
}
// Daily spend limit
if let Some(limit) = self.daily_spend_limit_yocto {
if daily_spend.saturating_add(analysis.total_value_yocto) > limit {
reasons.push(format!(
"daily spend limit exceeded: {} + {} > {}",
format_yocto(daily_spend),
format_yocto(analysis.total_value_yocto),
format_yocto(limit)
));
}
}
// Per-transaction limit
if analysis.total_value_yocto > self.per_tx_auto_approve_max_yocto
&& self.per_tx_auto_approve_max_yocto > 0
{
reasons.push(format!(
"transaction value {} exceeds per-tx auto-approve limit {}",
format_yocto(analysis.total_value_yocto),
format_yocto(self.per_tx_auto_approve_max_yocto)
));
}
// Per-action evaluation
for action in &analysis.actions {
if let Some(reason) = self.evaluate_action(action, key_permission) {
reasons.push(reason);
}
}
if reasons.is_empty() {
PolicyDecision::AutoApprove
} else {
PolicyDecision::RequireApproval { reasons }
}
}
/// Evaluate a chain signature request.
pub fn evaluate_chain_sig(
&self,
chain_sig: &ChainSigAnalysis,
daily_spend: u128,
) -> PolicyDecision {
let mut reasons = Vec::new();
// Check daily limit (chain sigs don't have a value, but check anyway)
if let Some(limit) = self.daily_spend_limit_yocto {
if daily_spend > limit {
reasons.push("daily spend limit exceeded".to_string());
}
}
// Find matching chain sig rule
let matching_rule = self.chain_sig_rules.iter().find(|rule| {
rule.allowed_domains.contains(&chain_sig.domain)
&& chain_sig.payload_size <= rule.max_payload_bytes
&& rule
.allowed_paths
.iter()
.any(|pattern| glob_matches(pattern, &chain_sig.derivation_path))
});
match matching_rule {
Some(rule) if rule.auto_approve => PolicyDecision::AutoApprove,
Some(_) => {
reasons.push(format!(
"chain signature for path '{}' requires approval",
chain_sig.derivation_path
));
PolicyDecision::RequireApproval { reasons }
}
None => {
reasons.push(format!(
"no matching chain signature rule for path '{}'",
chain_sig.derivation_path
));
PolicyDecision::RequireApproval { reasons }
}
}
}
fn evaluate_action(
&self,
action: &ActionAnalysis,
key_permission: &AccessKeyPermission,
) -> Option<String> {
match &action.category {
ActionCategory::Transfer => {
// Auto-approve to whitelisted accounts under threshold
if self.transfer_whitelist.contains(&action.receiver)
&& action.value_yocto <= self.transfer_whitelist_max_yocto
{
return None;
}
// Auto-approve small transfers to anyone
if action.value_yocto <= self.transfer_auto_approve_max_yocto {
return None;
}
Some(format!(
"transfer {} to {} exceeds auto-approve threshold",
format_yocto(action.value_yocto),
action.receiver
))
}
ActionCategory::FunctionCall => {
// Check if key is already scoped to this receiver with zero deposit
if let AccessKeyPermission::FunctionCall {
receiver_id,
method_names,
..
} = key_permission
{
if receiver_id == &action.receiver
&& action.value_yocto == 0
&& (method_names.is_empty()
|| action
.method
.as_ref()
.map(|m| method_names.contains(m))
.unwrap_or(false))
{
return None;
}
}
// Check function call rules
if let Some(method) = &action.method {
for rule in &self.function_call_rules {
if rule.receiver_id == action.receiver
&& (rule.allowed_methods.is_empty()
|| rule.allowed_methods.contains(method))
&& action.value_yocto <= rule.max_deposit_yocto
&& rule.auto_approve
{
return None;
}
}
}
Some(format!(
"function call {} requires approval",
action.description
))
}
ActionCategory::Stake => {
if self.stake_validator_whitelist.contains(&action.receiver)
&& action.value_yocto <= self.stake_auto_approve_max_yocto
{
return None;
}
Some(format!("stake {} requires approval", action.description))
}
ActionCategory::AddKey { is_full_access } => {
if *is_full_access {
Some("adding full-access key requires approval".to_string())
} else {
Some("adding function-call key requires approval".to_string())
}
}
ActionCategory::DeleteKey
| ActionCategory::DeployContract
| ActionCategory::CreateAccount
| ActionCategory::DeleteAccount => {
Some(format!("{} requires approval", action.description))
}
}
}
}
/// Simple glob matching: supports `*` as wildcard for any suffix.
fn glob_matches(pattern: &str, value: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
value.starts_with(prefix)
} else {
pattern == value
}
}
/// Infer target chain from a derivation path.
pub fn infer_target_chain(derivation_path: &str) -> Option<String> {
let lower = derivation_path.to_lowercase();
if lower.starts_with("ethereum") || lower.starts_with("eth") {
Some("Ethereum".to_string())
} else if lower.starts_with("bitcoin") || lower.starts_with("btc") {
Some("Bitcoin".to_string())
} else if lower.starts_with("near") {
Some("NEAR".to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use crate::keys::policy::{
ChainSigAnalysis, ChainSigRule, FunctionCallRule, PolicyConfig, PolicyDecision, RiskLevel,
SignatureDomain, analyze_transaction, glob_matches, infer_target_chain,
};
use crate::keys::transaction::{Action, FunctionCall, ONE_NEAR, TGAS, Transfer};
use crate::keys::types::AccessKeyPermission;
fn default_policy() -> PolicyConfig {
PolicyConfig::default()
}
fn permissive_policy() -> PolicyConfig {
PolicyConfig {
transfer_auto_approve_max_yocto: ONE_NEAR,
transfer_whitelist_max_yocto: 10 * ONE_NEAR,
transfer_whitelist: vec!["bob.near".to_string()],
function_call_rules: vec![FunctionCallRule {
receiver_id: "intents.near".to_string(),
allowed_methods: vec!["execute_intents".to_string()],
max_deposit_yocto: 0,
max_gas: None,
auto_approve: true,
}],
per_tx_auto_approve_max_yocto: 5 * ONE_NEAR,
daily_spend_limit_yocto: Some(50 * ONE_NEAR),
..default_policy()
}
}
// -- Transfer tests --
#[test]
fn test_transfer_below_auto_approve() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: ONE_NEAR / 2,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("someone.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
#[test]
fn test_transfer_above_threshold_requires_approval() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: 2 * ONE_NEAR,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("unknown.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
#[test]
fn test_transfer_to_whitelisted_account() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: 5 * ONE_NEAR,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
#[test]
fn test_transfer_to_whitelisted_above_whitelist_limit() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: 15 * ONE_NEAR,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
// 15 NEAR > whitelist max (10 NEAR), and > per_tx limit (5 NEAR)
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
// -- Function call tests --
#[test]
fn test_function_call_matching_rule_auto_approve() {
let policy = permissive_policy();
let actions = vec![Action::FunctionCall(FunctionCall {
method_name: "execute_intents".to_string(),
args: vec![],
gas: 30 * TGAS,
deposit: 0,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("intents.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
#[test]
fn test_function_call_scoped_key_auto_approve() {
let policy = default_policy();
let actions = vec![Action::FunctionCall(FunctionCall {
method_name: "deposit".to_string(),
args: vec![],
gas: 30 * TGAS,
deposit: 0,
})];
let perm = AccessKeyPermission::FunctionCall {
allowance: None,
receiver_id: "contract.near".to_string(),
method_names: vec!["deposit".to_string()],
};
let analysis = analyze_transaction("contract.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
#[test]
fn test_function_call_no_rule_requires_approval() {
let policy = default_policy();
let actions = vec![Action::FunctionCall(FunctionCall {
method_name: "dangerous_method".to_string(),
args: vec![],
gas: 30 * TGAS,
deposit: ONE_NEAR,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("unknown.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
// -- Blanket denial tests --
#[test]
fn test_deny_full_access_operations() {
let policy = PolicyConfig {
deny_full_access_operations: true,
..default_policy()
};
let actions = vec![Action::Transfer(Transfer { deposit: 0 })];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert!(matches!(decision, PolicyDecision::Deny { .. }));
}
#[test]
fn test_deny_delete_account() {
let policy = PolicyConfig {
deny_delete_account: true,
..default_policy()
};
let actions = vec![Action::DeleteAccount(
crate::keys::transaction::DeleteAccount {
beneficiary_id: crate::keys::types::NearAccountId::new("bob.near").unwrap(),
},
)];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("alice.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
assert!(matches!(decision, PolicyDecision::Deny { .. }));
}
// -- Daily spend limit tests --
#[test]
fn test_daily_spend_limit_under() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: ONE_NEAR / 2,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 10 * ONE_NEAR);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
#[test]
fn test_daily_spend_limit_exceeded() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: ONE_NEAR / 2,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
// Current daily spend is 50 NEAR (at limit), adding 0.5 NEAR puts us over
let decision = policy.evaluate(&analysis, &perm, 50 * ONE_NEAR);
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
// -- Per-transaction limit tests --
#[test]
fn test_per_tx_limit() {
let policy = permissive_policy();
let actions = vec![Action::Transfer(Transfer {
deposit: 6 * ONE_NEAR,
})];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
// 6 NEAR > per_tx_auto_approve_max (5 NEAR)
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
// -- Most restrictive wins --
#[test]
fn test_mixed_actions_most_restrictive_wins() {
let policy = permissive_policy();
// One auto-approvable + one that requires approval
let actions = vec![
Action::FunctionCall(FunctionCall {
method_name: "execute_intents".to_string(),
args: vec![],
gas: 30 * TGAS,
deposit: 0,
}),
Action::Transfer(Transfer {
deposit: 100 * ONE_NEAR,
}),
];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("intents.near", &actions, &perm, &policy);
let decision = policy.evaluate(&analysis, &perm, 0);
// Transfer is too large, so the whole tx requires approval
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
// -- Transaction analysis tests --
#[test]
fn test_analysis_total_value() {
let policy = default_policy();
let actions = vec![
Action::Transfer(Transfer {
deposit: 2 * ONE_NEAR,
}),
Action::FunctionCall(FunctionCall {
method_name: "deposit".to_string(),
args: vec![],
gas: 30 * TGAS,
deposit: ONE_NEAR,
}),
];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("bob.near", &actions, &perm, &policy);
assert_eq!(analysis.total_value_yocto, 3 * ONE_NEAR);
assert_eq!(analysis.actions.len(), 2);
}
#[test]
fn test_analysis_risk_levels() {
let policy = default_policy();
let actions = vec![
Action::Transfer(Transfer { deposit: 0 }),
Action::DeleteAccount(crate::keys::transaction::DeleteAccount {
beneficiary_id: crate::keys::types::NearAccountId::new("bob.near").unwrap(),
}),
];
let perm = AccessKeyPermission::FullAccess;
let analysis = analyze_transaction("alice.near", &actions, &perm, &policy);
assert_eq!(analysis.actions[0].risk_level, RiskLevel::Low);
assert_eq!(analysis.actions[1].risk_level, RiskLevel::Critical);
}
// -- Chain signature tests --
#[test]
fn test_chain_sig_no_rule_requires_approval() {
let policy = default_policy();
let chain_sig = ChainSigAnalysis {
derivation_path: "ethereum-1".to_string(),
domain: SignatureDomain::Secp256k1,
target_chain: Some("Ethereum".to_string()),
payload_size: 256,
risk_level: RiskLevel::Medium,
};
let decision = policy.evaluate_chain_sig(&chain_sig, 0);
assert!(matches!(decision, PolicyDecision::RequireApproval { .. }));
}
#[test]
fn test_chain_sig_matching_rule_auto_approve() {
let policy = PolicyConfig {
chain_sig_rules: vec![ChainSigRule {
allowed_paths: vec!["ethereum-*".to_string()],
allowed_domains: vec![SignatureDomain::Secp256k1],
max_payload_bytes: 1024,
auto_approve: true,
}],
..default_policy()
};
let chain_sig = ChainSigAnalysis {
derivation_path: "ethereum-1".to_string(),
domain: SignatureDomain::Secp256k1,
target_chain: Some("Ethereum".to_string()),
payload_size: 256,
risk_level: RiskLevel::Medium,
};
let decision = policy.evaluate_chain_sig(&chain_sig, 0);
assert_eq!(decision, PolicyDecision::AutoApprove);
}
// -- Glob matching tests --
#[test]
fn test_glob_matches() {
assert!(glob_matches("ethereum-*", "ethereum-1"));
assert!(glob_matches("ethereum-*", "ethereum-mainnet"));
assert!(!glob_matches("ethereum-*", "bitcoin-0"));
assert!(glob_matches("exact-match", "exact-match"));
assert!(!glob_matches("exact-match", "other"));
}
// -- Infer target chain --
#[test]
fn test_infer_target_chain() {
assert_eq!(
infer_target_chain("ethereum-1"),
Some("Ethereum".to_string())
);
assert_eq!(
infer_target_chain("bitcoin/0/0"),
Some("Bitcoin".to_string())
);
assert_eq!(infer_target_chain("unknown-path"), None);
}
}

Some files were not shown because too many files have changed in this diff Show More