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
147 changed files with 7013 additions and 21821 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_URL=postgres://localhost/ironclaw
DATABASE_URL=postgres://ironclaw:password@localhost:5432/ironclaw
DATABASE_POOL_SIZE=10
# LLM Provider (NEAR AI)
# 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.
NEARAI_MODEL=claude-3-5-sonnet-20241022
NEARAI_BASE_URL=https://cloud-api.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
# CLI is always enabled
-21
View File
@@ -1,21 +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@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
profile: minimal
components: rustfmt, clippy
- name: Check formatting
run: |
cargo fmt --all -- --check
- name: Check lints (cargo clippy)
run: cargo clippy -- -D warnings
-65
View File
@@ -1,65 +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
# 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
- 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 }}
-329
View File
@@ -1,329 +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:
pull_request:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
plan:
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
publishing: ${{ !github.event.pull_request }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
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/*
publish-npm:
needs:
- plan
- host
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAN: ${{ needs.plan.outputs.val }}
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
steps:
- name: Fetch npm packages
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: npm/
merge-multiple: true
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- run: |
for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do
pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output)
npm publish --access public "./npm/${pkg}"
done
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
announce:
needs:
- plan
- host
- publish-npm
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
-20
View File
@@ -1,20 +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@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
profile: minimal
- name: Run Tests
run: cargo test --all-features -- --nocapture
-3
View File
@@ -4,6 +4,3 @@
target/
# WASM build artifacts (loaded from disk, not bundled)
*.wasm
-103
View File
@@ -1,103 +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.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
+11 -78
View File
@@ -11,13 +11,8 @@
- **Always available** - Multi-channel access with proactive background execution
### 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
- **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
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection
@@ -64,9 +59,7 @@ src/
│ ├── context_monitor.rs # Memory pressure detection
│ ├── undo.rs # Turn-based undo/redo with checkpoints
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
── task.rs # Sub-task execution framework
│ ├── routine.rs # Routine types (Trigger, Action, Guardrails)
│ └── routine_engine.rs # Routine execution (cron ticker, event matcher)
── task.rs # Sub-task execution framework
├── channels/ # Multi-channel input
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
@@ -79,33 +72,8 @@ src/
│ │ ├── overlay.rs # Approval overlays
│ │ └── composer.rs # Message composition
│ ├── http.rs # HTTP webhook (axum) with secret validation
│ ├── repl.rs # Simple REPL (for testing)
── web/ # Web gateway (browser UI)
│ │ ├── 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
│ ├── slack.rs # Stub
── telegram.rs # Stub
├── safety/ # Prompt injection defense
│ ├── sanitizer.rs # Pattern detection, content escaping
@@ -128,9 +96,6 @@ src/
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
│ │ ├── shell.rs # Shell command execution
│ │ ├── 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)
│ ├── builder/ # Dynamic tool building
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
@@ -271,30 +236,6 @@ HEARTBEAT_ENABLED=true
HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes
HEARTBEAT_NOTIFY_CHANNEL=tui
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
@@ -356,14 +297,13 @@ Key test patterns:
## Current Limitations / TODOs
1. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
2. **Integration tests** - Need testcontainers setup for PostgreSQL
3. **MCP stdio transport** - Only HTTP transport implemented
4. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
5. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
6. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
7. **Webhook trigger endpoint** - Routines webhook trigger not yet exposed in web gateway
8. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard
1. **Slack/Telegram channels** - Stubs only, need implementation
2. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
3. **Integration tests** - Need testcontainers setup for PostgreSQL
4. **MCP stdio transport** - Only HTTP transport implemented
5. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
6. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
7. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
### Completed
@@ -380,13 +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 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
-**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
## Adding a New Tool
Generated
+174 -391
View File
@@ -170,6 +170,18 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -182,12 +194,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as-any"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -313,28 +319,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "async-task"
version = "4.7.1"
@@ -431,6 +415,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -455,6 +445,15 @@ dependencies = [
"wyz",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "blake3"
version = "1.8.3"
@@ -573,6 +572,15 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
@@ -824,6 +832,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@@ -848,16 +862,6 @@ dependencies = [
"crossterm 0.29.0",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -1034,17 +1038,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "cron"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eee8b2b4516038bc0f1d3c9934bcb4a13dd316e04abbc63c96757a6d75978532"
dependencies = [
"chrono",
"nom",
"once_cell",
]
[[package]]
name = "crossbeam"
version = "0.8.4"
@@ -1164,6 +1157,33 @@ dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "darling"
version = "0.21.3"
@@ -1249,6 +1269,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.5"
@@ -1403,6 +1433,31 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core 0.6.4",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.15.0"
@@ -1517,17 +1572,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "eventsource-stream"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
dependencies = [
"futures-core",
"nom",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
@@ -1557,6 +1601,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.27"
@@ -1586,21 +1636,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1621,16 +1656,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "fs4"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eeb4ed9e12f43b7fa0baae3f9cdda28352770132ef2e09a23760c29cae8bd47"
dependencies = [
"rustix 0.38.44",
"windows-sys 0.48.0",
]
[[package]]
name = "funty"
version = "2.0.0"
@@ -1721,12 +1746,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.31"
@@ -1825,12 +1844,6 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
version = "0.4.13"
@@ -2030,22 +2043,6 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2064,11 +2061,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2291,31 +2286,32 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"aes-gcm",
"aho-corasick",
"anyhow",
"argon2",
"async-trait",
"axum",
"base64 0.22.1",
"blake3",
"bollard",
"borsh",
"bs58",
"bytes",
"chrono",
"clap",
"cron",
"crossterm 0.28.1",
"deadpool-postgres",
"dirs 6.0.0",
"dotenvy",
"fs4",
"ed25519-dalek",
"futures",
"hkdf",
"http-body-util",
"hyper",
"hyper-util",
"mime_guess",
"open",
"pgvector",
"postgres-types",
@@ -2324,13 +2320,12 @@ dependencies = [
"refinery",
"regex",
"reqwest",
"rig-core",
"rust_decimal",
"rust_decimal_macros",
"rustyline",
"secrecy",
"secret-service",
"security-framework 3.5.1",
"security-framework",
"serde",
"serde_json",
"sha2",
@@ -2353,6 +2348,7 @@ dependencies = [
"wasmtime",
"wasmtime-wasi",
"zbus",
"zeroize",
]
[[package]]
@@ -2614,16 +2610,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimad"
version = "0.14.0"
@@ -2633,12 +2619,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.1.1"
@@ -2651,32 +2631,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nanoid"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
dependencies = [
"rand 0.8.5",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.1.6",
"openssl-sys",
"schannel",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
@@ -2711,16 +2665,6 @@ dependencies = [
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2869,71 +2813,18 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d"
dependencies = [
"num-traits",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -2998,6 +2889,17 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
@@ -3045,26 +2947,6 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -3088,6 +2970,16 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -3597,22 +3489,16 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3623,7 +3509,6 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -3637,38 +3522,6 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "rig-core"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f7a3f0c7c00eaced15a68ee16e1bd6bb709ff598d11b9aedac8b628217dc09"
dependencies = [
"as-any",
"async-stream",
"base64 0.22.1",
"bytes",
"eventsource-stream",
"fastrand",
"futures",
"futures-timer",
"glob",
"http",
"mime",
"mime_guess",
"nanoid",
"ordered-float",
"pin-project-lite",
"reqwest",
"schemars 1.2.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-futures",
"url",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3816,10 +3669,10 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe 0.2.1",
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework 3.5.1",
"security-framework",
]
[[package]]
@@ -3936,23 +3789,10 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.114",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -3994,19 +3834,6 @@ dependencies = [
"zbus",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.5.1"
@@ -4014,7 +3841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -4070,17 +3897,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -4245,6 +4061,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
@@ -4282,6 +4107,16 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "sptr"
version = "0.3.2"
@@ -4394,27 +4229,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-interface"
version = "0.27.3"
@@ -4653,16 +4467,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-postgres"
version = "0.7.16"
@@ -4925,18 +4729,6 @@ dependencies = [
"valuable",
]
[[package]]
name = "tracing-futures"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
dependencies = [
"futures",
"futures-task",
"pin-project",
"tracing",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
@@ -5036,12 +4828,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -5164,12 +4950,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -5844,17 +5624,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -6332,6 +6101,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "zerotrie"
+7 -60
View File
@@ -1,19 +1,10 @@
[package]
name = "ironclaw"
version = "0.1.1"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
authors = ["NEAR AI <[email protected]>"]
license = "MIT OR Apache-2.0"
homepage = "https://github.com/nearai/ironclaw"
repository = "https://github.com/nearai/ironclaw"
[package.metadata.wix]
upgrade-guid = "D0156E61-BA37-451E-8AB9-1A2ECCCFA48F"
path-guid = "F90B6EA6-87F7-499B-BB19-CF55DE1EB339"
license = false
eula = false
[dependencies]
# Async runtime
@@ -67,16 +58,12 @@ axum = { version = "0.8", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors"] }
# Cron scheduling for routines
cron = "0.13"
# Safety/sanitization
regex = "1"
aho-corasick = "1"
# Filesystem paths
dirs = "6"
fs4 = "0.6"
# Secrecy for sensitive values
secrecy = { version = "0.10", features = ["serde"] }
@@ -103,8 +90,12 @@ sha2 = "0.10"
blake3 = "1"
rand = "0.8"
# Multi-provider LLM support
rig-core = "0.30"
# NEAR key management (ed25519 signing, borsh serialization, base58 encoding)
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
bollard = "0.18"
@@ -115,7 +106,6 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"]
http-body-util = "0.1"
bytes = "1"
base64 = "0.22.1"
mime_guess = "2.0.5"
# macOS keychain
[target.'cfg(target_os = "macos")'.dependencies]
@@ -136,46 +126,3 @@ tempfile = "3"
[features]
default = []
integration = []
# The profile that 'cargo dist' will build with
[profile.dist]
inherits = "release"
lto = "thin"
# Config for 'dist'
[workspace.metadata.dist]
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
cargo-dist-version = "0.30.3"
# CI backends to support
ci = "github"
# The installers to generate for each app
installers = ["shell", "powershell", "npm", "msi"]
# Publish jobs to run in CI
publish-jobs = ["npm"]
# Target platforms to build apps for (Rust target-triple syntax)
targets = [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"aarch64-pc-windows-msvc",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]
# The archive format to use for windows builds (defaults .zip)
windows-archive = ".tar.gz"
# The archive format to use for non-windows builds (defaults .tar.xz)
unix-archive = ".tar.gz"
# Which actions to run on pull requests
pr-run-mode = "upload"
# Path that installers should place binaries in
install-path = "CARGO_HOME"
# Whether to install an updater program
install-updater = 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"
aarch64-pc-windows-msvc = "windows-2025"
x86_64-apple-darwin = "macos-15-intel"
aarch64-apple-darwin = "macos-14"
-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.85-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.85.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"]
+34 -44
View File
@@ -16,8 +16,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| Hub-and-spoke architecture | ✅ | | Web gateway as central hub |
| WebSocket control plane | ✅ | | Gateway with WebSocket + SSE |
| Hub-and-spoke architecture | ✅ | 🚧 | IronClaw has channels but no central gateway |
| WebSocket control plane | ✅ | | Gateway with ws://127.0.0.1:18789 |
| Single-user system | ✅ | ✅ | |
| Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent |
| Session-based messaging | ✅ | ✅ | Per-sender sessions |
@@ -31,9 +31,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| Gateway control plane | ✅ | | Web gateway with 40+ API endpoints |
| HTTP endpoints for Control UI | ✅ | | Web dashboard with chat, memory, jobs, logs, extensions |
| Channel connection lifecycle | ✅ | | ChannelManager + WebSocket tracker |
| Gateway control plane | ✅ | | Central WebSocket server |
| HTTP endpoints for Control UI | ✅ | | Web dashboard |
| Channel connection lifecycle | ✅ | 🚧 | ChannelManager handles streams |
| Session management/routing | ✅ | ✅ | SessionManager exists |
| Configuration hot-reload | ✅ | ❌ | |
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
@@ -43,7 +43,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| launchd/systemd integration | ✅ | ❌ | |
| Bonjour/mDNS discovery | ✅ | ❌ | |
| Tailscale integration | ✅ | ❌ | |
| Health check endpoints | ✅ | | /api/health + /api/gateway/status |
| Health check endpoints | ✅ | | |
| `doctor` diagnostics | ✅ | ❌ | |
### Owner: _Unassigned_
@@ -59,14 +59,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| REPL (simple) | ✅ | ✅ | - | For testing |
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
| Telegram | ✅ | | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
| Telegram | ✅ | | P1 | grammY (Bot API) |
| Discord | ✅ | ❌ | P2 | discord.js |
| Signal | ✅ | ❌ | P2 | signal-cli |
| Slack | ✅ | | - | WASM tool |
| Slack | ✅ | 🚧 | P1 | Stub exists, needs implementation |
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
| Feishu/Lark | ✅ | ❌ | P3 | |
| LINE | ✅ | ❌ | P3 | |
| WebChat | ✅ | | - | Web gateway chat |
| WebChat | ✅ | | P2 | Browser-based chat |
| Matrix | ✅ | ❌ | P3 | E2EE support |
| Mattermost | ✅ | ❌ | P3 | |
| Google Chat | ✅ | ❌ | P3 | |
@@ -79,13 +79,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| DM pairing codes | ✅ | | `ironclaw pairing list/approve`, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| DM pairing codes | ✅ | | Verification for unknown senders |
| Allowlist/blocklist | ✅ | | Per-channel access control |
| 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 |
| 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 |
### Owner: _Unassigned_
@@ -99,17 +99,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `run` (agent) | ✅ | ✅ | - | Default command |
| `tool install/list/remove` | ✅ | ✅ | - | WASM tools |
| `gateway start/stop` | ✅ | ❌ | P2 | |
| `onboard` (wizard) | ✅ | | - | Interactive setup |
| `onboard` (wizard) | ✅ | | P2 | Interactive setup |
| `tui` | ✅ | ✅ | - | Ratatui TUI |
| `config` | ✅ | | - | Read/write config |
| `config` | ✅ | | P2 | Read/write config |
| `channels` | ✅ | ❌ | P2 | Channel management |
| `models` | ✅ | 🚧 | - | Model selector in TUI |
| `status` | ✅ | | - | System status |
| `status` | ✅ | | P2 | System status |
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
| `sessions` | ✅ | ❌ | P3 | Session listing |
| `memory` | ✅ | | - | Memory search CLI |
| `memory` | ✅ | | P2 | Memory search CLI |
| `skills` | ✅ | ❌ | P3 | Agent skills |
| `pairing` | ✅ | | - | list/approve for channel DM pairing |
| `pairing` | ✅ | | P3 | Node pairing |
| `nodes` | ✅ | ❌ | P3 | Device management |
| `plugins` | ✅ | ❌ | P3 | Plugin management |
| `hooks` | ✅ | ❌ | P2 | Lifecycle hooks |
@@ -132,7 +132,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| Pi agent runtime | ✅ | | IronClaw uses custom runtime |
| RPC-based execution | ✅ | | Orchestrator/worker pattern |
| RPC-based execution | ✅ | 🚧 | Worker isolation |
| Multi-provider failover | ✅ | ❌ | Provider fallback chains |
| Per-sender sessions | ✅ | ✅ | |
| Global sessions | ✅ | ❌ | Optional shared context |
@@ -303,13 +303,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Priority | Notes |
|---------|----------|----------|----------|-------|
| Control UI Dashboard | ✅ | | - | Web gateway with chat, memory, jobs, logs, extensions |
| Channel status view | ✅ | 🚧 | P2 | Gateway status widget, full channel view pending |
| Control UI Dashboard | ✅ | | P2 | Web status/config |
| Channel status view | ✅ | | P2 | |
| Agent management | ✅ | ❌ | P3 | |
| Model selection | ✅ | ✅ | - | TUI only |
| Config editing | ✅ | ❌ | P3 | |
| Debug/logs viewer | ✅ | | - | Real-time log streaming with level/target filters |
| WebChat interface | ✅ | | - | Web gateway chat with SSE/WebSocket |
| Debug/logs viewer | ✅ | | P3 | |
| WebChat interface | ✅ | | P2 | Browser chat |
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
### Owner: _Unassigned_
@@ -320,13 +320,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Priority | Notes |
|---------|----------|----------|----------|-------|
| Cron jobs | ✅ | | - | Routines with cron trigger |
| Timezone support | ✅ | | - | Via cron expressions |
| One-shot/recurring jobs | ✅ | | - | Manual + cron triggers |
| Cron jobs | ✅ | | P2 | Schedule-based tasks |
| Timezone support | ✅ | | P2 | |
| One-shot/recurring jobs | ✅ | | P2 | |
| `beforeInbound` hook | ✅ | ❌ | P2 | |
| `beforeOutbound` hook | ✅ | ❌ | P2 | |
| `beforeToolCall` hook | ✅ | ❌ | P2 | |
| `onMessage` hook | ✅ | | - | Routines with event trigger |
| `onMessage` hook | ✅ | | P2 | |
| `onSessionStart` hook | ✅ | ❌ | P2 | |
| `onSessionEnd` hook | ✅ | ❌ | P2 | |
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
@@ -346,18 +346,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| Gateway token auth | ✅ | | Bearer token auth on web gateway |
| Gateway token auth | ✅ | 🚧 | HTTP webhook secret |
| Device pairing | ✅ | ❌ | |
| Tailscale identity | ✅ | ❌ | |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
| DM pairing verification | ✅ | | ironclaw pairing approve, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| DM pairing verification | ✅ | | |
| Allowlist/blocklist | ✅ | | |
| Per-group tool policies | ✅ | ❌ | |
| Exec approvals | ✅ | ✅ | TUI overlay |
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
| SSRF protection | ✅ | ✅ | WASM allowlist |
| Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 |
| Docker sandbox | ✅ | | Orchestrator/worker containers |
| Docker sandbox | ✅ | | Uses WASM sandbox |
| WASM sandbox | ❌ | ✅ | IronClaw innovation |
| Tool policies | ✅ | ✅ | |
| Elevated mode | ✅ | ❌ | |
@@ -397,7 +397,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
### P0 - Core (Already Done)
- ✅ TUI channel with approval overlays
- ✅ HTTP webhook channel
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
- ✅ WASM tool sandbox
- ✅ Workspace/memory with hybrid search
- ✅ Prompt injection defense
@@ -405,32 +404,23 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ✅ Session management
- ✅ Context compaction
- ✅ 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
- ❌ Slack channel (real implementation)
- Telegram channel (WASM, DM pairing, caption, /start)
- Telegram channel
- ❌ WhatsApp channel
- ❌ Multi-provider failover
- ❌ Gateway control plane + WebSocket
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
### P2 - Medium Priority
- ❌ Cron job scheduling
- ❌ Web Control UI
- ❌ WebChat channel
- 🚧 Media handling (caption support; no image/PDF processing)
- Media handling (images, PDFs)
- ❌ CLI subcommands (config, status, memory, doctor)
- ❌ Ollama/local model support
- ❌ Configuration hot-reload
- ❌ Webhook trigger endpoint in web gateway
### P3 - Lower Priority
- ❌ Discord channel
+35 -52
View File
@@ -43,10 +43,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
### Always Available
- **Multi-channel** - REPL, HTTP webhooks, WASM channels (Telegram, Slack), and web gateway
- **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
- **Multi-channel** - REPL, HTTP webhooks, and extensible WASM channels (Telegram, Slack, and more)
- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks
- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts
- **Self-repair** - Automatic detection and recovery of stuck operations
@@ -68,7 +65,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
### Prerequisites
- 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)
### Build
@@ -85,8 +82,6 @@ cargo build --release
cargo test
```
For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first.
### Database Setup
```bash
@@ -102,7 +97,7 @@ psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
Run the setup wizard to configure IronClaw:
```bash
ironclaw onboard
ironclaw setup
```
The wizard handles database connection, NEAR AI authentication (via browser OAuth),
@@ -148,42 +143,37 @@ External content passes through multiple security layers:
## Architecture
```
┌────────────────────────────────────────────────────────────────────
Channels
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐
│ │ REPL │ │ HTTP │ │WASM Channels│ │ Web Gateway │
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │
│ └──────┬──────┘
└─────────┴──────────────┴────────────────┘
┌─────────▼─────────┐
│ Agent Loop Intent routing
└────┬─────────┬────┘
│ │
┌──────────▼───┐ ┌──▼──────────────┐
│ Scheduler │ Routines Engine
│(parallel jobs)│ │(cron, event, wh) │
─────────────┘ └────────┬─────────┘
┌────────────────────────────────
│ │
─────── ┌────▼────────────────
│ Local │ Orchestrator │
│Workers │ ┌───────────────┐ │
│(in-proc)│ │ Docker Sandbox│ │
└───┬────┘ │ │ Containers
┌───────────
│ │ │Worker / CC│ │ │ │
│ └───────────┘ │ │ │
└───────────────┘ │ │
└────────┬───────────┘ │
│ └──────────────────
│ │ │
│ ┌───────────▼──────────┐ │
│ │ Tool Registry │ │
│ │ Built-in, MCP, WASM │ │
│ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Channels │
│ ┌──────┐ ┌──────┐ ┌─────────────
│ │ REPL │ │ HTTP │ │ WASM Channels│
│ └──┬───┘ └──┬───┘ └──────┬──────
└─────────┴─────────────┘
┌────▼────┐
│ Router │ Intent classification
└────┬────┘
┌──────────▼──────────┐
│ Scheduler Parallel job management
└──────────┬──────────┘
───────────────┼───────────────┐
│ ┌─────────┐ ┌─────────┐ ┌─────────
│ Worker │ │ Worker │ Worker LLM reasoning
└──────── ────────┘ └────────
└───────────────┼───────────────┘
┌──────────▼──────────┐
│ Tool Registry │ │
│ │ ┌───────────────┐ │ │
│ │ │ Built-in │ │
│ │ MCP │ │
│ │ │ WASM Sandbox │
└───────────────┘
└─────────────────────┘
└─────────────────────────────────────────────────────────────────┘
```
### Core Components
@@ -194,9 +184,6 @@ External content passes through multiple security layers:
| **Router** | Classifies user intent (command, query, task) |
| **Scheduler** | Manages parallel job execution with priorities |
| **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 |
| **Safety Layer** | Prompt injection defense and content sanitization |
@@ -204,7 +191,7 @@ External content passes through multiple security layers:
```bash
# First-time setup (configures database, auth, etc.)
ironclaw onboard
ironclaw setup
# Start interactive REPL
cargo run
@@ -223,16 +210,12 @@ cargo fmt
cargo clippy --all --benches --tests --examples --all-features
# Run tests
createdb ironclaw_test
cargo test
# Run specific test
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
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);
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ if [ -f "$WASM_PATH" ]; then
wasm-tools strip slack.wasm -o slack.wasm
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
echo "Error: WASM output not found at $WASM_PATH"
exit 1
+22 -24
View File
@@ -108,10 +108,7 @@ struct SlackPostMessageResponse {
#[derive(Debug, Deserialize)]
struct SlackConfig {
/// 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")]
#[allow(dead_code)]
signing_secret_name: String,
}
@@ -178,7 +175,11 @@ impl Guest for SlackChannel {
// Actual event callback
"event_callback" => {
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)
json_response(200, serde_json::json!({"ok": true}))
@@ -229,7 +230,6 @@ impl Guest for SlackChannel {
"https://slack.com/api/chat.postMessage",
&headers.to_string(),
Some(&payload_bytes),
None,
);
match result {
@@ -243,15 +243,14 @@ impl Guest for SlackChannel {
// Parse Slack response
let slack_response: SlackPostMessageResponse =
serde_json::from_slice(&http_response.body)
.map_err(|e| format!("Failed to parse Slack response: {}", e))?;
serde_json::from_slice(&http_response.body).map_err(|e| {
format!("Failed to parse Slack response: {}", e)
})?;
if !slack_response.ok {
return Err(format!(
"Slack API error: {}",
slack_response
.error
.unwrap_or_else(|| "unknown".to_string())
slack_response.error.unwrap_or_else(|| "unknown".to_string())
));
}
@@ -278,16 +277,17 @@ impl Guest for SlackChannel {
}
/// 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() {
// Direct mention of the bot
"app_mention" => {
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
event.user,
event.channel.clone(),
event.text,
event.ts.clone(),
) {
if let (Some(user), Some(channel), Some(text), Some(ts)) =
(event.user, event.channel.clone(), event.text, event.ts.clone())
{
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;
}
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
event.user,
event.channel.clone(),
event.text,
event.ts.clone(),
) {
if let (Some(user), Some(channel), Some(text), Some(ts)) =
(event.user, event.channel.clone(), event.text, event.ts.clone())
{
// Only process DMs (channel IDs starting with D)
if channel.starts_with('D') {
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
@@ -338,7 +335,8 @@ fn emit_message(
team_id,
};
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());
// Strip @ mentions of the bot from the text for cleaner messages
let cleaned_text = strip_bot_mention(&text);
+2 -2
View File
@@ -32,8 +32,8 @@ if [ -f "$WASM_PATH" ]; then
echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))"
echo ""
echo "To install:"
echo " mkdir -p ~/.ironclaw/channels"
echo " cp telegram.wasm telegram.capabilities.json ~/.ironclaw/channels/"
echo " mkdir -p ~/.near-agent/channels"
echo " cp telegram.wasm telegram.capabilities.json ~/.near-agent/channels/"
echo ""
echo "Then add your bot token to secrets:"
echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store"
+100 -401
View File
@@ -72,10 +72,6 @@ struct TelegramMessage {
/// Message text.
text: Option<String>,
/// Caption for media (photo, video, document, etc.).
#[serde(default)]
caption: Option<String>,
/// Original message if this is a reply.
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.
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
// ============================================================================
@@ -215,14 +196,6 @@ struct TelegramConfig {
#[serde(default)]
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).
#[serde(default)]
respond_to_all_group_messages: bool,
@@ -284,28 +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
// If tunnel is configured, use webhooks. Otherwise, use polling.
let webhook_mode = config.tunnel_url.is_some();
@@ -437,9 +388,7 @@ impl Guest for TelegramChannel {
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, Some(35_000));
let result = channel_host::http_request("GET", &url, &headers.to_string(), None);
match result {
Ok(response) => {
@@ -512,52 +461,72 @@ impl Guest for TelegramChannel {
}
fn on_respond(response: AgentResponse) -> Result<(), String> {
// Parse metadata to get chat info
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
// Try sending with Markdown first; fall back to plain text if Telegram
// can't parse the entities (e.g. model leaked <tool_call> with underscores).
let result = send_message(
metadata.chat_id,
&response.content,
metadata.message_id,
Some("Markdown"),
// Build sendMessage payload
let mut payload = serde_json::json!({
"chat_id": metadata.chat_id,
"text": response.content,
"parse_mode": "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 {
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::LogLevel::Debug,
&format!(
"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(())
}
Err(e) => Err(e.to_string()),
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}
@@ -599,7 +568,6 @@ impl Guest for TelegramChannel {
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction",
&headers.to_string(),
Some(&payload_bytes),
None,
);
if let Err(e) = result {
@@ -618,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
// ============================================================================
@@ -731,7 +604,6 @@ fn delete_webhook() -> Result<(), String> {
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook",
&headers.to_string(),
None,
None,
);
match result {
@@ -794,7 +666,6 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
&headers.to_string(),
Some(&body_bytes),
None,
);
match result {
@@ -829,47 +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),
);
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
// ============================================================================
@@ -889,16 +719,11 @@ fn handle_update(update: TelegramUpdate) {
/// Process a single message.
fn handle_message(message: TelegramMessage) {
// Use text or caption (for media messages)
let content = message
.text
.filter(|t| !t.is_empty())
.or_else(|| message.caption.filter(|c| !c.is_empty()))
.unwrap_or_default();
if content.is_empty() {
return;
}
// Skip messages without text
let text = match message.text {
Some(t) if !t.is_empty() => t,
_ => return,
};
// Skip messages without a sender (channel posts)
let from = match message.from {
@@ -911,111 +736,41 @@ fn handle_message(message: TelegramMessage) {
return;
}
let is_private = message.chat.chat_type == "private";
// Owner validation: when owner_id is set, only that user can message
let owner_configured = channel_host::workspace_read(OWNER_ID_PATH)
.map(|s| !s.is_empty())
.unwrap_or(false);
if owner_configured {
if let Ok(owner_id) = channel_host::workspace_read(OWNER_ID_PATH)
.unwrap()
.parse::<i64>()
{
if from.id != owner_id {
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Dropping message from non-owner user {} (owner: {})",
from.id, owner_id
),
);
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),
);
}
}
// Owner validation: silently drop messages from non-owner users
if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) {
if !owner_id_str.is_empty() {
if let Ok(owner_id) = owner_id_str.parse::<i64>() {
if from.id != owner_id {
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Dropping message from non-owner user {} (owner: {})",
from.id, owner_id
),
);
return;
}
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 {
let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH)
.as_deref()
.unwrap_or("false")
== "true";
// In groups, only respond if there's a bot mention or command
// This is a simplified check - proper implementation would use entities
let has_command = text.starts_with('/');
let has_mention = text.contains('@');
if !respond_to_all {
let has_command = content.starts_with('/');
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH)
.unwrap_or_default();
let has_bot_mention = if bot_username.is_empty() {
content.contains('@')
} else {
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;
}
if !has_command && !has_mention {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Ignoring group message without mention: {}", text),
);
return;
}
}
@@ -1037,30 +792,17 @@ fn handle_message(message: TelegramMessage) {
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
// 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(
&content,
if bot_username.is_empty() {
None
} else {
Some(bot_username.as_str())
},
);
let cleaned_text = clean_message_text(&text);
// For /start with no args, emit placeholder so agent can respond with welcome
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() {
if cleaned_text.is_empty() {
return;
} else {
cleaned_text
};
}
// Emit the message to the agent
channel_host::emit_message(&EmittedMessage {
user_id: from.id.to_string(),
user_name: Some(user_name),
content: content_to_emit,
content: cleaned_text,
thread_id: None, // Telegram doesn't have threads in the same way
metadata_json,
});
@@ -1075,8 +817,7 @@ fn handle_message(message: TelegramMessage) {
}
/// 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, bot_username: Option<&str>) -> String {
fn clean_message_text(text: &str) -> String {
let mut result = text.trim().to_string();
// Remove leading /command
@@ -1091,30 +832,11 @@ fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
// Remove leading @mention
if result.starts_with('@') {
if let Some(bot) = bot_username {
let mention = format!("@{}", bot);
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();
}
}
if let Some(space_idx) = result.find(' ') {
result = result[space_idx..].trim_start().to_string();
} else {
// No bot_username: strip any leading @mention
if let Some(space_idx) = result.find(' ') {
result = result[space_idx..].trim_start().to_string();
} else {
return String::new();
}
// Just a mention with no text
return String::new();
}
}
@@ -1150,22 +872,12 @@ mod tests {
#[test]
fn test_clean_message_text() {
// Without bot_username: strips any leading @mention
assert_eq!(clean_message_text("/start hello", None), "hello");
assert_eq!(clean_message_text("@bot hello world", None), "hello world");
assert_eq!(clean_message_text("/start", None), "");
assert_eq!(clean_message_text("@botname", None), "");
assert_eq!(clean_message_text("just text", None), "just text");
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")), "");
assert_eq!(clean_message_text("/start hello"), "hello");
assert_eq!(clean_message_text("@bot hello world"), "hello world");
assert_eq!(clean_message_text("/start"), "");
assert_eq!(clean_message_text("@botname"), "");
assert_eq!(clean_message_text("just text"), "just text");
assert_eq!(clean_message_text(" spaced "), "spaced");
}
#[test]
@@ -1233,17 +945,4 @@ mod tests {
assert_eq!(from.id, 789);
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.
-1
View File
@@ -361,7 +361,6 @@ impl Guest for WhatsAppChannel {
&api_url,
&headers.to_string(),
Some(&payload_bytes),
None,
);
match result {
+3 -36
View File
@@ -246,46 +246,13 @@ Create `my-channel.capabilities.json`:
## 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
# Build the WASM component
cd channels-src/my-channel
cargo build --release --target wasm32-wasip2
cd channels/my-channel
cargo component build --release
# 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/
```
-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
-122
View File
@@ -1,122 +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(),
..Default::default()
})
.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"
+119 -641
View File
@@ -9,19 +9,19 @@ use uuid::Uuid;
use crate::agent::compaction::ContextCompactor;
use crate::agent::context_monitor::ContextMonitor;
use crate::agent::heartbeat::spawn_heartbeat;
use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
use crate::agent::session::{PendingApproval, Session, ThreadState};
use crate::agent::session_manager::SessionManager;
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, MessageIntent, Router, Scheduler};
use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse, StatusUpdate};
use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig};
use crate::config::{AgentConfig, HeartbeatConfig};
use crate::context::ContextManager;
use crate::context::JobContext;
use crate::error::Error;
use crate::extensions::ExtensionManager;
use crate::history::Store;
use crate::keys::KeyManager;
use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult};
use crate::safety::SafetyLayer;
use crate::tools::ToolRegistry;
@@ -65,6 +65,7 @@ pub struct AgentDeps {
pub tools: Arc<ToolRegistry>,
pub workspace: Option<Arc<Workspace>>,
pub extension_manager: Option<Arc<ExtensionManager>>,
pub key_manager: Option<Arc<KeyManager>>,
}
/// The main agent that coordinates all components.
@@ -78,7 +79,6 @@ pub struct Agent {
session_manager: Arc<SessionManager>,
context_monitor: ContextMonitor,
heartbeat_config: Option<HeartbeatConfig>,
routine_config: Option<RoutineConfig>,
}
impl Agent {
@@ -91,7 +91,6 @@ impl Agent {
deps: AgentDeps,
channels: ChannelManager,
heartbeat_config: Option<HeartbeatConfig>,
routine_config: Option<RoutineConfig>,
context_manager: Option<Arc<ContextManager>>,
session_manager: Option<Arc<SessionManager>>,
) -> Self {
@@ -119,7 +118,6 @@ impl Agent {
session_manager,
context_monitor: ContextMonitor::new(),
heartbeat_config,
routine_config,
}
}
@@ -260,28 +258,53 @@ impl Agent {
let channels = self.channels.clone();
tokio::spawn(async move {
while let Some(response) = notify_rx.recv().await {
let user = notify_user.as_deref().unwrap_or("default");
// Try the configured channel first, fall back to
// broadcasting on all channels.
let targeted_ok = if let Some(ref channel) = notify_channel {
channels
.broadcast(channel, user, response.clone())
.await
.is_ok()
} else {
false
};
if !targeted_ok {
let results = channels.broadcast_all(user, response).await;
for (ch, result) in results {
if let Err(e) = result {
// Route notification to configured channel/user, or broadcast to all
match (&notify_channel, &notify_user) {
(Some(channel), Some(user)) => {
// Send to specific channel and user
if let Err(e) =
channels.broadcast(channel, user, response.clone()).await
{
tracing::warn!(
"Failed to broadcast heartbeat to {}: {}",
ch,
"Failed to send heartbeat to {}/{}: {}",
channel,
user,
e
);
} else {
tracing::debug!(
"Heartbeat notification sent to {}/{}",
channel,
user
);
}
}
(None, Some(user)) => {
// Broadcast to all channels for this user
let results = channels.broadcast_all(user, response).await;
for (ch, result) in results {
if let Err(e) = result {
tracing::warn!(
"Failed to broadcast heartbeat to {}: {}",
ch,
e
);
}
}
}
_ => {
// No explicit target, broadcast to all channels
// for the default user so notifications actually
// reach someone instead of vanishing into logs.
let results = channels.broadcast_all("default", response).await;
for (ch, result) in results {
if let Err(e) = result {
tracing::warn!(
"Failed to broadcast heartbeat to {}: {}",
ch,
e
);
}
}
}
}
@@ -309,85 +332,6 @@ impl Agent {
None
};
// Spawn routine engine if enabled
let routine_handle = if let Some(ref rt_config) = self.routine_config {
if rt_config.enabled {
if let (Some(store), Some(workspace)) = (self.store(), self.workspace()) {
// Set up notification channel (same pattern as heartbeat)
let (notify_tx, mut notify_rx) =
tokio::sync::mpsc::channel::<OutgoingResponse>(32);
let engine = Arc::new(RoutineEngine::new(
rt_config.clone(),
Arc::clone(store),
self.llm().clone(),
Arc::clone(workspace),
notify_tx,
));
// Register routine tools
self.deps
.tools
.register_routine_tools(Arc::clone(store), Arc::clone(&engine));
// Load initial event cache
engine.refresh_event_cache().await;
// Spawn notification forwarder
let channels = self.channels.clone();
tokio::spawn(async move {
while let Some(response) = notify_rx.recv().await {
let user = response
.metadata
.get("notify_user")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string();
let results = channels.broadcast_all(&user, response).await;
for (ch, result) in results {
if let Err(e) = result {
tracing::warn!(
"Failed to broadcast routine notification to {}: {}",
ch,
e
);
}
}
}
});
// Spawn cron ticker
let cron_interval =
std::time::Duration::from_secs(rt_config.cron_check_interval_secs);
let cron_handle = spawn_cron_ticker(Arc::clone(&engine), cron_interval);
// Store engine reference for event trigger checking
// Safety: we're in run() which takes self, no other reference exists
let engine_ref = Arc::clone(&engine);
// SAFETY: self is consumed by run(), we can smuggle the engine in
// via a local to use in the message loop below.
tracing::info!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
rt_config.cron_check_interval_secs,
rt_config.max_concurrent_routines
);
Some((cron_handle, engine_ref))
} else {
tracing::warn!("Routines enabled but store/workspace not available");
None
}
} else {
None
}
} else {
None
};
// Extract engine ref for use in message loop
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
// Main message loop
tracing::info!("Agent {} ready and listening", self.config.name);
@@ -432,14 +376,6 @@ impl Agent {
.await;
}
}
// Check event triggers (cheap in-memory regex, fires async if matched)
if let Some(ref engine) = routine_engine_for_loop {
let fired = engine.check_event_triggers(&message).await;
if fired > 0 {
tracing::debug!("Fired {} event-triggered routines", fired);
}
}
}
// Cleanup
@@ -449,9 +385,6 @@ impl Agent {
if let Some(handle) = heartbeat_handle {
handle.abort();
}
if let Some((cron_handle, _)) = routine_handle {
cron_handle.abort();
}
self.scheduler.stop_all().await;
self.channels.shutdown_all().await?;
@@ -462,11 +395,6 @@ impl Agent {
// Parse submission type first
let submission = SubmissionParser::parse(&message.content);
// Hydrate thread from DB if it's a historical thread not in memory
if let Some(ref external_thread_id) = message.thread_id {
self.maybe_hydrate_thread(message, external_thread_id).await;
}
// Resolve session and thread
let (session, thread_id) = self
.session_manager
@@ -518,9 +446,6 @@ impl Agent {
self.process_user_input(message, session, thread_id, &content)
.await
}
Submission::SystemCommand { command, args } => {
self.handle_system_command(&command, &args).await
}
Submission::Undo => self.process_undo(session, thread_id).await,
Submission::Redo => self.process_redo(session, thread_id).await,
Submission::Interrupt => self.process_interrupt(session, thread_id).await,
@@ -592,107 +517,6 @@ impl Agent {
}
}
/// Hydrate a historical thread from DB into memory if not already present.
///
/// Called before `resolve_thread` so that the session manager finds the
/// thread on lookup instead of creating a new one.
///
/// Creates an in-memory thread with the exact UUID the frontend sent,
/// even when the conversation has zero messages (e.g. a brand-new
/// assistant thread). Without this, `resolve_thread` would mint a
/// fresh UUID and all messages would land in the wrong conversation.
async fn maybe_hydrate_thread(&self, message: &IncomingMessage, external_thread_id: &str) {
// Only hydrate UUID-shaped thread IDs (web gateway uses UUIDs)
let thread_uuid = match Uuid::parse_str(external_thread_id) {
Ok(id) => id,
Err(_) => return,
};
// Check if already in memory
let session = self
.session_manager
.get_or_create_session(&message.user_id)
.await;
{
let sess = session.lock().await;
if sess.threads.contains_key(&thread_uuid) {
return;
}
}
// Load history from DB (may be empty for a newly created thread).
let mut chat_messages: Vec<ChatMessage> = Vec::new();
let msg_count;
if let Some(store) = self.store() {
let db_messages = store
.list_conversation_messages(thread_uuid)
.await
.unwrap_or_default();
msg_count = db_messages.len();
chat_messages = db_messages
.iter()
.filter_map(|m| match m.role.as_str() {
"user" => Some(ChatMessage::user(&m.content)),
"assistant" => Some(ChatMessage::assistant(&m.content)),
_ => None,
})
.collect();
} else {
msg_count = 0;
}
// Create thread with the historical ID and restore messages
let session_id = {
let sess = session.lock().await;
sess.id
};
let mut thread = crate::agent::session::Thread::with_id(thread_uuid, session_id);
if !chat_messages.is_empty() {
thread.restore_from_messages(chat_messages);
}
// Restore response chain from conversation metadata
if let Some(store) = self.store() {
if let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await {
if let Some(rid) = metadata
.get("last_response_id")
.and_then(|v| v.as_str())
.map(String::from)
{
thread.last_response_id = Some(rid.clone());
self.llm()
.seed_response_chain(&thread_uuid.to_string(), rid);
tracing::debug!("Restored response chain for thread {}", thread_uuid);
}
}
}
// Insert into session and register with session manager
{
let mut sess = session.lock().await;
sess.threads.insert(thread_uuid, thread);
sess.active_thread = Some(thread_uuid);
sess.last_active_at = chrono::Utc::now();
}
self.session_manager
.register_thread(
&message.user_id,
&message.channel,
thread_uuid,
Arc::clone(&session),
)
.await;
tracing::debug!(
"Hydrated thread {} from DB ({} messages)",
thread_uuid,
msg_count
);
}
async fn process_user_input(
&self,
message: &IncomingMessage,
@@ -872,7 +696,6 @@ impl Agent {
match result {
Ok(AgenticLoopResult::Response(response)) => {
thread.complete_turn(&response);
self.persist_response_chain(thread);
let _ = self
.channels
.send_status(
@@ -881,10 +704,6 @@ impl Agent {
&message.metadata,
)
.await;
// Fire-and-forget: persist turn to DB
self.persist_turn(thread_id, &message.user_id, content, Some(&response));
Ok(SubmissionResult::response(response))
}
Ok(AgenticLoopResult::NeedApproval { pending }) => {
@@ -911,95 +730,11 @@ impl Agent {
}
Err(e) => {
thread.fail_turn(e.to_string());
// Persist the user message even on failure
self.persist_turn(thread_id, &message.user_id, content, None);
Ok(SubmissionResult::error(e.to_string()))
}
}
}
/// Fire-and-forget: persist a turn (user message + optional assistant response) to the DB.
fn persist_turn(
&self,
thread_id: Uuid,
user_id: &str,
user_input: &str,
response: Option<&str>,
) {
let store = match self.store() {
Some(s) => Arc::clone(s),
None => return,
};
let user_id = user_id.to_string();
let user_input = user_input.to_string();
let response = response.map(String::from);
tokio::spawn(async move {
if let Err(e) = store
.ensure_conversation(thread_id, "gateway", &user_id, None)
.await
{
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
return;
}
if let Err(e) = store
.add_conversation_message(thread_id, "user", &user_input)
.await
{
tracing::warn!("Failed to persist user message: {}", e);
return;
}
if let Some(ref resp) = response {
if let Err(e) = store
.add_conversation_message(thread_id, "assistant", resp)
.await
{
tracing::warn!("Failed to persist assistant message: {}", e);
}
}
});
}
/// Sync the provider's response chain ID to the thread and DB metadata.
///
/// Call after a successful agentic loop to persist the latest
/// `previous_response_id` so chaining survives restarts.
fn persist_response_chain(&self, thread: &mut crate::agent::session::Thread) {
let tid = thread.id.to_string();
let response_id = match self.llm().get_response_chain_id(&tid) {
Some(rid) => rid,
None => return,
};
// Update in-memory thread
thread.last_response_id = Some(response_id.clone());
// Fire-and-forget DB write
let store = match self.store() {
Some(s) => Arc::clone(s),
None => return,
};
let thread_id = thread.id;
tokio::spawn(async move {
let val = serde_json::json!(response_id);
if let Err(e) = store
.update_conversation_metadata_field(thread_id, "last_response_id", &val)
.await
{
tracing::warn!(
"Failed to persist response chain for thread {}: {}",
thread_id,
e
);
}
});
}
/// Run the agentic loop: call LLM, execute tools, repeat until text response.
///
/// Returns `AgenticLoopResult::Response` on completion, or
@@ -1049,7 +784,7 @@ impl Agent {
iteration += 1;
if iteration > MAX_TOOL_ITERATIONS {
return Err(crate::error::LlmError::InvalidResponse {
provider: "agent".to_string(),
provider: "nearai".to_string(),
reason: format!("Exceeded maximum tool iterations ({})", MAX_TOOL_ITERATIONS),
}
.into());
@@ -1075,12 +810,7 @@ impl Agent {
// Call LLM with current context
let context = ReasoningContext::new()
.with_messages(context_messages.clone())
.with_tools(tool_defs)
.with_metadata({
let mut m = std::collections::HashMap::new();
m.insert("thread_id".to_string(), thread_id.to_string());
m
});
.with_tools(tool_defs);
let result = reasoning.respond_with_tools(&context).await?;
@@ -1104,16 +834,13 @@ impl Agent {
// Tools have been executed or we've tried multiple times, return response
return Ok(AgenticLoopResult::Response(text));
}
RespondResult::ToolCalls {
tool_calls,
content,
} => {
RespondResult::ToolCalls(tool_calls) => {
tools_executed = true;
// Add the assistant message with tool_calls to context.
// OpenAI protocol requires this before tool-result messages.
// OpenAI-compatible APIs require this before tool-result messages.
context_messages.push(ChatMessage::assistant_with_tool_calls(
content,
"",
tool_calls.clone(),
));
@@ -1235,26 +962,10 @@ impl Agent {
if let Some((ext_name, instructions)) =
detect_auth_awaiting(&tc.name, &tool_result)
{
let auth_data = parse_auth_result(&tool_result);
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(ext_name.clone());
}
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(ext_name);
}
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
extension_name: ext_name,
instructions: Some(instructions.clone()),
auth_url: auth_data.auth_url,
setup_url: auth_data.setup_url,
},
&message.metadata,
)
.await;
return Ok(AgenticLoopResult::Response(instructions));
}
@@ -1315,59 +1026,19 @@ impl Agent {
.into());
}
tracing::debug!(
tool = %tool_name,
params = %params,
"Tool call started"
);
// Execute with per-tool timeout
let timeout = tool.execution_timeout();
let start = std::time::Instant::now();
let result = tokio::time::timeout(timeout, async {
// Execute with timeout
let result = tokio::time::timeout(std::time::Duration::from_secs(60), async {
tool.execute(params.clone(), job_ctx).await
})
.await;
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 = timeout.as_secs(),
"Tool call timed out"
);
}
}
let result = result
.map_err(|_| crate::error::ToolError::Timeout {
name: tool_name.to_string(),
timeout,
})?
.map_err(|e| crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: e.to_string(),
})?;
.await
.map_err(|_| crate::error::ToolError::Timeout {
name: tool_name.to_string(),
timeout: std::time::Duration::from_secs(60),
})?
.map_err(|e| crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: e.to_string(),
})?;
// Convert result to string
serde_json::to_string_pretty(&result.result).map_err(|e| {
@@ -1711,11 +1382,10 @@ impl Agent {
if let Some((ext_name, instructions)) =
detect_auth_awaiting(&pending.tool_name, &tool_result)
{
let auth_data = parse_auth_result(&tool_result);
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(ext_name.clone());
thread.enter_auth_mode(ext_name);
thread.complete_turn(&instructions);
}
}
@@ -1723,12 +1393,7 @@ impl Agent {
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
extension_name: ext_name,
instructions: Some(instructions.clone()),
auth_url: auth_data.auth_url,
setup_url: auth_data.setup_url,
},
StatusUpdate::Status("Awaiting token".into()),
&message.metadata,
)
.await;
@@ -1771,7 +1436,6 @@ impl Agent {
match result {
Ok(AgenticLoopResult::Response(response)) => {
thread.complete_turn(&response);
self.persist_response_chain(thread);
let _ = self
.channels
.send_status(
@@ -1870,6 +1534,16 @@ impl Agent {
pending.extension_name
);
// Notify via channel status so the response doesn't echo the token
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::Status("Authenticated, loading tools...".into()),
&message.metadata,
)
.await;
// Auto-activate so tools are available immediately after auth
match ext_mgr.activate(&pending.extension_name).await {
Ok(activate_result) => {
@@ -1879,23 +1553,10 @@ impl Agent {
} else {
format!("\n\nTools: {}", activate_result.tools_loaded.join(", "))
};
let msg = format!(
Ok(Some(format!(
"{} authenticated and activated ({} tools loaded).{}",
pending.extension_name, tool_count, tool_list
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
)))
}
Err(e) => {
tracing::warn!(
@@ -1903,29 +1564,16 @@ impl Agent {
pending.extension_name,
e
);
let msg = format!(
Ok(Some(format!(
"{} authenticated successfully, but activation failed: {}. \
Try activating manually.",
pending.extension_name, e
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
)))
}
}
}
Ok(result) => {
// Invalid token, re-enter auth mode
// Unexpected state, re-enter auth mode
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
@@ -1934,43 +1582,13 @@ impl Agent {
}
let msg = result
.instructions
.clone()
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
// Re-emit AuthRequired so web UI re-shows the card
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
extension_name: pending.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: result.auth_url,
setup_url: result.setup_url,
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
Err(e) => {
let msg = format!(
"Authentication failed for {}: {}",
pending.extension_name, e
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: false,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
Err(e) => Ok(Some(format!(
"Authentication failed for {}: {}",
pending.extension_name, e
))),
}
}
@@ -2321,49 +1939,40 @@ impl Agent {
}
}
/// Handle system commands that bypass thread-state checks entirely.
async fn handle_system_command(
async fn handle_command(
&self,
command: &str,
args: &[String],
) -> Result<SubmissionResult, Error> {
_args: &[String],
) -> Result<Option<String>, Error> {
match command {
"help" => Ok(SubmissionResult::response(concat!(
"System:\n",
" /help Show this help\n",
" /model [name] Show or switch the active model\n",
" /version Show version info\n",
" /tools List available tools\n",
" /debug Toggle debug mode\n",
" /ping Connectivity check\n",
"\n",
"Jobs:\n",
" /job <desc> Create a new job\n",
" /status [id] Check job status\n",
" /cancel <id> Cancel a job\n",
" /list List all jobs\n",
"\n",
"Session:\n",
" /undo Undo last turn\n",
" /redo Redo undone turn\n",
" /compact Compress context window\n",
" /clear Clear current thread\n",
" /interrupt Stop current operation\n",
" /new New conversation thread\n",
" /thread <id> Switch to thread\n",
" /resume <id> Resume from checkpoint\n",
"\n",
"Agent:\n",
" /heartbeat Run heartbeat check\n",
" /summarize Summarize current thread\n",
" /suggest Suggest next steps\n",
"\n",
" /quit Exit",
))),
"help" => Ok(Some(
r#"Commands:
/job <desc> - Create a job
/status [id] - Check job status
/cancel <id> - Cancel a job
/list - List all jobs
/help <job_id> - Help a stuck job
"ping" => Ok(SubmissionResult::response("pong!")),
/undo - Undo last turn
/redo - Redo undone turn
/compact - Compress context
/clear - Clear thread
/interrupt - Stop current turn
/thread new - New thread
/thread <id> - Switch thread
/resume <id> - Resume checkpoint
"version" => Ok(SubmissionResult::response(format!(
/heartbeat - Run heartbeat check now
/summarize - Summarize current thread
/suggest - Suggest next steps
/quit - Exit"#
.to_string(),
)),
"ping" => Ok(Some("pong!".to_string())),
"version" => Ok(Some(format!(
"{} v{}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
@@ -2371,113 +1980,12 @@ impl Agent {
"tools" => {
let tools = self.tools().list().await;
Ok(SubmissionResult::response(format!(
"Available tools: {}",
tools.join(", ")
)))
Ok(Some(format!("Available tools: {}", tools.join(", "))))
}
"debug" => {
// Debug toggle is handled client-side in the REPL.
// For non-REPL channels, just acknowledge.
Ok(SubmissionResult::ok_with_message(
"Debug toggle is handled by your client.",
))
}
"model" => {
if args.is_empty() {
// Show current model
let name = self.llm().active_model_name();
Ok(SubmissionResult::response(format!(
"Active model: {}",
name
)))
} else {
let requested = &args[0];
// Validate the model exists
match self.llm().list_models().await {
Ok(models) if !models.is_empty() => {
if !models.iter().any(|m| m == requested) {
return Ok(SubmissionResult::error(format!(
"Unknown model: {}. Available models:\n {}",
requested,
models.join("\n ")
)));
}
}
Ok(_) => {
// Empty model list, can't validate but try anyway
}
Err(e) => {
tracing::warn!("Could not fetch model list for validation: {}", e);
// Proceed anyway, the provider will error on the next call if invalid
}
}
match self.llm().set_model(requested) {
Ok(()) => Ok(SubmissionResult::response(format!(
"Switched model to: {}",
requested
))),
Err(e) => Ok(SubmissionResult::error(format!(
"Failed to switch model: {}",
e
))),
}
}
}
_ => Ok(SubmissionResult::error(format!(
"Unknown command: {}. Try /help",
command
))),
_ => Ok(Some(format!("Unknown command: {}. Try /help", command))),
}
}
/// Handle legacy command routing from the Router (job commands that go through
/// process_user_input -> router -> handle_job_or_command -> here).
async fn handle_command(
&self,
command: &str,
args: &[String],
) -> Result<Option<String>, Error> {
// System commands are now handled directly via Submission::SystemCommand,
// but the router may still send us unknown /commands.
match self.handle_system_command(command, args).await? {
SubmissionResult::Response { content } => Ok(Some(content)),
SubmissionResult::Ok { message } => Ok(message),
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
_ => Ok(None),
}
}
}
/// Parsed auth result fields for emitting StatusUpdate::AuthRequired.
struct ParsedAuthData {
auth_url: Option<String>,
setup_url: Option<String>,
}
/// Extract auth_url and setup_url from a tool_auth result JSON string.
fn parse_auth_result(result: &Result<String, Error>) -> ParsedAuthData {
let parsed = result
.as_ref()
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
ParsedAuthData {
auth_url: parsed
.as_ref()
.and_then(|v| v.get("auth_url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
setup_url: parsed
.as_ref()
.and_then(|v| v.get("setup_url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
}
}
/// Check if a tool_auth result indicates the extension is awaiting a token.
@@ -2488,7 +1996,7 @@ fn detect_auth_awaiting(
tool_name: &str,
result: &Result<String, Error>,
) -> Option<(String, String)> {
if tool_name != "tool_auth" && tool_name != "tool_activate" {
if tool_name != "tool_auth" {
return None;
}
let output = result.as_ref().ok()?;
@@ -2572,34 +2080,4 @@ mod tests {
let (_, instructions) = detect_auth_awaiting("tool_auth", &result).unwrap();
assert_eq!(instructions, "Please provide your API token/key.");
}
#[test]
fn test_detect_auth_awaiting_tool_activate() {
let result: Result<String, Error> = Ok(serde_json::json!({
"name": "slack",
"kind": "McpServer",
"awaiting_token": true,
"status": "awaiting_token",
"instructions": "Provide your Slack Bot token."
})
.to_string());
let detected = detect_auth_awaiting("tool_activate", &result);
assert!(detected.is_some());
let (name, instructions) = detected.unwrap();
assert_eq!(name, "slack");
assert!(instructions.contains("Slack Bot"));
}
#[test]
fn test_detect_auth_awaiting_tool_activate_not_awaiting() {
let result: Result<String, Error> = Ok(serde_json::json!({
"name": "slack",
"tools_loaded": ["slack_post_message"],
"message": "Activated"
})
.to_string());
assert!(detect_auth_awaiting("tool_activate", &result).is_none());
}
}
+3 -34
View File
@@ -29,7 +29,7 @@ use std::time::Duration;
use tokio::sync::mpsc;
use crate::channels::OutgoingResponse;
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
use crate::workspace::Workspace;
/// 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)
.with_max_tokens(max_tokens)
.with_temperature(0.3);
.with_max_tokens(1024)
.with_temperature(0.3); // Lower temperature for more focused responses
let response = match self.llm.complete(request).await {
Ok(r) => r,
@@ -245,20 +228,6 @@ impl HeartbeatRunner {
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
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
return HeartbeatResult::Ok;
-5
View File
@@ -6,7 +6,6 @@
//! - Tool invocation with safety
//! - Self-repair for stuck jobs
//! - Proactive heartbeat execution
//! - Routine-based scheduled and reactive jobs
//! - Turn-based session management with undo
//! - Context compaction for long conversations
@@ -15,8 +14,6 @@ pub mod compaction;
pub mod context_monitor;
mod heartbeat;
mod router;
pub mod routine;
pub mod routine_engine;
mod scheduler;
mod self_repair;
pub mod session;
@@ -31,8 +28,6 @@ pub use compaction::{CompactionResult, ContextCompactor};
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
pub use router::{MessageIntent, Router};
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
pub use routine_engine::RoutineEngine;
pub use scheduler::Scheduler;
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
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");
}
}
-606
View File
@@ -1,606 +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::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::history::Store;
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
use crate::workspace::Workspace;
/// The routine execution engine.
pub struct RoutineEngine {
config: RoutineConfig,
store: Arc<Store>,
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<RwLock<usize>>,
/// 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<Store>,
llm: Arc<dyn LlmProvider>,
workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>,
) -> Self {
Self {
config,
store,
llm,
workspace,
notify_tx,
running_count: Arc::new(RwLock::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
{
if 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.read().await >= 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.read().await >= 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<Store>,
llm: Arc<dyn LlmProvider>,
workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>,
running_count: Arc<RwLock<usize>>,
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
{
let mut count = ctx.running_count.write().await;
*count += 1;
}
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
{
let mut count = ctx.running_count.write().await;
*count = count.saturating_sub(1);
}
// 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 {
format!("{}...", &s[..max])
}
}
#[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();
}
}
}
+17 -17
View File
@@ -373,23 +373,23 @@ impl Scheduler {
.into());
}
// Execute with per-tool timeout
let tool_timeout = tool.execution_timeout();
let result =
tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await })
.await
.map_err(|_| {
Error::Tool(crate::error::ToolError::Timeout {
name: tool_name.to_string(),
timeout: tool_timeout,
})
})?
.map_err(|e| {
Error::Tool(crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: e.to_string(),
})
})?;
// Execute with timeout
let result = tokio::time::timeout(Duration::from_secs(60), async {
tool.execute(params, &job_ctx).await
})
.await
.map_err(|_| {
Error::Tool(crate::error::ToolError::Timeout {
name: tool_name.to_string(),
timeout: Duration::from_secs(60),
})
})?
.map_err(|e| {
Error::Tool(crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: e.to_string(),
})
})?;
Ok(TaskOutput::new(result.result, start.elapsed()))
}
-404
View File
@@ -173,10 +173,6 @@ pub struct Thread {
/// Pending auth token request (thread is in auth mode).
#[serde(default)]
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 {
@@ -193,24 +189,6 @@ impl Thread {
metadata: serde_json::Value::Null,
pending_approval: 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,
}
}
@@ -615,386 +593,4 @@ mod tests {
let restored: Thread = serde_json::from_str(&json).expect("should deserialize");
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
);
}
}
-375
View File
@@ -110,41 +110,6 @@ impl SessionManager {
(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.
pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc<Mutex<UndoManager>> {
// Fast path
@@ -331,344 +296,4 @@ mod tests {
.await;
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);
}
}
-131
View File
@@ -43,49 +43,6 @@ impl SubmissionParser {
if lower == "/thread new" || lower == "/new" {
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" {
return Submission::Quit;
}
@@ -215,15 +172,6 @@ pub enum Submission {
/// Quit the agent. Bypasses thread-state checks.
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 {
@@ -290,7 +238,6 @@ impl Submission {
| Self::Heartbeat
| Self::Summarize
| Self::Suggest
| Self::SystemCommand { .. }
)
}
}
@@ -557,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]
fn test_parser_quit() {
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
+4 -52
View File
@@ -272,10 +272,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
));
}
}
RespondResult::ToolCalls {
tool_calls,
content,
} => {
RespondResult::ToolCalls(tool_calls) => {
// Model returned tool calls - execute them
tracing::debug!(
"Job {} respond_with_tools returned {} tool calls",
@@ -283,14 +280,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
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 {
let result = self.execute_tool(&tc.name, &tc.arguments).await;
@@ -428,51 +417,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.into());
}
tracing::debug!(
tool = %tool_name,
params = %params,
job = %job_id,
"Tool call started"
);
// Execute with per-tool timeout and timing
let tool_timeout = tool.execution_timeout();
// Execute with timeout and timing
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
})
.await;
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
let action = match &result {
Ok(Ok(output)) => {
@@ -527,7 +479,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
let output = result
.map_err(|_| crate::error::ToolError::Timeout {
name: tool_name.to_string(),
timeout: tool_timeout,
timeout: Duration::from_secs(60),
})?
.map_err(|e| crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
-325
View File
@@ -1,325 +0,0 @@
//! Bootstrap configuration for IronClaw.
//!
//! These are the only settings that MUST live on disk because they're needed
//! before the database connection is established. Everything else lives in the
//! `settings` table in PostgreSQL.
//!
//! File: `~/.ironclaw/bootstrap.json`
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::settings::KeySource;
/// Minimal config needed to connect to the database and decrypt secrets.
///
/// This is the only JSON file IronClaw reads from disk at startup.
/// All other configuration lives in the `settings` table in PostgreSQL.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapConfig {
/// Database connection URL (postgres://...).
#[serde(default)]
pub database_url: Option<String>,
/// Database connection pool size.
#[serde(default)]
pub database_pool_size: Option<usize>,
/// Source for the secrets master key.
#[serde(default)]
pub secrets_master_key_source: KeySource,
/// Whether onboarding wizard has been completed.
#[serde(default)]
pub onboard_completed: bool,
}
impl Default for BootstrapConfig {
fn default() -> Self {
Self {
database_url: None,
database_pool_size: None,
secrets_master_key_source: KeySource::None,
onboard_completed: false,
}
}
}
impl BootstrapConfig {
/// Default bootstrap file path: `~/.ironclaw/bootstrap.json`.
pub fn default_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
.join("bootstrap.json")
}
/// Legacy settings.json path (for migration detection).
pub fn legacy_settings_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
.join("settings.json")
}
/// Load from the default path, falling back to legacy settings.json,
/// then to defaults if neither exists.
pub fn load() -> Self {
let bootstrap_path = Self::default_path();
if bootstrap_path.exists() {
return Self::load_from(&bootstrap_path);
}
// Fall back to legacy settings.json (extract just the 4 bootstrap fields)
let legacy_path = Self::legacy_settings_path();
if legacy_path.exists() {
return Self::load_from_legacy(&legacy_path);
}
Self::default()
}
/// Load from a specific path.
pub fn load_from(path: &PathBuf) -> Self {
match std::fs::read_to_string(path) {
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
Err(_) => Self::default(),
}
}
/// Extract bootstrap fields from a legacy settings.json.
fn load_from_legacy(path: &PathBuf) -> Self {
match std::fs::read_to_string(path) {
Ok(data) => {
// The legacy Settings struct is a superset; serde will ignore extra fields.
serde_json::from_str(&data).unwrap_or_default()
}
Err(_) => Self::default(),
}
}
/// Save to the default path.
pub fn save(&self) -> std::io::Result<()> {
self.save_to(&Self::default_path())
}
/// Save to a specific path.
pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
std::fs::write(path, json)
}
}
/// One-time migration from disk config files to the database settings table.
///
/// On first boot after upgrade, checks if:
/// 1. `~/.ironclaw/settings.json` exists
/// 2. The DB settings table is empty for this user
///
/// If both conditions hold, migrates settings, MCP servers, and session data
/// to the database, writes `bootstrap.json`, and renames old files to `.migrated`.
pub async fn migrate_disk_to_db(
store: &crate::history::Store,
user_id: &str,
) -> Result<(), MigrationError> {
let legacy_settings_path = BootstrapConfig::legacy_settings_path();
if !legacy_settings_path.exists() {
tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration");
return Ok(());
}
// Only migrate if DB is empty for this user
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::debug!(
"DB already has settings for user '{}', skipping migration",
user_id
);
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 bootstrap.json with the 4 essential fields
let bootstrap = BootstrapConfig {
database_url: settings.database_url.clone(),
database_pool_size: settings.database_pool_size,
secrets_master_key_source: settings.secrets_master_key_source,
onboard_completed: settings.onboard_completed,
};
bootstrap
.save()
.map_err(|e| MigrationError::Io(format!("Failed to write bootstrap.json: {}", e)))?;
tracing::info!("Wrote bootstrap.json");
// 3. Migrate mcp-servers.json if it exists
let ironclaw_dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw");
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", &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);
tracing::info!("Disk-to-DB migration complete");
Ok(())
}
/// Rename a file to `<name>.migrated` as a safety net.
fn rename_to_migrated(path: &PathBuf) {
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_bootstrap_save_load() {
let dir = tempdir().unwrap();
let path = dir.path().join("bootstrap.json");
let config = BootstrapConfig {
database_url: Some("postgres://localhost/test".to_string()),
database_pool_size: Some(5),
secrets_master_key_source: KeySource::Keychain,
onboard_completed: true,
};
config.save_to(&path).unwrap();
let loaded = BootstrapConfig::load_from(&path);
assert_eq!(
loaded.database_url,
Some("postgres://localhost/test".to_string())
);
assert_eq!(loaded.database_pool_size, Some(5));
assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain);
assert!(loaded.onboard_completed);
}
#[test]
fn test_bootstrap_from_legacy_settings() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
// Write a legacy settings.json with many extra fields
let legacy = serde_json::json!({
"database_url": "postgres://localhost/ironclaw",
"database_pool_size": 10,
"secrets_master_key_source": "keychain",
"onboard_completed": true,
"selected_model": "claude-3-5-sonnet",
"agent": { "name": "testbot", "max_parallel_jobs": 3 },
"heartbeat": { "enabled": true }
});
std::fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap();
let config = BootstrapConfig::load_from_legacy(&path);
assert_eq!(
config.database_url,
Some("postgres://localhost/ironclaw".to_string())
);
assert_eq!(config.database_pool_size, Some(10));
assert_eq!(config.secrets_master_key_source, KeySource::Keychain);
assert!(config.onboard_completed);
}
#[test]
fn test_bootstrap_defaults() {
let config = BootstrapConfig::default();
assert!(config.database_url.is_none());
assert!(config.database_pool_size.is_none());
assert_eq!(config.secrets_master_key_source, KeySource::None);
assert!(!config.onboard_completed);
}
}
-19
View File
@@ -114,12 +114,6 @@ pub enum StatusUpdate {
StreamChunk(String),
/// General status message.
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.
ApprovalNeeded {
request_id: String,
@@ -127,19 +121,6 @@ pub enum StatusUpdate {
description: String,
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.
+2 -52
View File
@@ -42,25 +42,12 @@ const SLASH_COMMANDS: &[&str] = &[
"/quit",
"/exit",
"/debug",
"/model",
"/undo",
"/redo",
"/clear",
"/compact",
"/new",
"/interrupt",
"/version",
"/tools",
"/ping",
"/job",
"/status",
"/cancel",
"/list",
"/heartbeat",
"/summarize",
"/suggest",
"/thread",
"/resume",
];
/// Rustyline helper for slash-command tab completion.
@@ -308,11 +295,10 @@ impl Channel for ReplChannel {
continue;
}
// Handle local REPL commands (only commands that need
// immediate local handling stay here)
// Handle local REPL commands
match line.to_lowercase().as_str() {
"/quit" | "/exit" => break,
"/help" => {
"/help" | "/?" => {
print_help();
continue;
}
@@ -427,15 +413,6 @@ impl Channel for ReplChannel {
print!("{chunk}");
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) => {
if debug || msg.contains("approval") || msg.contains("Approval") {
eprintln!(" \x1b[90m{msg}\x1b[0m");
@@ -495,33 +472,6 @@ impl Channel for ReplChannel {
eprintln!(" {bot_border}");
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(())
}
+48 -109
View File
@@ -1,125 +1,68 @@
//! Known WASM channels that can be installed from build artifacts.
//!
//! 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
//! Bundled WASM channels that can be installed locally.
use std::path::{Path, PathBuf};
use std::path::Path;
use tokio::fs;
/// Compile-time project root, used to locate channels-src/ in dev builds.
const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
/// Known channel names and their crate names (for locating build artifacts).
const KNOWN_CHANNELS: &[(&str, &str)] = &[
("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()
#[derive(Clone, Copy)]
struct BundledChannel {
name: &'static str,
wasm: &'static [u8],
capabilities: &'static [u8],
}
/// Resolve the channels source directory.
///
/// Checks (in order):
/// 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")
/// Names of bundled channels shipped with IronClaw.
pub fn bundled_channel_names() -> &'static [&'static str] {
&["telegram"]
}
/// Locate the build artifacts for a channel.
///
/// 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.
/// Install a bundled channel into a channels directory.
pub async fn install_bundled_channel(
name: &str,
target_dir: &Path,
force: bool,
) -> 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)
.await
.map_err(|e| format!("Failed to create channels directory: {}", e))?;
let wasm_dst = target_dir.join(format!("{}.wasm", name));
let caps_dst = target_dir.join(format!("{}.capabilities.json", name));
let wasm_path = target_dir.join(format!("{}.wasm", channel.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 {
return Err(format!(
"Channel '{}' already exists at {}",
name,
channel.name,
target_dir.display()
));
}
fs::copy(&wasm_src, &wasm_dst)
fs::write(&wasm_path, channel.wasm)
.await
.map_err(|e| format!("Failed to copy {}: {}", wasm_src.display(), e))?;
fs::copy(&caps_src, &caps_dst)
.map_err(|e| format!("Failed to write {}: {}", wasm_path.display(), e))?;
fs::write(&caps_path, channel.capabilities)
.await
.map_err(|e| format!("Failed to copy {}: {}", caps_src.display(), e))?;
.map_err(|e| format!("Failed to write {}: {}", caps_path.display(), e))?;
Ok(())
}
/// Check which known channels have build artifacts available.
pub fn available_channel_names() -> Vec<&'static str> {
KNOWN_CHANNELS
.iter()
.filter(|(name, _)| locate_channel_artifacts(name).is_ok())
.map(|(name, _)| *name)
.collect()
fn bundled_channel(name: &str) -> Option<BundledChannel> {
if name.eq_ignore_ascii_case("telegram") {
Some(BundledChannel {
name: "telegram",
wasm: include_bytes!("../../../channels-src/telegram/telegram.wasm"),
capabilities: include_bytes!(
"../../../channels-src/telegram/telegram.capabilities.json"
),
})
} else {
None
}
}
#[cfg(test)]
@@ -130,35 +73,31 @@ mod tests {
use super::*;
#[test]
fn test_known_channels_includes_all_three() {
let names = bundled_channel_names();
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());
fn test_bundled_channel_names_contains_telegram() {
assert!(bundled_channel_names().contains(&"telegram"));
}
#[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 wasm_path = dir.path().join("telegram.wasm");
fs::write(&wasm_path, b"custom").await.unwrap();
let result = install_bundled_channel("telegram", dir.path(), false).await;
// Either fails because artifacts missing OR because file exists
assert!(result.is_err());
// Original file should be untouched
let existing = fs::read(&wasm_path).await.unwrap();
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::schema::ChannelCapabilitiesFile;
use crate::channels::wasm::wrapper::WasmChannel;
use crate::pairing::PairingStore;
/// Loads WASM channels from the filesystem.
pub struct WasmChannelLoader {
runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
}
impl WasmChannelLoader {
/// Create a new loader with the given runtime and pairing store.
pub fn new(runtime: Arc<WasmChannelRuntime>, pairing_store: Arc<PairingStore>) -> Self {
Self {
runtime,
pairing_store,
}
/// Create a new loader with the given runtime.
pub fn new(runtime: Arc<WasmChannelRuntime>) -> Self {
Self { runtime }
}
/// Load a single WASM channel from a file pair.
@@ -119,13 +114,7 @@ impl WasmChannelLoader {
.await?;
// Create the channel
let channel = WasmChannel::new(
self.runtime.clone(),
prepared,
capabilities,
config_json,
self.pairing_store.clone(),
);
let channel = WasmChannel::new(self.runtime.clone(), prepared, capabilities, config_json);
tracing::info!(
name = name,
@@ -363,7 +352,6 @@ mod tests {
use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels};
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
use crate::pairing::PairingStore;
use std::sync::Arc;
#[tokio::test]
@@ -420,7 +408,7 @@ mod tests {
async fn test_loader_invalid_name() {
let config = WasmChannelRuntimeConfig::for_testing();
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 wasm_path = dir.path().join("test.wasm");
+1 -1
View File
@@ -89,7 +89,7 @@ mod schema;
mod wrapper;
// 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 error::WasmChannelError;
pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
-2
View File
@@ -478,7 +478,6 @@ mod tests {
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
};
use crate::channels::wasm::wrapper::WasmChannel;
use crate::pairing::PairingStore;
use crate::tools::wasm::ResourceLimits;
fn create_test_channel(name: &str) -> Arc<WasmChannel> {
@@ -500,7 +499,6 @@ mod tests {
prepared,
capabilities,
"{}".to_string(),
Arc::new(PairingStore::new()),
))
}
+22 -271
View File
@@ -48,7 +48,6 @@ use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
use crate::channels::wasm::schema::ChannelConfig;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
use crate::pairing::PairingStore;
use crate::safety::LeakDetector;
use crate::tools::wasm::LogLevel;
use crate::tools::wasm::WasmResourceLimiter;
@@ -74,8 +73,6 @@ struct ChannelStoreData {
/// Injected credentials for URL substitution (e.g., bot tokens).
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
credentials: HashMap<String, String>,
/// Pairing store for DM pairing (guest access control).
pairing_store: Arc<PairingStore>,
}
impl ChannelStoreData {
@@ -84,7 +81,6 @@ impl ChannelStoreData {
channel_name: &str,
capabilities: ChannelCapabilities,
credentials: HashMap<String, String>,
pairing_store: Arc<PairingStore>,
) -> Self {
// Create a minimal WASI context (no filesystem, no env vars for security)
let wasi = WasiCtxBuilder::new().build();
@@ -95,7 +91,6 @@ impl ChannelStoreData {
wasi,
table: ResourceTable::new(),
credentials,
pairing_store,
}
}
@@ -146,22 +141,6 @@ impl ChannelStoreData {
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
@@ -208,7 +187,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
url: String,
headers_json: String,
body: Option<Vec<u8>>,
timeout_ms: Option<u32>,
) -> Result<near::agent::channel_host::HttpResponse, String> {
tracing::info!(
method = %method,
@@ -298,21 +276,12 @@ impl near::agent::channel_host::Host for ChannelStoreData {
request = request.body(body_bytes);
}
// Send request with caller-specified timeout (default 30s).
// Cap at callback_timeout to prevent outliving the host wrapper.
let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64);
let response = request.timeout(timeout).send().await.map_err(|e| {
// Walk the full error chain so we get the actual root cause
// (DNS, TLS, connection refused, etc.) instead of just
// "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
})?;
// Send request with timeout
let response = request
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("HTTP request failed: {}", e))?;
let status = response.status().as_u16();
let response_headers: std::collections::HashMap<String, String> = response
@@ -361,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 {
Ok(resp) => {
tracing::info!(status = resp.status, "http_request completed successfully");
@@ -408,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.
@@ -497,9 +424,6 @@ pub struct WasmChannel {
/// Background task that repeats typing indicators every 4 seconds.
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
/// Pairing store for DM pairing (guest access control).
pairing_store: Arc<PairingStore>,
}
impl WasmChannel {
@@ -509,7 +433,6 @@ impl WasmChannel {
prepared: Arc<PreparedChannelModule>,
capabilities: ChannelCapabilities,
config_json: String,
pairing_store: Arc<PairingStore>,
) -> Self {
let name = prepared.name.clone();
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
@@ -529,7 +452,6 @@ impl WasmChannel {
endpoints: RwLock::new(Vec::new()),
credentials: Arc::new(RwLock::new(HashMap::new())),
typing_task: RwLock::new(None),
pairing_store,
}
}
@@ -611,7 +533,6 @@ impl WasmChannel {
prepared: &PreparedChannelModule,
capabilities: &ChannelCapabilities,
credentials: HashMap<String, String>,
pairing_store: Arc<PairingStore>,
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
let engine = runtime.engine();
let limits = &prepared.limits;
@@ -622,7 +543,6 @@ impl WasmChannel {
&prepared.name,
capabilities.clone(),
credentials,
pairing_store,
);
let mut store = Store::new(engine, store_data);
@@ -723,18 +643,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store = Self::create_store(
&runtime,
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_start using the generated typed interface
@@ -839,7 +753,6 @@ impl WasmChannel {
let capabilities = self.capabilities.clone();
let timeout = self.runtime.config().callback_timeout;
let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Prepare request data
let method = method.to_string();
@@ -853,13 +766,8 @@ impl WasmChannel {
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store = Self::create_store(
&runtime,
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Build the WIT request type
@@ -932,18 +840,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store = Self::create_store(
&runtime,
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_poll using the generated typed interface
@@ -1027,7 +929,6 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
// Prepare response data
let message_id_str = message_id.to_string();
@@ -1041,13 +942,8 @@ impl WasmChannel {
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
tracing::info!("Creating WASM store for on_respond");
let mut store = Self::create_store(
&runtime,
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
tracing::info!("Instantiating WASM component for on_respond");
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
@@ -1140,19 +1036,13 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
let wit_update = status_to_wit(status, metadata);
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store = Self::create_store(
&runtime,
&prepared,
&capabilities,
credentials,
pairing_store,
)?;
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
let channel_iface = instance.near_agent_channel();
@@ -1190,14 +1080,12 @@ impl WasmChannel {
///
/// Static method for use by the background typing repeat task (which
/// doesn't have access to `&self`).
#[allow(clippy::too_many_arguments)]
async fn execute_status(
channel_name: &str,
runtime: &Arc<WasmChannelRuntime>,
prepared: &Arc<PreparedChannelModule>,
capabilities: &ChannelCapabilities,
credentials: &RwLock<HashMap<String, String>>,
pairing_store: Arc<PairingStore>,
timeout: Duration,
wit_update: wit_channel::StatusUpdate,
) -> Result<(), WasmChannelError> {
@@ -1213,13 +1101,8 @@ impl WasmChannel {
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store = Self::create_store(
&runtime,
&prepared,
&capabilities,
credentials_snapshot,
pairing_store,
)?;
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
let channel_iface = instance.near_agent_channel();
@@ -1287,7 +1170,6 @@ impl WasmChannel {
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let credentials = self.credentials.clone();
let pairing_store = self.pairing_store.clone();
let callback_timeout = self.runtime.config().callback_timeout;
let wit_update = status_to_wit(&status, metadata);
@@ -1307,7 +1189,6 @@ impl WasmChannel {
&prepared,
&capabilities,
&credentials,
pairing_store.clone(),
callback_timeout,
wit_update_clone,
)
@@ -1438,7 +1319,6 @@ impl WasmChannel {
let message_tx = self.message_tx.clone();
let rate_limiter = self.rate_limiter.clone();
let credentials = self.credentials.clone();
let pairing_store = self.pairing_store.clone();
let callback_timeout = self.runtime.config().callback_timeout;
tokio::spawn(async move {
@@ -1460,7 +1340,6 @@ impl WasmChannel {
&prepared,
&capabilities,
&credentials,
pairing_store.clone(),
callback_timeout,
).await;
@@ -1512,7 +1391,6 @@ impl WasmChannel {
prepared: &Arc<PreparedChannelModule>,
capabilities: &ChannelCapabilities,
credentials: &RwLock<HashMap<String, String>>,
pairing_store: Arc<PairingStore>,
timeout: Duration,
) -> Result<Vec<EmittedMessage>, WasmChannelError> {
// Skip if no WASM bytes (testing mode)
@@ -1533,13 +1411,8 @@ impl WasmChannel {
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
let mut store = Self::create_store(
&runtime,
&prepared,
&capabilities,
credentials_snapshot,
pairing_store,
)?;
let mut store =
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
// Call on_poll using the generated typed interface
@@ -1985,29 +1858,6 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
message: format!("Approval needed: {} - {}", tool_name, description),
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,
},
}
}
@@ -2079,7 +1929,6 @@ mod tests {
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
};
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
use crate::pairing::PairingStore;
use crate::tools::wasm::ResourceLimits;
fn create_test_channel() -> WasmChannel {
@@ -2095,13 +1944,7 @@ mod tests {
let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test");
WasmChannel::new(
runtime,
prepared,
capabilities,
"{}".to_string(),
Arc::new(PairingStore::new()),
)
WasmChannel::new(runtime, prepared, capabilities, "{}".to_string())
}
#[test]
@@ -2176,7 +2019,6 @@ mod tests {
&prepared,
&capabilities,
&credentials,
Arc::new(PairingStore::new()),
timeout,
)
.await;
@@ -2270,13 +2112,7 @@ mod tests {
.with_path("/webhook/poll")
.with_polling(1000);
let channel = WasmChannel::new(
runtime,
prepared,
capabilities,
"{}".to_string(),
Arc::new(PairingStore::new()),
);
let channel = WasmChannel::new(runtime, prepared, capabilities, "{}".to_string());
// Start the channel
let _stream = channel.start().await.expect("Channel should start");
@@ -2514,89 +2350,4 @@ mod tests {
assert_eq!(cloned.message, "hello");
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 the block_on-inside-spawn_blocking pattern used by the WASM
/// channel HTTP host function doesn't deadlock or panic.
#[tokio::test]
async fn test_block_on_inside_spawn_blocking_does_not_deadlock() {
let result = tokio::task::spawn_blocking(|| {
tokio::runtime::Handle::current().block_on(async { 42 })
})
.await
.expect("spawn_blocking panicked");
assert_eq!(result, 42);
}
}
+24 -98
View File
@@ -10,7 +10,7 @@
//! ◄── GET /api/chat/events ── SSE stream
//! ─── GET /api/chat/ws ─────► WebSocket (bidirectional)
//! ─── GET /api/memory/* ────► Workspace
//! ─── GET /api/jobs/* ──────► Database
//! ─── GET /api/jobs/* ──────► ContextManager
//! ◄── GET / ───────────────── Static HTML/CSS/JS
//! ```
@@ -31,10 +31,9 @@ use tokio_stream::wrappers::ReceiverStream;
use crate::agent::SessionManager;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::config::GatewayConfig;
use crate::context::ContextManager;
use crate::error::ChannelError;
use crate::extensions::ExtensionManager;
use crate::history::Store;
use crate::orchestrator::job_manager::ContainerJobManager;
use crate::tools::ToolRegistry;
use crate::workspace::Workspace;
@@ -71,13 +70,11 @@ impl GatewayChannel {
msg_tx: tokio::sync::RwLock::new(None),
sse: SseManager::new(),
workspace: None,
context_manager: None,
session_manager: None,
log_broadcaster: None,
extension_manager: None,
tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
user_id: config.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
@@ -96,13 +93,11 @@ impl GatewayChannel {
msg_tx: tokio::sync::RwLock::new(None),
sse: SseManager::new(),
workspace: self.state.workspace.clone(),
context_manager: self.state.context_manager.clone(),
session_manager: self.state.session_manager.clone(),
log_broadcaster: self.state.log_broadcaster.clone(),
extension_manager: self.state.extension_manager.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(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: self.state.ws_tracker.clone(),
@@ -117,6 +112,12 @@ impl GatewayChannel {
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.
pub fn with_session_manager(mut self, sm: Arc<SessionManager>) -> Self {
self.rebuild_state(|s| s.session_manager = Some(sm));
@@ -141,34 +142,6 @@ impl GatewayChannel {
self
}
/// Inject the database store for sandbox job persistence.
pub fn with_store(mut self, store: Arc<Store>) -> 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
}
/// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
&self.auth_token
@@ -200,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)))
}
@@ -223,48 +200,17 @@ impl Channel for GatewayChannel {
async fn send_status(
&self,
status: StatusUpdate,
metadata: &serde_json::Value,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
let thread_id = metadata
.get("thread_id")
.and_then(|v| v.as_str())
.map(String::from);
let event = match status {
StatusUpdate::Thinking(msg) => SseEvent::Thinking {
message: msg,
thread_id: thread_id.clone(),
},
StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted {
name,
thread_id: thread_id.clone(),
},
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::Thinking(msg) => SseEvent::Thinking { message: msg },
StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted { name },
StatusUpdate::ToolCompleted { name, success } => {
SseEvent::ToolCompleted { name, success }
}
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult { name, preview },
StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk { content },
StatusUpdate::Status(msg) => SseEvent::Status { message: msg },
StatusUpdate::ApprovalNeeded {
request_id,
tool_name,
@@ -277,26 +223,6 @@ impl Channel for GatewayChannel {
parameters: serde_json::to_string_pretty(&parameters)
.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);
+96 -1333
View File
File diff suppressed because it is too large Load Diff
+2 -12
View File
@@ -79,15 +79,7 @@ impl SseManager {
SseEvent::StreamChunk { .. } => "stream_chunk",
SseEvent::Status { .. } => "status",
SseEvent::ApprovalNeeded { .. } => "approval_needed",
SseEvent::AuthRequired { .. } => "auth_required",
SseEvent::AuthCompleted { .. } => "auth_completed",
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",
};
Ok(Event::default().event(event_type).data(data))
@@ -160,14 +152,13 @@ mod tests {
manager.broadcast(SseEvent::Status {
message: "test".to_string(),
thread_id: None,
});
let event = rx.next().await;
assert!(event.is_some());
let event = event.unwrap().unwrap();
match event {
SseEvent::Status { message, .. } => assert_eq!(message, "test"),
SseEvent::Status { message } => assert_eq!(message, "test"),
_ => panic!("unexpected event type"),
}
}
@@ -181,12 +172,11 @@ mod tests {
manager.broadcast(SseEvent::Thinking {
message: "working".to_string(),
thread_id: None,
});
let event = stream.next().await.unwrap();
match event {
SseEvent::Thinking { message, .. } => assert_eq!(message, "working"),
SseEvent::Thinking { message } => assert_eq!(message, "working"),
_ => panic!("Expected Thinking event"),
}
}
File diff suppressed because it is too large Load Diff
+7 -82
View File
@@ -10,19 +10,12 @@
<body>
<!-- Auth Screen -->
<div id="auth-screen">
<div class="auth-card-login">
<div class="auth-brand">
<h1>IronClaw</h1>
<p class="auth-tagline">Secure AI Assistant</p>
</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>
<h1>IronClaw</h1>
<div class="auth-form">
<input type="password" id="token-input" placeholder="Auth token" autofocus>
<button onclick="authenticate()">Connect</button>
</div>
<div id="auth-error"></div>
</div>
<!-- Main App (hidden until authenticated) -->
@@ -33,33 +26,16 @@
<button data-tab="memory">Memory</button>
<button data-tab="jobs">Jobs</button>
<button data-tab="logs">Logs</button>
<button data-tab="routines">Routines</button>
<button data-tab="extensions">Extensions</button>
<div class="spacer"></div>
<div class="status" id="gateway-status-trigger">
<div class="status">
<div class="dot" id="sse-dot"></div>
<span id="sse-status">Connected</span>
<div class="gateway-popover" id="gateway-popover"></div>
</div>
</div>
<!-- Chat Tab -->
<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-messages" id="chat-messages"></div>
<div class="chat-status" id="chat-status"></div>
@@ -80,20 +56,10 @@
<div class="memory-tree" id="memory-tree"></div>
</div>
<div class="memory-content">
<div class="memory-breadcrumb" id="memory-breadcrumb">
<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-breadcrumb" id="memory-breadcrumb">workspace /</div>
<div class="memory-viewer" id="memory-viewer">
<div class="empty">Select a file to view its contents</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>
@@ -107,7 +73,6 @@
<tr>
<th>ID</th>
<th>Title</th>
<th>Source</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
@@ -139,48 +104,9 @@
</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 -->
<div class="tab-panel" id="tab-extensions">
<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">
<h3>Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
@@ -204,7 +130,6 @@
</div>
</div>
<div id="toasts"></div>
<script src="/app.js"></script>
</body>
</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 created_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)]
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 active_thread: Option<Uuid>,
}
@@ -61,12 +54,6 @@ pub struct ToolCallInfo {
pub struct HistoryResponse {
pub thread_id: Uuid,
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 ---
@@ -76,8 +63,6 @@ pub struct ApprovalRequest {
pub request_id: String,
/// "approve", "always", or "deny"
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 ---
@@ -88,49 +73,17 @@ pub enum SseEvent {
#[serde(rename = "response")]
Response { content: String, thread_id: String },
#[serde(rename = "thinking")]
Thinking {
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
Thinking { message: String },
#[serde(rename = "tool_started")]
ToolStarted {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
ToolStarted { name: String },
#[serde(rename = "tool_completed")]
ToolCompleted {
name: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
ToolCompleted { name: String, success: bool },
#[serde(rename = "tool_result")]
ToolResult {
name: String,
preview: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
ToolResult { name: String, preview: String },
#[serde(rename = "stream_chunk")]
StreamChunk {
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
StreamChunk { content: String },
#[serde(rename = "status")]
Status {
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,
},
Status { message: String },
#[serde(rename = "approval_needed")]
ApprovalNeeded {
request_id: String,
@@ -138,59 +91,10 @@ pub enum SseEvent {
description: 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")]
Error {
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
Error { message: String },
#[serde(rename = "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 ---
@@ -284,54 +188,6 @@ pub struct JobSummaryResponse {
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 ---
#[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 ---
/// Message sent by a WebSocket client to the server.
@@ -439,18 +280,7 @@ pub enum WsClientMessage {
request_id: String,
/// "approve", "always", or "deny"
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.
#[serde(rename = "ping")]
Ping,
@@ -484,20 +314,12 @@ impl WsServerMessage {
SseEvent::Thinking { .. } => "thinking",
SseEvent::ToolStarted { .. } => "tool_started",
SseEvent::ToolCompleted { .. } => "tool_completed",
SseEvent::ToolResult { .. } => "tool_result",
SseEvent::StreamChunk { .. } => "stream_chunk",
SseEvent::Status { .. } => "status",
SseEvent::JobStarted { .. } => "job_started",
SseEvent::ApprovalNeeded { .. } => "approval_needed",
SseEvent::AuthRequired { .. } => "auth_required",
SseEvent::AuthCompleted { .. } => "auth_completed",
SseEvent::Error { .. } => "error",
SseEvent::ToolResult { .. } => "tool_result",
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);
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 ---
#[derive(Debug, Serialize)]
@@ -639,36 +371,12 @@ mod tests {
#[test]
fn test_ws_client_approval_parse() {
let json =
r#"{"type":"approval","request_id":"abc-123","action":"approve","thread_id":"t1"}"#;
let json = r#"{"type":"approval","request_id":"abc-123","action":"approve"}"#;
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
match msg {
WsClientMessage::Approval {
request_id,
action,
thread_id,
} => {
WsClientMessage::Approval { request_id, action } => {
assert_eq!(request_id, "abc-123");
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"),
}
@@ -729,7 +437,6 @@ mod tests {
fn test_ws_server_from_sse_thinking() {
let sse = SseEvent::Thinking {
message: "reasoning...".to_string(),
thread_id: None,
};
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
@@ -770,115 +477,4 @@ mod tests {
_ => 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");
}
}
+3 -73
View File
@@ -170,11 +170,7 @@ async fn handle_client_message(
.await;
}
}
WsClientMessage::Approval {
request_id,
action,
thread_id,
} => {
WsClientMessage::Approval { request_id, action } => {
let (approved, always) = match action.as_str() {
"approve" => (true, false),
"always" => (true, true),
@@ -218,71 +214,12 @@ async fn handle_client_message(
}
};
let mut msg = IncomingMessage::new("gateway", user_id, content);
if let Some(ref tid) = thread_id {
msg = msg.with_thread(tid);
}
let msg = IncomingMessage::new("gateway", user_id, content);
let tx_guard = state.msg_tx.read().await;
if let Some(ref tx) = *tx_guard {
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 => {
let _ = direct_tx.send(WsServerMessage::Pong).await;
}
@@ -391,7 +328,6 @@ mod tests {
WsClientMessage::Approval {
request_id: request_id.to_string(),
action: "approve".to_string(),
thread_id: Some("thread-42".to_string()),
},
&state,
"user1",
@@ -402,8 +338,6 @@ mod tests {
let incoming = agent_rx.recv().await.unwrap();
// The content should be a serialized 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]
@@ -415,7 +349,6 @@ mod tests {
WsClientMessage::Approval {
request_id: Uuid::new_v4().to_string(),
action: "maybe".to_string(),
thread_id: None,
},
&state,
"user1",
@@ -441,7 +374,6 @@ mod tests {
WsClientMessage::Approval {
request_id: "not-a-uuid".to_string(),
action: "approve".to_string(),
thread_id: None,
},
&state,
"user1",
@@ -466,13 +398,11 @@ mod tests {
msg_tx: tokio::sync::RwLock::new(msg_tx),
sse: SseManager::new(),
workspace: None,
context_manager: None,
session_manager: None,
log_broadcaster: None,
extension_manager: None,
tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
user_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
+52 -119
View File
@@ -1,7 +1,6 @@
//! Configuration management CLI commands.
//!
//! Commands for viewing and modifying settings.
//! Settings are stored in PostgreSQL (env > DB > default).
use clap::Subcommand;
@@ -37,82 +36,41 @@ pub enum ConfigCommand {
path: String,
},
/// Show the settings storage info
/// Show the settings file path
Path,
}
/// Run a config command.
///
/// 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<()> {
let _ = dotenvy::dotenv();
// Try to connect to the DB for settings access
let store = match connect_store().await {
Ok(s) => Some(s),
Err(e) => {
eprintln!(
"Warning: Could not connect to database ({}), using disk fallback",
e
);
None
}
};
pub fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
match cmd {
ConfigCommand::List { filter } => list_settings(store.as_ref(), filter).await,
ConfigCommand::Get { path } => get_setting(store.as_ref(), &path).await,
ConfigCommand::Set { path, value } => set_setting(store.as_ref(), &path, &value).await,
ConfigCommand::Reset { path } => reset_setting(store.as_ref(), &path).await,
ConfigCommand::Path => show_path(store.is_some()),
ConfigCommand::List { filter } => list_settings(filter),
ConfigCommand::Get { path } => get_setting(&path),
ConfigCommand::Set { path, value } => set_setting(&path, &value),
ConfigCommand::Reset { path } => reset_setting(&path),
ConfigCommand::Path => show_path(),
}
}
/// Bootstrap a DB connection for config commands.
async fn connect_store() -> anyhow::Result<crate::history::Store> {
let config = crate::config::Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let store = crate::history::Store::new(&config.database).await?;
store.run_migrations().await?;
Ok(store)
}
const DEFAULT_USER_ID: &str = "default";
/// Load settings: DB if available, else disk.
async fn load_settings(store: Option<&crate::history::Store>) -> 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::load()
}
/// List all settings.
async fn list_settings(
store: Option<&crate::history::Store>,
filter: Option<String>,
) -> anyhow::Result<()> {
let settings = load_settings(store).await;
fn list_settings(filter: Option<String>) -> anyhow::Result<()> {
let settings = Settings::load();
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 source = if store.is_some() { "database" } else { "disk" };
println!("Settings (source: {}):", source);
println!("Settings:");
println!();
for (key, value) in all {
// Skip if filter is set and doesn't match
if let Some(ref f) = filter {
if !key.starts_with(f) {
continue;
}
}
// Truncate long values for display
let display_value = if value.len() > 60 {
format!("{}...", &value[..57])
} else {
@@ -126,8 +84,8 @@ async fn list_settings(
}
/// Get a specific setting.
async fn get_setting(store: Option<&crate::history::Store>, path: &str) -> anyhow::Result<()> {
let settings = load_settings(store).await;
fn get_setting(path: &str) -> anyhow::Result<()> {
let settings = Settings::load();
match settings.get(path) {
Some(value) => {
@@ -141,92 +99,67 @@ async fn get_setting(store: Option<&crate::history::Store>, path: &str) -> anyho
}
/// Set a setting value.
async fn set_setting(
store: Option<&crate::history::Store>,
path: &str,
value: &str,
) -> anyhow::Result<()> {
let mut settings = load_settings(store).await;
fn set_setting(path: &str, value: &str) -> anyhow::Result<()> {
let mut settings = Settings::load();
// Try to set the value
settings
.set(path, value)
.map_err(|e| anyhow::anyhow!("{}", e))?;
// Save to DB if available, otherwise disk
if let Some(store) = store {
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))?;
} else {
settings.save()?;
}
// Save to disk
settings.save()?;
println!("Set {} = {}", path, value);
Ok(())
}
/// Reset a setting to default.
async fn reset_setting(store: Option<&crate::history::Store>, 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_value = default
.get(path)
.ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
// Delete from DB (falling back to default) or reset on disk
if let Some(store) = store {
store
.delete_setting(DEFAULT_USER_ID, path)
.await
.map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?;
} else {
let mut settings = Settings::load();
settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?;
settings.save()?;
}
// Reset it
settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?;
// Save to disk
settings.save()?;
println!("Reset {} to default: {}", path, default_value);
Ok(())
}
/// Show the settings storage info.
fn show_path(has_db: bool) -> anyhow::Result<()> {
if has_db {
println!("Settings stored in: PostgreSQL (settings table)");
println!(
"Bootstrap config: {}",
crate::bootstrap::BootstrapConfig::default_path().display()
);
} else {
let path = Settings::default_path();
println!("Settings stored in: {} (disk fallback)", path.display());
/// Show the settings file path.
fn show_path() -> anyhow::Result<()> {
let path = Settings::default_path();
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);
}
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 {
println!(" (does not exist, using defaults)");
}
} else {
println!(" (does not exist, using defaults)");
}
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());
}
}
+12 -61
View File
@@ -13,7 +13,9 @@ use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore};
use crate::tools::mcp::{
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
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)]
@@ -171,11 +173,8 @@ async fn add_server(
// Validate
config.validate()?;
// Save (DB if available, else disk)
let store = connect_store().await;
let mut servers = load_servers(store.as_ref()).await?;
servers.upsert(config);
save_servers(store.as_ref(), &servers).await?;
// Save
add_mcp_server(config).await?;
println!();
println!(" ✓ Added MCP server '{}'", name);
@@ -193,12 +192,7 @@ async fn add_server(
/// Remove an MCP server.
async fn remove_server(name: String) -> anyhow::Result<()> {
let store = connect_store().await;
let mut servers = load_servers(store.as_ref()).await?;
if !servers.remove(&name) {
anyhow::bail!("Server '{}' not found", name);
}
save_servers(store.as_ref(), &servers).await?;
remove_mcp_server(&name).await?;
println!();
println!(" ✓ Removed MCP server '{}'", name);
@@ -209,8 +203,7 @@ async fn remove_server(name: String) -> anyhow::Result<()> {
/// List configured MCP servers.
async fn list_servers(verbose: bool) -> anyhow::Result<()> {
let store = connect_store().await;
let servers = load_servers(store.as_ref()).await?;
let servers = load_mcp_servers().await?;
if servers.servers.is_empty() {
println!();
@@ -268,12 +261,7 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> {
/// Authenticate with an MCP server.
async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
// Get server config
let store = connect_store().await;
let servers = load_servers(store.as_ref()).await?;
let server = servers
.get(&name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?;
let server = get_mcp_server(&name).await?;
// Initialize secrets store
let secrets = get_secrets_store().await?;
@@ -341,12 +329,7 @@ async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
/// Test connection to an MCP server.
async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
// Get server config
let store = connect_store().await;
let servers = load_servers(store.as_ref()).await?;
let server = servers
.get(&name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?;
let server = get_mcp_server(&name).await?;
println!();
println!(" Testing connection to '{}'...", name);
@@ -437,8 +420,7 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
/// Toggle server enabled/disabled state.
async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> {
let store = connect_store().await;
let mut servers = load_servers(store.as_ref()).await?;
let mut servers = load_mcp_servers().await?;
let server = servers
.get_mut(&name)
@@ -453,7 +435,7 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
};
server.enabled = new_state;
save_servers(store.as_ref(), &servers).await?;
save_mcp_servers(&servers).await?;
let status = if new_state { "enabled" } else { "disabled" };
println!();
@@ -463,40 +445,9 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
Ok(())
}
const DEFAULT_USER_ID: &str = "default";
/// Try to connect to the database store for DB-backed config.
async fn connect_store() -> Option<Store> {
let config = Config::from_env().await.ok()?;
let store = Store::new(&config.database).await.ok()?;
store.run_migrations().await.ok()?;
Some(store)
}
/// Load MCP servers (DB if available, else disk).
async fn load_servers(store: Option<&Store>) -> Result<McpServersFile, config::ConfigError> {
if let Some(store) = store {
config::load_mcp_servers_from_db(store, DEFAULT_USER_ID).await
} else {
config::load_mcp_servers().await
}
}
/// Save MCP servers (DB if available, else disk).
async fn save_servers(
store: Option<&Store>,
servers: &McpServersFile,
) -> Result<(), config::ConfigError> {
if let Some(store) = store {
config::save_mcp_servers_to_db(store, DEFAULT_USER_ID, servers).await
} else {
config::save_mcp_servers(servers).await
}
}
/// Initialize and return the secrets store.
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(|| {
anyhow::anyhow!(
+6 -42
View File
@@ -10,16 +10,16 @@
//! - Checking system health (`status`)
mod config;
pub mod key;
mod mcp;
pub mod memory;
mod pairing;
pub mod status;
mod tool;
pub use config::{ConfigCommand, run_config_command};
pub use key::{KeyCommand, run_key_command};
pub use mcp::{McpCommand, run_mcp_command};
pub use memory::{MemoryCommand, run_memory_command};
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
pub use status::run_status_command;
pub use tool::{ToolCommand, run_tool_command};
@@ -80,6 +80,10 @@ pub enum Command {
#[command(subcommand)]
Tool(ToolCommand),
/// Manage NEAR blockchain keys
#[command(subcommand)]
Key(KeyCommand),
/// Manage MCP servers (hosted tool providers)
#[command(subcommand)]
Mcp(McpCommand),
@@ -88,48 +92,8 @@ pub enum Command {
#[command(subcommand)]
Memory(MemoryCommand),
/// DM pairing (approve inbound requests from unknown senders)
#[command(subcommand)]
Pairing(PairingCommand),
/// Show system health and diagnostics
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 {
-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());
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ pub async fn run_status_command() -> anyhow::Result<()> {
print!(" Secrets: ");
let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None
|| std::env::var("SECRETS_MASTER_KEY").is_ok()
|| crate::secrets::keychain::has_master_key().await;
|| crate::secrets::keychain::has_master_key();
if secrets_configured {
println!("configured ({:?})", settings.secrets_master_key_source);
} else {
+1 -1
View File
@@ -715,7 +715,7 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
println!();
// Initialize secrets store
let config = Config::from_env().await?;
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"
+123 -390
View File
@@ -1,9 +1,4 @@
//! Configuration for IronClaw.
//!
//! Settings are loaded with priority: env var > database > default.
//! The database replaces the old `settings.json` file for all settings
//! except the 4 bootstrap fields (database_url, pool_size, secrets key
//! source, onboard_completed) which live in `~/.ironclaw/bootstrap.json`.
use std::path::PathBuf;
use std::time::Duration;
@@ -11,7 +6,6 @@ use std::time::Duration;
use secrecy::{ExposeSecret, SecretString};
use crate::error::ConfigError;
use crate::settings::Settings;
/// Main configuration for the agent.
#[derive(Debug, Clone)]
@@ -27,67 +21,28 @@ pub struct Config {
pub secrets: SecretsConfig,
pub builder: BuilderModeConfig,
pub heartbeat: HeartbeatConfig,
pub routines: RoutineConfig,
pub sandbox: SandboxModeConfig,
pub claude_code: ClaudeCodeConfig,
}
impl Config {
/// Load configuration from environment variables and the database.
///
/// Priority: env var > DB settings > default.
/// This is the primary way to load config after DB is connected.
pub async fn from_db(
store: &crate::history::Store,
user_id: &str,
bootstrap: &crate::bootstrap::BootstrapConfig,
) -> Result<Self, ConfigError> {
/// Load configuration from environment variables.
pub fn from_env() -> Result<Self, ConfigError> {
// Load .env file if present (ignore errors if not found)
let _ = dotenvy::dotenv();
// Load all settings from DB into a Settings struct
let db_settings = match store.get_all_settings(user_id).await {
Ok(map) => Settings::from_db_map(&map),
Err(e) => {
tracing::warn!("Failed to load settings from DB, using defaults: {}", e);
Settings::default()
}
};
Self::build(bootstrap, &db_settings).await
}
/// Load configuration from environment variables only (no database).
///
/// Used during early startup before the database is connected,
/// and by CLI commands that don't have DB access.
/// Falls back to legacy `settings.json` on disk if present.
pub async fn from_env() -> Result<Self, ConfigError> {
let _ = dotenvy::dotenv();
let bootstrap = crate::bootstrap::BootstrapConfig::load();
let settings = Settings::load();
Self::build(&bootstrap, &settings).await
}
/// Build config from bootstrap + settings (shared by from_env and from_db).
async fn build(
bootstrap: &crate::bootstrap::BootstrapConfig,
settings: &Settings,
) -> Result<Self, ConfigError> {
Ok(Self {
database: DatabaseConfig::resolve(bootstrap)?,
llm: LlmConfig::resolve(settings)?,
embeddings: EmbeddingsConfig::resolve(settings)?,
tunnel: TunnelConfig::resolve(settings)?,
channels: ChannelsConfig::resolve(settings)?,
agent: AgentConfig::resolve(settings)?,
safety: SafetyConfig::resolve()?,
wasm: WasmConfig::resolve()?,
secrets: SecretsConfig::resolve(bootstrap).await?,
builder: BuilderModeConfig::resolve()?,
heartbeat: HeartbeatConfig::resolve(settings)?,
routines: RoutineConfig::resolve()?,
sandbox: SandboxModeConfig::resolve()?,
claude_code: ClaudeCodeConfig::resolve()?,
database: DatabaseConfig::from_env()?,
llm: LlmConfig::from_env()?,
embeddings: EmbeddingsConfig::from_env()?,
tunnel: TunnelConfig::from_env()?,
channels: ChannelsConfig::from_env()?,
agent: AgentConfig::from_env()?,
safety: SafetyConfig::from_env()?,
wasm: WasmConfig::from_env()?,
secrets: SecretsConfig::from_env()?,
builder: BuilderModeConfig::from_env()?,
heartbeat: HeartbeatConfig::from_env()?,
sandbox: SandboxModeConfig::from_env()?,
})
}
}
@@ -96,17 +51,48 @@ impl Config {
///
/// Used by channels and tools that need public webhook endpoints.
/// The tunnel URL is shared across all channels (Telegram, Slack, etc.).
///
/// # Security Notes
///
/// **Webhook endpoints** (e.g., `/webhook/telegram`) should NOT use tunnel-level
/// authentication because webhook providers (Telegram, Slack, GitHub) need
/// unauthenticated access to POST updates. Security for webhooks comes from:
/// - Webhook signature verification (provider-specific secrets)
/// - IP allowlisting (if supported by provider)
///
/// **Non-webhook endpoints** (admin APIs, health checks) CAN be protected using
/// tunnel provider features:
/// - ngrok: Basic Auth, OAuth, IP restrictions
/// - Cloudflare: Access policies, mTLS
///
/// These protections are configured in the tunnel provider, not here.
///
/// # Supported Providers
///
/// - **ngrok**: `ngrok http 8080` -> `https://abc123.ngrok.io`
/// - **Cloudflare Tunnel**: `cloudflared tunnel --url http://localhost:8080`
/// - **localtunnel**: `lt --port 8080`
/// - Any service that provides a public HTTPS URL to localhost
#[derive(Debug, Clone, Default)]
pub struct TunnelConfig {
/// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io").
///
/// When set, channels that support webhooks will register their endpoints
/// with this base URL instead of using polling.
pub public_url: Option<String>,
}
impl TunnelConfig {
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
let public_url = optional_env("TUNNEL_URL")?
.or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));
fn from_env() -> Result<Self, ConfigError> {
// Priority: env var > settings file
let public_url = optional_env("TUNNEL_URL")?.or_else(|| {
crate::settings::Settings::load()
.tunnel
.public_url
.filter(|s| !s.is_empty())
});
// Validate URL format if provided
if let Some(ref url) = public_url {
if !url.starts_with("https://") {
return Err(ConfigError::InvalidValue {
@@ -125,6 +111,8 @@ impl TunnelConfig {
}
/// Get the webhook URL for a given path.
///
/// Returns `None` if no tunnel is configured.
pub fn webhook_url(&self, path: &str) -> Option<String> {
self.public_url.as_ref().map(|base| {
let base = base.trim_end_matches('/');
@@ -142,14 +130,18 @@ pub struct DatabaseConfig {
}
impl DatabaseConfig {
fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result<Self, ConfigError> {
fn from_env() -> Result<Self, ConfigError> {
let settings = crate::settings::Settings::load();
// Priority: env var > settings > error (required)
let url = optional_env("DATABASE_URL")?
.or_else(|| bootstrap.database_url.clone())
.or(settings.database_url.clone())
.ok_or_else(|| ConfigError::MissingRequired {
key: "database_url".to_string(),
hint: "Run 'ironclaw onboard' or set DATABASE_URL environment variable".to_string(),
})?;
// Priority: env var > settings > default
let pool_size = optional_env("DATABASE_POOL_SIZE")?
.map(|s| s.parse())
.transpose()
@@ -157,7 +149,7 @@ impl DatabaseConfig {
key: "DATABASE_POOL_SIZE".to_string(),
message: format!("must be a positive integer: {e}"),
})?
.or(bootstrap.database_pool_size)
.or(settings.database_pool_size)
.unwrap_or(10);
Ok(Self {
@@ -172,102 +164,10 @@ impl DatabaseConfig {
}
}
/// Which LLM backend to use.
///
/// Defaults to `NearAi` to keep IronClaw close to the NEAR ecosystem.
/// Users can override with `LLM_BACKEND` env var to use their own API keys.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LlmBackend {
/// NEAR AI proxy (default) -- session or API key auth
#[default]
NearAi,
/// Direct OpenAI API
OpenAi,
/// Direct Anthropic API
Anthropic,
/// Local Ollama instance
Ollama,
/// Any OpenAI-compatible endpoint (e.g. vLLM, LiteLLM, Together)
OpenAiCompatible,
}
impl std::str::FromStr for LlmBackend {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"nearai" | "near_ai" | "near" => Ok(Self::NearAi),
"openai" | "open_ai" => Ok(Self::OpenAi),
"anthropic" | "claude" => Ok(Self::Anthropic),
"ollama" => Ok(Self::Ollama),
"openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible),
_ => Err(format!(
"invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible",
s
)),
}
}
}
impl std::fmt::Display for LlmBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NearAi => write!(f, "nearai"),
Self::OpenAi => write!(f, "openai"),
Self::Anthropic => write!(f, "anthropic"),
Self::Ollama => write!(f, "ollama"),
Self::OpenAiCompatible => write!(f, "openai_compatible"),
}
}
}
/// Configuration for direct OpenAI API access.
#[derive(Debug, Clone)]
pub struct OpenAiDirectConfig {
pub api_key: SecretString,
pub model: String,
}
/// Configuration for direct Anthropic API access.
#[derive(Debug, Clone)]
pub struct AnthropicDirectConfig {
pub api_key: SecretString,
pub model: String,
}
/// Configuration for local Ollama.
#[derive(Debug, Clone)]
pub struct OllamaConfig {
pub base_url: String,
pub model: String,
}
/// Configuration for any OpenAI-compatible endpoint.
#[derive(Debug, Clone)]
pub struct OpenAiCompatibleConfig {
pub base_url: String,
pub api_key: Option<SecretString>,
pub model: String,
}
/// LLM provider configuration.
///
/// NEAR AI remains the default backend. Users can switch to other providers
/// by setting `LLM_BACKEND` (e.g. `openai`, `anthropic`, `ollama`).
/// LLM provider configuration (NEAR AI only).
#[derive(Debug, Clone)]
pub struct LlmConfig {
/// Which backend to use (default: NearAi)
pub backend: LlmBackend,
/// NEAR AI config (always populated for NEAR AI embeddings, etc.)
pub nearai: NearAiConfig,
/// Direct OpenAI config (populated when backend=openai)
pub openai: Option<OpenAiDirectConfig>,
/// Direct Anthropic config (populated when backend=anthropic)
pub anthropic: Option<AnthropicDirectConfig>,
/// Ollama config (populated when backend=ollama)
pub ollama: Option<OllamaConfig>,
/// OpenAI-compatible config (populated when backend=openai_compatible)
pub openai_compatible: Option<OpenAiCompatibleConfig>,
}
/// API mode for NEAR AI.
@@ -315,110 +215,42 @@ pub struct NearAiConfig {
}
impl LlmConfig {
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
// Determine backend (default: NearAi)
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
b.parse().map_err(|e| ConfigError::InvalidValue {
key: "LLM_BACKEND".to_string(),
message: e,
})?
} else {
LlmBackend::NearAi
};
// Always resolve NEAR AI config (used as fallback and for embeddings)
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
fn from_env() -> Result<Self, ConfigError> {
let api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
// Determine API mode: explicit setting, or infer from API key presence
let api_mode = if let Some(mode_str) = optional_env("NEARAI_API_MODE")? {
mode_str.parse().map_err(|e| ConfigError::InvalidValue {
key: "NEARAI_API_MODE".to_string(),
message: e,
})?
} else if nearai_api_key.is_some() {
} else if api_key.is_some() {
// If API key is provided, default to chat_completions mode
NearAiApiMode::ChatCompletions
} else {
NearAiApiMode::Responses
};
let nearai = NearAiConfig {
model: optional_env("NEARAI_MODEL")?
.or_else(|| settings.selected_model.clone())
.unwrap_or_else(|| {
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
.to_string()
}),
base_url: optional_env("NEARAI_BASE_URL")?
.unwrap_or_else(|| "https://cloud-api.near.ai".to_string()),
auth_base_url: optional_env("NEARAI_AUTH_URL")?
.unwrap_or_else(|| "https://private.near.ai".to_string()),
session_path: optional_env("NEARAI_SESSION_PATH")?
.map(PathBuf::from)
.unwrap_or_else(default_session_path),
api_mode,
api_key: nearai_api_key,
};
// Resolve provider-specific configs based on backend
let openai = if backend == LlmBackend::OpenAi {
let api_key = optional_env("OPENAI_API_KEY")?
.map(SecretString::from)
.ok_or_else(|| ConfigError::MissingRequired {
key: "OPENAI_API_KEY".to_string(),
hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(),
})?;
let model = optional_env("OPENAI_MODEL")?.unwrap_or_else(|| "gpt-4o".to_string());
Some(OpenAiDirectConfig { api_key, model })
} else {
None
};
let anthropic = if backend == LlmBackend::Anthropic {
let api_key = optional_env("ANTHROPIC_API_KEY")?
.map(SecretString::from)
.ok_or_else(|| ConfigError::MissingRequired {
key: "ANTHROPIC_API_KEY".to_string(),
hint: "Set ANTHROPIC_API_KEY when LLM_BACKEND=anthropic".to_string(),
})?;
let model = optional_env("ANTHROPIC_MODEL")?
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
Some(AnthropicDirectConfig { api_key, model })
} else {
None
};
let ollama = if backend == LlmBackend::Ollama {
let base_url = optional_env("OLLAMA_BASE_URL")?
.unwrap_or_else(|| "http://localhost:11434".to_string());
let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string());
Some(OllamaConfig { base_url, model })
} else {
None
};
let openai_compatible = if backend == LlmBackend::OpenAiCompatible {
let base_url =
optional_env("LLM_BASE_URL")?.ok_or_else(|| ConfigError::MissingRequired {
key: "LLM_BASE_URL".to_string(),
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
})?;
let api_key = optional_env("LLM_API_KEY")?.map(SecretString::from);
let model = optional_env("LLM_MODEL")?.unwrap_or_else(|| "default".to_string());
Some(OpenAiCompatibleConfig {
base_url,
api_key,
model,
})
} else {
None
};
Ok(Self {
backend,
nearai,
openai,
anthropic,
ollama,
openai_compatible,
nearai: NearAiConfig {
// Load model from saved settings first, then env, then default
model: crate::settings::Settings::load()
.selected_model
.or_else(|| optional_env("NEARAI_MODEL").ok().flatten())
.unwrap_or_else(|| {
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
.to_string()
}),
base_url: optional_env("NEARAI_BASE_URL")?
.unwrap_or_else(|| "https://cloud-api.near.ai".to_string()),
auth_base_url: optional_env("NEARAI_AUTH_URL")?
.unwrap_or_else(|| "https://private.near.ai".to_string()),
session_path: optional_env("NEARAI_SESSION_PATH")?
.map(PathBuf::from)
.unwrap_or_else(default_session_path),
api_mode,
api_key,
},
})
}
}
@@ -433,6 +265,8 @@ pub struct EmbeddingsConfig {
/// OpenAI API key (for OpenAI provider).
pub openai_api_key: Option<SecretString>,
/// Model to use for embeddings.
/// For OpenAI: "text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002"
/// For NEAR AI: Uses the configured session for auth.
pub model: String,
}
@@ -448,15 +282,18 @@ impl Default for EmbeddingsConfig {
}
impl EmbeddingsConfig {
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
fn from_env() -> Result<Self, ConfigError> {
let settings = crate::settings::Settings::load();
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
// Priority: env var > settings > default
let provider = optional_env("EMBEDDING_PROVIDER")?
.unwrap_or_else(|| settings.embeddings.provider.clone());
let model =
optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone());
// Priority: env var > settings > auto-detect from API key
let enabled = optional_env("EMBEDDING_ENABLED")?
.map(|s| s.parse())
.transpose()
@@ -464,7 +301,10 @@ impl EmbeddingsConfig {
key: "EMBEDDING_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or_else(|| settings.embeddings.enabled || openai_api_key.is_some());
.unwrap_or_else(|| {
// Check settings, or auto-enable if API key present
settings.embeddings.enabled || openai_api_key.is_some()
});
Ok(Self {
enabled,
@@ -498,8 +338,6 @@ pub struct ChannelsConfig {
pub wasm_channels_dir: std::path::PathBuf,
/// Whether WASM channels are enabled.
pub wasm_channels_enabled: bool,
/// Telegram owner user ID. When set, the bot only responds to this user.
pub telegram_owner_id: Option<i64>,
}
#[derive(Debug, Clone)]
@@ -526,7 +364,7 @@ pub struct GatewayConfig {
}
impl ChannelsConfig {
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
fn from_env() -> Result<Self, ConfigError> {
let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() {
Some(HttpConfig {
host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()),
@@ -547,7 +385,7 @@ impl ChannelsConfig {
let gateway = if optional_env("GATEWAY_ENABLED")?
.map(|s| s.to_lowercase() == "true" || s == "1")
.unwrap_or(true)
.unwrap_or(false)
{
Some(GatewayConfig {
host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()),
@@ -587,14 +425,6 @@ impl ChannelsConfig {
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(true),
telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "TELEGRAM_OWNER_ID".to_string(),
message: format!("must be an integer: {e}"),
})?
.or(settings.channels.telegram_owner_id),
})
}
}
@@ -620,13 +450,14 @@ pub struct AgentConfig {
pub use_planning: bool,
/// Session idle timeout. Sessions inactive longer than this are pruned.
pub session_idle_timeout: Duration,
/// Allow chat to use filesystem/shell tools directly (bypass sandbox).
pub allow_local_tools: bool,
}
impl AgentConfig {
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
fn from_env() -> Result<Self, ConfigError> {
let settings = crate::settings::Settings::load();
Ok(Self {
// Priority: env var > settings > default
name: optional_env("AGENT_NAME")?.unwrap_or_else(|| settings.agent.name.clone()),
max_parallel_jobs: optional_env("AGENT_MAX_PARALLEL_JOBS")?
.map(|s| s.parse())
@@ -692,14 +523,6 @@ impl AgentConfig {
})?
.unwrap_or(settings.agent.session_idle_timeout_secs),
),
allow_local_tools: optional_env("ALLOW_LOCAL_TOOLS")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "ALLOW_LOCAL_TOOLS".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(false),
})
}
}
@@ -712,7 +535,7 @@ pub struct SafetyConfig {
}
impl SafetyConfig {
fn resolve() -> Result<Self, ConfigError> {
fn from_env() -> Result<Self, ConfigError> {
Ok(Self {
max_output_length: parse_optional_env("SAFETY_MAX_OUTPUT_LENGTH", 100_000)?,
injection_check_enabled: optional_env("SAFETY_INJECTION_CHECK_ENABLED")?
@@ -750,6 +573,7 @@ pub struct WasmConfig {
#[derive(Clone, Default)]
pub struct SecretsConfig {
/// Master key for encrypting secrets.
/// Source determined by KeySource in settings.
pub master_key: Option<SecretString>,
/// Whether secrets management is enabled.
pub enabled: bool,
@@ -768,16 +592,20 @@ impl std::fmt::Debug for SecretsConfig {
}
impl SecretsConfig {
async fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result<Self, ConfigError> {
fn from_env() -> Result<Self, ConfigError> {
use crate::settings::KeySource;
let settings = crate::settings::Settings::load();
// Priority: env var > keychain (based on settings) > disabled
let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? {
// Env var takes priority (for CI/Docker)
(Some(SecretString::from(env_key)), KeySource::Env)
} else {
match bootstrap.secrets_master_key_source {
match settings.secrets_master_key_source {
KeySource::Keychain => {
// Try to load from OS keychain (async on Linux)
match crate::secrets::keychain::get_master_key().await {
// Try to load from OS keychain
match crate::secrets::keychain::get_master_key() {
Ok(key_bytes) => {
let key_hex: String =
key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
@@ -795,6 +623,7 @@ impl SecretsConfig {
}
}
KeySource::Env => {
// Settings say env, but no env var found
tracing::warn!(
"Secrets configured for env var but SECRETS_MASTER_KEY not set."
);
@@ -806,6 +635,7 @@ impl SecretsConfig {
let enabled = master_key.is_some();
// Validate master key length if provided
if let Some(ref key) = master_key {
if key.expose_secret().len() < 32 {
return Err(ConfigError::InvalidValue {
@@ -851,7 +681,7 @@ fn default_tools_dir() -> PathBuf {
}
impl WasmConfig {
fn resolve() -> Result<Self, ConfigError> {
fn from_env() -> Result<Self, ConfigError> {
Ok(Self {
enabled: optional_env("WASM_ENABLED")?
.map(|s| s.parse())
@@ -922,7 +752,7 @@ pub struct BuilderModeConfig {
impl Default for BuilderModeConfig {
fn default() -> Self {
Self {
enabled: true,
enabled: true, // Builder enabled by default
build_dir: None,
max_iterations: 20,
timeout_secs: 600,
@@ -932,7 +762,7 @@ impl Default for BuilderModeConfig {
}
impl BuilderModeConfig {
fn resolve() -> Result<Self, ConfigError> {
fn from_env() -> Result<Self, ConfigError> {
Ok(Self {
enabled: optional_env("BUILDER_ENABLED")?
.map(|s| s.parse())
@@ -941,7 +771,7 @@ impl BuilderModeConfig {
key: "BUILDER_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(true),
.unwrap_or(true), // Builder enabled by default
build_dir: optional_env("BUILDER_DIR")?.map(PathBuf::from),
max_iterations: parse_optional_env("BUILDER_MAX_ITERATIONS", 20)?,
timeout_secs: parse_optional_env("BUILDER_TIMEOUT_SECS", 600)?,
@@ -996,8 +826,11 @@ impl Default for HeartbeatConfig {
}
impl HeartbeatConfig {
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
fn from_env() -> Result<Self, ConfigError> {
let settings = crate::settings::Settings::load();
Ok(Self {
// Priority: env var > settings > default
enabled: optional_env("HEARTBEAT_ENABLED")?
.map(|s| s.parse())
.transpose()
@@ -1015,55 +848,9 @@ impl HeartbeatConfig {
})?
.unwrap_or(settings.heartbeat.interval_secs),
notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?
.or_else(|| settings.heartbeat.notify_channel.clone()),
.or(settings.heartbeat.notify_channel.clone()),
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
.or_else(|| settings.heartbeat.notify_user.clone()),
})
}
}
/// Routines configuration.
#[derive(Debug, Clone)]
pub struct RoutineConfig {
/// Whether the routines system is enabled.
pub enabled: bool,
/// How often (seconds) to poll for cron routines that need firing.
pub cron_check_interval_secs: u64,
/// Max routines executing concurrently across all users.
pub max_concurrent_routines: usize,
/// Default cooldown between fires (seconds).
pub default_cooldown_secs: u64,
/// Max output tokens for lightweight routine LLM calls.
pub max_lightweight_tokens: u32,
}
impl Default for RoutineConfig {
fn default() -> Self {
Self {
enabled: true,
cron_check_interval_secs: 15,
max_concurrent_routines: 10,
default_cooldown_secs: 300,
max_lightweight_tokens: 4096,
}
}
}
impl RoutineConfig {
fn resolve() -> Result<Self, ConfigError> {
Ok(Self {
enabled: optional_env("ROUTINES_ENABLED")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "ROUTINES_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(true),
cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?,
max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?,
default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?,
max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?,
.or(settings.heartbeat.notify_user.clone()),
})
}
}
@@ -1092,7 +879,7 @@ pub struct SandboxModeConfig {
impl Default for SandboxModeConfig {
fn default() -> Self {
Self {
enabled: true,
enabled: true, // Enabled by default
policy: "readonly".to_string(),
timeout_secs: 120,
memory_limit_mb: 2048,
@@ -1105,7 +892,7 @@ impl Default for SandboxModeConfig {
}
impl SandboxModeConfig {
fn resolve() -> Result<Self, ConfigError> {
fn from_env() -> Result<Self, ConfigError> {
let extra_domains = optional_env("SANDBOX_EXTRA_DOMAINS")?
.map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
.unwrap_or_default();
@@ -1161,60 +948,6 @@ impl SandboxModeConfig {
}
}
/// Claude Code sandbox configuration.
#[derive(Debug, Clone)]
pub struct ClaudeCodeConfig {
/// Whether Claude Code sandbox mode is available.
pub enabled: bool,
/// Host directory containing Claude auth session (mounted read-only).
pub config_dir: std::path::PathBuf,
/// Claude model to use (e.g. "sonnet", "opus").
pub model: String,
/// Maximum agentic turns before stopping.
pub max_turns: u32,
/// Memory limit in MB for Claude Code containers (heavier than workers).
pub memory_limit_mb: u64,
}
impl Default for ClaudeCodeConfig {
fn default() -> Self {
Self {
enabled: false,
config_dir: dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".claude"),
model: "sonnet".to_string(),
max_turns: 50,
memory_limit_mb: 4096,
}
}
}
impl ClaudeCodeConfig {
fn resolve() -> Result<Self, ConfigError> {
let defaults = Self::default();
Ok(Self {
enabled: optional_env("CLAUDE_CODE_ENABLED")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "CLAUDE_CODE_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(defaults.enabled),
config_dir: optional_env("CLAUDE_CONFIG_DIR")?
.map(std::path::PathBuf::from)
.unwrap_or(defaults.config_dir),
model: optional_env("CLAUDE_CODE_MODEL")?.unwrap_or(defaults.model),
max_turns: parse_optional_env("CLAUDE_CODE_MAX_TURNS", defaults.max_turns)?,
memory_limit_mb: parse_optional_env(
"CLAUDE_CODE_MEMORY_LIMIT_MB",
defaults.memory_limit_mb,
)?,
})
}
}
// Helper functions
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
+2 -52
View File
@@ -40,11 +40,8 @@ pub enum Error {
#[error("Workspace error: {0}")]
Workspace(#[from] WorkspaceError),
#[error("Orchestrator error: {0}")]
Orchestrator(#[from] OrchestratorError),
#[error("Worker error: {0}")]
Worker(#[from] WorkerError),
#[error("Key management error: {0}")]
Key(#[from] crate::keys::KeyError),
}
/// Configuration-related errors.
@@ -314,52 +311,5 @@ pub enum WorkspaceError {
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.
pub type Result<T> = std::result::Result<T, Error>;
+10 -62
View File
@@ -23,7 +23,9 @@ use crate::tools::mcp::auth::{
PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata,
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::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools};
@@ -56,8 +58,6 @@ pub struct ExtensionManager {
/// Tunnel URL for remote OAuth callbacks (used in future iterations).
_tunnel_url: Option<String>,
user_id: String,
/// Optional database store for DB-backed MCP config.
store: Option<Arc<crate::history::Store>>,
}
impl ExtensionManager {
@@ -71,7 +71,6 @@ impl ExtensionManager {
wasm_channels_dir: PathBuf,
tunnel_url: Option<String>,
user_id: String,
store: Option<Arc<crate::history::Store>>,
) -> Self {
Self {
registry: ExtensionRegistry::new(),
@@ -86,7 +85,6 @@ impl ExtensionManager {
pending_auth: RwLock::new(HashMap::new()),
_tunnel_url: tunnel_url,
user_id,
store,
}
}
@@ -193,7 +191,7 @@ impl ExtensionManager {
// List MCP servers
if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) {
match self.load_mcp_servers().await {
match load_mcp_servers().await {
Ok(servers) => {
for server in &servers.servers {
let authenticated =
@@ -306,7 +304,7 @@ impl ExtensionManager {
self.mcp_clients.write().await.remove(name);
// Remove from config
self.remove_mcp_server(name)
remove_mcp_server(name)
.await
.map_err(|e| ExtensionError::Config(e.to_string()))?;
@@ -344,54 +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, &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, &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, &self.user_id, name).await
} else {
crate::tools::mcp::config::remove_mcp_server(name).await
}
}
// ── Private helpers ──────────────────────────────────────────────────
async fn install_from_entry(
@@ -431,7 +381,7 @@ impl ExtensionManager {
url: &str,
) -> Result<InstallResult, ExtensionError> {
// 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()));
}
@@ -440,7 +390,7 @@ impl ExtensionManager {
.validate()
.map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?;
self.add_mcp_server(config)
add_mcp_server(config)
.await
.map_err(|e| ExtensionError::Config(e.to_string()))?;
@@ -515,8 +465,7 @@ impl ExtensionManager {
name: &str,
token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
let server = self
.get_mcp_server(name)
let server = get_mcp_server(name)
.await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
@@ -835,8 +784,7 @@ impl ExtensionManager {
}
}
let server = self
.get_mcp_server(name)
let server = get_mcp_server(name)
.await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
@@ -945,7 +893,7 @@ impl ExtensionManager {
/// Determine what kind of installed extension this is.
async fn determine_installed_kind(&self, name: &str) -> Result<ExtensionKind, ExtensionError> {
// 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);
}
+1 -4
View File
@@ -9,7 +9,4 @@ mod analytics;
mod store;
pub use analytics::{JobStats, ToolStats};
pub use store::{
ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
SandboxJobSummary, Store,
};
pub use store::{LlmCallRecord, Store};
+6 -1117
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);
}
}
+297
View File
@@ -0,0 +1,297 @@
//! Lightweight NEAR JSON-RPC client.
//!
//! Thin reqwest wrapper for the subset of NEAR RPC we need:
//! - view_access_key (nonce + block_hash for transaction building)
//! - send_transaction (submit signed transaction)
//! - tx_status (poll for result)
//! - view_account (check balance)
use serde::{Deserialize, Serialize};
use crate::keys::KeyError;
use crate::keys::types::NearNetwork;
/// NEAR RPC client.
#[derive(Debug, Clone)]
pub struct NearRpcClient {
client: reqwest::Client,
rpc_url: String,
}
impl NearRpcClient {
pub fn new(network: &NearNetwork) -> Self {
Self {
client: reqwest::Client::new(),
rpc_url: network.rpc_url().to_string(),
}
}
pub fn with_url(url: &str) -> Self {
Self {
client: reqwest::Client::new(),
rpc_url: url.to_string(),
}
}
/// Fetch access key info (nonce + block hash) for signing a transaction.
pub async fn view_access_key(
&self,
account_id: &str,
public_key: &str,
) -> Result<AccessKeyView, KeyError> {
let response: RpcResponse<AccessKeyView> = self
.call(
"query",
serde_json::json!({
"request_type": "view_access_key",
"finality": "final",
"account_id": account_id,
"public_key": public_key,
}),
)
.await?;
Ok(response.result)
}
/// Submit a signed transaction (fire and forget, returns tx hash).
pub async fn send_transaction_async(&self, signed_tx_base64: &str) -> Result<String, KeyError> {
let response: RpcResponse<serde_json::Value> = self
.call("broadcast_tx_async", serde_json::json!([signed_tx_base64]))
.await?;
response
.result
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| KeyError::RpcError {
reason: "unexpected response from broadcast_tx_async".to_string(),
})
}
/// Submit a signed transaction and wait for result.
pub async fn send_transaction(&self, signed_tx_base64: &str) -> Result<TxOutcome, KeyError> {
let response: RpcResponse<TxOutcome> = self
.call("broadcast_tx_commit", serde_json::json!([signed_tx_base64]))
.await?;
Ok(response.result)
}
/// Check transaction status.
pub async fn tx_status(&self, tx_hash: &str, sender_id: &str) -> Result<TxOutcome, KeyError> {
let response: RpcResponse<TxOutcome> = self
.call("tx", serde_json::json!([tx_hash, sender_id]))
.await?;
Ok(response.result)
}
/// View account information.
pub async fn view_account(&self, account_id: &str) -> Result<AccountView, KeyError> {
let response: RpcResponse<AccountView> = self
.call(
"query",
serde_json::json!({
"request_type": "view_account",
"finality": "final",
"account_id": account_id,
}),
)
.await?;
Ok(response.result)
}
/// Make a JSON-RPC 2.0 call.
async fn call<T: for<'de> Deserialize<'de>>(
&self,
method: &str,
params: serde_json::Value,
) -> Result<RpcResponse<T>, KeyError> {
let request = RpcRequest {
jsonrpc: "2.0",
id: "ironclaw",
method,
params,
};
let response = self
.client
.post(&self.rpc_url)
.json(&request)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(KeyError::RpcError {
reason: format!("HTTP {}: {}", status, truncate(&body, 200)),
});
}
let body = response.text().await?;
let parsed: serde_json::Value =
serde_json::from_str(&body).map_err(|e| KeyError::RpcError {
reason: format!("invalid JSON response: {}", e),
})?;
// Check for JSON-RPC error
if let Some(error) = parsed.get("error") {
let cause = error
.get("cause")
.and_then(|c| c.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("unknown");
let message = error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("unknown error");
return Err(KeyError::RpcError {
reason: format!("{}: {}", cause, message),
});
}
serde_json::from_value(parsed).map_err(|e| KeyError::RpcError {
reason: format!("failed to parse RPC response: {}", e),
})
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max])
}
}
/// JSON-RPC 2.0 request.
#[derive(Serialize)]
struct RpcRequest<'a> {
jsonrpc: &'a str,
id: &'a str,
method: &'a str,
params: serde_json::Value,
}
/// JSON-RPC 2.0 response.
#[derive(Deserialize)]
struct RpcResponse<T> {
result: T,
}
/// Access key view from RPC.
#[derive(Debug, Clone, Deserialize)]
pub struct AccessKeyView {
pub nonce: u64,
pub block_hash: String,
pub permission: serde_json::Value,
}
/// Transaction outcome from RPC.
#[derive(Debug, Clone, Deserialize)]
pub struct TxOutcome {
pub status: serde_json::Value,
pub transaction: Option<serde_json::Value>,
pub transaction_outcome: Option<serde_json::Value>,
pub receipts_outcome: Option<Vec<serde_json::Value>>,
}
impl TxOutcome {
/// Check if the transaction succeeded.
pub fn is_success(&self) -> bool {
if let Some(obj) = self.status.as_object() {
obj.contains_key("SuccessValue") || obj.contains_key("SuccessReceiptId")
} else {
false
}
}
/// Get the failure reason if the transaction failed.
pub fn failure_reason(&self) -> Option<String> {
if let Some(obj) = self.status.as_object() {
if let Some(failure) = obj.get("Failure") {
return Some(format!("{}", failure));
}
}
None
}
}
/// Account view from RPC.
#[derive(Debug, Clone, Deserialize)]
pub struct AccountView {
pub amount: String,
pub locked: String,
pub storage_usage: u64,
pub code_hash: String,
pub block_height: u64,
pub block_hash: String,
}
impl AccountView {
/// Parse the balance as u128 (yoctoNEAR).
pub fn balance_yocto(&self) -> Result<u128, KeyError> {
self.amount.parse::<u128>().map_err(|e| KeyError::RpcError {
reason: format!("failed to parse account balance '{}': {}", self.amount, e),
})
}
}
#[cfg(test)]
mod tests {
use crate::keys::rpc::{AccessKeyView, AccountView, TxOutcome};
#[test]
fn test_tx_outcome_success() {
let outcome = TxOutcome {
status: serde_json::json!({"SuccessValue": ""}),
transaction: None,
transaction_outcome: None,
receipts_outcome: None,
};
assert!(outcome.is_success());
assert!(outcome.failure_reason().is_none());
}
#[test]
fn test_tx_outcome_failure() {
let outcome = TxOutcome {
status: serde_json::json!({"Failure": {"ActionError": "..."}}),
transaction: None,
transaction_outcome: None,
receipts_outcome: None,
};
assert!(!outcome.is_success());
assert!(outcome.failure_reason().is_some());
}
#[test]
fn test_access_key_view_deserialize() {
let json = serde_json::json!({
"nonce": 42,
"block_hash": "11111111111111111111111111111111",
"permission": "FullAccess"
});
let view: AccessKeyView = serde_json::from_value(json).unwrap();
assert_eq!(view.nonce, 42);
}
#[test]
fn test_account_view_balance() {
let view = AccountView {
amount: "1000000000000000000000000".to_string(), // 1 NEAR
locked: "0".to_string(),
storage_usage: 100,
code_hash: "11111111111111111111111111111111".to_string(),
block_height: 1000,
block_hash: "11111111111111111111111111111111".to_string(),
};
assert_eq!(
view.balance_yocto().unwrap(),
1_000_000_000_000_000_000_000_000
);
}
}
+243
View File
@@ -0,0 +1,243 @@
//! Ed25519 signing for NEAR transactions.
//!
//! SECURITY: Private keys are held in memory for the absolute minimum time.
//! The flow is: decrypt -> construct SigningKey -> sign -> drop (Zeroize).
//! The `ed25519_dalek::SigningKey` implements Zeroize, so memory is zeroed on drop.
use ed25519_dalek::Signer;
use sha2::{Digest, Sha256};
use zeroize::Zeroize;
use crate::keys::KeyError;
use crate::keys::types::NearPublicKey;
use crate::secrets::SecretsStore;
/// Parse a NEAR-format secret key and extract the 32-byte ed25519 seed.
///
/// NEAR secret keys are formatted as `ed25519:<base58-encoded-64-bytes>`.
/// The 64 bytes are the seed (32) + public key (32) concatenated.
/// Some wallets store only the 32-byte seed with the same prefix.
fn parse_near_secret_key(near_format: &str) -> Result<[u8; 32], KeyError> {
let data_str =
near_format
.strip_prefix("ed25519:")
.ok_or_else(|| KeyError::InvalidKeyFormat {
reason: "secret key must start with 'ed25519:'".to_string(),
})?;
let mut bytes = bs58::decode(data_str)
.into_vec()
.map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid base58 in secret key: {}", e),
})?;
let seed = match bytes.len() {
64 => {
// Standard NEAR format: seed (32) + public key (32)
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes[..32]);
bytes.zeroize();
seed
}
32 => {
// Some wallets export just the seed
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes);
bytes.zeroize();
seed
}
other => {
bytes.zeroize();
return Err(KeyError::InvalidKeyFormat {
reason: format!("ed25519 secret key must be 32 or 64 bytes, got {}", other),
});
}
};
Ok(seed)
}
/// Derive the public key from a NEAR-format secret key string.
pub fn public_key_from_secret(near_format_secret: &str) -> Result<NearPublicKey, KeyError> {
let seed = parse_near_secret_key(near_format_secret)?;
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
// signing_key implements Zeroize on drop
Ok(NearPublicKey {
key_type: crate::keys::types::KeyType::Ed25519,
data: verifying_key.to_bytes(),
})
}
/// Sign a 32-byte SHA-256 hash using a key from the secrets store.
///
/// This is the core signing function. It:
/// 1. Decrypts the private key from the secrets store
/// 2. Parses the NEAR-format key to extract the ed25519 seed
/// 3. Constructs a SigningKey (implements Zeroize on drop)
/// 4. Signs the hash
/// 5. Drops the SigningKey (memory zeroed)
///
/// The plaintext key exists in memory for microseconds.
pub async fn sign_hash(
secrets_store: &dyn SecretsStore,
user_id: &str,
label: &str,
hash: &[u8; 32],
) -> Result<[u8; 64], KeyError> {
let secret_name = format!("near_key:{}", label);
let decrypted = secrets_store
.get_decrypted(user_id, &secret_name)
.await
.map_err(|e| KeyError::SigningFailed {
reason: format!("failed to decrypt key '{}': {}", label, e),
})?;
let mut seed = parse_near_secret_key(decrypted.expose())?;
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
seed.zeroize();
let signature = signing_key.sign(hash);
// signing_key drops here, Zeroize zeroes the key material
Ok(signature.to_bytes())
}
/// SHA-256 hash of data (used for transaction signing).
pub fn sha256_hash(data: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().into()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
use secrecy::SecretString;
use crate::keys::signer::{
parse_near_secret_key, public_key_from_secret, sha256_hash, sign_hash,
};
use crate::keys::types::KeyType;
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))
}
/// Generate a test keypair and return (near_format_secret, near_format_public).
fn generate_test_keypair() -> (String, String) {
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
// NEAR format: 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 = format!("ed25519:{}", bs58::encode(&combined).into_string());
let public = format!(
"ed25519:{}",
bs58::encode(verifying_key.as_bytes()).into_string()
);
(secret, public)
}
#[test]
fn test_parse_near_secret_key_64_bytes() {
let (secret, _) = generate_test_keypair();
let seed = parse_near_secret_key(&secret).unwrap();
assert_eq!(seed.len(), 32);
}
#[test]
fn test_parse_near_secret_key_32_bytes() {
// Some wallets export just the 32-byte seed
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let secret = format!(
"ed25519:{}",
bs58::encode(signing_key.as_bytes()).into_string()
);
let seed = parse_near_secret_key(&secret).unwrap();
assert_eq!(seed, *signing_key.as_bytes());
}
#[test]
fn test_parse_invalid_prefix() {
assert!(parse_near_secret_key("secp256k1:abc").is_err());
}
#[test]
fn test_public_key_from_secret() {
let (secret, expected_public) = generate_test_keypair();
let pubkey = public_key_from_secret(&secret).unwrap();
assert_eq!(pubkey.key_type, KeyType::Ed25519);
assert_eq!(pubkey.to_near_format(), expected_public);
}
#[test]
fn test_sign_and_verify_roundtrip() {
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key = signing_key.verifying_key();
let message = b"test message for signing";
let hash = sha256_hash(message);
let signature = signing_key.sign(&hash);
// Verify
assert!(verifying_key.verify(&hash, &signature).is_ok());
}
#[tokio::test]
async fn test_sign_hash_from_store() {
let store = test_store();
let (secret, _public) = generate_test_keypair();
// Store the key
store
.create(
"user1",
CreateSecretParams::new("near_key:test-signer", &secret).with_provider("near_keys"),
)
.await
.unwrap();
// Sign
let hash = sha256_hash(b"test transaction data");
let sig_bytes = sign_hash(store.as_ref(), "user1", "test-signer", &hash)
.await
.unwrap();
// Verify using the public key derived from the secret
let pubkey = public_key_from_secret(&secret).unwrap();
let verifying_key = VerifyingKey::from_bytes(pubkey.as_bytes()).unwrap();
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
assert!(verifying_key.verify(&hash, &signature).is_ok());
}
#[tokio::test]
async fn test_sign_hash_key_not_found() {
let store = test_store();
let hash = [0u8; 32];
let result = sign_hash(store.as_ref(), "user1", "nonexistent", &hash).await;
assert!(result.is_err());
}
#[test]
fn test_sha256_hash() {
let hash = sha256_hash(b"hello");
let hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
// Known SHA-256 of "hello"
assert_eq!(
hex,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Daily spend tracking for rate-limiting value transfers.
//!
//! Tracks cumulative daily spend in yoctoNEAR to enforce `daily_spend_limit_yocto`.
//! Persisted to `~/.ironclaw/spend_tracking.json`. Resets automatically at midnight UTC.
use std::path::PathBuf;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::keys::KeyError;
use crate::keys::types::format_yocto;
/// Tracks daily cumulative spend for policy enforcement.
pub struct SpendTracker {
path: PathBuf,
}
impl SpendTracker {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
/// Default location: `~/.ironclaw/spend_tracking.json`
pub fn default_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".ironclaw").join("spend_tracking.json"))
.unwrap_or_else(|| PathBuf::from(".ironclaw/spend_tracking.json"))
}
/// Get today's cumulative spend in yoctoNEAR.
pub async fn get_daily_spend(&self) -> Result<u128, KeyError> {
let data = self.load().await?;
let today = Utc::now().date_naive();
Ok(data
.records
.iter()
.find(|r| r.date == today)
.map(|r| r.total_spent_yocto)
.unwrap_or(0))
}
/// Record a spend after successful transaction submission.
pub async fn record_spend(
&self,
value_yocto: u128,
description: String,
tx_hash: Option<String>,
) -> Result<(), KeyError> {
let mut data = self.load().await?;
let today = Utc::now().date_naive();
let record = data.records.iter_mut().find(|r| r.date == today);
let entry = SpendEntry {
timestamp: Utc::now(),
tx_hash,
value_yocto,
description,
};
if let Some(record) = record {
record.total_spent_yocto = record.total_spent_yocto.saturating_add(value_yocto);
record.transactions.push(entry);
} else {
data.records.push(SpendRecord {
date: today,
total_spent_yocto: value_yocto,
transactions: vec![entry],
});
}
// Keep only last 30 days of records
let cutoff = Utc::now().date_naive() - chrono::Duration::days(30);
data.records.retain(|r| r.date >= cutoff);
self.save(&data).await
}
/// Get spend history for the last N days.
pub async fn get_history(&self, days: u32) -> Result<Vec<SpendRecord>, KeyError> {
let data = self.load().await?;
let cutoff = Utc::now().date_naive() - chrono::Duration::days(days as i64);
Ok(data
.records
.into_iter()
.filter(|r| r.date >= cutoff)
.collect())
}
async fn load(&self) -> Result<SpendData, KeyError> {
if !self.path.exists() {
return Ok(SpendData::default());
}
let content = fs::read_to_string(&self.path).await?;
serde_json::from_str(&content).map_err(|e| {
KeyError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("corrupt spend tracking data: {}", e),
))
})
}
async fn save(&self, data: &SpendData) -> Result<(), KeyError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).await?;
}
let content = serde_json::to_string_pretty(data).map_err(|e| {
KeyError::SerializationFailed(format!("failed to serialize spend data: {}", e))
})?;
fs::write(&self.path, content).await?;
Ok(())
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct SpendData {
records: Vec<SpendRecord>,
}
/// A day's spend record with audit trail.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpendRecord {
pub date: NaiveDate,
pub total_spent_yocto: u128,
pub transactions: Vec<SpendEntry>,
}
impl std::fmt::Display for SpendRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: {} ({} txns)",
self.date,
format_yocto(self.total_spent_yocto),
self.transactions.len()
)
}
}
/// A single spend entry in the audit trail.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpendEntry {
pub timestamp: DateTime<Utc>,
pub tx_hash: Option<String>,
pub value_yocto: u128,
pub description: String,
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use crate::keys::spending::SpendTracker;
#[tokio::test]
async fn test_empty_spend() {
let dir = TempDir::new().unwrap();
let tracker = SpendTracker::new(dir.path().join("spend.json"));
assert_eq!(tracker.get_daily_spend().await.unwrap(), 0);
}
#[tokio::test]
async fn test_record_and_query_spend() {
let dir = TempDir::new().unwrap();
let tracker = SpendTracker::new(dir.path().join("spend.json"));
tracker
.record_spend(
1_000_000,
"test transfer".to_string(),
Some("hash1".to_string()),
)
.await
.unwrap();
assert_eq!(tracker.get_daily_spend().await.unwrap(), 1_000_000);
tracker
.record_spend(2_000_000, "another transfer".to_string(), None)
.await
.unwrap();
assert_eq!(tracker.get_daily_spend().await.unwrap(), 3_000_000);
}
#[tokio::test]
async fn test_get_history() {
let dir = TempDir::new().unwrap();
let tracker = SpendTracker::new(dir.path().join("spend.json"));
tracker
.record_spend(100, "test".to_string(), None)
.await
.unwrap();
let history = tracker.get_history(7).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].total_spent_yocto, 100);
assert_eq!(history[0].transactions.len(), 1);
}
}
+445
View File
@@ -0,0 +1,445 @@
//! Minimal NEAR transaction types with borsh serialization.
//!
//! Hand-rolled types that produce byte-identical borsh output to near-primitives,
//! without pulling in the massive nearcore dependency tree.
//!
//! # Serialization Format
//!
//! NEAR transactions are borsh-serialized, then SHA-256 hashed for signing.
//! The signed transaction includes the original transaction + ed25519 signature.
use borsh::BorshSerialize;
use crate::keys::signer::sha256_hash;
use crate::keys::types::{KeyType, NearAccountId, NearPublicKey};
/// A NEAR transaction ready for signing.
#[derive(Debug, Clone, BorshSerialize)]
pub struct Transaction {
pub signer_id: NearAccountId,
pub public_key: NearPublicKey,
pub nonce: u64,
pub receiver_id: NearAccountId,
pub block_hash: BlockHash,
pub actions: Vec<Action>,
}
impl Transaction {
/// Borsh-serialize and SHA-256 hash for signing.
pub fn hash_for_signing(&self) -> Result<[u8; 32], crate::keys::KeyError> {
let bytes = borsh::to_vec(self).map_err(|e| {
crate::keys::KeyError::SerializationFailed(format!(
"failed to serialize transaction: {}",
e
))
})?;
Ok(sha256_hash(&bytes))
}
}
/// A signed NEAR transaction with ed25519 signature.
#[derive(Debug, Clone)]
pub struct SignedTransaction {
pub transaction: Transaction,
pub signature: Signature,
}
impl SignedTransaction {
/// Encode as base64 for RPC submission.
pub fn to_base64(&self) -> Result<String, crate::keys::KeyError> {
let bytes = self.to_borsh()?;
Ok(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bytes,
))
}
/// Borsh-serialize the signed transaction.
pub fn to_borsh(&self) -> Result<Vec<u8>, crate::keys::KeyError> {
let mut buf = Vec::new();
borsh::BorshSerialize::serialize(&self.transaction, &mut buf).map_err(|e| {
crate::keys::KeyError::SerializationFailed(format!(
"failed to serialize signed transaction: {}",
e
))
})?;
borsh::BorshSerialize::serialize(&self.signature, &mut buf).map_err(|e| {
crate::keys::KeyError::SerializationFailed(format!(
"failed to serialize signature: {}",
e
))
})?;
Ok(buf)
}
/// Get the transaction hash (the hash that was signed).
pub fn tx_hash(&self) -> Result<[u8; 32], crate::keys::KeyError> {
self.transaction.hash_for_signing()
}
}
/// Block hash (32 bytes), used as recent block reference for transaction validity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlockHash(pub [u8; 32]);
impl BorshSerialize for BlockHash {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
writer.write_all(&self.0)
}
}
impl BlockHash {
pub fn from_base58(s: &str) -> Result<Self, crate::keys::KeyError> {
let bytes =
bs58::decode(s)
.into_vec()
.map_err(|e| crate::keys::KeyError::InvalidKeyFormat {
reason: format!("invalid base58 block hash: {}", e),
})?;
if bytes.len() != 32 {
return Err(crate::keys::KeyError::InvalidKeyFormat {
reason: format!("block hash must be 32 bytes, got {}", bytes.len()),
});
}
let mut hash = [0u8; 32];
hash.copy_from_slice(&bytes);
Ok(Self(hash))
}
}
/// Ed25519 signature (NEAR uses key_type prefix for borsh serialization).
#[derive(Debug, Clone)]
pub struct Signature {
pub key_type: KeyType,
pub data: [u8; 64],
}
impl BorshSerialize for Signature {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
BorshSerialize::serialize(&self.key_type, writer)?;
writer.write_all(&self.data)?;
Ok(())
}
}
/// NEAR transaction action variants.
///
/// Only includes the variants we actually need for key management operations.
/// Borsh enum discriminants MUST match near-primitives exactly.
#[derive(Debug, Clone)]
pub enum Action {
CreateAccount, // 0
DeployContract(DeployContract), // 1
FunctionCall(FunctionCall), // 2
Transfer(Transfer), // 3
Stake(Stake), // 4
AddKey(AddKey), // 5
DeleteKey(DeleteKey), // 6
DeleteAccount(DeleteAccount), // 7
}
impl BorshSerialize for Action {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
match self {
Action::CreateAccount => {
BorshSerialize::serialize(&0u8, writer)?;
}
Action::DeployContract(v) => {
BorshSerialize::serialize(&1u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::FunctionCall(v) => {
BorshSerialize::serialize(&2u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::Transfer(v) => {
BorshSerialize::serialize(&3u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::Stake(v) => {
BorshSerialize::serialize(&4u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::AddKey(v) => {
BorshSerialize::serialize(&5u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::DeleteKey(v) => {
BorshSerialize::serialize(&6u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
Action::DeleteAccount(v) => {
BorshSerialize::serialize(&7u8, writer)?;
BorshSerialize::serialize(v, writer)?;
}
}
Ok(())
}
}
/// Deploy contract action.
#[derive(Debug, Clone, BorshSerialize)]
pub struct DeployContract {
pub code: Vec<u8>,
}
/// Function call action.
#[derive(Debug, Clone, BorshSerialize)]
pub struct FunctionCall {
pub method_name: String,
pub args: Vec<u8>,
pub gas: u64,
pub deposit: u128,
}
/// Transfer action.
#[derive(Debug, Clone, BorshSerialize)]
pub struct Transfer {
pub deposit: u128,
}
/// Stake action.
#[derive(Debug, Clone, BorshSerialize)]
pub struct Stake {
pub stake: u128,
pub public_key: NearPublicKey,
}
/// Add key action.
#[derive(Debug, Clone)]
pub struct AddKey {
pub public_key: NearPublicKey,
pub access_key: AccessKeyBorsh,
}
impl BorshSerialize for AddKey {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
BorshSerialize::serialize(&self.public_key, writer)?;
BorshSerialize::serialize(&self.access_key, writer)?;
Ok(())
}
}
/// Delete key action.
#[derive(Debug, Clone)]
pub struct DeleteKey {
pub public_key: NearPublicKey,
}
impl BorshSerialize for DeleteKey {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
BorshSerialize::serialize(&self.public_key, writer)?;
Ok(())
}
}
/// Delete account action.
#[derive(Debug, Clone, BorshSerialize)]
pub struct DeleteAccount {
pub beneficiary_id: NearAccountId,
}
/// Borsh-serializable access key (for AddKey actions).
#[derive(Debug, Clone)]
pub struct AccessKeyBorsh {
pub nonce: u64,
pub permission: AccessKeyPermissionBorsh,
}
impl BorshSerialize for AccessKeyBorsh {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
BorshSerialize::serialize(&self.nonce, writer)?;
BorshSerialize::serialize(&self.permission, writer)?;
Ok(())
}
}
/// Borsh-serializable access key permission.
#[derive(Debug, Clone)]
pub enum AccessKeyPermissionBorsh {
FunctionCall(FunctionCallPermissionBorsh),
FullAccess,
}
impl BorshSerialize for AccessKeyPermissionBorsh {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
match self {
AccessKeyPermissionBorsh::FunctionCall(fc) => {
BorshSerialize::serialize(&0u8, writer)?;
BorshSerialize::serialize(fc, writer)?;
}
AccessKeyPermissionBorsh::FullAccess => {
BorshSerialize::serialize(&1u8, writer)?;
}
}
Ok(())
}
}
/// Borsh-serializable function call permission.
#[derive(Debug, Clone, BorshSerialize)]
pub struct FunctionCallPermissionBorsh {
/// Allowance in yoctoNEAR (None = unlimited within key scope).
pub allowance: Option<u128>,
pub receiver_id: String,
pub method_names: Vec<String>,
}
/// Standard gas amounts.
pub const TGAS: u64 = 1_000_000_000_000;
/// 300 TGas, the maximum per transaction.
pub const MAX_GAS: u64 = 300 * TGAS;
/// 1 yoctoNEAR, commonly used as a deposit to indicate "attached" value.
pub const ONE_YOCTO: u128 = 1;
/// 1 NEAR in yoctoNEAR.
pub const ONE_NEAR: u128 = 1_000_000_000_000_000_000_000_000;
#[cfg(test)]
mod tests {
use crate::keys::transaction::{
AccessKeyBorsh, AccessKeyPermissionBorsh, Action, BlockHash, FunctionCall,
FunctionCallPermissionBorsh, MAX_GAS, ONE_NEAR, ONE_YOCTO, Signature, TGAS, Transaction,
Transfer,
};
use crate::keys::types::{KeyType, NearAccountId, NearPublicKey};
fn test_public_key() -> NearPublicKey {
NearPublicKey::from_near_format("ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp")
.unwrap()
}
#[test]
fn test_transfer_action_borsh() {
let action = Action::Transfer(Transfer { deposit: ONE_NEAR });
let bytes = borsh::to_vec(&action).unwrap();
// Discriminant (1 byte) + u128 (16 bytes)
assert_eq!(bytes.len(), 1 + 16);
assert_eq!(bytes[0], 3); // Transfer = discriminant 3
}
#[test]
fn test_function_call_action_borsh() {
let action = Action::FunctionCall(FunctionCall {
method_name: "deposit".to_string(),
args: b"{}".to_vec(),
gas: 30 * TGAS,
deposit: ONE_YOCTO,
});
let bytes = borsh::to_vec(&action).unwrap();
assert_eq!(bytes[0], 2); // FunctionCall = discriminant 2
// Verify it serializes without error
assert!(bytes.len() > 1);
}
#[test]
fn test_transaction_hash_for_signing() {
let tx = Transaction {
signer_id: NearAccountId::new("alice.near").unwrap(),
public_key: test_public_key(),
nonce: 1,
receiver_id: NearAccountId::new("bob.near").unwrap(),
block_hash: BlockHash([0u8; 32]),
actions: vec![Action::Transfer(Transfer { deposit: ONE_NEAR })],
};
let hash = tx.hash_for_signing().unwrap();
assert_eq!(hash.len(), 32);
// Same transaction should produce same hash
let hash2 = tx.hash_for_signing().unwrap();
assert_eq!(hash, hash2);
}
#[test]
fn test_transaction_different_nonce_different_hash() {
let tx1 = Transaction {
signer_id: NearAccountId::new("alice.near").unwrap(),
public_key: test_public_key(),
nonce: 1,
receiver_id: NearAccountId::new("bob.near").unwrap(),
block_hash: BlockHash([0u8; 32]),
actions: vec![Action::Transfer(Transfer { deposit: ONE_NEAR })],
};
let tx2 = Transaction {
nonce: 2,
..tx1.clone()
};
assert_ne!(
tx1.hash_for_signing().unwrap(),
tx2.hash_for_signing().unwrap()
);
}
#[test]
fn test_signed_transaction_to_base64() {
let tx = Transaction {
signer_id: NearAccountId::new("alice.near").unwrap(),
public_key: test_public_key(),
nonce: 1,
receiver_id: NearAccountId::new("bob.near").unwrap(),
block_hash: BlockHash([0u8; 32]),
actions: vec![Action::Transfer(Transfer { deposit: ONE_NEAR })],
};
let signed = crate::keys::transaction::SignedTransaction {
transaction: tx,
signature: Signature {
key_type: KeyType::Ed25519,
data: [0u8; 64],
},
};
let b64 = signed.to_base64().unwrap();
assert!(!b64.is_empty());
// Should be valid base64
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b64).unwrap();
assert!(!decoded.is_empty());
}
#[test]
fn test_block_hash_from_base58() {
let hash_str = "11111111111111111111111111111111"; // 32 zero bytes in base58
let hash = BlockHash::from_base58(hash_str).unwrap();
assert_eq!(hash.0, [0u8; 32]);
}
#[test]
fn test_access_key_borsh_full_access() {
let ak = AccessKeyBorsh {
nonce: 0,
permission: AccessKeyPermissionBorsh::FullAccess,
};
let bytes = borsh::to_vec(&ak).unwrap();
// u64 (8 bytes) + discriminant (1 byte)
assert_eq!(bytes.len(), 9);
}
#[test]
fn test_access_key_borsh_function_call() {
let ak = AccessKeyBorsh {
nonce: 0,
permission: AccessKeyPermissionBorsh::FunctionCall(FunctionCallPermissionBorsh {
allowance: Some(ONE_NEAR),
receiver_id: "contract.near".to_string(),
method_names: vec!["deposit".to_string()],
}),
};
let bytes = borsh::to_vec(&ak).unwrap();
assert!(!bytes.is_empty());
// First 8 bytes = nonce, then discriminant 0 for FunctionCall
assert_eq!(bytes[8], 0);
}
#[test]
fn test_gas_constants() {
assert_eq!(TGAS, 1_000_000_000_000);
assert_eq!(MAX_GAS, 300_000_000_000_000);
}
}
+563
View File
@@ -0,0 +1,563 @@
//! Core types for NEAR key management.
//!
//! Types for account IDs, public keys, access key permissions, network selection,
//! and key metadata. All types validate on construction to prevent invalid states.
//!
//! SECURITY: Debug impls on key-related types MUST redact secret material.
use std::fmt;
use std::str::FromStr;
use borsh::BorshSerialize;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::keys::KeyError;
/// NEAR account ID with validation.
///
/// Rules: 2-64 chars, lowercase alphanumeric + `.`, `-`, `_`.
/// No leading/trailing separators, no consecutive separators.
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct NearAccountId(String);
impl NearAccountId {
pub fn new(id: &str) -> Result<Self, KeyError> {
Self::validate(id)?;
Ok(Self(id.to_string()))
}
fn validate(id: &str) -> Result<(), KeyError> {
if id.len() < 2 || id.len() > 64 {
return Err(KeyError::InvalidAccountId {
reason: format!("account ID must be 2-64 characters, got {}", id.len()),
});
}
let bytes = id.as_bytes();
// No leading/trailing separators
if matches!(bytes[0], b'.' | b'-' | b'_') {
return Err(KeyError::InvalidAccountId {
reason: "account ID must not start with a separator".to_string(),
});
}
if matches!(bytes[bytes.len() - 1], b'.' | b'-' | b'_') {
return Err(KeyError::InvalidAccountId {
reason: "account ID must not end with a separator".to_string(),
});
}
for ch in id.chars() {
if !matches!(ch, 'a'..='z' | '0'..='9' | '.' | '-' | '_') {
return Err(KeyError::InvalidAccountId {
reason: format!("invalid character '{}' in account ID", ch),
});
}
}
Ok(())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for NearAccountId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl fmt::Debug for NearAccountId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "NearAccountId({})", self.0)
}
}
impl FromStr for NearAccountId {
type Err = KeyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl BorshSerialize for NearAccountId {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
// NEAR protocol serializes account IDs as length-prefixed UTF-8 strings.
BorshSerialize::serialize(&self.0, writer)
}
}
/// Key type discriminant for borsh serialization (matches NEAR protocol).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum KeyType {
Ed25519 = 0,
}
impl BorshSerialize for KeyType {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
BorshSerialize::serialize(&(*self as u8), writer)
}
}
/// NEAR public key with format parsing.
///
/// Parses the NEAR format: `ed25519:<base58-encoded-32-bytes>`
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NearPublicKey {
pub key_type: KeyType,
pub data: [u8; 32],
}
impl NearPublicKey {
/// Parse from NEAR format string: `ed25519:<base58>`
pub fn from_near_format(s: &str) -> Result<Self, KeyError> {
let s = s.trim();
let data_str = s
.strip_prefix("ed25519:")
.ok_or_else(|| KeyError::InvalidKeyFormat {
reason: "public key must start with 'ed25519:'".to_string(),
})?;
let bytes = bs58::decode(data_str)
.into_vec()
.map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid base58 in public key: {}", e),
})?;
if bytes.len() != 32 {
return Err(KeyError::InvalidKeyFormat {
reason: format!("ed25519 public key must be 32 bytes, got {}", bytes.len()),
});
}
let mut data = [0u8; 32];
data.copy_from_slice(&bytes);
Ok(Self {
key_type: KeyType::Ed25519,
data,
})
}
/// Format as NEAR string: `ed25519:<base58>`
pub fn to_near_format(&self) -> String {
format!("ed25519:{}", bs58::encode(&self.data).into_string())
}
/// Raw 32-byte key data.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.data
}
}
impl fmt::Display for NearPublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_near_format())
}
}
impl fmt::Debug for NearPublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let encoded = bs58::encode(&self.data).into_string();
let preview = if encoded.len() > 8 {
&encoded[..8]
} else {
&encoded
};
write!(f, "NearPublicKey(ed25519:{}...)", preview)
}
}
impl BorshSerialize for NearPublicKey {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
// NEAR protocol: key_type byte + 32 bytes of key data
BorshSerialize::serialize(&self.key_type, writer)?;
writer.write_all(&self.data)?;
Ok(())
}
}
/// Access key permission level.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AccessKeyPermission {
FullAccess,
FunctionCall {
/// Max NEAR that can be spent (None = unlimited within key's scope).
allowance: Option<u128>,
/// Contract this key is scoped to.
receiver_id: String,
/// Allowed method names (empty = all methods on the contract).
method_names: Vec<String>,
},
}
impl fmt::Display for AccessKeyPermission {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AccessKeyPermission::FullAccess => write!(f, "FullAccess"),
AccessKeyPermission::FunctionCall {
receiver_id,
method_names,
allowance,
} => {
write!(f, "FunctionCall({}", receiver_id)?;
if !method_names.is_empty() {
write!(f, "::{}", method_names.join(","))?;
}
if let Some(a) = allowance {
write!(f, ", allowance={})", format_yocto(*a))?;
} else {
write!(f, ")")?;
}
Ok(())
}
}
}
}
/// NEAR network configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum NearNetwork {
Mainnet,
Testnet,
Custom(String),
}
impl NearNetwork {
pub fn rpc_url(&self) -> &str {
match self {
NearNetwork::Mainnet => "https://rpc.mainnet.near.org",
NearNetwork::Testnet => "https://rpc.testnet.near.org",
NearNetwork::Custom(url) => url.as_str(),
}
}
}
impl fmt::Display for NearNetwork {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NearNetwork::Mainnet => write!(f, "mainnet"),
NearNetwork::Testnet => write!(f, "testnet"),
NearNetwork::Custom(url) => write!(f, "custom({})", url),
}
}
}
impl FromStr for NearNetwork {
type Err = KeyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"mainnet" => Ok(NearNetwork::Mainnet),
"testnet" => Ok(NearNetwork::Testnet),
url if url.starts_with("http") => Ok(NearNetwork::Custom(url.to_string())),
other => Err(KeyError::InvalidKeyFormat {
reason: format!(
"unknown network '{}', expected mainnet, testnet, or an RPC URL",
other
),
}),
}
}
}
/// Metadata for a stored key (public info only, no secrets).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyMetadata {
pub label: String,
pub account_id: String,
pub public_key: String,
pub permission: AccessKeyPermission,
pub network: NearNetwork,
pub created_at: DateTime<Utc>,
/// Cached nonce for transaction building (avoids extra RPC round-trip).
pub cached_nonce: Option<u64>,
}
/// Top-level structure for ~/.ironclaw/keys.json
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct KeyStore {
pub keys: std::collections::HashMap<String, KeyMetadata>,
pub last_backup_at: Option<DateTime<Utc>>,
}
/// Format yoctoNEAR as human-readable NEAR amount.
pub fn format_yocto(yocto: u128) -> String {
const ONE_NEAR: u128 = 1_000_000_000_000_000_000_000_000;
const ONE_MILLI_NEAR: u128 = ONE_NEAR / 1000;
if yocto == 0 {
return "0 NEAR".to_string();
}
if yocto >= ONE_MILLI_NEAR {
let whole = yocto / ONE_NEAR;
let frac = (yocto % ONE_NEAR) / ONE_MILLI_NEAR; // 3 decimal places
if frac == 0 {
format!("{} NEAR", whole)
} else {
format!("{}.{:03} NEAR", whole, frac)
}
} else {
format!("{} yoctoNEAR", yocto)
}
}
/// Parse a NEAR amount string into yoctoNEAR.
///
/// Accepts: "1", "0.5", "1.5 NEAR", "100000 yoctoNEAR"
pub fn parse_near_amount(s: &str) -> Result<u128, KeyError> {
const ONE_NEAR: u128 = 1_000_000_000_000_000_000_000_000;
let s = s.trim();
// Check for explicit yoctoNEAR suffix
if let Some(yocto_str) = s
.strip_suffix("yoctoNEAR")
.or_else(|| s.strip_suffix("yocto"))
{
return yocto_str
.trim()
.parse::<u128>()
.map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid yoctoNEAR amount: {}", e),
});
}
// Strip optional "NEAR" suffix
let amount_str = s
.strip_suffix("NEAR")
.or_else(|| s.strip_suffix("near"))
.unwrap_or(s)
.trim();
// Parse as decimal NEAR
if let Some((whole_str, frac_str)) = amount_str.split_once('.') {
let whole: u128 = whole_str.parse().map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid NEAR amount: {}", e),
})?;
// Pad or truncate fractional part to 24 digits
let mut frac_padded = frac_str.to_string();
if frac_padded.len() > 24 {
frac_padded.truncate(24);
}
while frac_padded.len() < 24 {
frac_padded.push('0');
}
let frac: u128 = frac_padded
.parse()
.map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid NEAR fractional amount: {}", e),
})?;
Ok(whole * ONE_NEAR + frac)
} else {
let whole: u128 = amount_str.parse().map_err(|e| KeyError::InvalidKeyFormat {
reason: format!("invalid NEAR amount: {}", e),
})?;
Ok(whole * ONE_NEAR)
}
}
#[cfg(test)]
mod tests {
use crate::keys::types::{
AccessKeyPermission, KeyType, NearAccountId, NearNetwork, NearPublicKey, format_yocto,
parse_near_amount,
};
// -- NearAccountId tests --
#[test]
fn test_valid_account_ids() {
assert!(NearAccountId::new("alice.near").is_ok());
assert!(NearAccountId::new("bob.testnet").is_ok());
assert!(NearAccountId::new("system").is_ok());
assert!(NearAccountId::new("ab").is_ok()); // minimum 2 chars
assert!(NearAccountId::new("a0").is_ok());
assert!(NearAccountId::new("alice-bob.near").is_ok());
assert!(NearAccountId::new("alice_bob.near").is_ok());
// 64 chars max
let long_id = "a".repeat(64);
assert!(NearAccountId::new(&long_id).is_ok());
}
#[test]
fn test_invalid_account_ids() {
// Too short
assert!(NearAccountId::new("a").is_err());
// Too long
assert!(NearAccountId::new(&"a".repeat(65)).is_err());
// Uppercase
assert!(NearAccountId::new("Alice.near").is_err());
// Leading separator
assert!(NearAccountId::new(".alice").is_err());
assert!(NearAccountId::new("-alice").is_err());
// Trailing separator
assert!(NearAccountId::new("alice.").is_err());
// Invalid chars
assert!(NearAccountId::new("alice@near").is_err());
assert!(NearAccountId::new("alice near").is_err());
}
#[test]
fn test_account_id_display() {
let id = NearAccountId::new("alice.near").unwrap();
assert_eq!(id.to_string(), "alice.near");
assert_eq!(id.as_str(), "alice.near");
}
#[test]
fn test_account_id_from_str() {
let id: NearAccountId = "bob.testnet".parse().unwrap();
assert_eq!(id.as_str(), "bob.testnet");
}
// -- NearPublicKey tests --
#[test]
fn test_public_key_roundtrip() {
let key_str = "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp";
let key = NearPublicKey::from_near_format(key_str).unwrap();
assert_eq!(key.key_type, KeyType::Ed25519);
assert_eq!(key.to_near_format(), key_str);
}
#[test]
fn test_public_key_invalid_prefix() {
assert!(NearPublicKey::from_near_format("secp256k1:abc").is_err());
assert!(NearPublicKey::from_near_format("abc123").is_err());
}
#[test]
fn test_public_key_invalid_base58() {
assert!(NearPublicKey::from_near_format("ed25519:not-valid-base58!!!").is_err());
}
#[test]
fn test_public_key_wrong_length() {
// Too short (only 16 bytes encoded)
assert!(NearPublicKey::from_near_format("ed25519:3gZNbFLLDt").is_err());
}
#[test]
fn test_public_key_debug_redacts() {
let key_str = "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp";
let key = NearPublicKey::from_near_format(key_str).unwrap();
let debug = format!("{:?}", key);
// Should show first 8 chars of base58, not the whole thing
assert!(debug.contains("..."));
assert!(!debug.contains("6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp"));
}
// -- AccessKeyPermission tests --
#[test]
fn test_permission_display() {
assert_eq!(AccessKeyPermission::FullAccess.to_string(), "FullAccess");
let fc = AccessKeyPermission::FunctionCall {
allowance: None,
receiver_id: "intents.near".to_string(),
method_names: vec![],
};
assert_eq!(fc.to_string(), "FunctionCall(intents.near)");
let fc_methods = AccessKeyPermission::FunctionCall {
allowance: Some(1_000_000_000_000_000_000_000_000),
receiver_id: "contract.near".to_string(),
method_names: vec!["deposit".to_string(), "withdraw".to_string()],
};
assert!(fc_methods.to_string().contains("deposit,withdraw"));
assert!(fc_methods.to_string().contains("1 NEAR"));
}
// -- NearNetwork tests --
#[test]
fn test_network_rpc_urls() {
assert_eq!(
NearNetwork::Mainnet.rpc_url(),
"https://rpc.mainnet.near.org"
);
assert_eq!(
NearNetwork::Testnet.rpc_url(),
"https://rpc.testnet.near.org"
);
let custom = NearNetwork::Custom("https://custom.rpc.dev".to_string());
assert_eq!(custom.rpc_url(), "https://custom.rpc.dev");
}
#[test]
fn test_network_from_str() {
assert_eq!(
"mainnet".parse::<NearNetwork>().unwrap(),
NearNetwork::Mainnet
);
assert_eq!(
"testnet".parse::<NearNetwork>().unwrap(),
NearNetwork::Testnet
);
assert_eq!(
"https://custom.rpc".parse::<NearNetwork>().unwrap(),
NearNetwork::Custom("https://custom.rpc".to_string())
);
assert!("garbage".parse::<NearNetwork>().is_err());
}
// -- NEAR amount formatting/parsing --
#[test]
fn test_format_yocto() {
assert_eq!(format_yocto(0), "0 NEAR");
assert_eq!(format_yocto(1_000_000_000_000_000_000_000_000), "1 NEAR");
assert_eq!(
format_yocto(5_500_000_000_000_000_000_000_000),
"5.500 NEAR"
);
assert_eq!(format_yocto(1), "1 yoctoNEAR");
assert_eq!(format_yocto(500_000_000_000_000_000_000_000), "0.500 NEAR");
}
#[test]
fn test_parse_near_amount() {
assert_eq!(
parse_near_amount("1").unwrap(),
1_000_000_000_000_000_000_000_000
);
assert_eq!(
parse_near_amount("0.5").unwrap(),
500_000_000_000_000_000_000_000
);
assert_eq!(
parse_near_amount("1.5 NEAR").unwrap(),
1_500_000_000_000_000_000_000_000
);
assert_eq!(parse_near_amount("100 yoctoNEAR").unwrap(), 100);
assert_eq!(parse_near_amount("0").unwrap(), 0);
}
// -- Borsh serialization tests --
#[test]
fn test_account_id_borsh() {
let id = NearAccountId::new("alice.near").unwrap();
let bytes = borsh::to_vec(&id).unwrap();
// Length-prefixed string: 4 bytes length + 10 bytes "alice.near"
assert_eq!(bytes.len(), 4 + 10);
}
#[test]
fn test_public_key_borsh() {
let key_str = "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp";
let key = NearPublicKey::from_near_format(key_str).unwrap();
let bytes = borsh::to_vec(&key).unwrap();
// 1 byte key_type + 32 bytes data
assert_eq!(bytes.len(), 33);
assert_eq!(bytes[0], 0); // Ed25519 = 0
}
}
+1 -4
View File
@@ -39,7 +39,6 @@
//! - **Continuous learning** - Improve estimates from historical data
pub mod agent;
pub mod bootstrap;
pub mod channels;
pub mod cli;
pub mod config;
@@ -49,16 +48,14 @@ pub mod estimation;
pub mod evaluation;
pub mod extensions;
pub mod history;
pub mod keys;
pub mod llm;
pub mod orchestrator;
pub mod pairing;
pub mod safety;
pub mod sandbox;
pub mod secrets;
pub mod settings;
pub mod setup;
pub mod tools;
pub mod worker;
pub mod workspace;
pub use config::Config;
-124
View File
@@ -1,124 +0,0 @@
//! Per-model cost lookup table for multi-provider LLM support.
//!
//! Returns (input_cost_per_token, output_cost_per_token) as Decimal pairs.
//! Ollama and other local models return zero cost.
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
/// Look up known per-token costs for a model by its identifier.
///
/// Returns `Some((input_cost, output_cost))` for known models, `None` otherwise.
pub fn model_cost(model_id: &str) -> Option<(Decimal, Decimal)> {
// Normalize: strip provider prefixes (e.g., "openai/gpt-4o" -> "gpt-4o")
let id = model_id
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(model_id);
match id {
// OpenAI models -- prices per token (USD)
"gpt-4o" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" => {
Some((dec!(0.0000025), dec!(0.00001)))
}
"gpt-4o-mini" | "gpt-4o-mini-2024-07-18" => Some((dec!(0.00000015), dec!(0.0000006))),
"gpt-4-turbo" | "gpt-4-turbo-2024-04-09" => Some((dec!(0.00001), dec!(0.00003))),
"gpt-4" | "gpt-4-0613" => Some((dec!(0.00003), dec!(0.00006))),
"gpt-3.5-turbo" | "gpt-3.5-turbo-0125" => Some((dec!(0.0000005), dec!(0.0000015))),
"o1" | "o1-2024-12-17" => Some((dec!(0.000015), dec!(0.00006))),
"o1-mini" | "o1-mini-2024-09-12" => Some((dec!(0.000003), dec!(0.000012))),
"o3-mini" | "o3-mini-2025-01-31" => Some((dec!(0.0000011), dec!(0.0000044))),
// Anthropic models
"claude-3-5-sonnet-20241022" | "claude-3-5-sonnet-latest" | "claude-sonnet-4-20250514" => {
Some((dec!(0.000003), dec!(0.000015)))
}
"claude-3-5-haiku-20241022" | "claude-3-5-haiku-latest" => {
Some((dec!(0.0000008), dec!(0.000004)))
}
"claude-3-opus-20240229" | "claude-3-opus-latest" | "claude-opus-4-20250514" => {
Some((dec!(0.000015), dec!(0.000075)))
}
"claude-3-haiku-20240307" => Some((dec!(0.00000025), dec!(0.00000125))),
// Ollama / local models -- free
_ if is_local_model(id) => Some((Decimal::ZERO, Decimal::ZERO)),
_ => None,
}
}
/// Default cost for unknown models.
pub fn default_cost() -> (Decimal, Decimal) {
// Conservative estimate: roughly GPT-4o pricing
(dec!(0.0000025), dec!(0.00001))
}
/// Heuristic to detect local/self-hosted models (Ollama, llama.cpp, etc.).
fn is_local_model(model_id: &str) -> bool {
let lower = model_id.to_lowercase();
lower.starts_with("llama")
|| lower.starts_with("mistral")
|| lower.starts_with("mixtral")
|| lower.starts_with("phi")
|| lower.starts_with("gemma")
|| lower.starts_with("qwen")
|| lower.starts_with("codellama")
|| lower.starts_with("deepseek")
|| lower.starts_with("starcoder")
|| lower.starts_with("vicuna")
|| lower.starts_with("yi")
|| lower.contains(":latest")
|| lower.contains(":instruct")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_known_model_costs() {
let (input, output) = model_cost("gpt-4o").unwrap();
assert!(input > Decimal::ZERO);
assert!(output > input);
}
#[test]
fn test_claude_costs() {
let (input, output) = model_cost("claude-3-5-sonnet-20241022").unwrap();
assert!(input > Decimal::ZERO);
assert!(output > input);
}
#[test]
fn test_local_model_free() {
let (input, output) = model_cost("llama3").unwrap();
assert_eq!(input, Decimal::ZERO);
assert_eq!(output, Decimal::ZERO);
}
#[test]
fn test_ollama_tagged_model_free() {
let (input, output) = model_cost("mistral:latest").unwrap();
assert_eq!(input, Decimal::ZERO);
assert_eq!(output, Decimal::ZERO);
}
#[test]
fn test_unknown_model_returns_none() {
assert!(model_cost("some-totally-unknown-model-xyz").is_none());
}
#[test]
fn test_default_cost_nonzero() {
let (input, output) = default_cost();
assert!(input > Decimal::ZERO);
assert!(output > Decimal::ZERO);
}
#[test]
fn test_provider_prefix_stripped() {
// "openai/gpt-4o" should resolve to same as "gpt-4o"
assert_eq!(model_cost("openai/gpt-4o"), model_cost("gpt-4o"));
}
}
+10 -134
View File
@@ -1,172 +1,48 @@
//! LLM integration for the agent.
//!
//! Supports multiple backends:
//! - **NEAR AI** (default): Session-based or API key auth via NEAR AI proxy
//! - **OpenAI**: Direct API access with your own key
//! - **Anthropic**: Direct API access with your own key
//! - **Ollama**: Local model inference
//! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API
//! Supports two API modes:
//! - **Responses API** (chat-api): Session-based auth, uses `/v1/responses` endpoint
//! - **Chat Completions API** (cloud-api): API key auth, uses `/v1/chat/completions` endpoint
mod costs;
mod nearai;
mod nearai_chat;
mod provider;
mod reasoning;
mod rig_adapter;
pub mod session;
pub use nearai::{ModelInfo, NearAiProvider};
pub use nearai_chat::NearAiChatProvider;
pub use provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
ChatMessage, CompletionRequest, CompletionResponse, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
};
pub use reasoning::{ActionPlan, Reasoning, ReasoningContext, RespondResult, ToolSelection};
pub use rig_adapter::RigAdapter;
pub use session::{SessionConfig, SessionManager, create_session_manager};
use std::sync::Arc;
use rig::client::CompletionClient;
use secrecy::ExposeSecret;
use crate::config::{LlmBackend, LlmConfig, NearAiApiMode};
use crate::config::{LlmConfig, NearAiApiMode};
use crate::error::LlmError;
/// Create an LLM provider based on configuration.
///
/// - `NearAi` backend: Uses session manager for authentication (Responses API)
/// or API key (Chat Completions API)
/// - Other backends: Use rig-core adapter with provider-specific clients
/// - For `Responses` mode: Requires a session manager for authentication
/// - For `ChatCompletions` mode: Uses API key from config (session not needed)
pub fn create_llm_provider(
config: &LlmConfig,
session: Arc<SessionManager>,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
match config.backend {
LlmBackend::NearAi => create_nearai_provider(config, session),
LlmBackend::OpenAi => create_openai_provider(config),
LlmBackend::Anthropic => create_anthropic_provider(config),
LlmBackend::Ollama => create_ollama_provider(config),
LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config),
}
}
fn create_nearai_provider(
config: &LlmConfig,
session: Arc<SessionManager>,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
match config.nearai.api_mode {
NearAiApiMode::Responses => {
tracing::info!("Using NEAR AI Responses API (chat-api) with session auth");
tracing::info!("Using Responses API (chat-api) with session auth");
Ok(Arc::new(NearAiProvider::new(
config.nearai.clone(),
session,
)))
}
NearAiApiMode::ChatCompletions => {
tracing::info!("Using NEAR AI Chat Completions API (cloud-api) with API key auth");
tracing::info!("Using Chat Completions API (cloud-api) with API key auth");
Ok(Arc::new(NearAiChatProvider::new(config.nearai.clone())?))
}
}
}
fn create_openai_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
let oai = config.openai.as_ref().ok_or_else(|| LlmError::AuthFailed {
provider: "openai".to_string(),
})?;
use rig::providers::openai;
let client: openai::Client =
openai::Client::new(oai.api_key.expose_secret()).map_err(|e| LlmError::RequestFailed {
provider: "openai".to_string(),
reason: format!("Failed to create OpenAI client: {}", e),
})?;
let model = client.completion_model(&oai.model);
tracing::info!("Using OpenAI direct API (model: {})", oai.model);
Ok(Arc::new(RigAdapter::new(model, &oai.model)))
}
fn create_anthropic_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
let anth = config
.anthropic
.as_ref()
.ok_or_else(|| LlmError::AuthFailed {
provider: "anthropic".to_string(),
})?;
use rig::providers::anthropic;
let client: anthropic::Client =
anthropic::Client::new(anth.api_key.expose_secret()).map_err(|e| {
LlmError::RequestFailed {
provider: "anthropic".to_string(),
reason: format!("Failed to create Anthropic client: {}", e),
}
})?;
let model = client.completion_model(&anth.model);
tracing::info!("Using Anthropic direct API (model: {})", anth.model);
Ok(Arc::new(RigAdapter::new(model, &anth.model)))
}
fn create_ollama_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
let oll = config.ollama.as_ref().ok_or_else(|| LlmError::AuthFailed {
provider: "ollama".to_string(),
})?;
use rig::client::Nothing;
use rig::providers::ollama;
let client: ollama::Client = ollama::Client::builder()
.base_url(&oll.base_url)
.api_key(Nothing)
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "ollama".to_string(),
reason: format!("Failed to create Ollama client: {}", e),
})?;
let model = client.completion_model(&oll.model);
tracing::info!(
"Using Ollama (base_url: {}, model: {})",
oll.base_url,
oll.model
);
Ok(Arc::new(RigAdapter::new(model, &oll.model)))
}
fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
let compat = config
.openai_compatible
.as_ref()
.ok_or_else(|| LlmError::AuthFailed {
provider: "openai_compatible".to_string(),
})?;
use rig::providers::openai;
let api_key = compat
.api_key
.as_ref()
.map(|k| k.expose_secret().to_string())
.unwrap_or_else(|| "no-key".to_string());
let client: openai::Client = openai::Client::builder()
.base_url(&compat.base_url)
.api_key(api_key)
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "openai_compatible".to_string(),
reason: format!("Failed to create OpenAI-compatible client: {}", e),
})?;
let model = client.completion_model(&compat.model);
tracing::info!(
"Using OpenAI-compatible endpoint (base_url: {}, model: {})",
compat.base_url,
compat.model
);
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
}
+20 -308
View File
@@ -3,7 +3,6 @@
//! This provider uses the NEAR AI chat-api which provides a unified interface
//! to multiple LLM models (OpenAI, Anthropic, etc.) with user authentication.
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
@@ -32,23 +31,11 @@ pub struct ModelInfo {
pub provider: Option<String>,
}
/// Per-thread chaining state: the last response ID and how many input
/// messages were included in that request. This lets subsequent calls send
/// only the delta (new messages since last call).
struct ChainState {
response_id: String,
input_count: usize,
}
/// NEAR AI Chat API provider.
pub struct NearAiProvider {
client: Client,
config: NearAiConfig,
session: Arc<SessionManager>,
active_model: std::sync::RwLock<String>,
/// Per-thread response ID chaining state.
/// Key is thread_id from request metadata.
response_chains: std::sync::RwLock<HashMap<String, ChainState>>,
}
impl NearAiProvider {
@@ -59,64 +46,13 @@ impl NearAiProvider {
.build()
.unwrap_or_else(|_| Client::new());
let active_model = std::sync::RwLock::new(config.model.clone());
Self {
client,
config,
session,
active_model,
response_chains: std::sync::RwLock::new(HashMap::new()),
}
}
/// Seed a response chain for a thread (e.g. when restoring from DB).
pub fn seed_response_id(&self, thread_id: &str, response_id: String) {
let mut chains = self
.response_chains
.write()
.expect("response_chains lock poisoned");
chains.insert(
thread_id.to_string(),
ChainState {
response_id,
input_count: 0,
},
);
}
/// Get the last response ID for a thread (for persistence).
pub fn get_response_id(&self, thread_id: &str) -> Option<String> {
let chains = self
.response_chains
.read()
.expect("response_chains lock poisoned");
chains.get(thread_id).map(|c| c.response_id.clone())
}
/// Store a response chain state after a successful call.
fn store_chain(&self, thread_id: &str, response_id: String, input_count: usize) {
let mut chains = self
.response_chains
.write()
.expect("response_chains lock poisoned");
chains.insert(
thread_id.to_string(),
ChainState {
response_id,
input_count,
},
);
}
/// Clear the chain for a thread (on error / fallback).
fn clear_chain(&self, thread_id: &str) {
let mut chains = self
.response_chains
.write()
.expect("response_chains lock poisoned");
chains.remove(thread_id);
}
fn api_url(&self, path: &str) -> String {
format!(
"{}/v1/{}",
@@ -355,34 +291,18 @@ impl NearAiProvider {
}
}
/// Split messages into system instructions and non-system input items.
/// Split messages into system instructions and non-system input messages.
/// The OpenAI Responses API expects system prompts in an `instructions` field,
/// not as a message with role "system" in the input array.
///
/// When `chaining` is true, tool result messages (role=tool) are converted to
/// `NearAiInputItem::FunctionCallOutput` for the Responses API protocol.
fn split_messages(
messages: Vec<ChatMessage>,
chaining: bool,
) -> (Option<String>, Vec<NearAiInputItem>) {
fn split_messages(messages: Vec<ChatMessage>) -> (Option<String>, Vec<NearAiMessage>) {
let mut instructions: Vec<String> = Vec::new();
let mut input: Vec<NearAiInputItem> = Vec::new();
let mut input: Vec<NearAiMessage> = Vec::new();
for msg in messages {
if msg.role == Role::System {
instructions.push(msg.content);
} else if chaining && msg.role == Role::Tool {
if let Some(ref call_id) = msg.tool_call_id {
input.push(NearAiInputItem::FunctionCallOutput {
item_type: "function_call_output".to_string(),
call_id: call_id.clone(),
output: msg.content,
});
} else {
input.push(NearAiInputItem::Message(msg.into()));
}
} else {
input.push(NearAiInputItem::Message(msg.into()));
input.push(msg.into());
}
}
@@ -398,14 +318,12 @@ fn split_messages(
#[async_trait]
impl LlmProvider for NearAiProvider {
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let thread_id = req.metadata.get("thread_id").cloned();
let (instructions, input) = split_messages(req.messages, false);
let (instructions, input) = split_messages(req.messages);
let request = NearAiRequest {
model: self.active_model_name(),
model: self.config.model.clone(),
instructions,
input,
previous_response_id: None,
temperature: req.temperature,
max_output_tokens: req.max_tokens,
stream: Some(false),
@@ -432,7 +350,6 @@ impl LlmProvider for NearAiProvider {
finish_reason: FinishReason::Stop,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
response_id: None,
});
}
@@ -450,7 +367,6 @@ impl LlmProvider for NearAiProvider {
finish_reason: FinishReason::Stop,
input_tokens: 0,
output_tokens: 0,
response_id: None,
});
}
Err(e) => return Err(e),
@@ -507,17 +423,11 @@ impl LlmProvider for NearAiProvider {
);
}
// Store response ID for chaining
if let Some(ref tid) = thread_id {
self.store_chain(tid, response.id.clone(), 0);
}
Ok(CompletionResponse {
content: text,
finish_reason: FinishReason::Stop,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
response_id: Some(response.id),
})
}
@@ -525,33 +435,7 @@ impl LlmProvider for NearAiProvider {
&self,
req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let thread_id = req.metadata.get("thread_id").cloned();
// Look up chaining state for this thread
let chain_state = thread_id.as_ref().and_then(|tid| {
let chains = self
.response_chains
.read()
.expect("response_chains lock poisoned");
chains
.get(tid)
.map(|c| (c.response_id.clone(), c.input_count))
});
let chaining = chain_state.is_some();
let (previous_response_id, prev_input_count) = chain_state
.map(|(rid, count)| (Some(rid), count))
.unwrap_or((None, 0));
// When chaining, only send new messages (the delta since last call).
// Tool results are converted to function_call_output items.
let (instructions, all_input) = split_messages(req.messages, chaining);
let input = if chaining && all_input.len() > prev_input_count {
all_input[prev_input_count..].to_vec()
} else {
all_input.clone()
};
let total_input_count = all_input.len();
let (instructions, input) = split_messages(req.messages);
let tools: Vec<NearAiTool> = req
.tools
@@ -565,58 +449,18 @@ impl LlmProvider for NearAiProvider {
.collect();
let request = NearAiRequest {
model: self.active_model_name(),
instructions: if chaining { None } else { instructions.clone() },
model: self.config.model.clone(),
instructions,
input,
previous_response_id: previous_response_id.clone(),
temperature: req.temperature,
max_output_tokens: req.max_tokens,
stream: Some(false),
tools: if tools.is_empty() {
None
} else {
Some(tools.clone())
},
tools: if tools.is_empty() { None } else { Some(tools) },
};
// Try to get structured response, fall back to alternative formats.
// If chaining fails (bad previous_response_id), retry with full history.
// Try to get structured response, fall back to alternative formats
let response: NearAiResponse = match self.send_request("responses", &request).await {
Ok(r) => r,
Err(ref e) if chaining && is_chain_error(e) => {
tracing::warn!(
"Response chaining failed, retrying with full history: {}",
e
);
if let Some(ref tid) = thread_id {
self.clear_chain(tid);
}
let (instructions_full, input_full) = split_messages(
// Rebuild from the original input (non-chaining mode)
{
let mut msgs = Vec::new();
if let Some(ref instr) = instructions {
msgs.push(ChatMessage::system(instr.clone()));
}
for item in &all_input {
msgs.push(item.to_chat_message());
}
msgs
},
false,
);
let retry_request = NearAiRequest {
model: self.active_model_name(),
instructions: instructions_full,
input: input_full,
previous_response_id: None,
temperature: request.temperature,
max_output_tokens: request.max_output_tokens,
stream: Some(false),
tools: request.tools.clone(),
};
self.send_request("responses", &retry_request).await?
}
Err(LlmError::InvalidResponse { reason, .. }) if reason.contains("Raw: ") => {
let raw_text = reason.split("Raw: ").nth(1).unwrap_or("");
@@ -646,7 +490,6 @@ impl LlmProvider for NearAiProvider {
finish_reason,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
response_id: None,
});
}
@@ -664,7 +507,6 @@ impl LlmProvider for NearAiProvider {
finish_reason: FinishReason::Stop,
input_tokens: 0,
output_tokens: 0,
response_id: None,
});
}
Err(e) => return Err(e),
@@ -718,18 +560,12 @@ impl LlmProvider for NearAiProvider {
FinishReason::ToolUse
};
// Store response ID for chaining on subsequent calls
if let Some(ref tid) = thread_id {
self.store_chain(tid, response.id.clone(), total_input_count);
}
Ok(ToolCompletionResponse {
content: if text.is_empty() { None } else { Some(text) },
tool_calls,
finish_reason,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
response_id: Some(response.id),
})
}
@@ -748,30 +584,6 @@ impl LlmProvider for NearAiProvider {
let models = NearAiProvider::list_models(self).await?;
Ok(models.into_iter().map(|m| m.name).collect())
}
fn active_model_name(&self) -> String {
self.active_model
.read()
.expect("active_model lock poisoned")
.clone()
}
fn set_model(&self, model: &str) -> Result<(), LlmError> {
let mut guard = self
.active_model
.write()
.expect("active_model lock poisoned");
*guard = model.to_string();
Ok(())
}
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.seed_response_id(thread_id, response_id);
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.get_response_id(thread_id)
}
}
// NEAR AI API types
@@ -785,11 +597,8 @@ struct NearAiRequest {
/// System instructions (replaces sending system role in input)
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<String>,
/// Input items: messages and/or function_call_output entries.
input: Vec<NearAiInputItem>,
/// Chain this request to a previous response (avoids resending full context).
#[serde(skip_serializing_if = "Option::is_none")]
previous_response_id: Option<String>,
/// Input messages (user/assistant/tool only, NOT system)
input: Vec<NearAiMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -800,7 +609,7 @@ struct NearAiRequest {
tools: Option<Vec<NearAiTool>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize)]
struct NearAiMessage {
role: String,
content: String,
@@ -821,68 +630,7 @@ impl From<ChatMessage> for NearAiMessage {
}
}
/// Input item for the Responses API. Either a regular message or a
/// function_call_output (for returning tool results when chaining).
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
enum NearAiInputItem {
Message(NearAiMessage),
FunctionCallOutput {
#[serde(rename = "type")]
item_type: String,
call_id: String,
output: String,
},
}
impl NearAiInputItem {
/// Convert back to a ChatMessage (used for fallback retry).
fn to_chat_message(&self) -> ChatMessage {
match self {
NearAiInputItem::Message(msg) => {
let role = match msg.role.as_str() {
"system" => Role::System,
"user" => Role::User,
"assistant" => Role::Assistant,
"tool" => Role::Tool,
_ => Role::User,
};
ChatMessage {
role,
content: msg.content.clone(),
tool_call_id: None,
name: None,
tool_calls: None,
}
}
NearAiInputItem::FunctionCallOutput {
call_id, output, ..
} => ChatMessage {
role: Role::Tool,
content: output.clone(),
tool_call_id: Some(call_id.clone()),
name: None,
tool_calls: None,
},
}
}
}
/// Check if an LLM error is likely caused by an invalid previous_response_id.
fn is_chain_error(err: &LlmError) -> bool {
match err {
LlmError::RequestFailed { reason, .. } => {
let lower = reason.to_lowercase();
lower.contains("previous_response_id")
|| lower.contains("previous response")
|| lower.contains("not found")
|| lower.contains("invalid response id")
}
_ => false,
}
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Serialize)]
struct NearAiTool {
#[serde(rename = "type")]
tool_type: String,
@@ -1085,17 +833,14 @@ mod tests {
ChatMessage::user("Hello"),
ChatMessage::assistant("Hi there!"),
];
let (instructions, input) = split_messages(messages, false);
let (instructions, input) = split_messages(messages);
assert_eq!(
instructions,
Some("You are a helpful assistant".to_string())
);
assert_eq!(input.len(), 2);
// Verify the input items are messages
match &input[0] {
NearAiInputItem::Message(m) => assert_eq!(m.role, "user"),
_ => panic!("expected Message"),
}
assert_eq!(input[0].role, "user");
assert_eq!(input[1].role, "assistant");
}
#[test]
@@ -1104,7 +849,7 @@ mod tests {
ChatMessage::user("Hello"),
ChatMessage::assistant("Hi there!"),
];
let (instructions, input) = split_messages(messages, false);
let (instructions, input) = split_messages(messages);
assert!(instructions.is_none());
assert_eq!(input.len(), 2);
}
@@ -1116,44 +861,11 @@ mod tests {
ChatMessage::system("Second instruction"),
ChatMessage::user("Hello"),
];
let (instructions, input) = split_messages(messages, false);
let (instructions, input) = split_messages(messages);
assert_eq!(
instructions,
Some("First instruction\n\nSecond instruction".to_string())
);
assert_eq!(input.len(), 1);
}
#[test]
fn test_split_messages_chaining_converts_tool_results() {
let messages = vec![
ChatMessage::user("Hello"),
ChatMessage::tool_result("call_123", "my_tool", "result data"),
];
let (_, input) = split_messages(messages, true);
assert_eq!(input.len(), 2);
match &input[1] {
NearAiInputItem::FunctionCallOutput {
call_id, output, ..
} => {
assert_eq!(call_id, "call_123");
assert_eq!(output, "result data");
}
_ => panic!("expected FunctionCallOutput"),
}
}
#[test]
fn test_split_messages_no_chaining_keeps_tool_as_message() {
let messages = vec![
ChatMessage::user("Hello"),
ChatMessage::tool_result("call_123", "my_tool", "result data"),
];
let (_, input) = split_messages(messages, false);
assert_eq!(input.len(), 2);
match &input[1] {
NearAiInputItem::Message(m) => assert_eq!(m.role, "tool"),
_ => panic!("expected Message"),
}
}
}
+18 -69
View File
@@ -13,15 +13,14 @@ use serde::{Deserialize, Serialize};
use crate::config::NearAiConfig;
use crate::error::LlmError;
use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse,
};
/// NEAR AI Chat Completions API provider.
pub struct NearAiChatProvider {
client: Client,
config: NearAiConfig,
active_model: std::sync::RwLock<String>,
}
impl NearAiChatProvider {
@@ -38,12 +37,7 @@ impl NearAiChatProvider {
.build()
.unwrap_or_else(|_| Client::new());
let active_model = std::sync::RwLock::new(config.model.clone());
Ok(Self {
client,
config,
active_model,
})
Ok(Self { client, config })
}
fn api_url(&self, path: &str) -> String {
@@ -71,11 +65,6 @@ impl NearAiChatProvider {
tracing::debug!("Sending request to NEAR AI Chat: {}", url);
// Log the request body for debugging tool call issues
if let Ok(json) = serde_json::to_string(body) {
tracing::debug!("NEAR AI Chat request body: {}", json);
}
let response = self
.client
.post(&url)
@@ -122,8 +111,8 @@ impl NearAiChatProvider {
})
}
/// Fetch available models with full metadata from the `/v1/models` endpoint.
async fn fetch_models(&self) -> Result<Vec<ApiModelEntry>, LlmError> {
/// Fetch available models.
pub async fn list_models(&self) -> Result<Vec<String>, LlmError> {
let url = self.api_url("models");
let response = self
@@ -149,7 +138,12 @@ impl NearAiChatProvider {
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<ApiModelEntry>,
data: Vec<ModelEntry>,
}
#[derive(Deserialize)]
struct ModelEntry {
id: String,
}
let resp: ModelsResponse =
@@ -158,18 +152,10 @@ impl NearAiChatProvider {
reason: format!("JSON parse error: {}", e),
})?;
Ok(resp.data)
Ok(resp.data.into_iter().map(|m| m.id).collect())
}
}
/// Model entry as returned by the `/v1/models` API.
#[derive(Debug, Deserialize)]
struct ApiModelEntry {
id: String,
#[serde(default)]
context_length: Option<u32>,
}
#[async_trait]
impl LlmProvider for NearAiChatProvider {
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
@@ -177,7 +163,7 @@ impl LlmProvider for NearAiChatProvider {
req.messages.into_iter().map(|m| m.into()).collect();
let request = ChatCompletionRequest {
model: self.active_model_name(),
model: self.config.model.clone(),
messages,
temperature: req.temperature,
max_tokens: req.max_tokens,
@@ -211,7 +197,6 @@ impl LlmProvider for NearAiChatProvider {
finish_reason,
input_tokens: response.usage.prompt_tokens,
output_tokens: response.usage.completion_tokens,
response_id: None,
})
}
@@ -236,7 +221,7 @@ impl LlmProvider for NearAiChatProvider {
.collect();
let request = ChatCompletionRequest {
model: self.active_model_name(),
model: self.config.model.clone(),
messages,
temperature: req.temperature,
max_tokens: req.max_tokens,
@@ -293,7 +278,6 @@ impl LlmProvider for NearAiChatProvider {
finish_reason,
input_tokens: response.usage.prompt_tokens,
output_tokens: response.usage.completion_tokens,
response_id: None,
})
}
@@ -307,34 +291,7 @@ impl LlmProvider for NearAiChatProvider {
}
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
let models = self.fetch_models().await?;
Ok(models.into_iter().map(|m| m.id).collect())
}
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
let active = self.active_model_name();
let models = self.fetch_models().await?;
let current = models.iter().find(|m| m.id == active);
Ok(ModelMetadata {
id: active,
context_length: current.and_then(|m| m.context_length),
})
}
fn active_model_name(&self) -> String {
self.active_model
.read()
.expect("active_model lock poisoned")
.clone()
}
fn set_model(&self, model: &str) -> Result<(), crate::error::LlmError> {
let mut guard = self
.active_model
.write()
.expect("active_model lock poisoned");
*guard = model.to_string();
Ok(())
NearAiChatProvider::list_models(self).await
}
}
@@ -375,7 +332,6 @@ impl From<ChatMessage> for ChatCompletionMessage {
Role::Assistant => "assistant",
Role::Tool => "tool",
};
let tool_calls = msg.tool_calls.map(|calls| {
calls
.into_iter()
@@ -389,16 +345,9 @@ impl From<ChatMessage> for ChatCompletionMessage {
})
.collect()
});
let content = if role == "assistant" && tool_calls.is_some() && msg.content.is_empty() {
None
} else {
Some(msg.content)
};
Self {
role: role.to_string(),
content,
content: Some(msg.content),
tool_call_id: msg.tool_call_id,
name: msg.name,
tool_calls,
@@ -505,7 +454,7 @@ mod tests {
},
];
let msg = ChatMessage::assistant_with_tool_calls(None, tool_calls);
let msg = ChatMessage::assistant_with_tool_calls("", tool_calls);
let chat_msg: ChatCompletionMessage = msg.into();
assert_eq!(chat_msg.role, "assistant");
@@ -535,7 +484,7 @@ mod tests {
name: "test".to_string(),
arguments: serde_json::json!({"key": "value"}),
};
let msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]);
let msg = ChatMessage::assistant_with_tool_calls("", vec![tc]);
let chat_msg: ChatCompletionMessage = msg.into();
let calls = chat_msg.tool_calls.unwrap();
+11 -64
View File
@@ -27,8 +27,9 @@ pub struct ChatMessage {
/// Name of the tool for tool results.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Tool calls made by the assistant (OpenAI protocol requires these
/// to appear on the assistant message preceding tool result messages).
/// Tool calls requested by the assistant (for conversation replay).
/// OpenAI-compatible APIs require the assistant message to include
/// tool_calls when followed by tool result messages.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
@@ -67,14 +68,17 @@ impl ChatMessage {
}
}
/// Create an assistant message that includes tool calls.
/// Create an assistant message that requested tool calls.
///
/// Per the OpenAI protocol, an assistant message with tool_calls must
/// precede the corresponding tool result messages in the conversation.
pub fn assistant_with_tool_calls(content: Option<String>, tool_calls: Vec<ToolCall>) -> Self {
/// OpenAI-compatible APIs require the assistant message to carry the
/// `tool_calls` array when followed by tool-result messages.
pub fn assistant_with_tool_calls(
content: impl Into<String>,
tool_calls: Vec<ToolCall>,
) -> Self {
Self {
role: Role::Assistant,
content: content.unwrap_or_default(),
content: content.into(),
tool_call_id: None,
name: None,
tool_calls: if tool_calls.is_empty() {
@@ -108,8 +112,6 @@ pub struct CompletionRequest {
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub stop_sequences: Option<Vec<String>>,
/// Opaque metadata passed through to the provider (e.g. thread_id for chaining).
pub metadata: std::collections::HashMap<String, String>,
}
impl CompletionRequest {
@@ -120,7 +122,6 @@ impl CompletionRequest {
max_tokens: None,
temperature: None,
stop_sequences: None,
metadata: std::collections::HashMap::new(),
}
}
@@ -144,8 +145,6 @@ pub struct CompletionResponse {
pub input_tokens: u32,
pub output_tokens: u32,
pub finish_reason: FinishReason,
/// Provider-specific response ID (e.g. for NEAR AI response chaining).
pub response_id: Option<String>,
}
/// Why the completion finished.
@@ -192,8 +191,6 @@ pub struct ToolCompletionRequest {
pub temperature: Option<f32>,
/// How to handle tool use: "auto", "required", or "none".
pub tool_choice: Option<String>,
/// Opaque metadata passed through to the provider (e.g. thread_id for chaining).
pub metadata: std::collections::HashMap<String, String>,
}
impl ToolCompletionRequest {
@@ -205,7 +202,6 @@ impl ToolCompletionRequest {
max_tokens: None,
temperature: None,
tool_choice: None,
metadata: std::collections::HashMap::new(),
}
}
@@ -238,16 +234,6 @@ pub struct ToolCompletionResponse {
pub input_tokens: u32,
pub output_tokens: u32,
pub finish_reason: FinishReason,
/// Provider-specific response ID (e.g. for NEAR AI response chaining).
pub response_id: Option<String>,
}
/// Metadata about a model returned by the provider's API.
#[derive(Debug, Clone)]
pub struct ModelMetadata {
pub id: String,
/// Total context window size in tokens.
pub context_length: Option<u32>,
}
/// Trait for LLM providers.
@@ -274,45 +260,6 @@ pub trait LlmProvider: Send + Sync {
Ok(Vec::new())
}
/// Fetch metadata for the current model (context length, etc.).
/// Default returns the model name with no size info.
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
Ok(ModelMetadata {
id: self.model_name().to_string(),
context_length: None,
})
}
/// Get the currently active model name.
///
/// May differ from `model_name()` if the model was switched at runtime
/// via `set_model()`. Default returns `model_name()`.
fn active_model_name(&self) -> String {
self.model_name().to_string()
}
/// Switch the active model at runtime. Not all providers support this.
fn set_model(&self, _model: &str) -> Result<(), LlmError> {
Err(LlmError::RequestFailed {
provider: "unknown".to_string(),
reason: "Runtime model switching not supported by this provider".to_string(),
})
}
/// Seed a response chain for a thread (e.g. restoring from DB).
///
/// Providers that support response chaining (e.g. NEAR AI `previous_response_id`)
/// store this so subsequent calls send only delta messages.
fn seed_response_chain(&self, _thread_id: &str, _response_id: String) {}
/// Get the last response chain ID for a thread.
///
/// Returns `None` if the provider doesn't support chaining or has no
/// stored state for this thread.
fn get_response_chain_id(&self, _thread_id: &str) -> Option<String> {
None
}
/// Calculate cost for a completion.
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
let (input_cost, output_cost) = self.cost_per_token();
+29 -353
View File
@@ -21,8 +21,6 @@ pub struct ReasoningContext {
pub job_description: Option<String>,
/// Current state description.
pub current_state: Option<String>,
/// Opaque metadata forwarded to the LLM provider (e.g. thread_id for chaining).
pub metadata: std::collections::HashMap<String, String>,
}
impl ReasoningContext {
@@ -33,7 +31,6 @@ impl ReasoningContext {
available_tools: Vec::new(),
job_description: None,
current_state: None,
metadata: std::collections::HashMap::new(),
}
}
@@ -60,12 +57,6 @@ impl ReasoningContext {
self.job_description = Some(description.into());
self
}
/// Set metadata (forwarded to the LLM provider).
pub fn with_metadata(mut self, metadata: std::collections::HashMap<String, String>) -> Self {
self.metadata = metadata;
self
}
}
impl Default for ReasoningContext {
@@ -123,12 +114,7 @@ pub enum RespondResult {
/// A text response (no tools needed).
Text(String),
/// The model wants to call tools. Caller should execute them and call back.
/// Includes the optional content from the assistant message (some models
/// include explanatory text alongside tool calls).
ToolCalls {
tool_calls: Vec<ToolCall>,
content: Option<String>,
},
ToolCalls(Vec<ToolCall>),
}
/// Reasoning engine for the agent.
@@ -206,11 +192,10 @@ impl Reasoning {
return Ok(vec![]);
}
let mut request =
let request =
ToolCompletionRequest::new(context.messages.clone(), context.available_tools.clone())
.with_max_tokens(1024)
.with_tool_choice("auto");
request.metadata = context.metadata.clone();
let response = self.llm.complete_with_tools(request).await?;
@@ -286,9 +271,7 @@ Respond in JSON format:
pub async fn respond(&self, context: &ReasoningContext) -> Result<String, LlmError> {
match self.respond_with_tools(context).await? {
RespondResult::Text(text) => Ok(text),
RespondResult::ToolCalls {
tool_calls: calls, ..
} => {
RespondResult::ToolCalls(calls) => {
// Format tool calls as text (legacy behavior for non-agentic callers)
let tool_info: Vec<String> = calls
.iter()
@@ -315,49 +298,28 @@ Respond in JSON format:
// If we have tools, use tool completion mode
if !context.available_tools.is_empty() {
let mut request = ToolCompletionRequest::new(messages, context.available_tools.clone())
let request = ToolCompletionRequest::new(messages, context.available_tools.clone())
.with_max_tokens(4096)
.with_temperature(0.7)
.with_tool_choice("auto");
request.metadata = context.metadata.clone();
let response = self.llm.complete_with_tools(request).await?;
// If there were tool calls, return them for execution
if !response.tool_calls.is_empty() {
return Ok(RespondResult::ToolCalls {
tool_calls: response.tool_calls,
content: response.content,
});
return Ok(RespondResult::ToolCalls(response.tool_calls));
}
let content = response
.content
.unwrap_or_else(|| "I'm not sure how to respond to that.".to_string());
// Some models (e.g. GLM-4.7) emit tool calls as XML tags in content
// instead of using the structured tool_calls field. Try to recover
// them before giving up and returning plain text.
let recovered = recover_tool_calls_from_content(&content, &context.available_tools);
if !recovered.is_empty() {
let cleaned = clean_response(&content);
return Ok(RespondResult::ToolCalls {
tool_calls: recovered,
content: if cleaned.is_empty() {
None
} else {
Some(cleaned)
},
});
}
Ok(RespondResult::Text(clean_response(&content)))
} else {
// No tools, use simple completion
let mut request = CompletionRequest::new(messages)
let request = CompletionRequest::new(messages)
.with_max_tokens(4096)
.with_temperature(0.7);
request.metadata = context.metadata.clone();
let response = self.llm.complete(request).await?;
Ok(RespondResult::Text(clean_response(&response.content)))
@@ -500,178 +462,47 @@ fn extract_json(text: &str) -> Option<&str> {
}
}
/// Clean up LLM response by stripping model-internal tags and reasoning patterns.
///
/// Some models (GLM-4.7, etc.) emit XML-tagged internal state like
/// Try to extract tool calls from content text where the model emitted them
/// as XML tags instead of using the structured tool_calls field.
///
/// Handles these formats:
/// - `<tool_call>tool_name</tool_call>` (bare name)
/// - `<tool_call>{"name":"x","arguments":{}}</tool_call>` (JSON)
/// - `<|tool_call|>...<|/tool_call|>` (pipe-delimited variant)
/// - `<function_call>...</function_call>` (function_call variant)
///
/// Only returns calls whose name matches an available tool.
fn recover_tool_calls_from_content(
content: &str,
available_tools: &[ToolDefinition],
) -> Vec<ToolCall> {
let tool_names: std::collections::HashSet<&str> =
available_tools.iter().map(|t| t.name.as_str()).collect();
let mut calls = Vec::new();
for (open, close) in &[
("<tool_call>", "</tool_call>"),
("<|tool_call|>", "<|/tool_call|>"),
("<function_call>", "</function_call>"),
("<|function_call|>", "<|/function_call|>"),
] {
let mut remaining = content;
while let Some(start) = remaining.find(open) {
let inner_start = start + open.len();
let after = &remaining[inner_start..];
let Some(end) = after.find(close) else {
break;
};
let inner = after[..end].trim();
remaining = &after[end + close.len()..];
if inner.is_empty() {
continue;
}
// Try JSON first: {"name":"x","arguments":{}}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(inner) {
if let Some(name) = parsed.get("name").and_then(|v| v.as_str()) {
if tool_names.contains(name) {
let arguments = parsed
.get("arguments")
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
calls.push(ToolCall {
id: format!("recovered_{}", calls.len()),
name: name.to_string(),
arguments,
});
continue;
}
}
}
// Bare tool name (e.g. "<tool_call>tool_list</tool_call>")
let name = inner.trim();
if tool_names.contains(name) {
calls.push(ToolCall {
id: format!("recovered_{}", calls.len()),
name: name.to_string(),
arguments: serde_json::Value::Object(Default::default()),
});
}
}
}
calls
}
/// `<tool_call>tool_list</tool_call>` or `<|tool_call|>` in the content field
/// instead of using the standard OpenAI tool_calls array. We strip all of
/// these before the response reaches channels/users.
/// Clean up LLM response by stripping thinking tags and reasoning patterns.
fn clean_response(text: &str) -> String {
let text = strip_internal_tags(text);
let text = strip_thinking_tags(text);
strip_reasoning_patterns(&text)
}
/// Tags that are model-internal and should never reach users.
const INTERNAL_TAGS: &[&str] = &["thinking", "tool_call", "function_call", "tool_calls"];
/// Strip all model-internal XML tags from LLM output.
/// Strip `<thinking>...</thinking>` blocks from LLM output.
///
/// Handles standard XML tags (`<tag>...</tag>`) and pipe-delimited variants
/// (`<|tag|>...<|/tag|>`) used by some models (e.g. GLM-4.7).
fn strip_internal_tags(text: &str) -> String {
let mut result = text.to_string();
for tag in INTERNAL_TAGS {
result = strip_xml_tag(&result, tag);
result = strip_pipe_tag(&result, tag);
}
// Collapse triple+ newlines left behind by removed blocks
while result.contains("\n\n\n") {
result = result.replace("\n\n\n", "\n\n");
}
result.trim().to_string()
}
/// Strip `<tag>...</tag>` and `<tag ...>...</tag>` blocks from text.
fn strip_xml_tag(text: &str, tag: &str) -> String {
let open_exact = format!("<{}>", tag);
let open_prefix = format!("<{} ", tag); // for <tag attr="...">
let close = format!("</{}>", tag);
/// Some models (especially Claude with extended thinking) include internal
/// reasoning in thinking tags. We strip these before showing to users.
fn strip_thinking_tags(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut remaining = text;
loop {
// Find the next opening tag (exact or with attributes)
let exact_pos = remaining.find(&open_exact);
let prefix_pos = remaining.find(&open_prefix);
let start = match (exact_pos, prefix_pos) {
(Some(a), Some(b)) => a.min(b),
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => break,
};
while let Some(start) = remaining.find("<thinking>") {
// Add everything before the tag
result.push_str(&remaining[..start]);
// Find the end of the opening tag (the closing >)
let after_open = &remaining[start..];
let open_end = match after_open.find('>') {
Some(pos) => start + pos + 1,
None => break, // malformed, stop
};
// Find the closing tag
if let Some(close_offset) = remaining[open_end..].find(&close) {
let end = open_end + close_offset + close.len();
if let Some(end_offset) = remaining[start..].find("</thinking>") {
// Skip past the closing tag (start + offset + tag length)
let end = start + end_offset + "</thinking>".len();
remaining = &remaining[end..];
} else {
// No closing tag, discard from here (malformed)
// No closing tag found, discard everything from here
// (malformed, but handle gracefully by not including the unclosed tag)
remaining = "";
break;
}
}
// Add any remaining content after the last thinking block
result.push_str(remaining);
result
}
/// Strip `<|tag|>...<|/tag|>` pipe-delimited blocks from text.
///
/// Some models (e.g. certain Chinese LLMs) use this format instead of
/// standard XML tags.
fn strip_pipe_tag(text: &str, tag: &str) -> String {
let open = format!("<|{}|>", tag);
let close = format!("<|/{}|>", tag);
let mut result = String::with_capacity(text.len());
let mut remaining = text;
while let Some(start) = remaining.find(&open) {
result.push_str(&remaining[..start]);
if let Some(close_offset) = remaining[start..].find(&close) {
let end = start + close_offset + close.len();
remaining = &remaining[end..];
} else {
remaining = "";
break;
}
// Clean up any double newlines left behind
let mut cleaned = result.trim().to_string();
while cleaned.contains("\n\n\n") {
cleaned = cleaned.replace("\n\n\n", "\n\n");
}
result.push_str(remaining);
result
cleaned
}
/// Strip any remaining reasoning that wasn't in proper <thinking> tags.
@@ -743,7 +574,7 @@ That's my plan."#;
#[test]
fn test_strip_thinking_tags_basic() {
let input = "<thinking>Let me think about this...</thinking>Hello, user!";
let output = strip_internal_tags(input);
let output = strip_thinking_tags(input);
assert_eq!(output, "Hello, user!");
}
@@ -751,7 +582,7 @@ That's my plan."#;
fn test_strip_thinking_tags_multiple() {
let input =
"<thinking>First thought</thinking>Hello<thinking>Second thought</thinking> world!";
let output = strip_internal_tags(input);
let output = strip_thinking_tags(input);
assert_eq!(output, "Hello world!");
}
@@ -763,14 +594,14 @@ I need to consider:
2. How to respond
</thinking>
Here is my response to your question."#;
let output = strip_internal_tags(input);
let output = strip_thinking_tags(input);
assert_eq!(output, "Here is my response to your question.");
}
#[test]
fn test_strip_thinking_tags_no_tags() {
let input = "Just a normal response without thinking tags.";
let output = strip_internal_tags(input);
let output = strip_thinking_tags(input);
assert_eq!(output, "Just a normal response without thinking tags.");
}
@@ -778,77 +609,10 @@ Here is my response to your question."#;
fn test_strip_thinking_tags_unclosed() {
// Malformed: unclosed tag should strip from there to end
let input = "Hello <thinking>this never closes";
let output = strip_internal_tags(input);
let output = strip_thinking_tags(input);
assert_eq!(output, "Hello");
}
#[test]
fn test_strip_tool_call_tags() {
// GLM-4.7 emits this garbage instead of using the tool_calls array
let input = "<tool_call>tool_list</tool_call>";
let output = strip_internal_tags(input);
assert_eq!(output, "");
}
#[test]
fn test_strip_tool_call_with_surrounding_text() {
let input = "Here is my answer.\n\n<tool_call>\n{\"name\": \"search\", \"arguments\": {}}\n</tool_call>";
let output = strip_internal_tags(input);
assert_eq!(output, "Here is my answer.");
}
#[test]
fn test_strip_multiple_internal_tags() {
let input = "<thinking>Let me think</thinking>Hello!\n<tool_call>some_tool</tool_call>";
let output = strip_internal_tags(input);
assert_eq!(output, "Hello!");
}
#[test]
fn test_strip_function_call_tags() {
let input = "Response text<function_call>{\"name\": \"foo\"}</function_call>";
let output = strip_internal_tags(input);
assert_eq!(output, "Response text");
}
#[test]
fn test_strip_tool_calls_plural() {
let input = "<tool_calls>[{\"id\": \"1\"}]</tool_calls>Actual response.";
let output = strip_internal_tags(input);
assert_eq!(output, "Actual response.");
}
#[test]
fn test_strip_pipe_delimited_tags() {
let input = "<|tool_call|>{\"name\": \"search\"}<|/tool_call|>Hello!";
let output = strip_internal_tags(input);
assert_eq!(output, "Hello!");
}
#[test]
fn test_strip_pipe_delimited_thinking() {
let input = "<|thinking|>reasoning here<|/thinking|>The answer is 42.";
let output = strip_internal_tags(input);
assert_eq!(output, "The answer is 42.");
}
#[test]
fn test_strip_xml_tag_with_attributes() {
let input = "<tool_call type=\"function\">search()</tool_call>Done.";
let output = strip_internal_tags(input);
assert_eq!(output, "Done.");
}
#[test]
fn test_clean_response_preserves_normal_content() {
let input = "The function tool_call_handler works great. No tags here!";
let output = clean_response(input);
assert_eq!(
output,
"The function tool_call_handler works great. No tags here!"
);
}
#[test]
fn test_strip_reasoning_paragraph_break() {
// Content after paragraph break with "here" marker
@@ -896,92 +660,4 @@ Here is my response to your question."#;
let output = clean_response(input);
assert_eq!(output, "Here's the answer.");
}
// -- recover_tool_calls_from_content tests --
fn make_tools(names: &[&str]) -> Vec<ToolDefinition> {
names
.iter()
.map(|n| ToolDefinition {
name: n.to_string(),
description: String::new(),
parameters: serde_json::json!({}),
})
.collect()
}
#[test]
fn test_recover_bare_tool_name() {
let tools = make_tools(&["tool_list", "tool_auth"]);
let content = "<tool_call>tool_list</tool_call>";
let calls = recover_tool_calls_from_content(content, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "tool_list");
assert_eq!(calls[0].arguments, serde_json::json!({}));
}
#[test]
fn test_recover_json_tool_call() {
let tools = make_tools(&["memory_search"]);
let content =
r#"<tool_call>{"name": "memory_search", "arguments": {"query": "test"}}</tool_call>"#;
let calls = recover_tool_calls_from_content(content, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "memory_search");
assert_eq!(calls[0].arguments, serde_json::json!({"query": "test"}));
}
#[test]
fn test_recover_pipe_delimited() {
let tools = make_tools(&["tool_list"]);
let content = "<|tool_call|>tool_list<|/tool_call|>";
let calls = recover_tool_calls_from_content(content, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "tool_list");
}
#[test]
fn test_recover_unknown_tool_ignored() {
let tools = make_tools(&["tool_list"]);
let content = "<tool_call>nonexistent_tool</tool_call>";
let calls = recover_tool_calls_from_content(content, &tools);
assert!(calls.is_empty());
}
#[test]
fn test_recover_no_tags() {
let tools = make_tools(&["tool_list"]);
let content = "Just a normal response.";
let calls = recover_tool_calls_from_content(content, &tools);
assert!(calls.is_empty());
}
#[test]
fn test_recover_multiple_tool_calls() {
let tools = make_tools(&["tool_list", "tool_auth"]);
let content = "<tool_call>tool_list</tool_call>\n<tool_call>tool_auth</tool_call>";
let calls = recover_tool_calls_from_content(content, &tools);
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "tool_list");
assert_eq!(calls[1].name, "tool_auth");
}
#[test]
fn test_recover_function_call_variant() {
let tools = make_tools(&["shell"]);
let content =
r#"<function_call>{"name": "shell", "arguments": {"cmd": "ls"}}</function_call>"#;
let calls = recover_tool_calls_from_content(content, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "shell");
}
#[test]
fn test_recover_with_surrounding_text() {
let tools = make_tools(&["tool_list"]);
let content = "Let me check.\n\n<tool_call>tool_list</tool_call>\n\nDone.";
let calls = recover_tool_calls_from_content(content, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "tool_list");
}
}
-451
View File
@@ -1,451 +0,0 @@
//! Generic adapter that bridges rig-core's `CompletionModel` trait to IronClaw's `LlmProvider`.
//!
//! This lets us use any rig-core provider (OpenAI, Anthropic, Ollama, etc.) as an
//! `Arc<dyn LlmProvider>` without changing any of the agent, reasoning, or tool code.
use async_trait::async_trait;
use rig::OneOrMany;
use rig::completion::{
AssistantContent, CompletionModel, CompletionRequest as RigRequest,
ToolDefinition as RigToolDefinition, Usage as RigUsage,
};
use rig::message::{
Message as RigMessage, ToolChoice as RigToolChoice, ToolFunction, ToolResult as RigToolResult,
ToolResultContent, UserContent,
};
use rust_decimal::Decimal;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::error::LlmError;
use crate::llm::costs;
use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider,
ToolCall as IronToolCall, ToolCompletionRequest, ToolCompletionResponse,
ToolDefinition as IronToolDefinition,
};
/// Adapter that wraps a rig-core `CompletionModel` and implements `LlmProvider`.
pub struct RigAdapter<M: CompletionModel> {
model: M,
model_name: String,
input_cost: Decimal,
output_cost: Decimal,
}
impl<M: CompletionModel> RigAdapter<M> {
/// Create a new adapter wrapping the given rig-core model.
pub fn new(model: M, model_name: impl Into<String>) -> Self {
let name = model_name.into();
let (input_cost, output_cost) =
costs::model_cost(&name).unwrap_or_else(costs::default_cost);
Self {
model,
model_name: name,
input_cost,
output_cost,
}
}
}
// -- Type conversion helpers --
/// Convert IronClaw messages to rig-core format.
///
/// Returns `(preamble, chat_history)` where preamble is extracted from
/// any System message and chat_history contains the rest.
fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage>) {
let mut preamble: Option<String> = None;
let mut history = Vec::new();
for msg in messages {
match msg.role {
crate::llm::Role::System => {
// Concatenate system messages into preamble
match preamble {
Some(ref mut p) => {
p.push('\n');
p.push_str(&msg.content);
}
None => preamble = Some(msg.content.clone()),
}
}
crate::llm::Role::User => {
history.push(RigMessage::user(&msg.content));
}
crate::llm::Role::Assistant => {
if let Some(ref tool_calls) = msg.tool_calls {
// Assistant message with tool calls
let mut contents: Vec<AssistantContent> = Vec::new();
if !msg.content.is_empty() {
contents.push(AssistantContent::text(&msg.content));
}
for tc in tool_calls {
contents.push(AssistantContent::ToolCall(rig::message::ToolCall::new(
tc.id.clone(),
ToolFunction::new(tc.name.clone(), tc.arguments.clone()),
)));
}
if let Ok(many) = OneOrMany::many(contents) {
history.push(RigMessage::Assistant {
id: None,
content: many,
});
} else {
// Shouldn't happen but fall back to text
history.push(RigMessage::assistant(&msg.content));
}
} else {
history.push(RigMessage::assistant(&msg.content));
}
}
crate::llm::Role::Tool => {
// Tool result message: wrap as User { ToolResult }
let tool_id = msg.tool_call_id.clone().unwrap_or_default();
history.push(RigMessage::User {
content: OneOrMany::one(UserContent::ToolResult(RigToolResult {
id: tool_id,
call_id: None,
content: OneOrMany::one(ToolResultContent::text(&msg.content)),
})),
});
}
}
}
(preamble, history)
}
/// Convert IronClaw tool definitions to rig-core format.
fn convert_tools(tools: &[IronToolDefinition]) -> Vec<RigToolDefinition> {
tools
.iter()
.map(|t| RigToolDefinition {
name: t.name.clone(),
description: t.description.clone(),
parameters: t.parameters.clone(),
})
.collect()
}
/// Convert IronClaw tool_choice string to rig-core ToolChoice.
fn convert_tool_choice(choice: Option<&str>) -> Option<RigToolChoice> {
match choice.map(|s| s.to_lowercase()).as_deref() {
Some("auto") => Some(RigToolChoice::Auto),
Some("required") => Some(RigToolChoice::Required),
Some("none") => Some(RigToolChoice::None),
_ => None,
}
}
/// Extract text and tool calls from a rig-core completion response.
fn extract_response(
choice: &OneOrMany<AssistantContent>,
_usage: &RigUsage,
) -> (Option<String>, Vec<IronToolCall>, FinishReason) {
let mut text_parts: Vec<String> = Vec::new();
let mut tool_calls: Vec<IronToolCall> = Vec::new();
for content in choice.iter() {
match content {
AssistantContent::Text(t) => {
if !t.text.is_empty() {
text_parts.push(t.text.clone());
}
}
AssistantContent::ToolCall(tc) => {
tool_calls.push(IronToolCall {
id: tc.id.clone(),
name: tc.function.name.clone(),
arguments: tc.function.arguments.clone(),
});
}
// Reasoning and Image variants are not mapped to IronClaw types
_ => {}
}
}
let text = if text_parts.is_empty() {
None
} else {
Some(text_parts.join(""))
};
let finish = if !tool_calls.is_empty() {
FinishReason::ToolUse
} else {
FinishReason::Stop
};
(text, tool_calls, finish)
}
/// Saturate u64 to u32 for token counts.
fn saturate_u32(val: u64) -> u32 {
val.min(u32::MAX as u64) as u32
}
/// Build a rig-core CompletionRequest from our internal types.
fn build_rig_request(
preamble: Option<String>,
mut history: Vec<RigMessage>,
tools: Vec<RigToolDefinition>,
tool_choice: Option<RigToolChoice>,
temperature: Option<f32>,
max_tokens: Option<u32>,
) -> Result<RigRequest, LlmError> {
// rig-core requires at least one message in chat_history
if history.is_empty() {
history.push(RigMessage::user("Hello"));
}
let chat_history = OneOrMany::many(history).map_err(|e| LlmError::RequestFailed {
provider: "rig".to_string(),
reason: format!("Failed to build chat history: {}", e),
})?;
Ok(RigRequest {
preamble,
chat_history,
documents: Vec::new(),
tools,
temperature: temperature.map(|t| t as f64),
max_tokens: max_tokens.map(|t| t as u64),
tool_choice,
additional_params: None,
})
}
#[async_trait]
impl<M> LlmProvider for RigAdapter<M>
where
M: CompletionModel + Send + Sync + 'static,
M::Response: Send + Sync + Serialize + DeserializeOwned,
{
fn model_name(&self) -> &str {
&self.model_name
}
fn cost_per_token(&self) -> (Decimal, Decimal) {
(self.input_cost, self.output_cost)
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let (preamble, history) = convert_messages(&request.messages);
let rig_req = build_rig_request(
preamble,
history,
Vec::new(),
None,
request.temperature,
request.max_tokens,
)?;
let response =
self.model
.completion(rig_req)
.await
.map_err(|e| LlmError::RequestFailed {
provider: self.model_name.clone(),
reason: e.to_string(),
})?;
let (text, _tool_calls, finish) = extract_response(&response.choice, &response.usage);
Ok(CompletionResponse {
content: text.unwrap_or_default(),
input_tokens: saturate_u32(response.usage.input_tokens),
output_tokens: saturate_u32(response.usage.output_tokens),
finish_reason: finish,
response_id: None,
})
}
async fn complete_with_tools(
&self,
request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let (preamble, history) = convert_messages(&request.messages);
let tools = convert_tools(&request.tools);
let tool_choice = convert_tool_choice(request.tool_choice.as_deref());
let rig_req = build_rig_request(
preamble,
history,
tools,
tool_choice,
request.temperature,
request.max_tokens,
)?;
let response =
self.model
.completion(rig_req)
.await
.map_err(|e| LlmError::RequestFailed {
provider: self.model_name.clone(),
reason: e.to_string(),
})?;
let (text, tool_calls, finish) = extract_response(&response.choice, &response.usage);
Ok(ToolCompletionResponse {
content: text,
tool_calls,
input_tokens: saturate_u32(response.usage.input_tokens),
output_tokens: saturate_u32(response.usage.output_tokens),
finish_reason: finish,
response_id: None,
})
}
fn active_model_name(&self) -> String {
self.model_name.clone()
}
fn set_model(&self, _model: &str) -> Result<(), LlmError> {
// rig-core models are baked at construction time.
// Switching requires creating a new adapter.
Err(LlmError::RequestFailed {
provider: self.model_name.clone(),
reason: "Runtime model switching not supported for rig-core providers. \
Restart with a different model configured."
.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_convert_messages_system_to_preamble() {
let messages = vec![
ChatMessage::system("You are a helpful assistant."),
ChatMessage::user("Hello"),
];
let (preamble, history) = convert_messages(&messages);
assert_eq!(preamble, Some("You are a helpful assistant.".to_string()));
assert_eq!(history.len(), 1);
}
#[test]
fn test_convert_messages_multiple_systems_concatenated() {
let messages = vec![
ChatMessage::system("System 1"),
ChatMessage::system("System 2"),
ChatMessage::user("Hi"),
];
let (preamble, history) = convert_messages(&messages);
assert_eq!(preamble, Some("System 1\nSystem 2".to_string()));
assert_eq!(history.len(), 1);
}
#[test]
fn test_convert_messages_tool_result() {
let messages = vec![ChatMessage::tool_result(
"call_123",
"search",
"result text",
)];
let (preamble, history) = convert_messages(&messages);
assert!(preamble.is_none());
assert_eq!(history.len(), 1);
// Tool results become User messages in rig-core
match &history[0] {
RigMessage::User { .. } => {}
other => panic!("Expected User message, got: {:?}", other),
}
}
#[test]
fn test_convert_messages_assistant_with_tool_calls() {
let tc = IronToolCall {
id: "call_1".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}),
};
let msg = ChatMessage::assistant_with_tool_calls(Some("thinking".to_string()), vec![tc]);
let messages = vec![msg];
let (_preamble, history) = convert_messages(&messages);
assert_eq!(history.len(), 1);
match &history[0] {
RigMessage::Assistant { content, .. } => {
// Should have both text and tool call
assert!(content.iter().count() >= 2);
}
other => panic!("Expected Assistant message, got: {:?}", other),
}
}
#[test]
fn test_convert_tools() {
let tools = vec![IronToolDefinition {
name: "search".to_string(),
description: "Search the web".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {"type": "string"}
}
}),
}];
let rig_tools = convert_tools(&tools);
assert_eq!(rig_tools.len(), 1);
assert_eq!(rig_tools[0].name, "search");
assert_eq!(rig_tools[0].description, "Search the web");
}
#[test]
fn test_convert_tool_choice() {
assert!(matches!(
convert_tool_choice(Some("auto")),
Some(RigToolChoice::Auto)
));
assert!(matches!(
convert_tool_choice(Some("required")),
Some(RigToolChoice::Required)
));
assert!(matches!(
convert_tool_choice(Some("none")),
Some(RigToolChoice::None)
));
assert!(matches!(
convert_tool_choice(Some("AUTO")),
Some(RigToolChoice::Auto)
));
assert!(convert_tool_choice(None).is_none());
assert!(convert_tool_choice(Some("unknown")).is_none());
}
#[test]
fn test_extract_response_text_only() {
let content = OneOrMany::one(AssistantContent::text("Hello world"));
let usage = RigUsage::new();
let (text, calls, finish) = extract_response(&content, &usage);
assert_eq!(text, Some("Hello world".to_string()));
assert!(calls.is_empty());
assert_eq!(finish, FinishReason::Stop);
}
#[test]
fn test_extract_response_tool_call() {
let tc = AssistantContent::tool_call("call_1", "search", serde_json::json!({"q": "test"}));
let content = OneOrMany::one(tc);
let usage = RigUsage::new();
let (text, calls, finish) = extract_response(&content, &usage);
assert!(text.is_none());
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "search");
assert_eq!(finish, FinishReason::ToolUse);
}
#[test]
fn test_saturate_u32() {
assert_eq!(saturate_u32(100), 100);
assert_eq!(saturate_u32(u64::MAX), u32::MAX);
assert_eq!(saturate_u32(u32::MAX as u64), u32::MAX);
}
}
+2 -77
View File
@@ -61,10 +61,6 @@ pub struct SessionManager {
token: RwLock<Option<SecretString>>,
/// Prevents thundering herd during concurrent 401s.
renewal_lock: Mutex<()>,
/// Optional database store for persisting session to the settings table.
store: RwLock<Option<Arc<crate::history::Store>>>,
/// User ID for DB settings (default: "default").
user_id: RwLock<String>,
}
impl SessionManager {
@@ -78,8 +74,6 @@ impl SessionManager {
.unwrap_or_else(|_| Client::new()),
token: RwLock::new(None),
renewal_lock: Mutex::new(()),
store: RwLock::new(None),
user_id: RwLock::new("default".to_string()),
};
// Try to load existing session synchronously during construction
@@ -109,8 +103,6 @@ impl SessionManager {
.unwrap_or_else(|_| Client::new()),
token: RwLock::new(None),
renewal_lock: Mutex::new(()),
store: RwLock::new(None),
user_id: RwLock::new("default".to_string()),
};
if let Err(e) = manager.load_session().await {
@@ -120,21 +112,6 @@ impl SessionManager {
manager
}
/// Attach a database store for persisting session tokens.
///
/// When a store is attached, session tokens are saved to the `settings`
/// table (key: `nearai.session_token`) in addition to the disk file.
/// On load, DB is preferred over disk.
pub async fn attach_store(&self, store: Arc<crate::history::Store>, user_id: &str) {
*self.store.write().await = Some(store);
*self.user_id.write().await = user_id.to_string();
// Try to load from DB (may have been saved by a previous run)
if let Err(e) = self.load_session_from_db().await {
tracing::debug!("No session in DB: {}", e);
}
}
/// Get the current session token, returning an error if not authenticated.
pub async fn get_token(&self) -> Result<SecretString, LlmError> {
let guard = self.token.read().await;
@@ -483,7 +460,7 @@ impl SessionManager {
Ok(())
}
/// Save session data to disk and (if available) to the database.
/// Save session data to disk.
async fn save_session(&self, token: &str, auth_provider: Option<&str>) -> Result<(), LlmError> {
let session = SessionData {
session_token: token.to_string(),
@@ -491,7 +468,7 @@ impl SessionManager {
auth_provider: auth_provider.map(String::from),
};
// Save to disk (always, as bootstrap fallback)
// Ensure parent directory exists
if let Some(parent) = self.config.session_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
LlmError::Io(std::io::Error::new(
@@ -521,58 +498,6 @@ impl SessionManager {
})?;
tracing::debug!("Session saved to {}", self.config.session_path.display());
// Also save to DB if a store is attached
if let Some(ref store) = *self.store.read().await {
let user_id = self.user_id.read().await.clone();
let session_json = serde_json::to_value(&session)
.unwrap_or(serde_json::Value::String(token.to_string()));
if let Err(e) = store
.set_setting(&user_id, "nearai.session_token", &session_json)
.await
{
tracing::warn!("Failed to save session to DB: {}", e);
} else {
tracing::debug!("Session also saved to DB settings");
}
}
Ok(())
}
/// Try to load session from the database.
async fn load_session_from_db(&self) -> Result<(), LlmError> {
let store_guard = self.store.read().await;
let store = store_guard
.as_ref()
.ok_or_else(|| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "No DB store attached".to_string(),
})?;
let user_id = self.user_id.read().await.clone();
let value = store
.get_setting(&user_id, "nearai.session_token")
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("DB query failed: {}", e),
})?
.ok_or_else(|| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "No session in DB".to_string(),
})?;
let session: SessionData =
serde_json::from_value(value).map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Failed to parse DB session: {}", e),
})?;
let mut guard = self.token.write().await;
*guard = Some(SecretString::from(session.session_token));
tracing::info!("Loaded session from DB settings");
Ok(())
}
+41 -309
View File
@@ -17,26 +17,23 @@ use ironclaw::{
web::log_layer::{LogBroadcaster, WebLogLayer},
},
cli::{
Cli, Command, run_mcp_command, run_memory_command, run_pairing_command, run_status_command,
Cli, Command, run_key_command, run_mcp_command, run_memory_command, run_status_command,
run_tool_command,
},
config::Config,
context::ContextManager,
extensions::ExtensionManager,
history::Store,
keys::KeyManager,
llm::{SessionConfig, create_llm_provider, create_session_manager},
orchestrator::{
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
api::OrchestratorState,
},
pairing::PairingStore,
safety::SafetyLayer,
secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore},
settings::Settings,
setup::{SetupConfig, SetupWizard},
tools::{
ToolRegistry,
mcp::{McpClient, McpSessionManager, config::load_mcp_servers_from_db, is_authenticated},
wasm::{WasmToolLoader, WasmToolRuntime, load_dev_tools},
mcp::{McpClient, McpSessionManager, config::load_mcp_servers, is_authenticated},
wasm::{WasmToolLoader, WasmToolRuntime},
},
workspace::{EmbeddingProvider, NearAiEmbeddings, OpenAiEmbeddings, Workspace},
};
@@ -57,15 +54,20 @@ async fn main() -> anyhow::Result<()> {
return run_tool_command(tool_cmd.clone()).await;
}
Some(Command::Config(config_cmd)) => {
// Config commands need DB access for settings
Some(Command::Key(key_cmd)) => {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
)
.init();
return ironclaw::cli::run_config_command(config_cmd.clone()).await;
return run_key_command(key_cmd.clone()).await;
}
Some(Command::Config(config_cmd)) => {
// Config commands don't need logging setup
return ironclaw::cli::run_config_command(config_cmd.clone())
.map_err(|e| anyhow::anyhow!("{}", e));
}
Some(Command::Mcp(mcp_cmd)) => {
// Simple logging for MCP commands
@@ -86,9 +88,7 @@ async fn main() -> anyhow::Result<()> {
// Memory commands need database (and optionally embeddings)
let _ = dotenvy::dotenv();
let config = Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let config = Config::from_env().map_err(|e| anyhow::anyhow!("{}", e))?;
let store = ironclaw::history::Store::new(&config.database).await?;
store.run_migrations().await?;
@@ -132,15 +132,6 @@ async fn main() -> anyhow::Result<()> {
return run_memory_command(mem_cmd.clone(), store.pool(), embeddings).await;
}
Some(Command::Pairing(pairing_cmd)) => {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
)
.init();
return run_pairing_command(pairing_cmd.clone()).map_err(|e| anyhow::anyhow!("{}", e));
}
Some(Command::Status) => {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
@@ -151,83 +142,6 @@ async fn main() -> anyhow::Result<()> {
return run_status_command().await;
}
Some(Command::Worker {
job_id,
orchestrator_url,
max_iterations,
}) => {
// Worker mode: runs inside a Docker container.
// Simple logging (no TUI, no DB, no channels).
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info")),
)
.init();
tracing::info!(
"Starting worker for job {} (orchestrator: {})",
job_id,
orchestrator_url
);
let config = ironclaw::worker::runtime::WorkerConfig {
job_id: *job_id,
orchestrator_url: orchestrator_url.clone(),
max_iterations: *max_iterations,
timeout: std::time::Duration::from_secs(600),
};
let runtime = ironclaw::worker::WorkerRuntime::new(config)
.map_err(|e| anyhow::anyhow!("Worker init failed: {}", e))?;
runtime
.run()
.await
.map_err(|e| anyhow::anyhow!("Worker failed: {}", e))?;
return Ok(());
}
Some(Command::ClaudeBridge {
job_id,
orchestrator_url,
max_turns,
model,
}) => {
// Claude Code bridge mode: runs inside a Docker container.
// Spawns the `claude` CLI and streams output to the orchestrator.
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info")),
)
.init();
tracing::info!(
"Starting Claude Code bridge for job {} (orchestrator: {}, model: {})",
job_id,
orchestrator_url,
model
);
let config = ironclaw::worker::claude_bridge::ClaudeBridgeConfig {
job_id: *job_id,
orchestrator_url: orchestrator_url.clone(),
max_turns: *max_turns,
model: model.clone(),
timeout: std::time::Duration::from_secs(1800),
};
let runtime = ironclaw::worker::ClaudeBridgeRuntime::new(config)
.map_err(|e| anyhow::anyhow!("Claude bridge init failed: {}", e))?;
runtime
.run()
.await
.map_err(|e| anyhow::anyhow!("Claude bridge failed: {}", e))?;
return Ok(());
}
Some(Command::Onboard {
skip_auth,
channels_only,
@@ -253,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
// Enhanced first-run detection
if !cli.no_onboard {
if let Some(reason) = check_onboard_needed().await {
if let Some(reason) = check_onboard_needed() {
println!("Onboarding needed: {}", reason);
println!();
let mut wizard = SetupWizard::new();
@@ -261,11 +175,8 @@ async fn main() -> anyhow::Result<()> {
}
}
// Load bootstrap config (4 fields that must live on disk)
let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
// Load initial config from env + disk (before DB is available)
let mut config = match Config::from_env().await {
// Load configuration (after potential setup)
let config = match Config::from_env() {
Ok(c) => c,
Err(ironclaw::error::ConfigError::MissingRequired { key, hint }) => {
eprintln!("Configuration error: Missing required setting '{}'", key);
@@ -287,10 +198,8 @@ async fn main() -> anyhow::Result<()> {
};
let session = create_session_manager(session_config).await;
// Ensure we're authenticated before proceeding (only needed for NEAR AI backend)
if config.llm.backend == ironclaw::config::LlmBackend::NearAi {
session.ensure_authenticated().await?;
}
// Ensure we're authenticated before proceeding (may trigger login flow)
session.ensure_authenticated().await?;
// Initialize tracing
let env_filter = EnvFilter::try_from_default_env()
@@ -317,7 +226,7 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Starting IronClaw...");
tracing::info!("Loaded configuration for agent: {}", config.agent.name);
tracing::info!("LLM backend: {}", config.llm.backend);
tracing::info!("NEAR AI session authenticated");
// Initialize database store (optional for testing)
let store = if cli.no_db {
@@ -327,38 +236,7 @@ async fn main() -> anyhow::Result<()> {
let store = Store::new(&config.database).await?;
store.run_migrations().await?;
tracing::info!("Database connected and migrations applied");
// One-time migration: move disk config files into the DB settings table.
if let Err(e) = ironclaw::bootstrap::migrate_disk_to_db(&store, "default").await {
tracing::warn!("Disk-to-DB settings migration failed: {}", e);
}
// Reload config from DB now that we have a connection.
// Priority: env var > DB setting > default.
match Config::from_db(&store, "default", &bootstrap).await {
Ok(db_config) => {
config = db_config;
tracing::info!("Configuration reloaded from database");
}
Err(e) => {
tracing::warn!(
"Failed to reload config from DB, keeping env-based config: {}",
e
);
}
}
let store = Arc::new(store);
// Attach store to session manager so tokens save to DB too
session.attach_store(Arc::clone(&store), "default").await;
// Mark any jobs left in "running" or "creating" state as "interrupted".
if let Err(e) = store.cleanup_stale_sandbox_jobs().await {
tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e);
}
Some(store)
Some(Arc::new(store))
};
// Initialize LLM provider (clone session so we can reuse it for embeddings)
@@ -423,11 +301,8 @@ async fn main() -> anyhow::Result<()> {
tools.register_memory_tools(workspace);
}
// Register builder tool if enabled.
// When sandbox is enabled and allow_local_tools is false, skip builder registration
// because register_builder_tool also registers dev tools (shell, file ops) that would
// bypass the sandbox. The builder runs inside containers instead.
if config.builder.enabled && (config.agent.allow_local_tools || !config.sandbox.enabled) {
// Register builder tool if enabled
if config.builder.enabled {
tools
.register_builder_tool(
llm.clone(),
@@ -455,6 +330,11 @@ async fn main() -> anyhow::Result<()> {
None
};
// Create key manager if secrets store is available.
let key_manager: Option<Arc<KeyManager>> = secrets_store
.as_ref()
.map(|store| Arc::new(KeyManager::new(Arc::clone(store), "default".to_string())));
let mcp_session_manager = Arc::new(McpSessionManager::new());
// Create WASM tool runtime (sync, just builds the wasmtime engine)
@@ -476,8 +356,6 @@ async fn main() -> anyhow::Result<()> {
let wasm_tools_future = async {
if let Some(ref runtime) = wasm_tool_runtime {
let loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&tools));
// Load installed tools from ~/.ironclaw/tools/
match loader.load_from_dir(&config.wasm.tools_dir).await {
Ok(results) => {
if !results.loaded.is_empty() {
@@ -495,32 +373,12 @@ async fn main() -> anyhow::Result<()> {
tracing::warn!("Failed to scan WASM tools directory: {}", e);
}
}
// Load dev tools from build artifacts (overrides installed if newer)
match load_dev_tools(&loader, &config.wasm.tools_dir).await {
Ok(results) => {
if !results.loaded.is_empty() {
tracing::info!(
"Loaded {} dev WASM tools from build artifacts",
results.loaded.len()
);
}
}
Err(e) => {
tracing::debug!("No dev WASM tools found: {}", e);
}
}
}
};
let mcp_servers_future = async {
if let Some(ref secrets) = secrets_store {
let servers_result = if let Some(ref s) = store {
load_mcp_servers_from_db(s, "default").await
} else {
ironclaw::tools::mcp::config::load_mcp_servers().await
};
match servers_result {
match load_mcp_servers().await {
Ok(servers) => {
let enabled: Vec<_> = servers.enabled_servers().cloned().collect();
if !enabled.is_empty() {
@@ -629,7 +487,6 @@ async fn main() -> anyhow::Result<()> {
config.channels.wasm_channels_dir.clone(),
config.tunnel.public_url.clone(),
"default".to_string(),
store.clone(),
));
tools.register_extension_tools(Arc::clone(&manager));
tracing::info!("Extension manager initialized with in-chat discovery tools");
@@ -642,77 +499,6 @@ async fn main() -> anyhow::Result<()> {
None
};
// Set up orchestrator for sandboxed job execution
// When allow_local_tools is false (default), the LLM uses create_job for FS/shell work.
// When allow_local_tools is true, dev tools are also registered directly (current behavior).
if config.agent.allow_local_tools {
tools.register_dev_tools();
tracing::info!(
"Local tools enabled (allow_local_tools=true), dev tools registered directly"
);
}
// Shared state for job events (used by both orchestrator and web gateway)
let job_event_tx: Option<
tokio::sync::broadcast::Sender<(uuid::Uuid, ironclaw::channels::web::types::SseEvent)>,
> = if config.sandbox.enabled {
let (tx, _) = tokio::sync::broadcast::channel(256);
Some(tx)
} else {
None
};
let prompt_queue = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::<
uuid::Uuid,
std::collections::VecDeque<ironclaw::orchestrator::api::PendingPrompt>,
>::new()));
let container_job_manager: Option<Arc<ContainerJobManager>> = if config.sandbox.enabled {
let token_store = TokenStore::new();
let job_config = ContainerJobConfig {
image: config.sandbox.image.clone(),
memory_limit_mb: config.sandbox.memory_limit_mb,
cpu_shares: config.sandbox.cpu_shares,
orchestrator_port: 50051,
claude_config_dir: if config.claude_code.enabled {
Some(config.claude_code.config_dir.clone())
} else {
None
},
claude_code_model: config.claude_code.model.clone(),
claude_code_max_turns: config.claude_code.max_turns,
claude_code_memory_limit_mb: config.claude_code.memory_limit_mb,
};
let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone()));
// Start the orchestrator internal API in the background
let orchestrator_state = OrchestratorState {
llm: llm.clone(),
job_manager: Arc::clone(&jm),
token_store,
job_event_tx: job_event_tx.clone(),
prompt_queue: Arc::clone(&prompt_queue),
store: store.clone(),
};
tokio::spawn(async move {
if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await {
tracing::error!("Orchestrator API failed: {}", e);
}
});
tracing::info!("Orchestrator API started on :50051, sandbox delegation enabled");
if config.claude_code.enabled {
tracing::info!(
"Claude Code sandbox mode available (model: {}, max_turns: {})",
config.claude_code.model,
config.claude_code.max_turns
);
}
Some(jm)
} else {
None
};
tracing::info!(
"Tool registry initialized with {} total tools",
tools.count()
@@ -738,8 +524,7 @@ async fn main() -> anyhow::Result<()> {
match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
Ok(runtime) => {
let runtime = Arc::new(runtime);
let pairing_store = Arc::new(PairingStore::new());
let loader = WasmChannelLoader::new(Arc::clone(&runtime), pairing_store);
let loader = WasmChannelLoader::new(Arc::clone(&runtime));
match loader
.load_from_dir(&config.channels.wasm_channels_dir)
@@ -795,17 +580,6 @@ async fn main() -> anyhow::Result<()> {
);
}
// Inject owner_id for Telegram so the bot only responds
// to the bound user account.
if channel_name == "telegram" {
if let Some(owner_id) = config.channels.telegram_owner_id {
config_updates.insert(
"owner_id".to_string(),
serde_json::json!(owner_id),
);
}
}
if !config_updates.is_empty() {
channel_arc.update_config(config_updates).await;
tracing::info!(
@@ -864,7 +638,7 @@ async fn main() -> anyhow::Result<()> {
channels.add(Box::new(SharedWasmChannel::new(channel_arc)));
}
if has_webhook_channels {
if has_webhook_channels && config.tunnel.public_url.is_some() {
webhook_routes.push(create_wasm_channel_router(
wasm_router,
extension_manager.as_ref().map(Arc::clone),
@@ -936,19 +710,6 @@ async fn main() -> anyhow::Result<()> {
Arc::new(ws)
});
// Seed workspace with core identity files on first boot
if let Some(ref ws) = workspace {
match ws.seed_if_empty().await {
Ok(count) if count > 0 => {
tracing::info!("Workspace seeded with {} core files", count);
}
Ok(_) => {}
Err(e) => {
tracing::warn!("Failed to seed workspace: {}", e);
}
}
}
// Backfill embeddings if we just enabled the provider
if let (Some(ws), Some(_)) = (&workspace, &embeddings) {
match ws.backfill_embeddings().await {
@@ -968,12 +729,8 @@ async fn main() -> anyhow::Result<()> {
// Create session manager (shared between agent and web gateway)
let session_manager = Arc::new(SessionManager::new());
// Register job tools (sandbox deps auto-injected when container_job_manager is available)
tools.register_job_tools(
Arc::clone(&context_manager),
container_job_manager.clone(),
store.clone(),
);
// Register job tools
tools.register_job_tools(Arc::clone(&context_manager));
// Add web gateway channel if configured
if let Some(ref gw_config) = config.channels.gateway {
@@ -981,44 +738,19 @@ async fn main() -> anyhow::Result<()> {
if let Some(ref ws) = workspace {
gw = gw.with_workspace(Arc::clone(ws));
}
gw = gw.with_context_manager(Arc::clone(&context_manager));
gw = gw.with_session_manager(Arc::clone(&session_manager));
gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster));
gw = gw.with_tool_registry(Arc::clone(&tools));
if let Some(ref ext_mgr) = extension_manager {
gw = gw.with_extension_manager(Arc::clone(ext_mgr));
}
if let Some(ref s) = store {
gw = gw.with_store(Arc::clone(s));
}
if let Some(ref jm) = container_job_manager {
gw = gw.with_job_manager(Arc::clone(jm));
}
if config.sandbox.enabled {
gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
// Spawn a task to forward job events from the broadcast channel to SSE
if let Some(ref tx) = job_event_tx {
let mut rx = tx.subscribe();
let gw_state = Arc::clone(gw.state());
tokio::spawn(async move {
while let Ok((_job_id, event)) = rx.recv().await {
gw_state.sse.broadcast(event);
}
});
}
}
tracing::info!(
"Web gateway enabled on {}:{}",
gw_config.host,
gw_config.port
);
tracing::info!(
"Web UI: http://{}:{}/?token={}",
gw_config.host,
gw_config.port,
gw.auth_token()
);
channels.add(Box::new(gw));
}
@@ -1031,13 +763,13 @@ async fn main() -> anyhow::Result<()> {
tools,
workspace,
extension_manager,
key_manager,
};
let agent = Agent::new(
config.agent.clone(),
deps,
channels,
Some(config.heartbeat.clone()),
Some(config.routines.clone()),
Some(context_manager),
Some(session_manager),
);
@@ -1059,18 +791,18 @@ async fn main() -> anyhow::Result<()> {
/// Check if onboarding is needed and return the reason.
///
/// Returns `Some(reason)` if onboarding should be triggered, `None` otherwise.
async fn check_onboard_needed() -> Option<&'static str> {
let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
fn check_onboard_needed() -> Option<&'static str> {
let settings = Settings::load();
// Database not configured (and not in env)
if bootstrap.database_url.is_none() && std::env::var("DATABASE_URL").is_err() {
if settings.database_url.is_none() && std::env::var("DATABASE_URL").is_err() {
return Some("Database not configured");
}
// Secrets not configured (and not in env)
if bootstrap.secrets_master_key_source == ironclaw::settings::KeySource::None
if settings.secrets_master_key_source == ironclaw::settings::KeySource::None
&& std::env::var("SECRETS_MASTER_KEY").is_err()
&& !ironclaw::secrets::keychain::has_master_key().await
&& !ironclaw::secrets::keychain::has_master_key()
{
// Only require secrets setup if user hasn't explicitly disabled it
// For now, we don't require it for first run
@@ -1078,7 +810,7 @@ async fn check_onboard_needed() -> Option<&'static str> {
// First run (onboarding never completed and no session)
let session_path = ironclaw::llm::session::default_session_path();
if !bootstrap.onboard_completed && !session_path.exists() {
if !settings.onboard_completed && !session_path.exists() {
return Some("First run");
}
-511
View File
@@ -1,511 +0,0 @@
//! Internal HTTP API for worker-to-orchestrator communication.
//!
//! This runs on a separate port (default 50051) from the web gateway.
//! All endpoints are authenticated via per-job bearer tokens.
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, broadcast};
use uuid::Uuid;
use crate::channels::web::types::SseEvent;
use crate::history::Store;
use crate::llm::{CompletionRequest, LlmProvider, ToolCompletionRequest};
use crate::orchestrator::auth::{TokenStore, worker_auth_middleware};
use crate::orchestrator::job_manager::ContainerJobManager;
use crate::worker::api::JobEventPayload;
use crate::worker::api::{
CompletionReport, JobDescription, ProxyCompletionRequest, ProxyCompletionResponse,
ProxyToolCompletionRequest, ProxyToolCompletionResponse, StatusUpdate,
};
/// A follow-up prompt queued for a Claude Code bridge.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingPrompt {
pub content: String,
pub done: bool,
}
/// Shared state for the orchestrator API.
#[derive(Clone)]
pub struct OrchestratorState {
pub llm: Arc<dyn LlmProvider>,
pub job_manager: Arc<ContainerJobManager>,
pub token_store: TokenStore,
/// Broadcast channel for job events (consumed by the web gateway SSE).
pub job_event_tx: Option<broadcast::Sender<(Uuid, SseEvent)>>,
/// Buffered follow-up prompts for sandbox jobs, keyed by job_id.
pub prompt_queue: Arc<Mutex<HashMap<Uuid, VecDeque<PendingPrompt>>>>,
/// Database handle for persisting job events.
pub store: Option<Arc<Store>>,
}
/// The orchestrator's internal API server.
pub struct OrchestratorApi;
impl OrchestratorApi {
/// Build the axum router for the internal API.
pub fn router(state: OrchestratorState) -> Router {
Router::new()
// Worker routes: authenticated via route_layer middleware.
.route("/worker/{job_id}/job", get(get_job))
.route("/worker/{job_id}/llm/complete", post(llm_complete))
.route(
"/worker/{job_id}/llm/complete_with_tools",
post(llm_complete_with_tools),
)
.route("/worker/{job_id}/status", post(report_status))
.route("/worker/{job_id}/complete", post(report_complete))
.route("/worker/{job_id}/event", post(job_event_handler))
.route("/worker/{job_id}/prompt", get(get_prompt_handler))
.route_layer(axum::middleware::from_fn_with_state(
state.token_store.clone(),
worker_auth_middleware,
))
// Unauthenticated routes (added after the layer).
.route("/health", get(health_check))
.with_state(state)
}
/// Start the internal API server on the given port.
///
/// On macOS/Windows (Docker Desktop), binds to loopback only because
/// Docker Desktop routes `host.docker.internal` through its VM to the
/// host's `127.0.0.1`.
///
/// On Linux, containers reach the host via the docker bridge gateway
/// (`172.17.0.1`), which is NOT loopback. Binding to `127.0.0.1`
/// would reject container traffic. We bind to all interfaces instead
/// and rely on `worker_auth_middleware` (applied as a route_layer on
/// every `/worker/` endpoint) to reject unauthenticated requests.
pub async fn start(
state: OrchestratorState,
port: u16,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let router = Self::router(state);
let addr = if cfg!(target_os = "linux") {
std::net::SocketAddr::from(([0, 0, 0, 0], port))
} else {
std::net::SocketAddr::from(([127, 0, 0, 1], port))
};
tracing::info!("Orchestrator internal API listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router).await?;
Ok(())
}
}
// -- Handlers --
//
// All /worker/ handlers below are behind the worker_auth_middleware route_layer,
// so they don't need to validate tokens themselves.
async fn health_check() -> &'static str {
"ok"
}
async fn get_job(
State(state): State<OrchestratorState>,
Path(job_id): Path<Uuid>,
) -> Result<Json<JobDescription>, StatusCode> {
let handle = state
.job_manager
.get_handle(job_id)
.await
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(JobDescription {
title: format!("Job {}", job_id),
description: handle.task_description,
project_dir: handle.project_dir.map(|p| p.display().to_string()),
}))
}
async fn llm_complete(
State(state): State<OrchestratorState>,
Path(job_id): Path<Uuid>,
Json(req): Json<ProxyCompletionRequest>,
) -> Result<Json<ProxyCompletionResponse>, StatusCode> {
let completion_req = CompletionRequest {
messages: req.messages,
max_tokens: req.max_tokens,
temperature: req.temperature,
stop_sequences: req.stop_sequences,
metadata: std::collections::HashMap::new(),
};
let resp = state.llm.complete(completion_req).await.map_err(|e| {
tracing::error!("LLM completion failed for job {}: {}", job_id, e);
StatusCode::BAD_GATEWAY
})?;
Ok(Json(ProxyCompletionResponse {
content: resp.content,
input_tokens: resp.input_tokens,
output_tokens: resp.output_tokens,
finish_reason: format_finish_reason(resp.finish_reason),
}))
}
async fn llm_complete_with_tools(
State(state): State<OrchestratorState>,
Path(job_id): Path<Uuid>,
Json(req): Json<ProxyToolCompletionRequest>,
) -> Result<Json<ProxyToolCompletionResponse>, StatusCode> {
let tool_req = ToolCompletionRequest {
messages: req.messages,
tools: req.tools,
max_tokens: req.max_tokens,
temperature: req.temperature,
tool_choice: req.tool_choice,
metadata: std::collections::HashMap::new(),
};
let resp = state.llm.complete_with_tools(tool_req).await.map_err(|e| {
tracing::error!("LLM tool completion failed for job {}: {}", job_id, e);
StatusCode::BAD_GATEWAY
})?;
Ok(Json(ProxyToolCompletionResponse {
content: resp.content,
tool_calls: resp.tool_calls,
input_tokens: resp.input_tokens,
output_tokens: resp.output_tokens,
finish_reason: format_finish_reason(resp.finish_reason),
}))
}
async fn report_status(
Path(job_id): Path<Uuid>,
Json(update): Json<StatusUpdate>,
) -> Result<StatusCode, StatusCode> {
tracing::debug!(
job_id = %job_id,
state = %update.state,
iteration = update.iteration,
"Worker status update"
);
Ok(StatusCode::OK)
}
async fn report_complete(
State(state): State<OrchestratorState>,
Path(job_id): Path<Uuid>,
Json(report): Json<CompletionReport>,
) -> Result<StatusCode, StatusCode> {
if report.success {
tracing::info!(
job_id = %job_id,
"Worker reported job complete"
);
} else {
tracing::warn!(
job_id = %job_id,
message = ?report.message,
"Worker reported job failure"
);
}
// Store the result and clean up the container
let result = crate::orchestrator::job_manager::CompletionResult {
success: report.success,
message: report.message.clone(),
};
let _ = state.job_manager.complete_job(job_id, result).await;
Ok(StatusCode::OK)
}
// -- Sandbox job event handlers --
/// Receive a job event from a worker or Claude Code bridge and broadcast + persist it.
async fn job_event_handler(
State(state): State<OrchestratorState>,
Path(job_id): Path<Uuid>,
Json(payload): Json<JobEventPayload>,
) -> Result<StatusCode, StatusCode> {
tracing::debug!(
job_id = %job_id,
event_type = %payload.event_type,
"Job event received"
);
// Persist to DB (fire-and-forget)
if let Some(ref store) = state.store {
let store = Arc::clone(store);
let event_type = payload.event_type.clone();
let data = payload.data.clone();
tokio::spawn(async move {
if let Err(e) = store.save_job_event(job_id, &event_type, &data).await {
tracing::warn!(job_id = %job_id, "Failed to persist job event: {}", e);
}
});
}
// Convert to SSE event and broadcast
let job_id_str = job_id.to_string();
let sse_event = match payload.event_type.as_str() {
"message" => SseEvent::JobMessage {
job_id: job_id_str,
role: payload
.data
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("assistant")
.to_string(),
content: payload
.data
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
},
"tool_use" => SseEvent::JobToolUse {
job_id: job_id_str,
tool_name: payload
.data
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
input: payload
.data
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null),
},
"tool_result" => SseEvent::JobToolResult {
job_id: job_id_str,
tool_name: payload
.data
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
output: payload
.data
.get("output")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
},
"result" => SseEvent::JobResult {
job_id: job_id_str,
status: payload
.data
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
session_id: payload
.data
.get("session_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
},
_ => SseEvent::JobStatus {
job_id: job_id_str,
message: payload
.data
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
},
};
// Broadcast via the channel (if configured)
if let Some(ref tx) = state.job_event_tx {
let _ = tx.send((job_id, sse_event));
}
Ok(StatusCode::OK)
}
/// Return the next queued follow-up prompt for a Claude Code bridge.
/// Returns 204 No Content if no prompt is available.
async fn get_prompt_handler(
State(state): State<OrchestratorState>,
Path(job_id): Path<Uuid>,
) -> Result<(StatusCode, Json<serde_json::Value>), StatusCode> {
let mut queue = state.prompt_queue.lock().await;
if let Some(prompts) = queue.get_mut(&job_id) {
if let Some(prompt) = prompts.pop_front() {
return Ok((
StatusCode::OK,
Json(serde_json::json!({
"content": prompt.content,
"done": prompt.done,
})),
));
}
}
// Return 204 with an empty body. The Json wrapper requires some value
// but the status code signals "nothing here".
Ok((StatusCode::NO_CONTENT, Json(serde_json::Value::Null)))
}
fn format_finish_reason(reason: crate::llm::FinishReason) -> String {
match reason {
crate::llm::FinishReason::Stop => "stop".to_string(),
crate::llm::FinishReason::Length => "length".to_string(),
crate::llm::FinishReason::ToolUse => "tool_use".to_string(),
crate::llm::FinishReason::ContentFilter => "content_filter".to_string(),
crate::llm::FinishReason::Unknown => "unknown".to_string(),
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
use uuid::Uuid;
use crate::error::LlmError;
use crate::llm::{
CompletionRequest, CompletionResponse, ToolCompletionRequest, ToolCompletionResponse,
};
use crate::orchestrator::auth::TokenStore;
use crate::orchestrator::job_manager::{ContainerJobConfig, ContainerJobManager};
use super::*;
/// Stub LLM provider that panics if called (tests only exercise routing/auth).
struct StubLlm;
#[async_trait::async_trait]
impl crate::llm::LlmProvider for StubLlm {
fn model_name(&self) -> &str {
"stub"
}
fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) {
(rust_decimal::Decimal::ZERO, rust_decimal::Decimal::ZERO)
}
async fn complete(&self, _req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
Err(LlmError::RequestFailed {
provider: "stub".into(),
reason: "not implemented".into(),
})
}
async fn complete_with_tools(
&self,
_req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
Err(LlmError::RequestFailed {
provider: "stub".into(),
reason: "not implemented".into(),
})
}
}
fn test_state() -> OrchestratorState {
let token_store = TokenStore::new();
let jm = ContainerJobManager::new(ContainerJobConfig::default(), token_store.clone());
OrchestratorState {
llm: Arc::new(StubLlm),
job_manager: Arc::new(jm),
token_store,
job_event_tx: None,
prompt_queue: Arc::new(Mutex::new(HashMap::new())),
store: None,
}
}
#[tokio::test]
async fn health_requires_no_auth() {
let state = test_state();
let router = OrchestratorApi::router(state);
let req = Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn worker_route_rejects_missing_token() {
let state = test_state();
let router = OrchestratorApi::router(state);
let job_id = Uuid::new_v4();
let req = Request::builder()
.uri(format!("/worker/{}/job", job_id))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn worker_route_rejects_wrong_token() {
let state = test_state();
let router = OrchestratorApi::router(state);
let job_id = Uuid::new_v4();
let req = Request::builder()
.uri(format!("/worker/{}/job", job_id))
.header("Authorization", "Bearer totally-bogus")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn worker_route_accepts_valid_token() {
let state = test_state();
let job_id = Uuid::new_v4();
let token = state.token_store.create_token(job_id).await;
let router = OrchestratorApi::router(state);
let req = Request::builder()
.uri(format!("/worker/{}/job", job_id))
.header("Authorization", format!("Bearer {}", token))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
// 404 because no container exists for this job_id, but NOT 401.
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn token_for_job_a_rejected_on_job_b() {
let state = test_state();
let job_a = Uuid::new_v4();
let job_b = Uuid::new_v4();
let token_a = state.token_store.create_token(job_a).await;
let router = OrchestratorApi::router(state);
// Use job_a's token to hit job_b's endpoint
let req = Request::builder()
.uri(format!("/worker/{}/job", job_b))
.header("Authorization", format!("Bearer {}", token_a))
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}
-162
View File
@@ -1,162 +0,0 @@
//! Per-job bearer token authentication for worker-to-orchestrator communication.
//!
//! Security properties:
//! - Tokens are cryptographically random (32 bytes, hex-encoded)
//! - Tokens are scoped to a specific job_id
//! - Tokens are ephemeral (in-memory only, never persisted)
//! - A token for Job A cannot access endpoints for Job B
use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::Response;
use rand::Rng;
use tokio::sync::RwLock;
use uuid::Uuid;
/// In-memory store for per-job authentication tokens.
#[derive(Clone)]
pub struct TokenStore {
/// Maps job_id -> bearer token. Never logged or persisted.
tokens: Arc<RwLock<HashMap<Uuid, String>>>,
}
impl TokenStore {
pub fn new() -> Self {
Self {
tokens: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Generate and store a new token for a job.
pub async fn create_token(&self, job_id: Uuid) -> String {
let token = generate_token();
self.tokens.write().await.insert(job_id, token.clone());
token
}
/// Validate a token for a specific job.
pub async fn validate(&self, job_id: Uuid, token: &str) -> bool {
self.tokens
.read()
.await
.get(&job_id)
.map(|stored| stored == token)
.unwrap_or(false)
}
/// Remove a token (on container cleanup).
pub async fn revoke(&self, job_id: Uuid) {
self.tokens.write().await.remove(&job_id);
}
/// Get the number of active tokens (for diagnostics).
pub async fn active_count(&self) -> usize {
self.tokens.read().await.len()
}
}
impl Default for TokenStore {
fn default() -> Self {
Self::new()
}
}
/// Generate a cryptographically random token (32 bytes, hex-encoded = 64 chars).
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill(&mut bytes);
hex_encode(&bytes)
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
/// Axum middleware that validates worker bearer tokens.
///
/// Extracts the job_id from the path (`/worker/{job_id}/...`) and validates
/// the `Authorization: Bearer <token>` header against the token store.
///
/// Wire up with `axum::middleware::from_fn_with_state(token_store, worker_auth_middleware)`.
pub async fn worker_auth_middleware(
State(token_store): State<TokenStore>,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let path = request.uri().path().to_string();
let job_id = extract_job_id_from_path(&path).ok_or(StatusCode::BAD_REQUEST)?;
let token = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
if !token_store.validate(job_id, token).await {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(request).await)
}
/// Extract job UUID from a path like `/worker/{uuid}/...`
fn extract_job_id_from_path(path: &str) -> Option<Uuid> {
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
if parts.len() >= 2 && parts[0] == "worker" {
Uuid::parse_str(parts[1]).ok()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_token_create_and_validate() {
let store = TokenStore::new();
let job_id = Uuid::new_v4();
let token = store.create_token(job_id).await;
assert_eq!(token.len(), 64); // 32 bytes hex = 64 chars
assert!(store.validate(job_id, &token).await);
assert!(!store.validate(job_id, "wrong-token").await);
assert!(!store.validate(Uuid::new_v4(), &token).await);
}
#[tokio::test]
async fn test_token_revoke() {
let store = TokenStore::new();
let job_id = Uuid::new_v4();
let token = store.create_token(job_id).await;
assert!(store.validate(job_id, &token).await);
store.revoke(job_id).await;
assert!(!store.validate(job_id, &token).await);
}
#[test]
fn test_extract_job_id() {
let id = Uuid::new_v4();
let path = format!("/worker/{}/llm/complete", id);
assert_eq!(extract_job_id_from_path(&path), Some(id));
assert_eq!(extract_job_id_from_path("/other/path"), None);
assert_eq!(extract_job_id_from_path("/worker/not-a-uuid/foo"), None);
}
#[test]
fn test_token_is_random() {
let t1 = generate_token();
let t2 = generate_token();
assert_ne!(t1, t2);
}
}
-474
View File
@@ -1,474 +0,0 @@
//! Container lifecycle management for sandboxed jobs.
//!
//! Extends the existing `SandboxManager` infrastructure to support persistent
//! containers with their own agent loops (as opposed to ephemeral per-command containers).
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::error::OrchestratorError;
use crate::orchestrator::auth::TokenStore;
use crate::sandbox::connect_docker;
/// Which mode a sandbox container runs in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobMode {
/// Standard IronClaw worker with proxied LLM calls.
Worker,
/// Claude Code bridge that spawns the `claude` CLI directly.
ClaudeCode,
}
impl JobMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Worker => "worker",
Self::ClaudeCode => "claude_code",
}
}
}
impl std::fmt::Display for JobMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// Configuration for the container job manager.
#[derive(Debug, Clone)]
pub struct ContainerJobConfig {
/// Docker image for worker containers.
pub image: String,
/// Default memory limit in MB.
pub memory_limit_mb: u64,
/// Default CPU shares.
pub cpu_shares: u32,
/// Port the orchestrator internal API listens on.
pub orchestrator_port: u16,
/// Host directory containing Claude auth config (mounted read-only for ClaudeCode mode).
pub claude_config_dir: Option<PathBuf>,
/// Claude model to use in ClaudeCode mode.
pub claude_code_model: String,
/// Maximum turns for Claude Code.
pub claude_code_max_turns: u32,
/// Memory limit in MB for Claude Code containers (heavier than workers).
pub claude_code_memory_limit_mb: u64,
}
impl Default for ContainerJobConfig {
fn default() -> Self {
Self {
image: "ironclaw-worker:latest".to_string(),
memory_limit_mb: 2048,
cpu_shares: 1024,
orchestrator_port: 50051,
claude_config_dir: None,
claude_code_model: "sonnet".to_string(),
claude_code_max_turns: 50,
claude_code_memory_limit_mb: 4096,
}
}
}
/// State of a container.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContainerState {
Creating,
Running,
Stopped,
Failed,
}
impl std::fmt::Display for ContainerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Creating => write!(f, "creating"),
Self::Running => write!(f, "running"),
Self::Stopped => write!(f, "stopped"),
Self::Failed => write!(f, "failed"),
}
}
}
/// Handle to a running container job.
#[derive(Debug, Clone)]
pub struct ContainerHandle {
pub job_id: Uuid,
pub container_id: String,
pub state: ContainerState,
pub mode: JobMode,
pub created_at: DateTime<Utc>,
pub project_dir: Option<PathBuf>,
pub task_description: String,
/// Completion result from the worker (set when the worker reports done).
pub completion_result: Option<CompletionResult>,
// NOTE: auth_token is intentionally NOT in this struct.
// It lives only in the TokenStore (never logged, serialized, or persisted).
}
/// Result reported by a worker on completion.
#[derive(Debug, Clone)]
pub struct CompletionResult {
pub success: bool,
pub message: Option<String>,
}
/// Manages the lifecycle of Docker containers for sandboxed job execution.
pub struct ContainerJobManager {
config: ContainerJobConfig,
token_store: TokenStore,
containers: Arc<RwLock<HashMap<Uuid, ContainerHandle>>>,
}
impl ContainerJobManager {
pub fn new(config: ContainerJobConfig, token_store: TokenStore) -> Self {
Self {
config,
token_store,
containers: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Create and start a new container for a job.
///
/// The caller provides the `job_id` so it can be persisted to the database
/// before the container is created. Returns the auth token for the worker.
pub async fn create_job(
&self,
job_id: Uuid,
task: &str,
project_dir: Option<PathBuf>,
mode: JobMode,
) -> Result<String, OrchestratorError> {
// Generate auth token (stored in TokenStore, never logged)
let token = self.token_store.create_token(job_id).await;
// Record the handle
let handle = ContainerHandle {
job_id,
container_id: String::new(), // set after container creation
state: ContainerState::Creating,
mode,
created_at: Utc::now(),
project_dir: project_dir.clone(),
task_description: task.to_string(),
completion_result: None,
};
self.containers.write().await.insert(job_id, handle);
// Connect to Docker
let docker = connect_docker()
.await
.map_err(|e| OrchestratorError::Docker {
reason: e.to_string(),
})?;
// Build container configuration
let orchestrator_host = if cfg!(target_os = "linux") {
"172.17.0.1"
} else {
"host.docker.internal"
};
let orchestrator_url = format!(
"http://{}:{}",
orchestrator_host, self.config.orchestrator_port
);
let mut env_vec = vec![
format!("IRONCLAW_WORKER_TOKEN={}", token),
format!("IRONCLAW_JOB_ID={}", job_id),
format!("IRONCLAW_ORCHESTRATOR_URL={}", orchestrator_url),
];
// Build volume mounts (validate project_dir stays within ~/.ironclaw/projects/)
let mut binds = Vec::new();
if let Some(ref dir) = project_dir {
let canonical =
dir.canonicalize()
.map_err(|e| OrchestratorError::ContainerCreationFailed {
job_id,
reason: format!(
"failed to canonicalize project dir {}: {}",
dir.display(),
e
),
})?;
let projects_base = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
.join("projects");
if let Ok(canonical_base) = projects_base.canonicalize() {
if !canonical.starts_with(&canonical_base) {
return Err(OrchestratorError::ContainerCreationFailed {
job_id,
reason: format!(
"project directory {} is outside allowed base {}",
canonical.display(),
canonical_base.display()
),
});
}
}
binds.push(format!("{}:/workspace:rw", canonical.display()));
env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string());
}
// Claude Code mode: mount host ~/.claude read-only for auth
if mode == JobMode::ClaudeCode {
if let Some(ref claude_dir) = self.config.claude_config_dir {
binds.push(format!("{}:/home/sandbox/.claude:ro", claude_dir.display()));
}
}
// Memory limit: Claude Code gets more memory
let memory_mb = match mode {
JobMode::ClaudeCode => self.config.claude_code_memory_limit_mb,
JobMode::Worker => self.config.memory_limit_mb,
};
// Create the container
use bollard::container::{Config, CreateContainerOptions};
use bollard::models::HostConfig;
let host_config = HostConfig {
binds: if binds.is_empty() { None } else { Some(binds) },
memory: Some((memory_mb * 1024 * 1024) as i64),
cpu_shares: Some(self.config.cpu_shares as i64),
network_mode: Some("bridge".to_string()),
extra_hosts: Some(vec!["host.docker.internal:host-gateway".to_string()]),
cap_drop: Some(vec!["ALL".to_string()]),
cap_add: Some(vec![
"CHOWN".to_string(),
"SETUID".to_string(),
"SETGID".to_string(),
]),
security_opt: Some(vec!["no-new-privileges:true".to_string()]),
tmpfs: Some(
[("/tmp".to_string(), "size=512M".to_string())]
.into_iter()
.collect(),
),
..Default::default()
};
// Build CMD based on mode
let cmd = match mode {
JobMode::Worker => vec![
"worker".to_string(),
"--job-id".to_string(),
job_id.to_string(),
"--orchestrator-url".to_string(),
orchestrator_url,
],
JobMode::ClaudeCode => vec![
"claude-bridge".to_string(),
"--job-id".to_string(),
job_id.to_string(),
"--orchestrator-url".to_string(),
orchestrator_url,
"--max-turns".to_string(),
self.config.claude_code_max_turns.to_string(),
"--model".to_string(),
self.config.claude_code_model.clone(),
],
};
let container_config = Config {
image: Some(self.config.image.clone()),
cmd: Some(cmd),
env: Some(env_vec),
host_config: Some(host_config),
user: Some("1000:1000".to_string()),
working_dir: Some("/workspace".to_string()),
..Default::default()
};
let container_name = match mode {
JobMode::Worker => format!("ironclaw-worker-{}", job_id),
JobMode::ClaudeCode => format!("ironclaw-claude-{}", job_id),
};
let options = CreateContainerOptions {
name: container_name,
..Default::default()
};
let response = docker
.create_container(Some(options), container_config)
.await
.map_err(|e| OrchestratorError::ContainerCreationFailed {
job_id,
reason: e.to_string(),
})?;
let container_id = response.id;
// Start the container
docker
.start_container::<String>(&container_id, None)
.await
.map_err(|e| OrchestratorError::ContainerCreationFailed {
job_id,
reason: format!("failed to start container: {}", e),
})?;
// Update handle with container ID
if let Some(handle) = self.containers.write().await.get_mut(&job_id) {
handle.container_id = container_id;
handle.state = ContainerState::Running;
}
tracing::info!(
job_id = %job_id,
"Created and started worker container"
);
Ok(token)
}
/// Stop a running container job.
pub async fn stop_job(&self, job_id: Uuid) -> Result<(), OrchestratorError> {
let container_id = {
let containers = self.containers.read().await;
containers
.get(&job_id)
.map(|h| h.container_id.clone())
.ok_or(OrchestratorError::ContainerNotFound { job_id })?
};
if container_id.is_empty() {
return Err(OrchestratorError::InvalidContainerState {
job_id,
state: "creating (no container ID yet)".to_string(),
});
}
let docker = connect_docker()
.await
.map_err(|e| OrchestratorError::Docker {
reason: e.to_string(),
})?;
// Stop the container (10 second grace period)
let _ = docker
.stop_container(
&container_id,
Some(bollard::container::StopContainerOptions { t: 10 }),
)
.await;
// Remove the container
let _ = docker
.remove_container(
&container_id,
Some(bollard::container::RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await;
// Update state
if let Some(handle) = self.containers.write().await.get_mut(&job_id) {
handle.state = ContainerState::Stopped;
}
// Revoke the auth token
self.token_store.revoke(job_id).await;
tracing::info!(job_id = %job_id, "Stopped worker container");
Ok(())
}
/// Mark a job as complete with a result. The container is stopped but the
/// handle is kept so `CreateJobTool` can read the completion message.
pub async fn complete_job(
&self,
job_id: Uuid,
result: CompletionResult,
) -> Result<(), OrchestratorError> {
// Store the result before stopping
{
let mut containers = self.containers.write().await;
if let Some(handle) = containers.get_mut(&job_id) {
handle.completion_result = Some(result);
handle.state = ContainerState::Stopped;
}
}
// Stop container and revoke token (but keep handle in map)
let container_id = {
let containers = self.containers.read().await;
containers.get(&job_id).map(|h| h.container_id.clone())
};
if let Some(cid) = container_id {
if !cid.is_empty() {
if let Ok(docker) = connect_docker().await {
let _ = docker
.stop_container(
&cid,
Some(bollard::container::StopContainerOptions { t: 5 }),
)
.await;
let _ = docker
.remove_container(
&cid,
Some(bollard::container::RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await;
}
}
}
self.token_store.revoke(job_id).await;
tracing::info!(job_id = %job_id, "Completed worker container");
Ok(())
}
/// Remove a completed job handle from memory (called after result is read).
pub async fn cleanup_job(&self, job_id: Uuid) {
self.containers.write().await.remove(&job_id);
}
/// Get the handle for a job.
pub async fn get_handle(&self, job_id: Uuid) -> Option<ContainerHandle> {
self.containers.read().await.get(&job_id).cloned()
}
/// List all active container jobs.
pub async fn list_jobs(&self) -> Vec<ContainerHandle> {
self.containers.read().await.values().cloned().collect()
}
/// Get a reference to the token store.
pub fn token_store(&self) -> &TokenStore {
&self.token_store
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_container_job_config_default() {
let config = ContainerJobConfig::default();
assert_eq!(config.orchestrator_port, 50051);
assert_eq!(config.memory_limit_mb, 2048);
}
#[test]
fn test_container_state_display() {
assert_eq!(ContainerState::Running.to_string(), "running");
assert_eq!(ContainerState::Stopped.to_string(), "stopped");
}
}
-37
View File
@@ -1,37 +0,0 @@
//! Orchestrator for managing sandboxed worker containers.
//!
//! The orchestrator runs in the main agent process and provides:
//! - An internal HTTP API for worker communication (LLM proxy, status, secrets)
//! - Per-job bearer token authentication
//! - Container lifecycle management (create, monitor, stop)
//!
//! ```text
//! ┌───────────────────────────────────────────────┐
//! │ Orchestrator │
//! │ │
//! │ Internal API (:50051) │
//! │ POST /worker/{id}/llm/complete │
//! │ POST /worker/{id}/llm/complete_with_tools │
//! │ GET /worker/{id}/job │
//! │ POST /worker/{id}/status │
//! │ POST /worker/{id}/complete │
//! │ │
//! │ ContainerJobManager │
//! │ create_job() -> container + token │
//! │ stop_job() │
//! │ list_jobs() │
//! │ │
//! │ TokenStore │
//! │ per-job bearer tokens (in-memory only) │
//! └───────────────────────────────────────────────┘
//! ```
pub mod api;
pub mod auth;
pub mod job_manager;
pub use api::OrchestratorApi;
pub use auth::TokenStore;
pub use job_manager::{
CompletionResult, ContainerHandle, ContainerJobConfig, ContainerJobManager, JobMode,
};
-10
View File
@@ -1,10 +0,0 @@
//! DM pairing for channels.
//!
//! Gates DMs from unknown senders. Only approved senders can message the agent.
//! Unknown senders receive a pairing code and must be approved via `ironclaw pairing approve`.
//!
//! OpenClaw reference: src/pairing/pairing-store.ts
mod store;
pub use store::{PairingRequest, PairingStore, PairingStoreError};
-699
View File
@@ -1,699 +0,0 @@
//! Pairing store: pending requests and allowFrom list.
//!
//! Stored in ~/.ironclaw/{channel}-pairing.json and {channel}-allowFrom.json.
use std::collections::HashSet;
use std::fs;
use std::io::{Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use fs4::FileExt;
use rand::Rng;
use serde::{Deserialize, Serialize};
const PAIRING_CODE_LENGTH: usize = 8;
const PAIRING_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/// TTL for pending pairing requests (minutes, not hours — reduces brute-force window).
const PAIRING_PENDING_TTL_SECS: u64 = 15 * 60;
const PAIRING_PENDING_MAX: usize = 3;
/// Max failed approve attempts per channel before rate limit kicks in.
const PAIRING_APPROVE_RATE_LIMIT: usize = 10;
/// Time window for rate limit (seconds).
const PAIRING_APPROVE_RATE_WINDOW_SECS: u64 = 5 * 60;
/// Error from pairing store operations.
#[derive(Debug, thiserror::Error)]
pub enum PairingStoreError {
#[error("Invalid channel: {0}")]
InvalidChannel(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Rate limit: too many failed approve attempts; try again later")]
ApproveRateLimited,
}
/// Result of upserting a pairing request.
#[derive(Debug)]
pub struct UpsertResult {
pub code: String,
pub created: bool,
}
/// A pending pairing request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PairingRequest {
pub id: String,
pub code: String,
pub created_at: String,
pub last_seen_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PairingStoreFile {
version: u8,
requests: Vec<PairingRequest>,
}
#[derive(Debug, Serialize, Deserialize)]
struct AllowFromStoreFile {
version: u8,
#[serde(rename = "allowFrom")]
allow_from: Vec<String>,
}
fn default_pairing_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw")
}
fn safe_channel_key(channel: &str) -> Result<String, PairingStoreError> {
let raw = channel.trim().to_lowercase();
if raw.is_empty() {
return Err(PairingStoreError::InvalidChannel("empty".to_string()));
}
let safe = raw
.chars()
.map(|c| match c {
'\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => c,
})
.collect::<String>()
.replace("..", "_");
if safe.is_empty() || safe == "_" {
return Err(PairingStoreError::InvalidChannel(channel.to_string()));
}
Ok(safe)
}
fn pairing_path(base_dir: &Path, channel: &str) -> Result<PathBuf, PairingStoreError> {
let key = safe_channel_key(channel)?;
Ok(base_dir.join(format!("{}-pairing.json", key)))
}
fn allow_from_path(base_dir: &Path, channel: &str) -> Result<PathBuf, PairingStoreError> {
let key = safe_channel_key(channel)?;
Ok(base_dir.join(format!("{}-allowFrom.json", key)))
}
fn approve_attempts_path(base_dir: &Path, channel: &str) -> Result<PathBuf, PairingStoreError> {
let key = safe_channel_key(channel)?;
Ok(base_dir.join(format!("{}-approve-attempts.json", key)))
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct ApproveAttemptsFile {
failed_at: Vec<u64>,
}
fn now_iso() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
#[allow(clippy::cast_possible_wrap)]
chrono::DateTime::from_timestamp(now.as_secs() as i64, 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| now.as_secs().to_string())
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn parse_timestamp(value: &str) -> Option<u64> {
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.map(|dt| dt.timestamp() as u64)
.or_else(|| value.parse::<u64>().ok())
}
fn is_expired(req: &PairingRequest, now_secs: u64) -> bool {
let created = parse_timestamp(&req.created_at).unwrap_or(0);
now_secs.saturating_sub(created) > PAIRING_PENDING_TTL_SECS
}
fn random_code() -> String {
let mut rng = rand::thread_rng();
(0..PAIRING_CODE_LENGTH)
.map(|_| {
let idx = rng.gen_range(0..PAIRING_ALPHABET.len());
PAIRING_ALPHABET[idx] as char
})
.collect()
}
fn generate_unique_code(existing: &HashSet<String>) -> String {
let mut rng = rand::thread_rng();
for _ in 0..500 {
let code = random_code();
if !existing.contains(&code) {
return code;
}
}
// Fallback: add suffix
format!("{}{:04}", random_code(), rng.gen_range(0..10000))
}
/// Pairing store for a channel.
#[derive(Debug, Clone)]
pub struct PairingStore {
base_dir: PathBuf,
}
impl PairingStore {
/// Create a new pairing store using default directory (~/.ironclaw).
pub fn new() -> Self {
Self {
base_dir: default_pairing_dir(),
}
}
/// Create a pairing store with a custom base directory (for testing).
pub fn with_base_dir(base_dir: PathBuf) -> Self {
Self { base_dir }
}
/// List pending pairing requests for a channel.
pub fn list_pending(&self, channel: &str) -> Result<Vec<PairingRequest>, PairingStoreError> {
let path = pairing_path(&self.base_dir, channel)?;
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(e) => return Err(e.into()),
};
let file: PairingStoreFile = serde_json::from_str(&content).unwrap_or(PairingStoreFile {
version: 1,
requests: Vec::new(),
});
let now = now_secs();
let original_len = file.requests.len();
let mut requests: Vec<_> = file
.requests
.into_iter()
.filter(|r| !is_expired(r, now))
.collect();
if requests.len() != original_len {
self.write_pairing_file(channel, &requests)?;
}
requests.sort_by(|a, b| a.created_at.cmp(&b.created_at));
Ok(requests)
}
/// Upsert a pairing request. Returns (code, created).
pub fn upsert_request(
&self,
channel: &str,
id: &str,
meta: Option<serde_json::Value>,
) -> Result<UpsertResult, PairingStoreError> {
let path = pairing_path(&self.base_dir, channel)?;
fs::create_dir_all(path.parent().unwrap())?;
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut store: PairingStoreFile =
serde_json::from_str(&content).unwrap_or(PairingStoreFile {
version: 1,
requests: Vec::new(),
});
let now = now_iso();
let now_secs = now_secs();
let id = id.trim().to_string();
if id.is_empty() {
fs4::FileExt::unlock(&file)?;
return Err(PairingStoreError::InvalidChannel("empty id".to_string()));
}
store.requests.retain(|r| !is_expired(r, now_secs));
let existing_codes: HashSet<String> = store
.requests
.iter()
.map(|r| r.code.to_uppercase())
.collect();
if let Some(idx) = store.requests.iter().position(|r| r.id == id) {
let req = &mut store.requests[idx];
let code = if req.code.is_empty() {
generate_unique_code(&existing_codes)
} else {
req.code.clone()
};
req.last_seen_at = now.clone();
req.code = code.clone();
if let Some(m) = meta {
req.meta = Some(m);
}
self.write_pairing_file_locked(&mut file, channel, &store.requests)?;
fs4::FileExt::unlock(&file)?;
return Ok(UpsertResult {
code,
created: false,
});
}
if store.requests.len() >= PAIRING_PENDING_MAX {
fs4::FileExt::unlock(&file)?;
return Ok(UpsertResult {
code: String::new(),
created: false,
});
}
let code = generate_unique_code(&existing_codes);
store.requests.push(PairingRequest {
id: id.clone(),
code: code.clone(),
created_at: now.clone(),
last_seen_at: now,
meta,
});
self.write_pairing_file_locked(&mut file, channel, &store.requests)?;
fs4::FileExt::unlock(&file)?;
Ok(UpsertResult {
code,
created: true,
})
}
fn is_approve_rate_limited(&self, channel: &str) -> Result<bool, PairingStoreError> {
let path = approve_attempts_path(&self.base_dir, channel)?;
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e.into()),
};
let mut data: ApproveAttemptsFile = serde_json::from_str(&content).unwrap_or_default();
let now = now_secs();
let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS);
data.failed_at.retain(|&t| t >= cutoff);
Ok(data.failed_at.len() >= PAIRING_APPROVE_RATE_LIMIT)
}
fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> {
let path = approve_attempts_path(&self.base_dir, channel)?;
fs::create_dir_all(path.parent().unwrap())?;
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut data: ApproveAttemptsFile = serde_json::from_str(&content).unwrap_or_default();
let now = now_secs();
data.failed_at.push(now);
let cutoff = now.saturating_sub(PAIRING_APPROVE_RATE_WINDOW_SECS);
data.failed_at.retain(|&t| t >= cutoff);
let json = serde_json::to_string_pretty(&data)?;
fs::write(&path, json)?;
fs4::FileExt::unlock(&file)?;
Ok(())
}
/// Approve a pairing code and add the sender to allowFrom.
pub fn approve(
&self,
channel: &str,
code: &str,
) -> Result<Option<PairingRequest>, PairingStoreError> {
let code = code.trim().to_uppercase();
if code.is_empty() {
return Ok(None);
}
if self.is_approve_rate_limited(channel)? {
return Err(PairingStoreError::ApproveRateLimited);
}
let path = pairing_path(&self.base_dir, channel)?;
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(false)
.open(&path)
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
PairingStoreError::InvalidChannel("no pairing file".to_string())
} else {
PairingStoreError::Io(e)
}
})?;
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut store: PairingStoreFile =
serde_json::from_str(&content).unwrap_or(PairingStoreFile {
version: 1,
requests: Vec::new(),
});
let now_secs = now_secs();
store.requests.retain(|r| !is_expired(r, now_secs));
let idx = store
.requests
.iter()
.position(|r| r.code.to_uppercase() == code);
let entry = match idx {
Some(i) => store.requests.remove(i),
None => {
fs4::FileExt::unlock(&file)?;
self.record_failed_approve(channel)?;
return Ok(None);
}
};
self.write_pairing_file_locked(&mut file, channel, &store.requests)?;
fs4::FileExt::unlock(&file)?;
self.add_allow_from(channel, &entry.id)?;
Ok(Some(entry))
}
/// Read the allowFrom list for a channel.
pub fn read_allow_from(&self, channel: &str) -> Result<Vec<String>, PairingStoreError> {
let path = allow_from_path(&self.base_dir, channel)?;
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(e) => return Err(e.into()),
};
let file: AllowFromStoreFile =
serde_json::from_str(&content).unwrap_or(AllowFromStoreFile {
version: 1,
allow_from: Vec::new(),
});
Ok(file.allow_from)
}
/// Check if a sender is allowed (by id or username).
pub fn is_sender_allowed(
&self,
channel: &str,
id: &str,
username: Option<&str>,
) -> Result<bool, PairingStoreError> {
let allow = self.read_allow_from(channel)?;
let id = id.trim();
let id_ok = allow.iter().any(|e| e.trim() == id);
if id_ok {
return Ok(true);
}
if let Some(u) = username {
let u = u.trim().to_lowercase();
let u_norm = u.strip_prefix('@').unwrap_or(&u);
if allow.iter().any(|e| {
e.trim().to_lowercase() == u || e.trim().to_lowercase() == format!("@{}", u_norm)
}) {
return Ok(true);
}
}
Ok(false)
}
fn add_allow_from(&self, channel: &str, entry: &str) -> Result<(), PairingStoreError> {
let entry = entry.trim().to_string();
if entry.is_empty() {
return Ok(());
}
let path = allow_from_path(&self.base_dir, channel)?;
fs::create_dir_all(path.parent().unwrap())?;
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
file.lock_exclusive()?;
let content = fs::read_to_string(&path).unwrap_or_default();
let mut store: AllowFromStoreFile =
serde_json::from_str(&content).unwrap_or(AllowFromStoreFile {
version: 1,
allow_from: Vec::new(),
});
let normalized = entry.to_lowercase();
if store
.allow_from
.iter()
.any(|e| e.to_lowercase() == normalized)
{
fs4::FileExt::unlock(&file)?;
return Ok(());
}
store.allow_from.push(entry);
let json = serde_json::to_string_pretty(&store)?;
fs::write(&path, json)?;
fs4::FileExt::unlock(&file)?;
Ok(())
}
fn write_pairing_file(
&self,
channel: &str,
requests: &[PairingRequest],
) -> Result<(), PairingStoreError> {
let path = pairing_path(&self.base_dir, channel)?;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
file.lock_exclusive()?;
self.write_pairing_file_locked(&mut file, channel, requests)?;
fs4::FileExt::unlock(&file)?;
Ok(())
}
fn write_pairing_file_locked(
&self,
file: &mut fs::File,
_channel: &str,
requests: &[PairingRequest],
) -> Result<(), PairingStoreError> {
let store = PairingStoreFile {
version: 1,
requests: requests.to_vec(),
};
let json = serde_json::to_string_pretty(&store)?;
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(json.as_bytes())?;
file.sync_all()?;
Ok(())
}
}
impl Default for PairingStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_safe_channel_key() {
assert_eq!(safe_channel_key("telegram").unwrap(), "telegram");
assert_eq!(safe_channel_key("Telegram").unwrap(), "telegram");
safe_channel_key("").unwrap_err();
}
#[test]
fn test_random_code() {
let c = random_code();
assert_eq!(c.len(), PAIRING_CODE_LENGTH);
assert!(c.chars().all(|c| PAIRING_ALPHABET.contains(&(c as u8))));
}
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_pending_empty() {
let (store, _) = test_store();
let requests = store.list_pending("telegram").unwrap();
assert!(requests.is_empty());
}
#[test]
fn test_upsert_request_creates_new() {
let (store, _) = test_store();
let result = store
.upsert_request(
"telegram",
"user123",
Some(serde_json::json!({"chat_id": 456})),
)
.unwrap();
assert!(result.created);
assert_eq!(result.code.len(), PAIRING_CODE_LENGTH);
assert!(
result
.code
.chars()
.all(|c| PAIRING_ALPHABET.contains(&(c as u8)))
);
}
#[test]
fn test_upsert_request_updates_existing() {
let (store, _) = test_store();
let r1 = store.upsert_request("telegram", "user123", None).unwrap();
assert!(r1.created);
let r2 = store
.upsert_request("telegram", "user123", Some(serde_json::json!({"x": 1})))
.unwrap();
assert!(!r2.created);
assert_eq!(r1.code, r2.code);
let pending = store.list_pending("telegram").unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, "user123");
assert_eq!(pending[0].meta, Some(serde_json::json!({"x": 1})));
}
#[test]
fn test_approve_adds_to_allow_from() {
let (store, _) = test_store();
let r = store.upsert_request("telegram", "user456", None).unwrap();
assert!(r.created);
let approved = store.approve("telegram", &r.code).unwrap();
assert!(approved.is_some());
assert_eq!(approved.unwrap().id, "user456");
let allow = store.read_allow_from("telegram").unwrap();
assert_eq!(allow, vec!["user456"]);
}
#[test]
fn test_approve_case_insensitive_code() {
let (store, _) = test_store();
let r = store.upsert_request("telegram", "user789", None).unwrap();
let code_lower = r.code.to_lowercase();
let approved = store.approve("telegram", &code_lower).unwrap();
assert!(approved.is_some());
}
#[test]
fn test_approve_invalid_code_returns_none() {
let (store, _) = test_store();
store.upsert_request("telegram", "user123", None).unwrap();
let approved = store.approve("telegram", "BADCODE1").unwrap();
assert!(approved.is_none());
}
#[test]
fn test_approve_rate_limited_after_many_failures() {
let (store, _) = test_store();
store.upsert_request("telegram", "user123", None).unwrap();
for _ in 0..PAIRING_APPROVE_RATE_LIMIT {
let _ = store.approve("telegram", "WRONG01");
}
let err = store.approve("telegram", "WRONG02").unwrap_err();
assert!(matches!(err, PairingStoreError::ApproveRateLimited));
}
#[test]
fn test_is_sender_allowed_by_id() {
let (store, _) = test_store();
let r = store.upsert_request("telegram", "user999", None).unwrap();
store.approve("telegram", &r.code).unwrap();
assert!(
store
.is_sender_allowed("telegram", "user999", None)
.unwrap()
);
assert!(!store.is_sender_allowed("telegram", "other", None).unwrap());
}
#[test]
fn test_is_sender_allowed_by_username() {
let (store, _) = test_store();
store
.upsert_request(
"telegram",
"alice",
Some(serde_json::json!({"username": "alice"})),
)
.unwrap();
let pending = store.list_pending("telegram").unwrap();
store.approve("telegram", &pending[0].code).unwrap();
// approve adds id to allow_from. For username we need to add it manually.
// Actually approve adds entry.id which is "alice". So is_sender_allowed("telegram", "alice", None) would work.
assert!(store.is_sender_allowed("telegram", "alice", None).unwrap());
assert!(
store
.is_sender_allowed("telegram", "alice", Some("alice"))
.unwrap()
);
}
#[test]
fn test_channel_normalization() {
let (store, _) = test_store();
store.upsert_request("Telegram", "u1", None).unwrap();
let pending = store.list_pending("telegram").unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, "u1");
}
#[test]
fn test_invalid_channel_rejected() {
let (store, _) = test_store();
store.upsert_request("telegram", "u1", None).unwrap();
store.list_pending("").unwrap_err();
store.upsert_request("", "u1", None).unwrap_err();
}
}
+41
View File
@@ -511,6 +511,14 @@ fn default_patterns() -> Vec<LeakPattern> {
severity: LeakSeverity::High,
action: LeakAction::Redact,
},
// NEAR ed25519 private keys (base58 encoded, ~88 chars after prefix).
// Public keys are shorter (~44 chars), so this pattern is specific to secrets.
LeakPattern {
name: "near_ed25519_secret_key".to_string(),
regex: Regex::new(r"ed25519:[1-9A-HJ-NP-Za-km-z]{80,90}").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// High entropy hex (potential secrets, warn only)
// Uses word boundary since look-around isn't supported in the regex crate.
// This catches standalone 64-char hex strings (like SHA256 hashes used as secrets).
@@ -696,6 +704,39 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn test_detect_near_ed25519_secret_key() {
let detector = LeakDetector::new();
// A realistic NEAR secret key (88 base58 chars after prefix)
let content = "key: ed25519:3D4YudUahN1nawWogh9MFV2MXJBMHCS2RE1KU7rWAiMi3t12UiSnMYCJ7BFXbsFhKfNUWDj8CCEbifTByREAMkTi";
let result = detector.scan(content);
assert!(!result.is_clean());
assert!(result.should_block);
assert!(
result
.matches
.iter()
.any(|m| m.pattern_name == "near_ed25519_secret_key")
);
}
#[test]
fn test_near_public_key_not_blocked() {
let detector = LeakDetector::new();
// Public keys are ~44 base58 chars, should NOT match the 80-90 char pattern
let content = "pubkey: ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp";
let result = detector.scan(content);
// Should not match near_ed25519_secret_key pattern
assert!(
!result
.matches
.iter()
.any(|m| m.pattern_name == "near_ed25519_secret_key")
);
}
#[test]
fn test_scan_http_request_blocks_secret_in_body() {
let detector = LeakDetector::new();
+2 -29
View File
@@ -491,36 +491,9 @@ impl ContainerRunner {
}
/// Connect to the Docker daemon.
///
/// Tries these locations in order:
/// 1. `DOCKER_HOST` env var (bollard default)
/// 2. `/var/run/docker.sock` (Linux default)
/// 3. `~/.docker/run/docker.sock` (Docker Desktop on macOS)
pub async fn connect_docker() -> Result<Docker> {
// First try bollard defaults (checks DOCKER_HOST, then /var/run/docker.sock)
if let Ok(docker) = Docker::connect_with_local_defaults() {
if docker.ping().await.is_ok() {
return Ok(docker);
}
}
// Try Docker Desktop socket (macOS)
if let Some(home) = std::env::var_os("HOME") {
let desktop_sock = std::path::Path::new(&home).join(".docker/run/docker.sock");
if desktop_sock.exists() {
let sock_str = desktop_sock.to_string_lossy();
if let Ok(docker) =
Docker::connect_with_socket(&sock_str, 120, bollard::API_DEFAULT_VERSION)
{
if docker.ping().await.is_ok() {
return Ok(docker);
}
}
}
}
Err(SandboxError::DockerNotAvailable {
reason: "Socket not found: /var/run/docker.sock".to_string(),
Docker::connect_with_local_defaults().map_err(|e| SandboxError::DockerNotAvailable {
reason: e.to_string(),
})
}
+139 -107
View File
@@ -52,7 +52,7 @@ mod platform {
use super::*;
/// Store the master key in the macOS Keychain.
pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> {
pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> {
// Convert to hex for storage (keychain prefers strings)
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
@@ -61,7 +61,7 @@ mod platform {
}
/// Retrieve the master key from the macOS Keychain.
pub async fn get_master_key() -> Result<Vec<u8>, SecretError> {
pub fn get_master_key() -> Result<Vec<u8>, SecretError> {
let password = get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| {
SecretError::KeychainError(format!("Failed to get from keychain: {}", e))
})?;
@@ -74,14 +74,14 @@ mod platform {
}
/// Delete the master key from the macOS Keychain.
pub async fn delete_master_key() -> Result<(), SecretError> {
pub fn delete_master_key() -> Result<(), SecretError> {
delete_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| {
SecretError::KeychainError(format!("Failed to delete from keychain: {}", e))
})
}
/// Check if a master key exists in the keychain.
pub async fn has_master_key() -> bool {
pub fn has_master_key() -> bool {
get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).is_ok()
}
}
@@ -97,131 +97,163 @@ mod platform {
use super::*;
/// Store the master key in the Linux secret service (GNOME Keyring, KWallet).
pub async fn store_master_key(key: &[u8]) -> Result<(), SecretError> {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!("Failed to connect to secret service: {}", e))
pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> {
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?;
rt.block_on(async {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
let collection = ss.get_default_collection().await.map_err(|e| {
SecretError::KeychainError(format!("Failed to get collection: {}", e))
})?;
let collection = ss
.get_default_collection()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to get collection: {}", e)))?;
// Unlock if needed
if collection.is_locked().await.unwrap_or(true) {
collection.unlock().await.map_err(|e| {
SecretError::KeychainError(format!("Failed to unlock collection: {}", e))
})?;
}
// Unlock if needed
if collection.is_locked().await.unwrap_or(true) {
collection.unlock().await.map_err(|e| {
SecretError::KeychainError(format!("Failed to unlock collection: {}", e))
})?;
}
// Convert to hex for storage
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
// Convert to hex for storage
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
collection
.create_item(
&format!("{} master key", SERVICE_NAME),
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
key_hex.as_bytes(),
true, // Replace if exists
"text/plain",
)
.await
.map_err(|e| {
SecretError::KeychainError(format!("Failed to create secret: {}", e))
})?;
collection
.create_item(
&format!("{} master key", SERVICE_NAME),
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
key_hex.as_bytes(),
true, // Replace if exists
"text/plain",
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to create secret: {}", e)))?;
Ok(())
Ok(())
})
}
/// Retrieve the master key from the Linux secret service.
pub async fn get_master_key() -> Result<Vec<u8>, SecretError> {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!("Failed to connect to secret service: {}", e))
})?;
pub fn get_master_key() -> Result<Vec<u8>, SecretError> {
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?;
let items = ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?;
let item = items
.unlocked
.first()
.or(items.locked.first())
.ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?;
// Unlock if needed
if item.is_locked().await.unwrap_or(true) {
item.unlock()
rt.block_on(async {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?;
}
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
let secret = item
.get_secret()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?;
let items = ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?;
let hex_str = String::from_utf8(secret)
.map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?;
let item = items
.unlocked
.first()
.or(items.locked.first())
.ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?;
hex_to_bytes(&hex_str)
// Unlock if needed
if item.is_locked().await.unwrap_or(true) {
item.unlock()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?;
}
let secret = item
.get_secret()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?;
let hex_str = String::from_utf8(secret)
.map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?;
hex_to_bytes(&hex_str)
})
}
/// Delete the master key from the Linux secret service.
pub async fn delete_master_key() -> Result<(), SecretError> {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!("Failed to connect to secret service: {}", e))
})?;
pub fn delete_master_key() -> Result<(), SecretError> {
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?;
let items = ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?;
for item in items.unlocked.iter().chain(items.locked.iter()) {
item.delete()
rt.block_on(async {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?;
}
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
Ok(())
let items = ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?;
for item in items.unlocked.iter().chain(items.locked.iter()) {
item.delete()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?;
}
Ok(())
})
}
/// Check if a master key exists in the secret service.
pub async fn has_master_key() -> bool {
let ss = match SecretService::connect(EncryptionType::Dh).await {
Ok(ss) => ss,
pub fn has_master_key() -> bool {
let rt = match tokio::runtime::Handle::try_current() {
Ok(rt) => rt,
Err(_) => return false,
};
let items = match ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
{
Ok(items) => items,
Err(_) => return false,
};
rt.block_on(async {
let ss = match SecretService::connect(EncryptionType::Dh).await {
Ok(ss) => ss,
Err(_) => return false,
};
!items.unlocked.is_empty() || !items.locked.is_empty()
let items = match ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
{
Ok(items) => items,
Err(_) => return false,
};
!items.unlocked.is_empty() || !items.locked.is_empty()
})
}
}
@@ -233,25 +265,25 @@ mod platform {
mod platform {
use super::*;
pub async fn store_master_key(_key: &[u8]) -> Result<(), SecretError> {
pub fn store_master_key(_key: &[u8]) -> Result<(), SecretError> {
Err(SecretError::KeychainError(
"Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(),
))
}
pub async fn get_master_key() -> Result<Vec<u8>, SecretError> {
pub fn get_master_key() -> Result<Vec<u8>, SecretError> {
Err(SecretError::KeychainError(
"Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(),
))
}
pub async fn delete_master_key() -> Result<(), SecretError> {
pub fn delete_master_key() -> Result<(), SecretError> {
Err(SecretError::KeychainError(
"Keychain not supported on this platform".to_string(),
))
}
pub async fn has_master_key() -> bool {
pub fn has_master_key() -> bool {
false
}
}
+7 -2
View File
@@ -192,10 +192,9 @@ impl CreateSecretParams {
}
/// Where a credential should be injected in an HTTP request.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CredentialLocation {
/// Inject as Authorization header (e.g., "Bearer {secret}")
#[default]
AuthorizationBearer,
/// Inject as Authorization header with Basic auth
AuthorizationBasic { username: String },
@@ -210,6 +209,12 @@ pub enum CredentialLocation {
UrlPath { placeholder: String },
}
impl Default for CredentialLocation {
fn default() -> Self {
Self::AuthorizationBearer
}
}
/// Mapping from a secret name to where it should be injected.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialMapping {

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